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:
Philipinho
2026-04-27 21:35:39 +01:00
parent cd8d1e0ed8
commit bfa85b9835
14 changed files with 96 additions and 54 deletions
@@ -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<string | null>(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<EditingCell>(null);
export const activeViewIdAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const activePropertyMenuAtom = atom<string | null>(null);
export const editingCellAtomFamily = atomFamily((_pageId: string) =>
atom<EditingCell>(null),
);
export const propertyMenuDirtyAtom = atom<boolean>(false);
export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const propertyMenuCloseRequestAtom = atom<number>(0);
export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) =>
atom<boolean>(false),
);
export const selectedRowIdsAtom = atom<Set<string>>(new Set<string>());
export const lastToggledRowIndexAtom = atom<number | null>(null);
export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) =>
atom<number>(0),
);
export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
atom<Set<string>>(new Set<string>()),
);
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
atom<number | null>(null),
);
@@ -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(() => {
@@ -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;
}
@@ -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(
@@ -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<string>,
(val: Set<string> | ((prev: Set<string>) => Set<string>)) => void,
];
const [lastToggledIndex, setLastToggledIndex] = useAtom(
lastToggledRowIndexAtom,
lastToggledRowIndexAtomFamily(pageId),
) as unknown as [number | null, (val: number | null) => void];
const isSelected = useCallback(