diff --git a/apps/client/src/features/base/atoms/base-atoms.ts b/apps/client/src/features/base/atoms/base-atoms.ts index 6dbf1bb17..247d1a475 100644 --- a/apps/client/src/features/base/atoms/base-atoms.ts +++ b/apps/client/src/features/base/atoms/base-atoms.ts @@ -1,15 +1,38 @@ import { atom } from "jotai"; +import { atomFamily } from "jotai/utils"; import { EditingCell } from "@/features/base/types/base.types"; -export const activeViewIdAtom = atom(null); +// Atoms are scoped per-base via `pageId` so that two BaseTable instances +// rendered on the same page (e.g. multiple base embeds inside one +// document) don't share UI state. A global atom would otherwise cause +// each instance's `useEffect` writers to clobber the other's value +// every render — pinning React into a "Maximum update depth exceeded" +// loop. -export const editingCellAtom = atom(null); +export const activeViewIdAtomFamily = atomFamily((_pageId: string) => + atom(null), +); -export const activePropertyMenuAtom = atom(null); +export const editingCellAtomFamily = atomFamily((_pageId: string) => + atom(null), +); -export const propertyMenuDirtyAtom = atom(false); +export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) => + atom(null), +); -export const propertyMenuCloseRequestAtom = atom(0); +export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) => + atom(false), +); -export const selectedRowIdsAtom = atom>(new Set()); -export const lastToggledRowIndexAtom = atom(null); +export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) => + atom(0), +); + +export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) => + atom>(new Set()), +); + +export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) => + atom(null), +); diff --git a/apps/client/src/features/base/components/base-table.tsx b/apps/client/src/features/base/components/base-table.tsx index 16acdbbf7..393e38121 100644 --- a/apps/client/src/features/base/components/base-table.tsx +++ b/apps/client/src/features/base/components/base-table.tsx @@ -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]); diff --git a/apps/client/src/features/base/components/grid/grid-cell.tsx b/apps/client/src/features/base/components/grid/grid-cell.tsx index 6f7e11dd4..0a4feca33 100644 --- a/apps/client/src/features/base/components/grid/grid-cell.tsx +++ b/apps/client/src/features/base/components/grid/grid-cell.tsx @@ -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} /> ); } diff --git a/apps/client/src/features/base/components/grid/grid-container.tsx b/apps/client/src/features/base/components/grid/grid-container.tsx index c3b98c00e..65c7deaf1 100644 --- a/apps/client/src/features/base/components/grid/grid-container.tsx +++ b/apps/client/src/features/base/components/grid/grid-container.tsx @@ -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 ? { diff --git a/apps/client/src/features/base/components/grid/grid-header-cell.tsx b/apps/client/src/features/base/components/grid/grid-header-cell.tsx index 62bd454b3..d800b1cf5 100644 --- a/apps/client/src/features/base/components/grid/grid-header-cell.tsx +++ b/apps/client/src/features/base/components/grid/grid-header-cell.tsx @@ -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; 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(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 ? ( - + ) : (
{TypeIcon && ( @@ -207,6 +209,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({ opened={menuOpened} onClose={handleMenuClose} onDirtyChange={handleDirtyChange} + pageId={pageId} /> diff --git a/apps/client/src/features/base/components/grid/grid-header.tsx b/apps/client/src/features/base/components/grid/grid-header.tsx index 6c0585935..aa9c31917 100644 --- a/apps/client/src/features/base/components/grid/grid-header.tsx +++ b/apps/client/src/features/base/components/grid/grid-header.tsx @@ -7,7 +7,7 @@ import classes from "@/features/base/styles/grid.module.css"; type GridHeaderProps = { table: Table; - 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 && ( - - )} +
); }); diff --git a/apps/client/src/features/base/components/grid/grid-row.tsx b/apps/client/src/features/base/components/grid/grid-row.tsx index 238e3ea58..59a4ea9af 100644 --- a/apps/client/src/features/base/components/grid/grid-row.tsx +++ b/apps/client/src/features/base/components/grid/grid-row.tsx @@ -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 ? { diff --git a/apps/client/src/features/base/components/grid/row-number-cell.tsx b/apps/client/src/features/base/components/grid/row-number-cell.tsx index 4671cf90c..9cb393d17 100644 --- a/apps/client/src/features/base/components/grid/row-number-cell.tsx +++ b/apps/client/src/features/base/components/grid/row-number-cell.tsx @@ -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( diff --git a/apps/client/src/features/base/components/grid/row-number-header-cell.tsx b/apps/client/src/features/base/components/grid/row-number-header-cell.tsx index 0a0c1a018..405d214d8 100644 --- a/apps/client/src/features/base/components/grid/row-number-header-cell.tsx +++ b/apps/client/src/features/base/components/grid/row-number-header-cell.tsx @@ -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) { diff --git a/apps/client/src/features/base/components/grid/selection-action-bar.tsx b/apps/client/src/features/base/components/grid/selection-action-bar.tsx index adab15301..cec1adc50 100644 --- a/apps/client/src/features/base/components/grid/selection-action-bar.tsx +++ b/apps/client/src/features/base/components/grid/selection-action-bar.tsx @@ -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; diff --git a/apps/client/src/features/base/components/property/property-menu.tsx b/apps/client/src/features/base/components/property/property-menu.tsx index 322922326..7b2bb3353 100644 --- a/apps/client/src/features/base/components/property/property-menu.tsx +++ b/apps/client/src/features/base/components/property/property-menu.tsx @@ -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("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(() => { diff --git a/apps/client/src/features/base/hooks/use-base-socket.ts b/apps/client/src/features/base/hooks/use-base-socket.ts index 9c925b1de..41174e017 100644 --- a/apps/client/src/features/base/hooks/use-base-socket.ts +++ b/apps/client/src/features/base/hooks/use-base-socket.ts @@ -7,7 +7,7 @@ import { IBaseRow, IBaseView, } from "@/features/base/types/base.types"; -import { selectedRowIdsAtom } from "@/features/base/atoms/base-atoms"; +import { selectedRowIdsAtomFamily } from "@/features/base/atoms/base-atoms"; import { formulaRecomputeAtom } from "@/features/base/atoms/formula-recompute-atom"; import { IPagination } from "@/lib/types"; @@ -211,11 +211,12 @@ export function useBaseSocket(pageId: string | undefined): void { }, ); const store = getDefaultStore(); - const current = store.get(selectedRowIdsAtom); + const selectedIdsAtom = selectedRowIdsAtomFamily(pageId); + const current = store.get(selectedIdsAtom); if (current.has(e.rowId)) { const next = new Set(current); next.delete(e.rowId); - store.set(selectedRowIdsAtom, next); + store.set(selectedIdsAtom, next); } break; } @@ -236,14 +237,15 @@ export function useBaseSocket(pageId: string | undefined): void { }, ); const store = getDefaultStore(); - const current = store.get(selectedRowIdsAtom); + const selectedIdsAtom = selectedRowIdsAtomFamily(pageId); + const current = store.get(selectedIdsAtom); if (current.size > 0) { let changed = false; const next = new Set(current); for (const id of e.rowIds) { if (next.delete(id)) changed = true; } - if (changed) store.set(selectedRowIdsAtom, next); + if (changed) store.set(selectedIdsAtom, next); } break; } diff --git a/apps/client/src/features/base/hooks/use-delete-selected-rows.tsx b/apps/client/src/features/base/hooks/use-delete-selected-rows.tsx index 87e43286b..06678339b 100644 --- a/apps/client/src/features/base/hooks/use-delete-selected-rows.tsx +++ b/apps/client/src/features/base/hooks/use-delete-selected-rows.tsx @@ -10,7 +10,7 @@ const BATCH_SIZE = 500; export function useDeleteSelectedRows(pageId: string) { const { t } = useTranslation(); - const { selectedIds, clear } = useRowSelection(); + const { selectedIds, clear } = useRowSelection(pageId); const mutation = useDeleteRowsMutation(); const runDelete = useCallback( diff --git a/apps/client/src/features/base/hooks/use-row-selection.ts b/apps/client/src/features/base/hooks/use-row-selection.ts index 5e4ccbed2..52e1d7f8a 100644 --- a/apps/client/src/features/base/hooks/use-row-selection.ts +++ b/apps/client/src/features/base/hooks/use-row-selection.ts @@ -1,8 +1,8 @@ import { useCallback } from "react"; import { useAtom } from "jotai"; import { - selectedRowIdsAtom, - lastToggledRowIndexAtom, + selectedRowIdsAtomFamily, + lastToggledRowIndexAtomFamily, } from "@/features/base/atoms/base-atoms"; type ToggleOpts = { @@ -11,13 +11,15 @@ type ToggleOpts = { orderedRowIds: string[]; }; -export function useRowSelection() { - const [selectedIds, setSelectedIds] = useAtom(selectedRowIdsAtom) as unknown as [ +export function useRowSelection(pageId: string) { + const [selectedIds, setSelectedIds] = useAtom( + selectedRowIdsAtomFamily(pageId), + ) as unknown as [ Set, (val: Set | ((prev: Set) => Set)) => void, ]; const [lastToggledIndex, setLastToggledIndex] = useAtom( - lastToggledRowIndexAtom, + lastToggledRowIndexAtomFamily(pageId), ) as unknown as [number | null, (val: number | null) => void]; const isSelected = useCallback(