mirror of
https://github.com/docmost/docmost.git
synced 2026-07-15 20:26:47 +10:00
fix(base): scope per-base UI atoms by pageId to prevent embed render loops
Two BaseTable instances on the same page (e.g. multiple base embeds in one document) shared the same global jotai atoms for activeViewId, editingCell, property menu state, and row selection. Each instance's useEffect that synced activeViewId would clobber the other's value every render, pinning React into a "Maximum update depth exceeded" loop. Convert every UI atom in base-atoms.ts to an atomFamily keyed by pageId so each base owns its own scope, and thread pageId through the grid component tree (GridRow, GridCell, GridHeaderCell, RowNumberCell, RowNumberHeaderCell, PropertyMenuContent) plus useRowSelection so each consumer reaches the per-base atom. use-base-socket already had pageId in scope; its store.get/store.set calls now resolve through selectedRowIdsAtomFamily(pageId) too.
This commit is contained in:
@@ -24,7 +24,7 @@ import {
|
||||
useCreateViewMutation,
|
||||
useUpdateViewMutation,
|
||||
} from "@/features/base/queries/base-view-query";
|
||||
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { activeViewIdAtomFamily } from "@/features/base/atoms/base-atoms";
|
||||
import { useBaseTable } from "@/features/base/hooks/use-base-table";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
@@ -53,7 +53,7 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
|
||||
useBaseSocket(pageId);
|
||||
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(pageId);
|
||||
|
||||
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
|
||||
|
||||
const views = base?.views ?? [];
|
||||
const activeView = useMemo(() => {
|
||||
@@ -146,7 +146,7 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
|
||||
}
|
||||
}, [activeView, activeViewId, setActiveViewId]);
|
||||
|
||||
const { clear: clearSelection } = useRowSelection();
|
||||
const { clear: clearSelection } = useRowSelection(pageId);
|
||||
useEffect(() => {
|
||||
clearSelection();
|
||||
}, [pageId, activeView?.id, clearSelection]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, useCallback } from "react";
|
||||
import { Cell } from "@tanstack/react-table";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { editingCellAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { editingCellAtomFamily } from "@/features/base/atoms/base-atoms";
|
||||
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
|
||||
import { CellText } from "@/features/base/components/cells/cell-text";
|
||||
import { CellNumber } from "@/features/base/components/cells/cell-number";
|
||||
@@ -65,6 +65,7 @@ type GridCellProps = {
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
rowDragProps?: RowDragProps;
|
||||
orderedRowIds?: string[];
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const GridCell = memo(function GridCell({
|
||||
@@ -73,13 +74,14 @@ export const GridCell = memo(function GridCell({
|
||||
onCellUpdate,
|
||||
rowDragProps,
|
||||
orderedRowIds,
|
||||
pageId,
|
||||
}: GridCellProps) {
|
||||
const property = cell.column.columnDef.meta?.property;
|
||||
const isRowNumber = cell.column.id === "__row_number";
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const rowId = cell.row.id;
|
||||
const isEditing =
|
||||
@@ -121,6 +123,7 @@ export const GridCell = memo(function GridCell({
|
||||
isPinned={Boolean(isPinned)}
|
||||
pinOffset={pinOffset}
|
||||
rowDragProps={rowDragProps}
|
||||
pageId={pageId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { editingCellAtomFamily, activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily } from "@/features/base/atoms/base-atoms";
|
||||
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
|
||||
import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav";
|
||||
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
|
||||
@@ -53,7 +53,7 @@ type GridContainerProps = {
|
||||
properties: IBaseProperty[];
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: () => void;
|
||||
pageId?: string;
|
||||
pageId: string;
|
||||
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
@@ -96,16 +96,16 @@ export function GridContainer({
|
||||
const lastTriggeredRowsLenRef = useRef(0);
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean];
|
||||
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
|
||||
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean];
|
||||
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
|
||||
const propertyMenuDirtyRef = useRef(propertyMenuDirty);
|
||||
propertyMenuDirtyRef.current = propertyMenuDirty;
|
||||
const closeRequestCounterRef = useRef(0);
|
||||
|
||||
const { selectionCount, clear: clearSelection } = useRowSelection();
|
||||
const { deleteSelected } = useDeleteSelectedRows(pageId ?? "");
|
||||
const { selectionCount, clear: clearSelection } = useRowSelection(pageId);
|
||||
const { deleteSelected } = useDeleteSelectedRows(pageId);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
@@ -366,6 +366,7 @@ export function GridContainer({
|
||||
onCellUpdate={onCellUpdate}
|
||||
orderedRowIds={rowIds}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
pageId={pageId}
|
||||
dragHandlers={
|
||||
onRowReorder
|
||||
? {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom, editingCellAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily, editingCellAtomFamily } from "@/features/base/atoms/base-atoms";
|
||||
import {
|
||||
IconLetterT,
|
||||
IconHash,
|
||||
@@ -48,25 +48,27 @@ type GridHeaderCellProps = {
|
||||
header: Header<IBaseRow, unknown>;
|
||||
property: IBaseProperty | undefined;
|
||||
loadedRowIds: string[];
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
header,
|
||||
property,
|
||||
loadedRowIds,
|
||||
pageId,
|
||||
}: GridHeaderCellProps) {
|
||||
const isRowNumber = header.column.id === "__row_number";
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
||||
const { selectionCount } = useRowSelection();
|
||||
const { selectionCount } = useRowSelection(pageId);
|
||||
const hasSelection = selectionCount > 0;
|
||||
|
||||
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
|
||||
const menuOpened = activePropertyMenu === header.column.id;
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean, (val: boolean) => void];
|
||||
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
|
||||
const [, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
|
||||
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
|
||||
const [, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const handleDirtyChange = useCallback((dirty: boolean) => {
|
||||
setPropertyMenuDirty(dirty);
|
||||
@@ -109,7 +111,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
// Mantine's built-in `closeOnEscape` only fires when focus is inside the
|
||||
// dropdown, but opening the property menu (clicking the header) leaves
|
||||
// focus on the header itself. Mirror the click-outside path: when dirty,
|
||||
// bump `propertyMenuCloseRequestAtom` so property-menu shows its
|
||||
// bump `propertyMenuCloseRequestAtomFamily` so property-menu shows its
|
||||
// "Unsaved changes" confirmation panel; otherwise close directly.
|
||||
useEffect(() => {
|
||||
if (!menuOpened) return;
|
||||
@@ -156,7 +158,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
{...(isSortableDisabled ? {} : listeners)}
|
||||
>
|
||||
{isRowNumber ? (
|
||||
<RowNumberHeaderCell loadedRowIds={loadedRowIds} />
|
||||
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
|
||||
) : (
|
||||
<div className={classes.headerCellContent}>
|
||||
{TypeIcon && (
|
||||
@@ -207,6 +209,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -7,7 +7,7 @@ import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type GridHeaderProps = {
|
||||
table: Table<IBaseRow>;
|
||||
pageId?: string;
|
||||
pageId: string;
|
||||
// Passed explicitly to break memo when columns change
|
||||
// (table ref is stable from useReactTable, so memo won't fire without these)
|
||||
columnOrder: ColumnOrderState;
|
||||
@@ -43,15 +43,14 @@ export const GridHeader = memo(function GridHeader({
|
||||
header={header}
|
||||
property={propertyById.get(header.column.id)}
|
||||
loadedRowIds={loadedRowIds}
|
||||
pageId={pageId}
|
||||
/>
|
||||
))}
|
||||
{pageId && (
|
||||
<CreatePropertyPopover
|
||||
pageId={pageId}
|
||||
properties={properties}
|
||||
onPropertyCreated={onPropertyCreated}
|
||||
/>
|
||||
)}
|
||||
<CreatePropertyPopover
|
||||
pageId={pageId}
|
||||
properties={properties}
|
||||
onPropertyCreated={onPropertyCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ type GridRowProps = {
|
||||
dragHandlers?: RowDragHandlers;
|
||||
orderedRowIds: string[];
|
||||
columnVisibility: VisibilityState;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const GridRow = memo(function GridRow({
|
||||
@@ -32,8 +33,9 @@ export const GridRow = memo(function GridRow({
|
||||
orderedRowIds,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
columnVisibility: _columnVisibility,
|
||||
pageId,
|
||||
}: GridRowProps) {
|
||||
const isSelected = useRowSelection().isSelected(row.id);
|
||||
const isSelected = useRowSelection(pageId).isSelected(row.id);
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
@@ -76,6 +78,7 @@ export const GridRow = memo(function GridRow({
|
||||
rowIndex={rowIndex}
|
||||
onCellUpdate={onCellUpdate}
|
||||
orderedRowIds={orderedRowIds}
|
||||
pageId={pageId}
|
||||
rowDragProps={
|
||||
isRowNumber && dragHandlers
|
||||
? {
|
||||
|
||||
@@ -16,6 +16,7 @@ type RowNumberCellProps = {
|
||||
isPinned: boolean;
|
||||
pinOffset?: number;
|
||||
rowDragProps?: RowDragProps;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const RowNumberCell = memo(function RowNumberCell({
|
||||
@@ -25,8 +26,9 @@ export const RowNumberCell = memo(function RowNumberCell({
|
||||
isPinned,
|
||||
pinOffset,
|
||||
rowDragProps,
|
||||
pageId,
|
||||
}: RowNumberCellProps) {
|
||||
const { isSelected, toggle } = useRowSelection();
|
||||
const { isSelected, toggle } = useRowSelection(pageId);
|
||||
const selected = isSelected(rowId);
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
|
||||
@@ -5,12 +5,14 @@ import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type RowNumberHeaderCellProps = {
|
||||
loadedRowIds: string[];
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
|
||||
loadedRowIds,
|
||||
pageId,
|
||||
}: RowNumberHeaderCellProps) {
|
||||
const { selectedIds, toggleAll } = useRowSelection();
|
||||
const { selectedIds, toggleAll } = useRowSelection(pageId);
|
||||
|
||||
const { checked, indeterminate } = useMemo(() => {
|
||||
if (loadedRowIds.length === 0) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const SelectionActionBar = memo(function SelectionActionBar({
|
||||
pageId,
|
||||
}: SelectionActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectionCount, clear } = useRowSelection();
|
||||
const { selectionCount, clear } = useRowSelection(pageId);
|
||||
const { deleteSelected, isPending } = useDeleteSelectedRows(pageId);
|
||||
|
||||
const isOpen = selectionCount > 0;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import { useAtom } from "jotai";
|
||||
import { propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { propertyMenuCloseRequestAtomFamily } from "@/features/base/atoms/base-atoms";
|
||||
import {
|
||||
useUpdatePropertyMutation,
|
||||
useDeletePropertyMutation,
|
||||
@@ -34,6 +34,7 @@ type PropertyMenuContentProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
type MenuPanel = "main" | "rename" | "options" | "confirmDelete" | "confirmDiscard";
|
||||
@@ -43,6 +44,7 @@ export function PropertyMenuContent({
|
||||
opened,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
pageId,
|
||||
}: PropertyMenuContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [panel, setPanel] = useState<MenuPanel>("main");
|
||||
@@ -51,7 +53,7 @@ export function PropertyMenuContent({
|
||||
const [optionsDirty, setOptionsDirty] = useState(false);
|
||||
const pendingActionRef = useRef<"back" | "close" | null>(null);
|
||||
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
|
||||
const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number];
|
||||
const [closeRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number];
|
||||
const closeRequestRef = useRef(closeRequest);
|
||||
|
||||
const renameDirty = renameValue !== property.name;
|
||||
@@ -79,7 +81,7 @@ export function PropertyMenuContent({
|
||||
|
||||
// Single dirty signal to the outside — reflects whichever panel is
|
||||
// currently accumulating unsaved work. Keeps rename and options in
|
||||
// lockstep with the `propertyMenuDirtyAtom` so the grid-container's
|
||||
// lockstep with the `propertyMenuDirtyAtomFamily` so the grid-container's
|
||||
// outside-click handler and the header's ESC handler both prompt
|
||||
// "Unsaved changes" consistently.
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user