diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 457a04d29..8eeb59007 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -140,6 +140,14 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr pointer-events: none; } +body[data-board-grid-interacting] .board-grid-resize-handle { + pointer-events: none; +} + +body[data-board-grid-interacting] .board-grid-resize-handle::after { + opacity: 0; +} + .board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] { outline: 2px solid var(--mantine-primary-color-filled); outline-offset: 4px; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index ff186c583..6aefc96c2 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -1,7 +1,18 @@ "use client"; import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react"; -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import @@ -47,7 +58,10 @@ import { previewGridResize, } from "./dnd"; import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd"; +import type { DndGridEntryProps } from "./grid-entry-memo"; +import { areDndGridEntryPropsEqual } from "./grid-entry-memo"; import { getGridDepth } from "./grid-depth"; +import { createGridInteractionStore } from "./grid-interaction-store"; import { useBoardGridPortalHost } from "./grid-portal-host"; import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions"; import { useGridLayoutActions } from "./use-grid-layout-actions"; @@ -124,8 +138,7 @@ interface ResizePreviewInput { minHeight: number; } -interface BoardGridEditorContextValue { - interaction: GridInteraction | null; +interface BoardGridEditorActionsContextValue { registerGrid: (grid: RegisteredGrid) => () => void; getDepth: (gridId: string) => number; isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean; @@ -136,14 +149,35 @@ interface BoardGridEditorContextValue { cancelResize: () => void; } -const BoardGridEditorContext = createContext(null); +const BoardGridEditorActionsContext = createContext(null); +const BoardGridEditorInteractionContext = createContext +> | null>(null); -const useBoardGridEditor = () => { - const value = useContext(BoardGridEditorContext); +const useBoardGridEditorActions = () => { + const value = useContext(BoardGridEditorActionsContext); if (!value) throw new Error("BoardGridEditorProvider is required"); return value; }; +const useBoardGridEditorInteractionStore = () => { + const store = useContext(BoardGridEditorInteractionContext); + if (!store) throw new Error("BoardGridEditorProvider is required"); + return store; +}; + +const useBoardGridEditorInteraction = () => { + const store = useBoardGridEditorInteractionStore(); + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +}; + +const useBoardGridEditorGridInteraction = (sectionId: string) => { + const store = useBoardGridEditorInteractionStore(); + const subscribe = useCallback((listener: () => void) => store.subscribeGrid(sectionId, listener), [sectionId, store]); + const getSnapshot = useCallback(() => store.getGridSnapshot(sectionId), [sectionId, store]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; + /** One provider coordinates every root and nested grid on the board. */ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const board = useRequiredBoard(); @@ -155,7 +189,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const activeRef = useRef(null); const renderedInteractionRef = useRef(null); const collisionFrameRef = useRef(0); - const [interaction, setInteraction] = useState(null); + const [interactionStore] = useState(() => createGridInteractionStore()); const [overlayZoom, setOverlayZoom] = useState(1); const parentGridById = useMemo( () => @@ -176,21 +210,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { collisionFrameRef.current = 0; activeRef.current = null; renderedInteractionRef.current = null; - setInteraction(null); + interactionStore.publish(null); document.body.removeAttribute("data-board-grid-interacting"); - }, []); - - useLayoutEffect(() => { - const active = activeRef.current; - renderedInteractionRef.current = - interaction && - active && - interaction.mode === active.mode && - interaction.activeId === active.transaction.activeId && - interaction.sourceGridId === active.transaction.sourceGridId - ? interaction - : null; - }, [interaction]); + }, [interactionStore]); useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]); @@ -265,7 +287,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { previewRevision: 0, }; document.body.setAttribute("data-board-grid-interacting", mode); - setInteraction({ + interactionStore.publish({ activeId, sourceGridId, targetGridId: mode === "resize" ? sourceGridId : null, @@ -282,7 +304,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { return false; } }, - [announce, clearInteraction, getCurrentState, t], + [announce, clearInteraction, getCurrentState, interactionStore, t], ); const updateDragPreview = useCallback( @@ -297,7 +319,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const publishPreview = (preview: Omit) => { active.previewRevision += 1; - setInteraction({ ...preview, previewRevision: active.previewRevision }); + interactionStore.publish({ ...preview, previewRevision: active.previewRevision }); }; const showInvalidPreview = (targetGridId: string | null, previewKey: string) => { @@ -406,7 +428,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { valid: true, }); }, - [canAcceptDrop, isDropTargetEligible], + [canAcceptDrop, interactionStore, isDropTargetEligible], ); const commitActiveTransaction = useCallback( @@ -573,49 +595,52 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { [beginTransaction], ); - const applyResizePreview = useCallback((input: ResizePreviewInput) => { - const active = activeRef.current; - if (!active) return false; - active.mode = "resize"; - active.targetGridId = active.transaction.sourceGridId; + const applyResizePreview = useCallback( + (input: ResizePreviewInput) => { + const active = activeRef.current; + if (!active) return false; + active.mode = "resize"; + active.targetGridId = active.transaction.sourceGridId; - const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`; - if (active.lastPreviewKey === key) return active.valid; - active.lastPreviewKey = key; - const result = previewGridResize(active.transaction, input); - active.valid = result.accepted; - if (!result.accepted) { + const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`; + if (active.lastPreviewKey === key) return active.valid; + active.lastPreviewKey = key; + const result = previewGridResize(active.transaction, input); + active.valid = result.accepted; + if (!result.accepted) { + active.previewRevision += 1; + interactionStore.publish({ + activeId: active.transaction.activeId, + sourceGridId: active.transaction.sourceGridId, + targetGridId: active.transaction.sourceGridId, + targetPlacement: null, + state: cancelGridTransaction(active.transaction), + mode: "resize", + valid: false, + previewRevision: active.previewRevision, + }); + return false; + } + + active.transaction = result.transaction; active.previewRevision += 1; - setInteraction({ + interactionStore.publish({ activeId: active.transaction.activeId, sourceGridId: active.transaction.sourceGridId, targetGridId: active.transaction.sourceGridId, - targetPlacement: null, - state: cancelGridTransaction(active.transaction), + targetPlacement: + result.transaction.preview.grids + .find((grid) => grid.id === active.transaction.sourceGridId) + ?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null, + state: result.transaction.preview, mode: "resize", - valid: false, + valid: true, previewRevision: active.previewRevision, }); - return false; - } - - active.transaction = result.transaction; - active.previewRevision += 1; - setInteraction({ - activeId: active.transaction.activeId, - sourceGridId: active.transaction.sourceGridId, - targetGridId: active.transaction.sourceGridId, - targetPlacement: - result.transaction.preview.grids - .find((grid) => grid.id === active.transaction.sourceGridId) - ?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null, - state: result.transaction.preview, - mode: "resize", - valid: true, - previewRevision: active.previewRevision, - }); - return true; - }, []); + return true; + }, + [interactionStore], + ); const previewResize = useCallback( (input: ResizePreviewInput) => { @@ -626,9 +651,8 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { [applyResizePreview], ); - const value = useMemo( + const actions = useMemo( () => ({ - interaction, registerGrid, getDepth, isDropTargetEligible, @@ -643,13 +667,74 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { canAcceptDrop, cancelActiveInteraction, commitActiveTransaction, - interaction, isDropTargetEligible, previewResize, getDepth, registerGrid, ], ); + + const syncRenderedInteraction = useCallback((interaction: GridInteraction | null) => { + const active = activeRef.current; + renderedInteractionRef.current = + interaction && + active && + interaction.mode === active.mode && + interaction.activeId === active.transaction.activeId && + interaction.sourceGridId === active.transaction.sourceGridId + ? interaction + : null; + }, []); + + return ( + + + + {children} + + {(source) => { + const data = getGridEntryData(source); + return data ?
{data.label}
: null; + }} +
+ +
+
+
+ ); +}; + +const GridInteractionEffects = ({ + cancelActiveInteraction, + syncRenderedInteraction, +}: { + cancelActiveInteraction: () => void; + syncRenderedInteraction: (interaction: GridInteraction | null) => void; +}) => { + const interaction = useBoardGridEditorInteraction(); + + useLayoutEffect(() => syncRenderedInteraction(interaction), [interaction, syncRenderedInteraction]); + useEffect(() => { if (interaction?.mode !== "resize") return; const handleKeyDown = (event: KeyboardEvent) => { @@ -661,48 +746,17 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [cancelActiveInteraction, interaction?.mode]); - return ( - - - {children} - - {(source) => { - const data = getGridEntryData(source); - return data ?
{data.label}
: null; - }} -
- -
-
- ); + return ; }; -const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => { +const GridCollisionSynchronizer = ({ previewRevision }: { previewRevision: number | null }) => { const manager = useDragDropManager(); - const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null; useLayoutEffect(() => { - if (!geometryKey || !manager) return; + if (previewRevision === null || !manager) return; for (const target of manager.registry.droppables.value) target.refreshShape(); manager.collisionObserver.forceUpdate(true); - }, [geometryKey, manager]); + }, [manager, previewRevision]); return null; }; @@ -718,7 +772,8 @@ export default function GridEditor({ }: GridEditorProps) { const t = useI18n(); const { section, items, innerSections } = useSectionContext(); - const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor(); + const interaction = useBoardGridEditorGridInteraction(sectionId); + const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions(); const gridRef = useRef(null); const parentGridId = "parentSectionId" in section ? section.parentSectionId : null; const ownerPlacementId = "parentSectionId" in section ? section.id : null; @@ -738,9 +793,17 @@ export default function GridEditor({ } return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId); }, [controlledById, interaction, placements, previewGrid, sectionId]); - const entryById = useMemo( - () => new Map([...items, ...innerSections].map((entry) => [entry.id, entry])), - [innerSections, items], + const labelById = useMemo( + () => + new Map( + [...items, ...innerSections].map((entry) => [ + entry.id, + entry.type === "item" + ? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`) + : entry.options.title.trim() || t("section.container.untitled"), + ]), + ), + [innerSections, items, t], ); const targetPlacement = interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null; @@ -819,20 +882,15 @@ export default function GridEditor({ } > {renderPlacements.map((placement) => { - const entry = entryById.get(placement.id); - const label = entry - ? entry.type === "item" - ? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`) - : entry.options.title.trim() || t("section.container.untitled") - : placement.id; return ( ); })} @@ -859,17 +917,16 @@ export default function GridEditor({ ); } -interface DndGridEntryProps { - sectionId: string; - placement: SectionGridPlacement; - label: string; - columnCount: number; - maxRowCount: number | null; -} - -const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: DndGridEntryProps) => { +const DndGridEntryComponent = ({ + sectionId, + placement, + label, + columnCount, + maxRowCount, + isActive, +}: DndGridEntryProps) => { const { acquireContainer } = useBoardGridPortalHost(); - const { interaction, beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditor(); + const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions(); const [isSectionChromeActive, setIsSectionChromeActive] = useState(false); const [continuousResize, setContinuousResize] = useState<{ placement: SectionGridPlacement; @@ -963,7 +1020,6 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true }); }, [placement.type]); - const isActive = interaction?.activeId === placement.id; const style = getLogicalItemStyle(placement) as CSSProperties; const continuousResizeStyle = continuousResize && isActive @@ -1004,7 +1060,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: sectionId={sectionId} placement={placement} label={label} - disabled={interaction !== null || (placement.type === "section" && !isSectionChromeActive)} + disabled={placement.type === "section" && !isSectionChromeActive} columnCount={columnCount} maxRowCount={maxRowCount} beginResize={beginResize} @@ -1017,6 +1073,8 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: ); }; +const DndGridEntry = memo(DndGridEntryComponent, areDndGridEntryPropsEqual); + interface GridResizeHandlesProps { sectionId: string; placement: SectionGridPlacement; @@ -1024,10 +1082,10 @@ interface GridResizeHandlesProps { disabled: boolean; columnCount: number; maxRowCount: number | null; - beginResize: BoardGridEditorContextValue["beginResize"]; - previewResize: BoardGridEditorContextValue["previewResize"]; - completeResize: BoardGridEditorContextValue["completeResize"]; - cancelResize: BoardGridEditorContextValue["cancelResize"]; + beginResize: BoardGridEditorActionsContextValue["beginResize"]; + previewResize: BoardGridEditorActionsContextValue["previewResize"]; + completeResize: BoardGridEditorActionsContextValue["completeResize"]; + cancelResize: BoardGridEditorActionsContextValue["cancelResize"]; setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void; } @@ -1355,18 +1413,3 @@ const getGridTargetData = (target: { data: Record }): GridTarge const data = target.data as Partial; return data.kind === GRID_TARGET_TYPE && data.sectionId ? (data as GridTargetData) : null; }; - -const getGridGeometryKey = (state: TransactionalGridState) => - JSON.stringify( - state.grids - .map((grid) => ({ - id: grid.id, - columnCount: grid.columnCount, - maxRowCount: grid.maxRowCount, - parentGridId: grid.parentGridId, - placements: grid.placements - .map(({ id, x, y, w, h }) => ({ id, x, y, w, h })) - .toSorted((first, second) => first.id.localeCompare(second.id)), - })) - .toSorted((first, second) => first.id.localeCompare(second.id)), - ); diff --git a/apps/nextjs/src/components/board/sections/grid/grid-entry-memo.ts b/apps/nextjs/src/components/board/sections/grid/grid-entry-memo.ts new file mode 100644 index 000000000..063e2023c --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/grid-entry-memo.ts @@ -0,0 +1,25 @@ +import type { SectionGridPlacement } from "./use-grid-layout-actions"; + +export interface DndGridEntryProps { + sectionId: string; + placement: SectionGridPlacement; + label: string; + columnCount: number; + maxRowCount: number | null; + isActive: boolean; +} + +export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) => + previous.sectionId === next.sectionId && + previous.label === next.label && + previous.columnCount === next.columnCount && + previous.maxRowCount === next.maxRowCount && + previous.isActive === next.isActive && + previous.placement.id === next.placement.id && + previous.placement.type === next.placement.type && + previous.placement.x === next.placement.x && + previous.placement.y === next.placement.y && + previous.placement.w === next.placement.w && + previous.placement.h === next.placement.h && + previous.placement.minW === next.placement.minW && + previous.placement.minH === next.placement.minH; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-interaction-store.ts b/apps/nextjs/src/components/board/sections/grid/grid-interaction-store.ts new file mode 100644 index 000000000..60ab829f1 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/grid-interaction-store.ts @@ -0,0 +1,58 @@ +interface GridScopedInteraction { + sourceGridId: string; + targetGridId: string | null; +} + +export interface GridInteractionStore { + getSnapshot: () => TInteraction | null; + getGridSnapshot: (sectionId: string) => TInteraction | null; + publish: (interaction: TInteraction | null) => void; + subscribe: (listener: () => void) => () => void; + subscribeGrid: (sectionId: string, listener: () => void) => () => void; +} + +const getAffectedGridIds = (interaction: GridScopedInteraction | null) => { + const ids = new Set(); + if (interaction) { + ids.add(interaction.sourceGridId); + if (interaction.targetGridId) ids.add(interaction.targetGridId); + } + return ids; +}; + +export const createGridInteractionStore = < + TInteraction extends GridScopedInteraction, +>(): GridInteractionStore => { + let snapshot: TInteraction | null = null; + const listeners = new Set<() => void>(); + const gridListeners = new Map void>>(); + + return { + getSnapshot: () => snapshot, + getGridSnapshot: (sectionId) => + snapshot && (snapshot.sourceGridId === sectionId || snapshot.targetGridId === sectionId) ? snapshot : null, + publish: (interaction) => { + if (Object.is(snapshot, interaction)) return; + const affectedGridIds = getAffectedGridIds(snapshot); + for (const sectionId of getAffectedGridIds(interaction)) affectedGridIds.add(sectionId); + snapshot = interaction; + for (const listener of listeners) listener(); + for (const sectionId of affectedGridIds) { + for (const listener of gridListeners.get(sectionId) ?? []) listener(); + } + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + subscribeGrid: (sectionId, listener) => { + const sectionListeners = gridListeners.get(sectionId) ?? new Set<() => void>(); + sectionListeners.add(listener); + gridListeners.set(sectionId, sectionListeners); + return () => { + sectionListeners.delete(listener); + if (sectionListeners.size === 0) gridListeners.delete(sectionId); + }; + }, + }; +}; diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-entry-memo.spec.tsx b/apps/nextjs/src/components/board/sections/grid/test/grid-entry-memo.spec.tsx new file mode 100644 index 000000000..7d783a58f --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-entry-memo.spec.tsx @@ -0,0 +1,106 @@ +import { act, createElement, memo } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { DndGridEntryProps } from "../grid-entry-memo"; +import { areDndGridEntryPropsEqual } from "../grid-entry-memo"; + +describe("grid entry memoization", () => { + let container: HTMLDivElement; + let root: Root; + let renders: number; + + const Probe = memo((props: DndGridEntryProps) => { + renders += 1; + return createElement("span", { "data-x": props.placement.x }); + }, areDndGridEntryPropsEqual); + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + renders = 0; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + test("skips cloned unchanged preview placements and renders changed entries", () => { + const props = createProps(); + + render(props); + expect(renders).toBe(1); + + render({ ...props, placement: { ...props.placement } }); + expect(renders).toBe(1); + + render({ ...props, placement: { ...props.placement, x: 2 } }); + expect(renders).toBe(2); + }); + + test("rerenders only the changed entry on a 100 item preview", () => { + const entries = Array.from({ length: 100 }, (_, index) => ({ + ...createProps(), + placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) }, + })); + + renderBoard(entries); + expect(renders).toBe(100); + + renderBoard(entries.map((entry) => ({ ...entry, placement: { ...entry.placement } }))); + expect(renders).toBe(100); + + renderBoard( + entries.map((entry, index) => + index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 1 } } : entry, + ), + ); + expect(renders).toBe(101); + }); + + test.each([ + ["active state", (props: DndGridEntryProps) => ({ ...props, isActive: true })], + ["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })], + ["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })], + [ + "minimum size", + (props: DndGridEntryProps) => ({ + ...props, + placement: { ...props.placement, minW: 2 }, + }), + ], + ])("renders when %s changes", (_name, update) => { + const props = createProps(); + render(props); + render(update(props)); + expect(renders).toBe(2); + }); + + const render = (props: DndGridEntryProps) => { + act(() => root.render(createElement(Probe, props))); + }; + + const renderBoard = (entries: DndGridEntryProps[]) => { + act(() => + root.render( + createElement( + "div", + null, + entries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })), + ), + ), + ); + }; +}); + +const createProps = (): DndGridEntryProps => ({ + sectionId: "root", + placement: { id: "weather", type: "item", x: 0, y: 0, w: 1, h: 1 }, + label: "Weather", + columnCount: 12, + maxRowCount: null, + isActive: false, +}); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-interaction-store.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-interaction-store.spec.ts new file mode 100644 index 000000000..a93ceb0be --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-interaction-store.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createGridInteractionStore } from "../grid-interaction-store"; + +describe("grid interaction store", () => { + test("notifies only source, current target and previous target grids", () => { + const store = createGridInteractionStore(); + const globalListener = vi.fn(); + const sourceListener = vi.fn(); + const firstTargetListener = vi.fn(); + const secondTargetListener = vi.fn(); + const unrelatedListener = vi.fn(); + + store.subscribe(globalListener); + store.subscribeGrid("source", sourceListener); + store.subscribeGrid("target-a", firstTargetListener); + store.subscribeGrid("target-b", secondTargetListener); + store.subscribeGrid("unrelated", unrelatedListener); + + store.publish(createInteraction("target-a", 1)); + expect(sourceListener).toHaveBeenCalledTimes(1); + expect(firstTargetListener).toHaveBeenCalledTimes(1); + expect(secondTargetListener).not.toHaveBeenCalled(); + expect(unrelatedListener).not.toHaveBeenCalled(); + expect(store.getGridSnapshot("target-a")?.previewRevision).toBe(1); + + store.publish(createInteraction("target-b", 2)); + expect(sourceListener).toHaveBeenCalledTimes(2); + expect(firstTargetListener).toHaveBeenCalledTimes(2); + expect(secondTargetListener).toHaveBeenCalledTimes(1); + expect(unrelatedListener).not.toHaveBeenCalled(); + expect(store.getGridSnapshot("target-a")).toBeNull(); + expect(store.getGridSnapshot("target-b")?.previewRevision).toBe(2); + + store.publish(null); + expect(sourceListener).toHaveBeenCalledTimes(3); + expect(firstTargetListener).toHaveBeenCalledTimes(2); + expect(secondTargetListener).toHaveBeenCalledTimes(2); + expect(unrelatedListener).not.toHaveBeenCalled(); + expect(globalListener).toHaveBeenCalledTimes(3); + }); +}); + +interface TestInteraction { + sourceGridId: "source"; + targetGridId: string; + previewRevision: number; +} + +const createInteraction = (targetGridId: string, previewRevision: number): TestInteraction => ({ + sourceGridId: "source", + targetGridId, + previewRevision, +});