From a5937d5183e1a3b3212e9f4475ea6525db9a1890 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 31 Jul 2026 13:43:15 +0200 Subject: [PATCH] fix(widgets): bind pending table layouts --- .../common/use-persisted-table-layout.spec.ts | 83 +++++++++++++++++++ .../src/common/use-persisted-table-layout.ts | 47 +++++++---- 2 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 packages/widgets/src/common/use-persisted-table-layout.spec.ts diff --git a/packages/widgets/src/common/use-persisted-table-layout.spec.ts b/packages/widgets/src/common/use-persisted-table-layout.spec.ts new file mode 100644 index 000000000..0e1297b88 --- /dev/null +++ b/packages/widgets/src/common/use-persisted-table-layout.spec.ts @@ -0,0 +1,83 @@ +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; + +import type { Root } from "react-dom/client"; +import type { DataTableColumn } from "mantine-datatable"; + +const tableState = vi.hoisted(() => ({ + columnsOrder: ["name", "state"], + columnsWidth: [] as Record[], +})); + +vi.mock("mantine-datatable", () => ({ + useDataTableColumns: ({ columns }: { columns: DataTableColumn[] }) => ({ + effectiveColumns: columns, + columnsOrder: tableState.columnsOrder, + columnsWidth: tableState.columnsWidth, + setColumnsOrder: vi.fn(), + setMultipleColumnWidths: vi.fn(), + }), +})); + +import { usePersistedTableLayout } from "./use-persisted-table-layout"; + +const columns: DataTableColumn<{ name: string; state: string }>[] = [{ accessor: "name" }, { accessor: "state" }]; +const columnAccessors = ["name", "state"] as const; + +const LayoutHarness = ({ itemId, onLayoutChange }: { itemId: string; onLayoutChange: (layout: unknown) => void }) => { + usePersistedTableLayout({ + columns, + columnAccessors, + columnOrder: "", + columnWidths: "", + itemId, + storeKeyPrefix: "test-table", + onLayoutChange, + }); + return null; +}; + +describe("usePersistedTableLayout", () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true }); + }); + + beforeEach(() => { + vi.useFakeTimers(); + tableState.columnsOrder = ["name", "state"]; + tableState.columnsWidth = []; + container = document.createElement("div"); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + vi.useRealTimers(); + }); + + test("flushes a pending layout through the callback for its originating item", async () => { + const firstItemLayoutChange = vi.fn(); + const secondItemLayoutChange = vi.fn(); + + await act(async () => { + root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange })); + }); + + tableState.columnsOrder = ["state", "name"]; + await act(async () => { + root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange })); + }); + expect(firstItemLayoutChange).not.toHaveBeenCalled(); + + await act(async () => { + root.render(createElement(LayoutHarness, { itemId: "second", onLayoutChange: secondItemLayoutChange })); + }); + + expect(firstItemLayoutChange).toHaveBeenCalledWith({ columnOrder: JSON.stringify(["state", "name"]) }); + expect(secondItemLayoutChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/widgets/src/common/use-persisted-table-layout.ts b/packages/widgets/src/common/use-persisted-table-layout.ts index c9961bba5..9a126f076 100644 --- a/packages/widgets/src/common/use-persisted-table-layout.ts +++ b/packages/widgets/src/common/use-persisted-table-layout.ts @@ -9,6 +9,12 @@ interface TableLayoutOptions extends Record { columnWidths?: string; } +interface PendingTableLayout { + layout: TableLayoutOptions; + onLayoutChange: (layout: TableLayoutOptions) => void; + storeKey: string; +} + interface UseTableLayoutPersistenceProps { boardId: string | undefined; hasChangeAccess: boolean; @@ -168,25 +174,19 @@ export const usePersistedTableLayout = ({ visibleAccessorSet, ]); - const pendingLayout = useRef({}); + const pendingLayout = useRef(undefined); const saveTimeout = useRef(undefined); - const onLayoutChangeRef = useRef(onLayoutChange); - useEffect(() => { - onLayoutChangeRef.current = onLayoutChange; - }, [onLayoutChange]); const flushPendingLayout = useCallback(() => { - const layout = pendingLayout.current; - pendingLayout.current = {}; + const pending = pendingLayout.current; + pendingLayout.current = undefined; + window.clearTimeout(saveTimeout.current); saveTimeout.current = undefined; - if (Object.keys(layout).length > 0) onLayoutChangeRef.current(layout); + if (pending) pending.onLayoutChange(pending.layout); }, []); - useEffect( - () => () => { - window.clearTimeout(saveTimeout.current); - flushPendingLayout(); - }, - [flushPendingLayout], - ); + useEffect(() => { + if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout(); + }, [flushPendingLayout, storeKey]); + useEffect(() => () => flushPendingLayout(), [flushPendingLayout]); useEffect(() => { if (!hydrated.current) return; @@ -211,10 +211,23 @@ export const usePersistedTableLayout = ({ newLayout.columnWidths = JSON.stringify(widthMap); } - pendingLayout.current = { ...pendingLayout.current, ...newLayout }; + if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout(); + pendingLayout.current = pendingLayout.current + ? { ...pendingLayout.current, layout: { ...pendingLayout.current.layout, ...newLayout } } + : { layout: newLayout, onLayoutChange, storeKey }; window.clearTimeout(saveTimeout.current); saveTimeout.current = window.setTimeout(flushPendingLayout, layoutSaveDelay); - }, [columnAccessors, columnsOrder, columnsWidth, flushPendingLayout, savedOrder, savedWidths, visibleAccessorSet]); + }, [ + columnAccessors, + columnsOrder, + columnsWidth, + flushPendingLayout, + onLayoutChange, + savedOrder, + savedWidths, + storeKey, + visibleAccessorSet, + ]); return { effectiveColumns, storeKey }; };