diff --git a/.changeset/react-cells-detached-remount.md b/.changeset/react-cells-detached-remount.md new file mode 100644 index 0000000000..75ea4a66ff --- /dev/null +++ b/.changeset/react-cells-detached-remount.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useCells - read current detached collection members when the hook remounts diff --git a/.changeset/react-cells-equality-update.md b/.changeset/react-cells-equality-update.md new file mode 100644 index 0000000000..b32026b92b --- /dev/null +++ b/.changeset/react-cells-equality-update.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useCells - refresh a cached selection when its equality function changes diff --git a/.changeset/react-cells-inline-selector.md b/.changeset/react-cells-inline-selector.md new file mode 100644 index 0000000000..fc12f1aafe --- /dev/null +++ b/.changeset/react-cells-inline-selector.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useCells - fix an inline selector that captures a changed prop returning a stale value until the next graph change diff --git a/.changeset/react-cells-target-membership.md b/.changeset/react-cells-target-membership.md new file mode 100644 index 0000000000..1a1dd6aed7 --- /dev/null +++ b/.changeset/react-cells-target-membership.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useCells - disambiguate target kinds and refresh same-id collection replacements diff --git a/.changeset/react-elements-measured-deprecate.md b/.changeset/react-elements-measured-deprecate.md new file mode 100644 index 0000000000..b9c946eed4 --- /dev/null +++ b/.changeset/react-elements-measured-deprecate.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useOnElementsMeasured - deprecate in favor of `useOnGraphStateChange()` with `selectGraphSizingVersion` diff --git a/.changeset/react-graph-state.md b/.changeset/react-graph-state.md new file mode 100644 index 0000000000..00dcd790ba --- /dev/null +++ b/.changeset/react-graph-state.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useGraphState - add the hook, with `useOnGraphStateChange()`, `useOnCellsChange()` and the `selectGraphSizingVersion` / `selectIsGraphSizingComplete` selectors diff --git a/.changeset/react-store-callbacks.md b/.changeset/react-store-callbacks.md new file mode 100644 index 0000000000..0103451a05 --- /dev/null +++ b/.changeset/react-store-callbacks.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + + - fix a store callback that throws or changes the graph breaking later updates diff --git a/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx b/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx index 112e9a3dcb..02a75b297a 100644 --- a/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx +++ b/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx @@ -46,7 +46,8 @@ import type * as ElementModelModule from '../../../mvc/element-model'; import type * as LinkModelModule from '../../../mvc/link-model'; import type * as UseGraphStoreModule from '../../../hooks/use-graph-store'; import type * as UseCellIdsModule from '../../../hooks/use-cell-ids'; -import type * as UseOnElementsMeasuredModule from '../../../hooks/use-on-elements-measured'; +import type * as UseOnGraphStateChangeModule from '../../../hooks/use-on-graph-state-change'; +import type * as SelectorsModule from '../../../selectors'; import type * as GraphProviderModule from '../graph-provider'; import type * as PaperModule from '../../paper/paper'; @@ -88,8 +89,9 @@ const linkModelModule: typeof LinkModelModule = require('../../../mvc/link-model const { useGraphStore }: typeof UseGraphStoreModule = require('../../../hooks/use-graph-store'); const { useCellIds }: typeof UseCellIdsModule = require('../../../hooks/use-cell-ids'); const { - useOnElementsMeasured, -}: typeof UseOnElementsMeasuredModule = require('../../../hooks/use-on-elements-measured'); + useOnGraphStateChange, +}: typeof UseOnGraphStateChangeModule = require('../../../hooks/use-on-graph-state-change'); +const { selectGraphSizingVersion }: typeof SelectorsModule = require('../../../selectors'); const graphProviderV1: typeof GraphProviderModule = require('../graph-provider'); const paperV1: typeof PaperModule = require('../../paper/paper'); @@ -191,8 +193,8 @@ function Probe() { mountSequence += 1; return mountSequence; }); - useOnElementsMeasured(({ isInitial }) => { - measuredCalls.push(isInitial); + useOnGraphStateChange(selectGraphSizingVersion, (version, previousSizingVersion) => { + if (version) measuredCalls.push(!previousSizingVersion); }); const ids = useCellIds(); return h( diff --git a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx index 934e8a2f7f..8224f2ae10 100644 --- a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx @@ -1,9 +1,4 @@ -import { - useLayoutEffect, - useMemo, - type ComponentType, - type CSSProperties, -} from 'react'; +import { useLayoutEffect, useMemo, type ComponentType, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import typedMemo from '../../../utils/typed-react'; import { useGraphStore } from '../../../hooks/use-graph-store'; diff --git a/packages/joint-react/src/hooks/__tests__/collection-target-regressions.test.tsx b/packages/joint-react/src/hooks/__tests__/collection-target-regressions.test.tsx new file mode 100644 index 0000000000..ec4f0184ca --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/collection-target-regressions.test.tsx @@ -0,0 +1,150 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { dia, mvc } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import type { ElementRecord } from '../../types/cell.types'; +import type { GraphState } from '../../store/graph-state'; +import { useCells } from '../use-cells'; +import { useGraphState } from '../use-graph-state'; +import { useOnCellsChange } from '../use-on-cells-change'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useGraphStore } from '../use-graph-store'; + +const element = (id: string, x: number): ElementRecord => ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, +}); +const INITIAL_CELLS = [ + element('a', 10), + element('["a"]', 80), + element('ids:["a"]', 120), + element('id:ids:["a"]', 160), +]; +function wrapper({ children }: Readonly<{ children: ReactNode }>) { + return {children}; +} +function selectX(source: unknown): number | undefined { + const cell: unknown = Array.isArray(source) ? source[0] : source; + if (!cell || typeof cell !== 'object' || !('position' in cell)) return undefined; + const { position } = cell; + if (!position || typeof position !== 'object' || !('x' in position)) return undefined; + return typeof position.x === 'number' ? position.x : undefined; +} +const flushMutation = (mutate: () => void) => act(async () => { + mutate(); + await new Promise((resolve) => queueMicrotask(resolve)); +}); + +it.each([false, true])('preserves the selector-free return shape when starting with array=%s', (arrayFirst) => { + const initialTarget: string | readonly string[] = arrayFirst ? ['a'] : '["a"]'; + const nextTarget: string | readonly string[] = arrayFirst ? '["a"]' : ['a']; + const { result, rerender } = renderHook( + ({ target }: { readonly target: string | readonly string[] }) => ({ + // @ts-expect-error Each target is valid; the overloads cannot express their JavaScript union. + value: useCells(target), + cells: useGraphStore().graphProjection.cells, + }), + { wrapper, initialProps: { target: initialTarget } } + ); + const arrayMember = result.current.cells.get('a'); + const scalarRecord = result.current.cells.get('["a"]'); + expect(Array.isArray(result.current.value)).toBe(arrayFirst); + expect(result.current.value).toEqual(arrayFirst ? [arrayMember] : scalarRecord); + + rerender({ target: nextTarget }); + + expect(Array.isArray(result.current.value)).toBe(!arrayFirst); + if (arrayFirst) expect(result.current.value).toBe(scalarRecord); + else expect(result.current.value).toEqual([arrayMember]); + const selected = result.current.value; + rerender({ target: nextTarget }); + expect(result.current.value).toBe(selected); +}); + +describe('serialized ids do not alias literal cell ids', () => { + it.each([ + { scalar: '["a"]', scalarX: 80, arrayFirst: false }, + { scalar: '["a"]', scalarX: 80, arrayFirst: true }, + { scalar: 'ids:["a"]', scalarX: 120, arrayFirst: false }, + { scalar: 'ids:["a"]', scalarX: 120, arrayFirst: true }, + { scalar: 'id:ids:["a"]', scalarX: 160, arrayFirst: false }, + { scalar: 'id:ids:["a"]', scalarX: 160, arrayFirst: true }, + ])('retargets $scalar with array first=$arrayFirst', async ({ scalar, scalarX, arrayFirst }) => { + const onChange = jest.fn(); + const initialTarget: string | readonly string[] = arrayFirst ? ['a'] : scalar; + const nextTarget: string | readonly string[] = arrayFirst ? scalar : ['a']; + const nextId = arrayFirst ? scalar : 'a'; + const oldId = arrayFirst ? 'a' : scalar; + const { result, rerender } = renderHook( + ({ target }: { readonly target: string | readonly string[] }) => { + // @ts-expect-error Each target is valid; existing overloads cannot express this union. + const value = useCells(target, selectX); + // @ts-expect-error Exercise the same mounted watcher with either valid JavaScript target. + useOnCellsChange(target, selectX, onChange); + return { value, graph: useGraphStore().graph }; + }, + { wrapper, initialProps: { target: initialTarget } } + ); + expect(result.current.value).toBe(arrayFirst ? 10 : scalarX); + onChange.mockClear(); + + rerender({ target: nextTarget }); + expect(result.current.value).toBe(arrayFirst ? scalarX : 10); + expect(onChange.mock.calls).toEqual([[arrayFirst ? scalarX : 10]]); + onChange.mockClear(); + + await flushMutation(() => result.current.graph.getCell(oldId).set('position', { x: 99, y: 0 })); + expect(onChange).not.toHaveBeenCalled(); + await flushMutation(() => result.current.graph.getCell(nextId).set('position', { x: 45, y: 0 })); + expect(result.current.value).toBe(45); + expect(onChange.mock.calls).toEqual([[45, arrayFirst ? scalarX : 10]]); + onChange.mockClear(); + rerender({ target: arrayFirst ? scalar : ['a'] }); + expect(onChange).not.toHaveBeenCalled(); + expect(result.current.value).toBe(45); + }); +}); + +describe('same-id collection model replacement', () => { + it.each([false, true])('reads replacement models while graph-bound=%s', async (bound) => { + const id = bound ? 'a' : 'detached'; + const first = new dia.Element(element(id, 10)); + const collection = new mvc.Collection([first]); + const onCellsChange = jest.fn(); + const onStateChange = jest.fn(); + const selectCollection = (state: GraphState) => state.getCells(collection); + const selectCollectionX = (state: GraphState) => selectX(state.getCells(collection)); + const { result, rerender } = renderHook(() => { + const cells = useCells(collection); + const stateCells = useGraphState(selectCollection); + useOnCellsChange(collection, selectX, onCellsChange); + useOnGraphStateChange(selectCollectionX, onStateChange); + return { cells, stateCells }; + }, { wrapper }); + expect(selectX(result.current.cells)).toBe(10); + expect(selectX(result.current.stateCells)).toBe(10); + onCellsChange.mockClear(); + onStateChange.mockClear(); + + await flushMutation(() => collection.reset([new dia.Element(element(id, 80))])); + + expect(selectX(result.current.cells)).toBe(bound ? 10 : 80); + expect(selectX(result.current.stateCells)).toBe(bound ? 10 : 80); + expect(onCellsChange.mock.calls).toEqual(bound ? [] : [[80, 10]]); + expect(onStateChange.mock.calls).toEqual(bound ? [] : [[80, 10]]); + const previousCells = result.current.cells; + const previousStateCells = result.current.stateCells; + onCellsChange.mockClear(); + onStateChange.mockClear(); + + rerender(); + + expect(result.current.cells).toBe(previousCells); + expect(result.current.stateCells).toBe(previousStateCells); + expect(onCellsChange).not.toHaveBeenCalled(); + expect(onStateChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/graph-state-cache-regressions.test.tsx b/packages/joint-react/src/hooks/__tests__/graph-state-cache-regressions.test.tsx new file mode 100644 index 0000000000..b8e087718c --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/graph-state-cache-regressions.test.tsx @@ -0,0 +1,132 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { dia, mvc } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import type { ElementRecord } from '../../types/cell.types'; +import type { GraphState, SelectedEqual } from '../../store/graph-state'; +import { useGraphState } from '../use-graph-state'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useOnCellsChange } from '../use-on-cells-change'; +import { useGraphStore } from '../use-graph-store'; + +const element = (id: string): ElementRecord => ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 10, height: 10 }, +}); +const INITIAL_CELLS = [element('a'), element('b')]; +function wrapper({ children }: Readonly<{ children: ReactNode }>) { + return {children}; +} +const commit = (mutate: () => void) => + act(async () => { + mutate(); + await new Promise((resolve) => queueMicrotask(resolve)); + }); +const selectCount = (state: GraphState) => state.cells.length; +const selectCellsCount = (cells: readonly unknown[]) => cells.length; +const alwaysEqual: SelectedEqual = () => true; +const selectNaN = (state: GraphState) => Number.NaN + state.cells.length; +const selectCellsNaN = (cells: readonly unknown[]) => Number.NaN + cells.length; +const selectSignedZero = (state: GraphState) => (state.cells.length === 2 ? 0 : -0); +const selectCellsSignedZero = (cells: readonly unknown[]) => (cells.length === 2 ? 0 : -0); + +type Selection = 'nan' | 'zero' | 'count'; +type Callback = (value: number, previous?: number) => void; +const GRAPH_SELECTORS = { nan: selectNaN, zero: selectSignedZero, count: selectCount }; +const CELL_SELECTORS = { nan: selectCellsNaN, zero: selectCellsSignedZero, count: selectCellsCount }; +function useGraphSelection(kind: Selection, callback: Callback, equal?: SelectedEqual) { + useOnGraphStateChange(GRAPH_SELECTORS[kind], callback, equal); +} +function useCellsSelection(kind: Selection, callback: Callback, equal?: SelectedEqual) { + useOnCellsChange(CELL_SELECTORS[kind], callback, equal); +} + +for (const hook of ['graph', 'cells'] as const) { + const useSelection = hook === 'graph' ? useGraphSelection : useCellsSelection; + describe(`${hook} callback selected-value identity`, () => { + it('does not deliver unchanged NaN selections after a watched source changes', async () => { + const onChange = jest.fn(); + const { result } = renderHook( + () => { + useSelection('nan', onChange); + return useGraphStore().graph; + }, + { wrapper } + ); + onChange.mockClear(); + + await commit(() => result.current.addCell(element('c'))); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('delivers the change from positive zero to negative zero', async () => { + const onChange = jest.fn(); + const { result } = renderHook( + () => { + useSelection('zero', onChange); + return useGraphStore().graph; + }, + { wrapper } + ); + onChange.mockClear(); + + await commit(() => result.current.addCell(element('c'))); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toBe(-0); + expect(onChange.mock.calls[0][1]).toBe(0); + }); + + it('rechecks a stable selector when only its equality function changes', async () => { + const onChange = jest.fn(); + const { result, rerender } = renderHook( + ({ equal }: { readonly equal: SelectedEqual }) => { + useSelection('count', onChange, equal); + return useGraphStore().graph; + }, + { wrapper, initialProps: { equal: alwaysEqual } } + ); + onChange.mockClear(); + await commit(() => result.current.addCell(element('c'))); + expect(onChange).not.toHaveBeenCalled(); + + rerender({ equal: Object.is }); + + expect(onChange.mock.calls).toEqual([[3, 2]]); + }); + }); +} + +it('useGraphState refreshes a cached selection when only its equality changes', async () => { + const { result, rerender } = renderHook( + ({ equal }: { readonly equal: SelectedEqual }) => ({ + value: useGraphState(selectCount, equal), + graph: useGraphStore().graph, + }), + { wrapper, initialProps: { equal: alwaysEqual } } + ); + await commit(() => result.current.graph.addCell(element('c'))); + expect(result.current.value).toBe(2); + + rerender({ equal: Object.is }); + + expect(result.current.value).toBe(3); +}); + +it('a fresh hook reads current detached model attributes after an earlier hook unmounts', () => { + const model = new dia.Element({ ...element('detached'), position: { x: 10, y: 0 } }); + const collection = new mvc.Collection([model]); + const selectDetachedX = (state: GraphState) => state.getCells(collection)[0]?.position?.x; + const first = renderHook(() => useGraphState(selectDetachedX), { wrapper }); + expect(first.result.current).toBe(10); + first.unmount(); + model.position(80, 0); + + const second = renderHook(() => useGraphState(selectDetachedX), { wrapper }); + + expect(second.result.current).toBe(80); +}); diff --git a/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx new file mode 100644 index 0000000000..b36c61a342 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx @@ -0,0 +1,409 @@ +/** + * Specification for when the measurement version (`selectGraphSizingVersion`) + * changes, written as the events a `useOnGraphStateChange` subscriber receives. It + * was written for the former `useOnElementsMeasured` hook; the cases and their + * expectations are unchanged. + * + * The hook exists so an application can run a layout once element sizes are + * known. That only works if one settled change delivers exactly one event: a + * layout must not run while an element is still waiting to be measured, and it + * must not run several times for the same change. + * + * An element is pending only when something is actually going to measure it. + * Here that is real: a `plain()` element renders as an SVG `` and nothing + * ever measures it, so it is settled the moment it is added, whatever its size. + * A `pending()` element renders through ``, which calls + * `useMeasureElement` and registers the node with the store's observer, so it + * stays outstanding until a measured size arrives. `measured()` writes that + * size with the `autoSize` option, exactly as the observer pipeline does — + * jsdom's `ResizeObserver` is a no-op mock, so it never reports on its own. + * + * The probe is mounted beside ``, not inside `renderElement`, so exactly + * one instance of the hook is under test. Mounting it inside `renderElement` + * would create one instance per element and count their separate events. + */ +import { render, waitFor, act } from '@testing-library/react'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { Paper } from '../../components/paper/paper'; +import { HTMLHost } from '../../components/html-host'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../../store/measurement'; +import { selectGraphSizingVersion } from '../../selectors'; +import type { CellRecord } from '../../types/cell.types'; +import type { dia } from '@joint/core'; + +const PAPER_ID = 'events-paper'; +const PAPER_STYLE = { width: 100, height: 100 }; + +/** + * Lets everything settle: React effects, the scheduler's microtask batch, the + * paper's render frame (`requestAnimationFrame`) in which a newly added + * element's view mounts, and the commit after it in which the element's portal + * content mounts and registers with the observer. A microtask, or a plain + * `setTimeout`, lands before that frame, while the portal does not exist yet. + */ +const flush = () => + act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +/** Elements flagged in their `data` render through a host that measures itself. */ +const renderElement = ({ measured }: { measured?: boolean }) => + measured ? node : ; + +/** Renders as a plain ``: nothing measures it, so it is settled on arrival. */ +const plain = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 50, height: 50 }, + data: {}, + }) as CellRecord; + +/** Renders through ``: registers for measurement and waits for a size. */ +const pending = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + data: { measured: true }, + }) as CellRecord; + +/** + * Zero-sized on purpose and never measured: a layout anchor, the shape a + * `scalable`-free diagram uses to mark a position without drawing anything. + * Its size is its real size, not a size it is waiting for. + */ +const anchor = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 0, height: 0 }, + data: {}, + }) as CellRecord; + +/** One delivered event, reduced to what these tests assert on. */ +interface RecordedEvent { + readonly isInitial: boolean; +} + +interface Harness { + readonly graph: dia.Graph; + readonly events: RecordedEvent[]; +} + +/** Renders a graph with one hook instance mounted beside the paper. */ +function renderGraph(initialCells: CellRecord[]): Harness { + const events: RecordedEvent[] = []; + let graph: dia.Graph | undefined; + + function Probe() { + const { graph: currentGraph } = useGraphStore(); + graph = currentGraph; + // An event is a change to a non-zero version; it is the initial one when + // the version before it was `0` (nothing measured) or the hook just mounted. + useOnGraphStateChange(selectGraphSizingVersion, (version, previousSizingVersion) => { + if (version) events.push({ isInitial: !previousSizingVersion }); + }); + return null; + } + + render( + + + + + ); + + return { graph: graph as dia.Graph, events }; +} + +/** The write the measurement pipeline makes once a node has been measured. */ +function reportMeasurement(graph: dia.Graph, ...ids: string[]) { + act(() => { + for (const id of ids) { + (graph.getCell(id) as dia.Element).set('size', { width: 120, height: 40 }, { + [AUTO_SIZE_OPTION]: true, + } as object); + } + }); +} + +/** Waits for the seed pass, then clears it so a test counts only its own events. */ +async function settleAndClear(harness: Harness) { + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + harness.events.length = 0; +} + +describe('selectGraphSizingVersion — one event per settled change', () => { + it('delivers one event for the seed pass', async () => { + const harness = renderGraph([plain('a')]); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('delivers one event when an element nothing measures is added', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(plain('b') as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers no event while an added element is waiting to be measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + }); + + it('delivers one event once the added element has been measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + reportMeasurement(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers one event for two added elements that nothing measures', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), plain('c')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers one event for two added elements that both wait to be measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([pending('b'), pending('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b', 'c'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + // The mixed diagram: one element is settled on arrival, the other is not. + // The event belongs to the batch, not to either element, so it waits for the + // one that is still outstanding and then fires once. + it('delivers one event for a batch mixing a plain element with one that waits', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), pending('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'c'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('reports isInitial on the first event only', async () => { + const harness = renderGraph([plain('a')]); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + reportMeasurement(harness.graph, 'b'); + await flush(); + + act(() => { + harness.graph.addCell(plain('c') as never); + }); + await flush(); + + const initial = harness.events.filter((event) => event.isInitial); + expect(initial).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); +}); + +describe('selectGraphSizingVersion — a graph reset starts a new measurement history', () => { + // Resetting the graph replaces the diagram, so the next pass is that + // diagram's first one: a consumer that fits the paper on `isInitial` has a + // new set of contents to fit. + it('reports isInitial again after the graph is reset', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([plain('x'), plain('y')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('reports isInitial after a reset, once the new elements are measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([pending('x'), pending('y')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'x', 'y'); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('reports isInitial once per reset, not on later changes', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([plain('x')] as never); + }); + await flush(); + act(() => { + harness.graph.addCell(plain('y') as never); + }); + await flush(); + + const initial = harness.events.filter((event) => event.isInitial); + expect(initial).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); +}); + +// A size written by the application never produces an event. Either the +// element is settled already, in which case nothing about readiness changed, or +// it is waiting to be measured, in which case the measurement is still owed and +// will overwrite the write anyway. +describe('selectGraphSizingVersion — sizes written by the application', () => { + // #3514: a layout that resizes cells must not re-enter its own callback. + // Nothing was outstanding before the write and nothing is after it. + it('delivers no event when the application resizes an element nothing measures', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + (harness.graph.getCell('a') as dia.Element).resize(70, 70); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + }); + + // A resize does not discharge a pending measurement. The element is still + // registered, the measured size will overwrite this one, and the library + // already warns about it (`warnResizeOnAutoSizedElement`). So the element is + // outstanding until it is measured, whatever size it happens to hold: what + // the hook waits on is the measurement, not the presence of a size. + it('delivers no event when the application sizes an element that is waiting', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + act(() => { + // A size other than the measured one: writing the measured size itself + // would be a no-op `set()` in JointJS, with no `change:size` for anything + // to observe (the browser's ResizeObserver covers that case instead). + (harness.graph.getCell('b') as dia.Element).resize(70, 70); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); + +// An element can be zero-sized for good, rather than briefly on its way to a +// measurement. Nothing will ever give it a size, so treating it as outstanding +// holds every later batch open and the hook stops firing altogether. +describe('selectGraphSizingVersion — an element that stays zero-sized', () => { + it('delivers the seed pass with a zero-sized element in the graph', async () => { + const harness = renderGraph([plain('a'), anchor('anchor')]); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('delivers the batch that adds a zero-sized element', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), anchor('anchor')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('keeps delivering later batches once a zero-sized element is in the graph', async () => { + const harness = renderGraph([plain('a'), anchor('anchor')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx new file mode 100644 index 0000000000..a2cc3619ec --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx @@ -0,0 +1,332 @@ +/** + * Scenarios beyond the #3520 specification in `measurement-events`: + * an element the paper does not render, a waiting element that is removed, and + * a content change that re-measures. Same harness and helpers as the spec, + * except that `flush()` also awaits the paper's render frame, in which a newly + * added element's view mounts and its portal content registers a measurer. + */ +import { render, waitFor, act } from '@testing-library/react'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { Paper } from '../../components/paper/paper'; +import { HTMLHost } from '../../components/html-host'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useGraphStore } from '../use-graph-store'; +import { selectGraphSizingVersion } from '../../selectors'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import type { CellRecord } from '../../types/cell.types'; +import type { PaperProps } from '../../components/paper/paper.types'; +import type { dia } from '@joint/core'; + +const PAPER_ID = 'events-paper'; +const PAPER_STYLE = { width: 100, height: 100 }; + +/** + * Lets everything settle: React effects, the scheduler's microtask batch, the + * paper's render frame (`requestAnimationFrame`) in which a newly added + * element's view mounts, and the commit after it in which the element's portal + * content mounts and registers with the observer. A microtask, or a plain + * `setTimeout`, lands before that frame, while the portal does not exist yet. + */ +const flush = () => + act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +/** Elements flagged in their `data` render through a host that measures itself. */ +const renderElement = ({ measured, label = 'node' }: { measured?: boolean; label?: string }) => + measured ? {label} : ; + +/** Renders as a plain ``: nothing measures it, so it is settled on arrival. */ +const plain = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 50, height: 50 }, + data: {}, + }) as CellRecord; + +/** Renders through ``: registers for measurement and waits for a size. */ +const pending = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + data: { measured: true }, + }) as CellRecord; + +/** + * Zero-sized on purpose and never measured: a layout anchor, the shape a + * `scalable`-free diagram uses to mark a position without drawing anything. + * Its size is its real size, not a size it is waiting for. + */ +const anchor = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 0, height: 0 }, + data: {}, + }) as CellRecord; + +/** One delivered event, reduced to what these tests assert on. */ +interface RecordedEvent { + readonly isInitial: boolean; +} + +interface Harness { + readonly graph: dia.Graph; + readonly events: RecordedEvent[]; +} + +/** Renders a graph with one hook instance mounted beside the paper. */ +function renderGraph(initialCells: CellRecord[], paperProps: Partial = {}): Harness { + const events: RecordedEvent[] = []; + let graph: dia.Graph | undefined; + + function Probe() { + const { graph: currentGraph } = useGraphStore(); + graph = currentGraph; + // An event is a change to a non-zero version; it is the initial one when + // the version before it was `0` (nothing measured) or the hook just mounted. + useOnGraphStateChange(selectGraphSizingVersion, (version, previousSizingVersion) => { + if (version) events.push({ isInitial: !previousSizingVersion }); + }); + return null; + } + + render( + + + + + ); + + return { graph: graph as dia.Graph, events }; +} + +/** Waits for the seed pass, then clears it so a test counts only its own events. */ +async function settleAndClear(harness: Harness) { + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + harness.events.length = 0; +} + +// An element the paper never renders: culled by `cellVisibility` (or by the +// viewport). Nothing can measure it while it is unmounted, so it is settled +// with the size it has, whatever that is; when it mounts and registers a +// measurer, it becomes outstanding then. +const hideAnchor: PaperProps['cellVisibility'] = ({ model }) => model.id !== 'anchor'; + +describe('selectGraphSizingVersion — an element the paper does not render', () => { + it('delivers the seed pass with a culled zero-sized element in the graph', async () => { + const harness = renderGraph([plain('a'), anchor('anchor')], { cellVisibility: hideAnchor }); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + expect(harness.events).toEqual([{ isInitial: true }]); + }); + + it('delivers the batch that adds a culled zero-sized element', async () => { + const harness = renderGraph([plain('a')], { cellVisibility: hideAnchor }); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), anchor('anchor')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); + +// What else ends the wait: the waiting element leaves the graph. +describe('selectGraphSizingVersion — a waiting element is removed', () => { + it('delivers the batch once the waiting element is removed before it is measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), pending('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + act(() => { + harness.graph.getCell('c').remove(); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); + +// Regression: an element that stopped measuring before it was measured stayed +// "waiting" forever and held back every later event. +describe('selectGraphSizingVersion — a waiting element stops measuring', () => { + it('delivers the batch, and later changes, once nothing measures the element any more', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + // Its content switches to a plain shape: the measuring node unmounts. + act(() => { + harness.graph.getCell('b').set('data', {}); + }); + await flush(); + expect(harness.events).toHaveLength(1); + + act(() => { + harness.graph.addCell(plain('c') as never); + }); + await flush(); + expect(harness.events).toHaveLength(2); + }); +}); + +// A removal changes what a layout has to arrange, so it is a settled change too. +describe('selectGraphSizingVersion — a settled element is removed', () => { + it('delivers one event for the removal', async () => { + const harness = renderGraph([plain('a'), plain('b')]); + await settleAndClear(harness); + + act(() => { + harness.graph.getCell('b').remove(); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + }); +}); + +// The case the hook exists for in a live diagram: `renderElement` renders +// something else (a longer label, an expanded card), the node grows, the +// ResizeObserver reports the new size and the layout runs again. jsdom has no +// layout, so a local ResizeObserver mock delivers the entry the browser would. +describe('selectGraphSizingVersion — the content of an element changes', () => { + class TestResizeObserver { + static readonly instances: TestResizeObserver[] = []; + readonly observed = new Set(); + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + TestResizeObserver.instances.push(this); + } + observe(target: Element) { + this.observed.add(target); + } + unobserve(target: Element) { + this.observed.delete(target); + } + disconnect() { + this.observed.clear(); + } + /** What the browser reports after layout: the node's new border box. */ + report(target: Element, width: number, height: number) { + const entry = { target, borderBoxSize: [{ inlineSize: width, blockSize: height }] }; + this.callback([entry as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + } + } + + beforeEach(() => { + TestResizeObserver.instances.length = 0; + globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; + }); + + /** + * The observer holding the node `` registered. The paper observes + * its own host with another `ResizeObserver`, so the instance is found by + * the node: the measured one lives inside the element's `foreignObject`. + */ + function findMeasuredNode() { + for (const observer of TestResizeObserver.instances) { + for (const node of observer.observed) { + if (node.closest('foreignObject')) return { observer, node }; + } + } + throw new Error('no ResizeObserver has the measured node registered'); + } + + /** Mounts one measured element, lets it register, and measures it once. */ + async function mountMeasured() { + const harness = renderGraph([pending('b')]); + await flush(); + const { observer, node } = findMeasuredNode(); + + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + expect(harness.events).toEqual([{ isInitial: true }]); + harness.events.length = 0; + + const element = harness.graph.getCell('b') as dia.Element; + return { harness, observer, node, element }; + } + + it('delivers one event when the re-rendered content measures larger', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + // The application changes what `renderElement` shows; the browser lays the + // node out larger and the observer reports it. + act(() => { + element.set('data', { measured: true, label: 'a much longer label' }); + }); + await flush(); + expect(node.textContent).toBe('a much longer label'); + expect(harness.events).toHaveLength(0); + + act(() => { + observer.report(node, 240, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + expect(element.size()).toEqual({ width: 240, height: 40 }); + }); + + it('delivers no event when the re-rendered content measures the same', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + act(() => { + element.set('data', { measured: true, label: 'same size' }); + }); + await flush(); + expect(node.textContent).toBe('same size'); + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + expect(element.size()).toEqual({ width: 120, height: 40 }); + }); + + it('measures an element the application pre-sized to what it will measure', async () => { + const harness = renderGraph([pending('b')]); + await flush(); + const { observer, node } = findMeasuredNode(); + + act(() => { + (harness.graph.getCell('b') as dia.Element).resize(120, 40); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + // Same size as the model: the observer writes nothing, yet the element is + // measured now and the pass is delivered. + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: true }]); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx index 9eeb650a6f..598b0b8d8e 100644 --- a/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx @@ -1310,3 +1310,78 @@ describe('useCells (selector reactivity — no collection)', () => { expect(renderedIds).toEqual(['a', 'b']); }); }); + +describe('useCells — what a re-render changes', () => { + const element = (id: string | number, x: number): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, + }) as CellRecord; + const cells: readonly CellRecord[] = [element(1, 100), element('1', 200), element('a|b', 300)]; + const selectXs = (picked: readonly CellRecord[]) => + picked.map((cell) => (cell as { readonly position?: { readonly x: number } }).position?.x); + function cellsWrapper({ children }: { readonly children: React.ReactNode }) { + return {children}; + } + + // Regression: ids were joined into the key that says "same target", so the + // number 1 and the string '1' were one target and the hook kept the first. + it('tells a numeric id from the same id as a string', () => { + const { result, rerender } = renderHook( + ({ ids }: { readonly ids: ReadonlyArray }) => useCells(ids, selectXs), + { wrapper: cellsWrapper, initialProps: { ids: [1] as ReadonlyArray } } + ); + expect(result.current).toEqual([100]); + + rerender({ ids: ['1'] }); + + expect(result.current).toEqual([200]); + }); + + // Regression: the selector was read through a ref and run on a store change + // only, so an inline selector returned what the previous props selected. + it('runs an inline selector that captured a changed prop, without a graph change', () => { + const { result, rerender } = renderHook( + ({ minimumX }: { readonly minimumX: number }) => + useCells( + (all) => + all.filter( + (cell) => + ((cell as { readonly position?: { readonly x: number } }).position?.x ?? 0) >= + minimumX + ).length + ), + { wrapper: cellsWrapper, initialProps: { minimumX: 0 } } + ); + expect(result.current).toBe(3); + + rerender({ minimumX: 250 }); + + expect(result.current).toBe(1); + }); +}); + +describe('useCells dev warnings', () => { + // Regression: the warning named `useGraphState`, which useCells runs on, not + // the hook the application called. + it('names useCells when its selector returns new objects on every run', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook( + () => { + useCells((cells) => cells.map((cell) => ({ id: cell.id }))); + return useGraphStore().graph; + }, + { wrapper } + ); + + await act(async () => { + (result.current.getCell('a') as dia.Element).position(5, 0); + await flush(); + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[useCells] Selector returns')); + warn.mockRestore(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx new file mode 100644 index 0000000000..fa74bd1912 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx @@ -0,0 +1,503 @@ +import React, { Suspense, startTransition, useState } from 'react'; +import { render, renderHook, act, screen } from '@testing-library/react'; +import { dia } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { useGraphState } from '../use-graph-state'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { DEFAULT_CELL_NAMESPACE } from '../../store/graph-store'; +import { selectIsGraphSizingComplete, selectGraphSizingVersion } from '../../selectors'; +import type { GraphState } from '../../store/graph-state'; +import type { CellRecord } from '../../types/cell.types'; + +const element = (id: string, x = 0): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, + }) as CellRecord; + +const initialCells: readonly CellRecord[] = [element('a'), element('b', 50)]; + +function wrapper({ children }: { readonly children: React.ReactNode }) { + return {children}; +} + +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +/** Runs a graph mutation and drains the store's microtask commit. */ +const commit = (run: () => void) => + act(async () => { + run(); + await flush(); + }); + +const selectCount = (state: GraphState) => state.cells.length; +const selectIds = (state: GraphState) => state.cells.map((cell) => cell.id); +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const selectXOf = (id: string) => (state: GraphState) => state.getCell(id)?.position?.x; +const selectAX = selectXOf('a'); +const noop = () => {}; +const selectCountOrThrow = (state: GraphState) => { + if (state.cells.length > 2) throw new Error('too many cells'); + return state.cells.length; +}; +// Custom selectors derived from the shipped ones: plain functions compose. +const selectHasSettledTwice = (state: GraphState) => selectGraphSizingVersion(state) > 1; +const selectMeasuredCount = (state: GraphState) => + selectIsGraphSizingComplete(state) ? state.cells.length : 0; + +/** Mounts a hook next to a handle on the graph, counting renders. */ +function mount(useHook: () => Result) { + let renderCount = 0; + const view = renderHook( + () => { + renderCount += 1; + return { value: useHook(), graph: useGraphStore().graph }; + }, + { wrapper } + ); + const getElement = (id: string) => view.result.current.graph.getCell(id) as dia.Element; + return { + ...view, + value: () => view.result.current.value, + graph: () => view.result.current.graph, + getElement, + renders: () => renderCount, + }; +} + +/** The calls made since mount, without StrictMode's repeated mount call. */ +const changesOf = (onChange: jest.Mock) => + onChange.mock.calls.filter(([, previous]) => previous !== undefined); + +describe('useGraphState', () => { + it('selects from the cells and re-renders when the selected value changes', async () => { + const { value, graph } = mount(() => useGraphState(selectCount)); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('c'))); + + expect(value()).toBe(3); + }); + + it('does not re-render for a change that leaves the selected value equal', async () => { + const { getElement, renders } = mount(() => useGraphState(selectCount)); + const rendersBefore = renders(); + + await commit(() => getElement('a').position(30, 30)); + + expect(renders()).toBe(rendersBefore); + }); + + it('reads one cell without running its selector when another cell changes', async () => { + const selector = jest.fn(selectAX); + const { value, getElement, renders } = mount(() => useGraphState(selector)); + const rendersBefore = renders(); + const callsBefore = selector.mock.calls.length; + + await commit(() => getElement('b').position(70, 0)); + expect(selector).toHaveBeenCalledTimes(callsBefore); + expect(renders()).toBe(rendersBefore); + + await commit(() => getElement('a').position(30, 0)); + expect(value()).toBe(30); + }); + + it('keeps the selected reference stable across re-renders and equal results', async () => { + const { value, getElement, rerender } = mount(() => useGraphState(selectIds)); + const before = value(); + + rerender(); + await commit(() => getElement('a').position(30, 0)); + + expect(value()).toBe(before); + }); + + it('uses the custom equality to decide what a change is', async () => { + const { value, graph } = mount(() => useGraphState(selectCount, isSameParity)); + + await commit(() => graph().addCells([element('c'), element('d')])); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('e'))); + expect(value()).toBe(5); + }); + + // The selector is part of what the hook renders: a new one (a changed prop + // captured by an inline selector) is read at once, not on the next store change. + it('runs a new selector on re-render, without a store change', () => { + const { result } = renderHook( + () => { + const [id, setId] = useState('a'); + return { x: useGraphState((state) => state.getCell(id)?.position?.x), setId }; + }, + { wrapper } + ); + expect(result.current.x).toBe(0); + + act(() => result.current.setId('b')); + + expect(result.current.x).toBe(50); + }); + + it('follows the cell a re-rendered selector reads, and drops the previous one', async () => { + const { result } = renderHook( + () => { + const [id, setId] = useState('a'); + const { graph } = useGraphStore(); + return { x: useGraphState((state) => state.getCell(id)?.position?.x), setId, graph }; + }, + { wrapper } + ); + act(() => result.current.setId('b')); + + await commit(() => (result.current.graph.getCell('b') as dia.Element).position(70, 0)); + + expect(result.current.x).toBe(70); + }); + + // Regression: a consumer mounted after the graph settled has to see the + // settled state on its first render, with no change to wake it. + it('reads the current state when mounted after the graph settled', async () => { + const { result } = renderHook( + () => { + const [isShown, setIsShown] = useState(false); + return { isShown, setIsShown }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.isShown).toBe(false); + + const late = mount(() => useGraphState(selectIsGraphSizingComplete)); + await act(async () => flush()); + + expect(late.value()).toBe(true); + }); + + describe('sizing version', () => { + it('re-renders its reader once per settled change, not per commit', async () => { + const { value, getElement, graph, renders } = mount(() => + useGraphState(selectGraphSizingVersion) + ); + await act(async () => flush()); + expect(value()).toBe(1); + const rendersBefore = renders(); + + // A move is a commit, but not a measurement. + await commit(() => getElement('a').position(9, 9)); + expect(value()).toBe(1); + expect(renders()).toBe(rendersBefore); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(2); + }); + + it('selectIsGraphSizingComplete turns true once sizes settle and re-renders its reader only then', async () => { + const { value, graph, renders } = mount(() => useGraphState(selectIsGraphSizingComplete)); + await act(async () => flush()); + expect(value()).toBe(true); + const rendersBefore = renders(); + + // A later settled change bumps the sizing version, but the diagram stays measured. + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(true); + expect(renders()).toBe(rendersBefore); + + // A reset to a diagram without sizes is not measured. + await commit(() => graph().resetCells([])); + expect(value()).toBe(false); + }); + + it('is measured for an external graph that already holds elements', async () => { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + graph.addCells([element('a'), element('b')] as never); + const { result } = renderHook(() => useGraphState(selectIsGraphSizingComplete), { + wrapper: ({ children }: { readonly children: React.ReactNode }) => ( + {children} + ), + }); + await act(async () => flush()); + expect(result.current).toBe(true); + + await commit(() => graph.getCell('a').remove()); + expect(result.current).toBe(true); + }); + + // A selector derived from a shipped one is a selector like any other. + it('re-renders a custom selector derived from selectGraphSizingVersion only when its value changes', async () => { + const { value, graph, renders } = mount(() => useGraphState(selectHasSettledTwice)); + await act(async () => flush()); + expect(value()).toBe(false); + const rendersBefore = renders(); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(true); + const rendersAfterChange = renders(); + expect(rendersAfterChange).toBeGreaterThan(rendersBefore); + + // Settles a third time: the sizing version changes, the derived value does not. + await commit(() => graph().addCell(element('d'))); + expect(value()).toBe(true); + expect(renders()).toBe(rendersAfterChange); + }); + + it('combines the sizing version with the cells in one selector', async () => { + const { value, graph } = mount(() => useGraphState(selectMeasuredCount)); + await act(async () => flush()); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(3); + + await commit(() => graph().resetCells([])); + expect(value()).toBe(0); + }); + + // Regression: read through the cells, a measurement selector made every + // commit rebuild the cells array (O(n) per drag frame). A selector that + // reads only the sizing version is not even run on such a commit. + it.each([ + [ + 'useOnGraphStateChange(selectGraphSizingVersion)', + () => useOnGraphStateChange(selectGraphSizingVersion, () => {}), + ], + ['useGraphState(selectGraphSizingVersion)', () => useGraphState(selectGraphSizingVersion)], + [ + 'useGraphState(selectIsGraphSizingComplete)', + () => useGraphState(selectIsGraphSizingComplete), + ], + ['useGraphState(a derived selector)', () => useGraphState(selectHasSettledTwice)], + ])('%s does no work on a commit that is not a measurement', async (_name, useReader) => { + const { getElement, renders } = mount(useReader); + await act(async () => flush()); + const rendersBefore = renders(); + const buildArray = jest.spyOn(Array, 'from'); + + for (let frame = 1; frame <= 10; frame += 1) { + await commit(() => getElement('a').position(frame, frame)); + } + + // The container builds its cells array with `Array.from`, and only on demand. + expect(buildArray).not.toHaveBeenCalled(); + expect(renders()).toBe(rendersBefore); + buildArray.mockRestore(); + }); + }); +}); + +// Regression: the subscription followed every read, and React reads while +// rendering. A render it never commits (here a transition that suspends) moved +// the subscription away from the cell the committed tree shows. +describe('useGraphState — a render React throws away', () => { + const NEVER = new Promise(() => {}); + let liveGraph: dia.Graph | undefined; + let startSuspendedTransition: (() => void) | undefined; + + function Suspender({ isSuspended }: Readonly<{ isSuspended: boolean }>) { + // Throwing a promise is how a component suspends. + if (isSuspended) throw NEVER; + return null; + } + function PositionReader({ id }: Readonly<{ id: string }>) { + const x = useGraphState((state: GraphState) => state.getCell(id)?.position?.x); + return {String(x)}; + } + function TransitionApp() { + liveGraph = useGraphStore().graph; + const [id, setId] = useState('a'); + const [isSuspended, setIsSuspended] = useState(false); + startSuspendedTransition = () => + startTransition(() => { + setId('b'); + setIsSuspended(true); + }); + return ( + + + + + ); + } + + it('keeps following the cell the committed tree reads', async () => { + render( + + + + ); + expect(screen.getByTestId('x').textContent).toBe('0'); + + // Renders the reader with `b`, suspends, and keeps showing `a`. + await commit(() => startSuspendedTransition?.()); + expect(screen.getByTestId('x').textContent).toBe('0'); + + await commit(() => (liveGraph?.getCell('a') as dia.Element).position(33, 0)); + expect(screen.getByTestId('x').textContent).toBe('33'); + }); +}); + +describe('useOnGraphStateChange', () => { + it('calls back on mount with the current value and no previous one', () => { + const onChange = jest.fn(); + mount(() => useOnGraphStateChange(selectCount, onChange)); + + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenLastCalledWith(2); + }); + + it('calls back with the new and the previous value, without re-rendering', async () => { + const onChange = jest.fn(); + const { graph, renders } = mount(() => useOnGraphStateChange(selectCount, onChange)); + const rendersBefore = renders(); + + await commit(() => graph().addCell(element('c'))); + await commit(() => graph().getCell('a').remove()); + + expect(changesOf(onChange)).toEqual([ + [3, 2], + [2, 3], + ]); + expect(renders()).toBe(rendersBefore); + }); + + it('watches one cell, ignoring the others', async () => { + const onChange = jest.fn(); + const { getElement } = mount(() => useOnGraphStateChange(selectAX, onChange)); + + await commit(() => getElement('b').position(70, 0)); + expect(changesOf(onChange)).toEqual([]); + + await commit(() => getElement('a').position(30, 0)); + expect(changesOf(onChange)).toEqual([[30, 0]]); + }); + + // An inline selector is a new function on every render. That must not start + // the watch over, which would call back with no previous value each time. + it('does not call back again when re-rendered with an inline selector', () => { + const onChange = jest.fn(); + const { rerender } = mount(() => + useOnGraphStateChange((state) => state.cells.length, onChange) + ); + const callsBefore = onChange.mock.calls.length; + + rerender(); + rerender(); + + expect(onChange).toHaveBeenCalledTimes(callsBefore); + }); + + // A new selector may read another part of the state, which no notification + // of the previous one would ever report. + it('reads a new selector at once, calls back if the value differs, and follows what it reads', async () => { + const first = jest.fn(); + const second = jest.fn(); + const { result } = renderHook( + () => { + const [isSecond, setIsSecond] = useState(false); + useOnGraphStateChange( + isSecond ? selectXOf('b') : selectXOf('a'), + isSecond ? second : first + ); + return { setIsSecond, graph: useGraphStore().graph }; + }, + { wrapper } + ); + first.mockClear(); + + act(() => result.current.setIsSecond(true)); + expect(second).toHaveBeenLastCalledWith(50, 0); + + await commit(() => (result.current.graph.getCell('b') as dia.Element).position(70, 0)); + expect(second).toHaveBeenLastCalledWith(70, 50); + expect(first).not.toHaveBeenCalled(); + }); + + it('is 0 until the sizes settle, then changes once per settled change', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnGraphStateChange(selectGraphSizingVersion, onChange)); + await act(async () => flush()); + // No paper renders these elements, so the seed settles on its own. + expect(changesOf(onChange)).toEqual([[1, 0]]); + + await commit(() => graph().addCells([element('c'), element('d')])); + expect(changesOf(onChange)).toEqual([ + [1, 0], + [2, 1], + ]); + }); + + it('changes when a measured element is removed, so what is left can be laid out again', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnGraphStateChange(selectGraphSizingVersion, onChange)); + await act(async () => flush()); + onChange.mockClear(); + + await commit(() => graph().getCell('a').remove()); + + expect(changesOf(onChange)).toEqual([[2, 1]]); + }); + + // Regression: removing the last sized element left the diagram "measured", + // while `resetCells([])` did not. + it('is not measured any more once every element has been removed', async () => { + const onChange = jest.fn(); + const { result } = renderHook( + () => { + useOnGraphStateChange(selectGraphSizingVersion, onChange); + return { + isSizingComplete: useGraphState(selectIsGraphSizingComplete), + graph: useGraphStore().graph, + }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.isSizingComplete).toBe(true); + + await commit(() => { + result.current.graph.getCell('a').remove(); + result.current.graph.getCell('b').remove(); + }); + + expect(result.current.isSizingComplete).toBe(false); + expect(onChange.mock.calls.at(-1)).toEqual([0, 1]); + }); + + // Regression: only the callback was guarded. A selector that throws inside + // the store's notification cut the other subscribers of that change off. + it('survives a selector that throws, and still notifies the other subscribers', async () => { + // The graph's microtask commit has to stay real. + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + const sibling = jest.fn(); + const { result } = renderHook( + () => { + useOnGraphStateChange(selectCountOrThrow, noop); + useOnGraphStateChange(selectCount, sibling); + return useGraphStore().graph; + }, + { wrapper } + ); + sibling.mockClear(); + + await commit(() => result.current.addCell(element('c'))); + + expect(sibling).toHaveBeenLastCalledWith(3, 2); + expect(() => jest.runOnlyPendingTimers()).toThrow('too many cells'); + jest.useRealTimers(); + }); + + it('stops calling back after unmount', async () => { + const onChange = jest.fn(); + const { graph, unmount } = mount(() => useOnGraphStateChange(selectCount, onChange)); + const liveGraph = graph(); + unmount(); + onChange.mockClear(); + + await commit(() => liveGraph.addCell(element('c'))); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-graph-state.type.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph-state.type.test.ts new file mode 100644 index 0000000000..197d4c6a47 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-graph-state.type.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +/** + * Type-only tests for `useGraphState` / `useOnGraphStateChange`. If the file + * compiles, the contract holds; the dummy `describe`/`it` exists only so Jest + * does not error on a test file with no test cases. + * + * Locked patterns (DX contract): + * - a shipped selector infers its return type + * - an inline selector reads the default `GraphState` + * - a selector annotated with `GraphState` types the cells it reads + * - selectors compose: a custom selector can call a shipped one + * - a graph state selector is NOT a `useCells` selector, which receives cells + */ +import { useGraphState } from '../use-graph-state'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useCells } from '../use-cells'; +import { selectIsGraphSizingComplete, selectGraphSizingVersion } from '../../selectors'; +import type { GraphState } from '../../store/graph-state'; +import type { Computed, ElementRecord } from '../../types/cell.types'; + +type MyElement = ElementRecord<{ readonly label: string }>; + +const expectType = (_value: Expected): void => {}; + +export function typeContracts(): void { + expectType(useGraphState(selectGraphSizingVersion)); + expectType(useGraphState(selectIsGraphSizingComplete)); + expectType(useGraphState((state) => state.cells.length)); + expectType(useGraphState((state) => selectGraphSizingVersion(state) > 1)); + expectType>>( + useGraphState((state: GraphState) => state.cells) + ); + expectType | undefined>( + useGraphState((state: GraphState) => state.getCell('a')) + ); + expectType>>( + useGraphState((state: GraphState) => state.getCells(['a', 'b'])) + ); + + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion, previousSizingVersion) => { + expectType(sizingVersion); + expectType(previousSizingVersion); + }); + + // @ts-expect-error -- a `useCells` selector receives the cells, not the graph state + useCells(selectGraphSizingVersion); + // @ts-expect-error -- the same in a target form + useCells('a', selectIsGraphSizingComplete); +} + +describe('useGraphState types', () => { + it('compiles', () => { + expect(typeof typeContracts).toBe('function'); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx new file mode 100644 index 0000000000..437a21fdd6 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import type { dia } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { useOnCellsChange } from '../use-on-cells-change'; +import { useCells } from '../use-cells'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import type { CellRecord } from '../../types/cell.types'; + +const element = (id: string, x = 0): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, + }) as CellRecord; + +const initialCells: readonly CellRecord[] = [element('a'), element('b', 50)]; + +function wrapper({ children }: { readonly children: React.ReactNode }) { + return {children}; +} + +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +/** Runs a graph mutation and drains the store's microtask commit. */ +const commit = (run: () => void) => + act(async () => { + run(); + await flush(); + }); + +const selectCount = (cells: readonly unknown[]) => cells.length; +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const selectX = (cell: { readonly position?: { readonly x: number } } | undefined) => + cell?.position?.x; + +/** Mounts the hook under test next to a handle on the graph, counting renders. */ +function mount(useHook: () => void) { + let renderCount = 0; + const { result, unmount } = renderHook( + () => { + renderCount += 1; + useHook(); + return useGraphStore().graph; + }, + { wrapper } + ); + const getElement = (id: string) => result.current.getCell(id) as dia.Element; + return { graph: () => result.current, getElement, renders: () => renderCount, unmount }; +} + +/** The calls made since mount, without StrictMode's repeated mount call. */ +const changesOf = (onChange: jest.Mock) => + onChange.mock.calls.filter(([, previous]) => previous !== undefined); + +describe('useOnCellsChange', () => { + it('calls back on mount with the current value and no previous one', () => { + const onChange = jest.fn(); + mount(() => useOnCellsChange(selectCount, onChange)); + + expect(onChange).toHaveBeenCalledWith(2); + expect(changesOf(onChange)).toHaveLength(0); + }); + + it('calls back with the new and the previous value when the selection changes', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange(selectCount, onChange)); + + await commit(() => graph().addCell(element('c'))); + await commit(() => graph().getCell('a').remove()); + + expect(changesOf(onChange)).toEqual([ + [3, 2], + [2, 3], + ]); + }); + + it('does not call back, or re-render, for a commit that leaves the selection unchanged', async () => { + const onChange = jest.fn(); + const { getElement, renders } = mount(() => useOnCellsChange(selectCount, onChange)); + const rendersBefore = renders(); + + await commit(() => getElement('a').position(30, 30)); + + expect(changesOf(onChange)).toHaveLength(0); + expect(renders()).toBe(rendersBefore); + }); + + it('never re-renders the component, unlike useCells', async () => { + const onChange = jest.fn(); + const watching = mount(() => useOnCellsChange(selectCount, onChange)); + const reading = mount(() => useCells(selectCount)); + const watchingBefore = watching.renders(); + const readingBefore = reading.renders(); + + await commit(() => watching.graph().addCell(element('c'))); + await commit(() => reading.graph().addCell(element('c'))); + + expect(changesOf(onChange)).toEqual([[3, 2]]); + expect(watching.renders()).toBe(watchingBefore); + expect(reading.renders()).toBeGreaterThan(readingBefore); + }); + + it('watches one cell by id, ignoring the others', async () => { + const onChange = jest.fn(); + const { getElement } = mount(() => useOnCellsChange('a', selectX, onChange)); + expect(onChange).toHaveBeenCalledWith(0); + + await commit(() => getElement('b').position(70, 0)); + expect(changesOf(onChange)).toHaveLength(0); + + await commit(() => getElement('a').position(30, 0)); + expect(changesOf(onChange)).toEqual([[30, 0]]); + }); + + it('passes undefined to the selector for a missing cell', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange('late', selectX, onChange)); + expect(onChange.mock.calls[0]).toEqual([undefined]); + + await commit(() => graph().addCell(element('late', 5))); + // The cell appeared: from `undefined` to its x. + expect(onChange.mock.calls.at(-1)).toEqual([5, undefined]); + }); + + it('watches several cells by ids', async () => { + const onChange = jest.fn(); + const ids = ['a', 'missing'] as const; + const { graph } = mount(() => useOnCellsChange(ids, selectCount, onChange)); + expect(onChange).toHaveBeenCalledWith(1); + + await commit(() => graph().addCell(element('c'))); + expect(changesOf(onChange)).toHaveLength(0); + + await commit(() => graph().addCell(element('missing'))); + expect(changesOf(onChange)).toEqual([[2, 1]]); + }); + + it('uses the custom equality to decide what a change is', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange(selectCount, onChange, isSameParity)); + + await commit(() => graph().addCells([element('c'), element('d')])); + expect(changesOf(onChange)).toHaveLength(0); + + await commit(() => graph().addCell(element('e'))); + expect(changesOf(onChange)).toEqual([[5, 2]]); + }); + + // Regression: the equality function was a dependency of the subscription, so + // an inline one re-subscribed on every render and repeated the mount call. + it('does not call back again when re-rendered with an inline isEqual', () => { + const onChange = jest.fn(); + const { rerender } = renderHook( + () => useOnCellsChange(selectCount, onChange, (a, b) => a === b), + { wrapper } + ); + const callsAfterMount = onChange.mock.calls.length; + + rerender(); + rerender(); + + expect(onChange).toHaveBeenCalledTimes(callsAfterMount); + }); + + it('calls the latest callback without subscribing again', async () => { + const first = jest.fn(); + const second = jest.fn(); + let onChange = first; + const { result, rerender } = renderHook( + () => { + useOnCellsChange(selectCount, onChange); + return useGraphStore().graph; + }, + { wrapper } + ); + const mountCalls = first.mock.calls.length; + + onChange = second; + rerender(); + // A new callback is not a new subscription: no mount call for it. + expect(first).toHaveBeenCalledTimes(mountCalls); + expect(second).not.toHaveBeenCalled(); + + await commit(() => result.current.addCell(element('c'))); + expect(first).toHaveBeenCalledTimes(mountCalls); + expect(second.mock.calls).toEqual([[3, 2]]); + }); + + // Regression: the callback runs inside the store's notification. An error + // thrown there used to stop the store for good, and to skip the other + // subscribers of that change. + it('survives a callback that throws, and still surfaces the error', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + let shouldThrow = false; + const throwOnDemand = () => { + if (shouldThrow) throw new Error('boom from a callback'); + }; + const { result } = renderHook( + () => { + useOnCellsChange(selectCount, throwOnDemand); + return { count: useCells(selectCount), graph: useGraphStore().graph }; + }, + { wrapper } + ); + + shouldThrow = true; + await commit(() => result.current.graph.addCell(element('c'))); + // The other subscriber of the same change was still notified. + expect(result.current.count).toBe(3); + expect(() => jest.runOnlyPendingTimers()).toThrow('boom from a callback'); + + shouldThrow = false; + await commit(() => result.current.graph.addCell(element('d'))); + expect(result.current.count).toBe(4); + jest.useRealTimers(); + }); + + // Regression: the projection cleared its pending changes only after notifying + // its subscribers, so a change a callback made to the graph during that + // notification was dropped and the store no longer matched the graph. + it('keeps the store in sync when the callback changes the graph', async () => { + const { result } = renderHook( + () => { + const { graph } = useGraphStore(); + useOnCellsChange(selectCount, (count) => { + if (count !== 3) return; + graph.addCell(element('added-by-callback')); + (graph.getCell('a') as dia.Element).position(77, 0); + }); + return { graph, count: useCells(selectCount), x: useCells('a', selectX) }; + }, + { wrapper } + ); + + await commit(() => result.current.graph.addCell(element('c'))); + + expect(result.current.graph.getCells()).toHaveLength(4); + expect(result.current.count).toBe(4); + expect(result.current.x).toBe(77); + }); + + // Regression: a callback that changes what it listens to re-triggered itself + // inside one endless microtask and froze the page. + it('does not freeze when the callback keeps changing the cells', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const LAST_CALL = 400; + let calls = 0; + const { result } = renderHook( + () => { + const { graph } = useGraphStore(); + useOnCellsChange(selectCount, (count) => { + calls += 1; + if (calls < LAST_CALL) graph.addCell(element(`extra-${count}`)); + }); + return { graph, count: useCells(selectCount) }; + }, + { wrapper } + ); + await act(async () => flush()); + + // The flush handed control back long before the callback stopped by itself. + expect(calls).toBeLessThan(LAST_CALL); + expect(error).toHaveBeenCalledWith(expect.stringContaining('keeps scheduling')); + + // The rest runs over later tasks, and the store ends in sync with the graph. + for (let task = 0; task < 50 && calls < LAST_CALL; task += 1) { + await act(async () => { + jest.runOnlyPendingTimers(); + await flush(); + }); + } + expect(calls).toBe(LAST_CALL); + expect(result.current.count).toBe(result.current.graph.getCells().length); + + error.mockRestore(); + jest.useRealTimers(); + }); + + it('stops calling back after unmount', async () => { + const onChange = jest.fn(); + const { graph, unmount } = mount(() => useOnCellsChange(selectCount, onChange)); + const liveGraph = graph(); + unmount(); + onChange.mockClear(); + + await commit(() => liveGraph.addCell(element('c'))); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx index 789389c238..51876fb85d 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable unicorn/consistent-function-scoping */ import { renderHook, waitFor, act } from '@testing-library/react'; import { paperRenderElementWrapper } from '../../utils/test-wrappers'; import { useOnElementsMeasured } from '../use-on-elements-measured'; @@ -174,49 +173,4 @@ describe('useOnElementsMeasured', () => { }); }); - // Regression (#3514): the store's `change:size` listener dropped the event - // options, so an application's own resize bumped `measureState` like a - // measurement write and woke every subscriber. - describe('application resizes vs measurement writes', () => { - function renderResizeProbe(callback: jest.Mock) { - let graphRef: dia.Graph | undefined; - function Probe() { - const store = useGraphStore(); - graphRef = store.graph; - useOnElementsMeasured('measured-effect-paper', callback); - return null; - } - renderHook(() => Probe(), { wrapper }); - return () => graphRef!.getCell('a') as dia.Element; - } - - it('does not fire when the application resizes an element', async () => { - const callback = jest.fn(); - const getElement = renderResizeProbe(callback); - await waitFor(() => expect(callback).toHaveBeenCalled()); - callback.mockClear(); - - act(() => { - getElement().resize(80, 80); - }); - await act(async () => flush()); - await act(async () => flush()); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('fires with isInitial=false for a measurement write', async () => { - const callback = jest.fn(); - const getElement = renderResizeProbe(callback); - await waitFor(() => expect(callback).toHaveBeenCalled()); - callback.mockClear(); - - act(() => { - getElement().set('size', { width: 80, height: 80 }, { autoSize: true }); - }); - - await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); - expect(callback.mock.calls[0][0].isInitial).toBe(false); - }); - }); }); diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts index 8693acb52f..5f13a00c29 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -10,6 +10,9 @@ export * from './use-graph-store'; export * from './use-combined-ref'; export * from './use-stores'; export * from './use-markup'; +export * from './use-graph-state'; +export * from './use-on-graph-state-change'; +export * from './use-on-cells-change'; export * from './use-on-elements-measured'; export * from './use-create-portal-paper'; export * from './use-create-features'; diff --git a/packages/joint-react/src/hooks/use-cell.ts b/packages/joint-react/src/hooks/use-cell.ts index 7582bd76b3..25dc5b36f9 100644 --- a/packages/joint-react/src/hooks/use-cell.ts +++ b/packages/joint-react/src/hooks/use-cell.ts @@ -159,8 +159,8 @@ export function useCell | Selected>( diff --git a/packages/joint-react/src/hooks/use-cells-state-selector.ts b/packages/joint-react/src/hooks/use-cells-state-selector.ts new file mode 100644 index 0000000000..3ea9a9522e --- /dev/null +++ b/packages/joint-react/src/hooks/use-cells-state-selector.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import type { AnyCellRecord } from '../types/cell.types'; +import type { GraphStateSelector } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSource, CellsTarget } from './use-cells.types'; + +/** + * The graph state selector of a cells hook: reads what `target` names and + * hands it to `selector`. The same function for as long as the target and the + * selector are. + * @param target - What to read: all cells (`undefined`), no cell (`null`), a cell id, ids or a collection. + * @param targetKey - `toTargetKey(target)`, computed once by the caller. + * @param selector - Derives the value from what was read; the read itself when omitted. + * @returns The selector to pass to `useGraphState` / `useOnGraphStateChange`. + * @internal + */ +export function useCellsStateSelector( + target: CellsTarget, + targetKey: unknown, + selector?: AnyCellsSelector +): GraphStateSelector> { + return useMemo( + () => (state) => { + let source: CellsSource; + if (target === undefined) source = state.cells; + else if (target === null) source = undefined; + else if (typeof target === 'object') source = state.getCells(target); + else source = state.getCell(target); + return selector ? selector(source) : source; + }, + // `targetKey` stands in for `target`, see `toTargetKey` in `use-cells.utils`. + // eslint-disable-next-line react-hooks/exhaustive-deps + [targetKey, selector] + ); +} diff --git a/packages/joint-react/src/hooks/use-cells.ts b/packages/joint-react/src/hooks/use-cells.ts index 5f0631785b..6222446688 100644 --- a/packages/joint-react/src/hooks/use-cells.ts +++ b/packages/joint-react/src/hooks/use-cells.ts @@ -1,115 +1,23 @@ -import { useCallback, useMemo, useRef } from 'react'; -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; import { type mvc, type dia } from '@joint/core'; -import { useGraphStore } from './use-graph-store'; import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types'; -import type { ReadonlyContainer } from '../store/state-container'; -import { areArraysShallowEqual, arrayAwareEqual } from '../utils/selector-utils'; -import { isCollection } from '../utils/is'; -import { subscribeToCollection } from '../utils/collection-subscription'; -import { parseUseCellsArgs } from './use-cells.utils'; -import { warnUnstableSelector } from '../utils/dev-warnings'; -import { toCellRecord } from '../state/data-mapping/cell-record-merge'; - -// ── Types ─────────────────────────────────────────────────────────────────── - -/** - * Union of all possible `useCells` return shapes (depends on argument form). - * `Cell` here is the already-resolved (Computed) record the helpers work with. - */ -type CellsResult = - | readonly Cell[] - | Cell - | undefined - | Selected; - -/** Equality function on raw `unknown` values. */ -type UnknownEqual = (a: unknown, b: unknown) => boolean; - -/** Selector over the resolved cells array (the array forms of `useCells`). */ -type CellsSelector = ( - cells: ReadonlyArray> -) => Selected; - -/** Equality test that short-circuits a re-render when the selected value is unchanged. */ -type SelectedEqual = (a: Selected, b: Selected) => boolean; - -// ── Module-scoped helpers ─────────────────────────────────────────────────── - -/** - * Resolves a single cell record for the collection form. Tries the graph - * container first; falls back to the collection itself for cells that exist - * outside the graph (e.g. `ui.Clipboard` clones). Fallback records are cached - * by `dia.Cell` identity so repeat renders share the same reference and the - * outer shallow-equality short-circuit can fire. - */ -function resolveCell( - id: CellId, - container: ReadonlyContainer, - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined -): Cell | undefined { - const fromContainer = container.get(id); - if (fromContainer !== undefined) return fromContainer; - if (!collection || !fallbackCache) return undefined; - const rawCell = collection.get(id); - if (!rawCell) return undefined; - const cached = fallbackCache.get(rawCell); - if (cached !== undefined) return cached as Cell; - const record = toCellRecord(rawCell) as Cell; - fallbackCache.set(rawCell, record); - return record; -} - -/** - * Picks cells named by `subscribedIds` in order. Missing ids (not in the - * graph container and not in the optional fallback `collection`) are skipped. - */ -function pickCells( - container: ReadonlyContainer, - subscribedIds: readonly CellId[], - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined -): readonly Cell[] { - const picked: Cell[] = []; - for (const id of subscribedIds) { - const cell = resolveCell(id, container, collection, fallbackCache); - if (cell !== undefined) picked.push(cell); - } - return picked; -} - -/** - * Builds the next selector result for {@link useCells}. Module-scoped so the - * closure does not nest deeply inside {@link useCells}.select. - */ -function computeNext( - container: ReadonlyContainer, - targetId: CellId | undefined, - subscribedIds: readonly CellId[] | undefined, - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined, - arraySelector: ((cells: readonly Cell[]) => Selected) | undefined, - cellSelector: ((cell: Cell | undefined) => Selected) | undefined -): CellsResult { - // Single-cell-with-selector form: a nullish id resolves to no cell, so the - // selector runs against `undefined` instead of falling through to all cells. - if (cellSelector) { - const cell = - targetId === undefined - ? undefined - : resolveCell(targetId, container, collection, fallbackCache); - return cellSelector(cell); - } - if (targetId !== undefined) { - return resolveCell(targetId, container, collection, fallbackCache); - } - const source: readonly Cell[] = - subscribedIds === undefined - ? container.getSnapshot() - : pickCells(container, subscribedIds, collection, fallbackCache); - return arraySelector ? arraySelector(source) : source; -} +import type { SelectedEqual } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSelector, CellsSource, CellsTarget } from './use-cells.types'; +import { useCellsStateSelector } from './use-cells-state-selector'; +import { toTargetKey } from './use-cells.utils'; +import { useSelectGraphState } from './use-graph-state'; + +export type { SelectedEqual } from '../store/graph-state'; +export type { CellsSelector } from './use-cells.types'; + +/** The arguments of every `useCells` form: an optional target, a selector, an equality function. */ +type CellsArguments = [ + argument1?: CellsTarget | CellsSelector, + argument2?: + | CellsSelector + | ((cell: Computed | undefined) => Selected) + | SelectedEqual, + argument3?: SelectedEqual, +]; // ── Overloads ─────────────────────────────────────────────────────────────── @@ -149,8 +57,8 @@ export function useCells< /** * Subscribe to the full cells array. * - * Returned array reference is stable across data-only mutations (the internal - * container mutates items in-place). Size changes produce a new snapshot token. + * The array is a new reference on every graph change; the records in it keep + * their reference until they change themselves. * @title Subscribe to all cells * @template Cell - input cell record shape (defaults to CellRecord); reads resolve to its Computed form * @returns readonly resolved cells array @@ -261,158 +169,26 @@ export function useCells< export function useCells< Cell extends AnyCellRecord = CellRecord, Selected = ReadonlyArray>, ->( - argument1?: - | CellId - | null - | readonly CellId[] - | CellsSelector - | mvc.Collection, - argument2?: - | CellsSelector - | ((cell: Computed | undefined) => Selected) - | SelectedEqual, - argument3?: SelectedEqual -): CellsResult, Selected> { - const store = useGraphStore(); - // The store holds resolved (Computed) records; the public `Cell` generic is the - // input record shape, so internally we work in `Computed`. - const container = store.graphProjection.cells as ReadonlyContainer>; - - const collectionArgument = isCollection(argument1) ? argument1 : undefined; - - const { targetId, ids, arraySelector, cellSelector, isEqual } = parseUseCellsArgs< - Computed, - Selected - >( - argument1, - argument2, - argument3 - ); - const hasSelector = arraySelector !== undefined || cellSelector !== undefined; - - const arraySelectorRef = useRef(arraySelector); - arraySelectorRef.current = arraySelector; - const cellSelectorRef = useRef(cellSelector); - cellSelectorRef.current = cellSelector; - - /** Local alias for the hook's return shape so the cache type stays readable. */ - type Result = CellsResult, Selected>; - const cachedRef = useRef<{ hasValue: boolean; value: Result }>({ - hasValue: false, - value: undefined, - }); - - const idsKey = useMemo(() => (ids ? ids.join('|') : ''), [ids]); - const idsRef = useRef(ids); - idsRef.current = ids; - - // ── Collection refs (no-op when not in collection form) ── - - const collectionIdsRef = useRef([]); - const collectionVersionRef = useRef(0); - const fallbackCacheRef = useRef>(new WeakMap()); - - // Use `null` sentinel so the init-block runs on first render too, picking up - // any models that already exist in the collection at subscribe time. - const previousCollectionRef = useRef | undefined | null>(null); - if (previousCollectionRef.current !== collectionArgument) { - previousCollectionRef.current = collectionArgument; - collectionIdsRef.current = collectionArgument - ? collectionArgument.models.map((m) => m.id as CellId) - : []; - collectionVersionRef.current++; - } - - // ── Subscribe ── - - const subscribe = useCallback( - (listener: () => void) => { - if (collectionArgument) { - return subscribeToCollection( - collectionArgument, - container, - listener, - collectionIdsRef, - collectionVersionRef - ); - } - if (targetId !== undefined) return container.subscribeById(targetId, listener); - const subscribedIds = idsRef.current; - if (subscribedIds && subscribedIds.length > 0) { - const unsubscribers = subscribedIds.map((id) => container.subscribeById(id, listener)); - return () => { - for (const unsubscribe of unsubscribers) unsubscribe(); - }; - } - return container.subscribe(listener); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [container, collectionArgument, targetId, idsKey] - ); - - // ── Snapshot ── - - const getSnapshot = useCallback(() => { - // Per-id token for ANY single-cell form (with or without a selector): the - // token is the cell record itself, so the store only re-reads when THIS cell - // changes, never on unrelated commits. Immutable records keep the reference - // stable between changes, so useSyncExternalStore stays consistent. - if (targetId !== undefined) return container.get(targetId); - if (collectionArgument) return collectionVersionRef.current; - // All-cells / ids / selector-only forms: the immutable snapshot reference - // changes on every commit and doubles as the change token. - return container.getSnapshot(); - }, [container, collectionArgument, targetId]); - - // ── Equality ── - - const isEqualCallback = useMemo(() => { - if (isEqual) return isEqual as unknown as UnknownEqual; - if (targetId === undefined && !hasSelector) { - return (a, b) => areArraysShallowEqual(a as readonly unknown[], b as readonly unknown[]); - } - if (hasSelector) return arrayAwareEqual; - return Object.is; - }, [isEqual, targetId, hasSelector]); - - // ── Selector ── - - const select = useCallback( - (): Result => { - const subscribedIds = collectionArgument ? collectionIdsRef.current : idsRef.current; - const next = computeNext, Selected>( - container, - targetId, - subscribedIds, - collectionArgument, - fallbackCacheRef.current, - arraySelectorRef.current, - cellSelectorRef.current - ); - // Reuse the cached value when equal so the returned reference stays stable - // across commits that didn't affect this subscription. - if (cachedRef.current.hasValue && isEqualCallback(cachedRef.current.value, next)) { - return cachedRef.current.value; - } - if (hasSelector && cachedRef.current.hasValue) { - warnUnstableSelector('useCells', cachedRef.current.value, next, !!isEqual); - } - // No defensive copy for the all-cells form: the container's snapshot is - // already immutable and yields a fresh reference on every commit, so it is - // safe to return as-is. - cachedRef.current = { hasValue: true, value: next }; - return next; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [container, collectionArgument, targetId, idsKey, isEqualCallback] - ); - - return useSyncExternalStoreWithSelector( - subscribe, - getSnapshot, - getSnapshot, - select, - isEqualCallback +>(...args: CellsArguments): Selected | CellsSource { + const [argument1, argument2, argument3] = args; + const isSelectorFirst = typeof argument1 === 'function'; + // The overloads pair each target with its selector shape, which the argument + // unions hide from TypeScript. + const selector = (isSelectorFirst ? argument1 : argument2) as + | AnyCellsSelector + | undefined; + const hasSelector = typeof selector === 'function'; + // A nullish id next to a selector reads no cell; on its own it reads all cells. + let target: CellsTarget; + if (!isSelectorFirst) target = argument1 ?? (hasSelector ? null : undefined); + const isEqual = (isSelectorFirst ? argument2 : argument3) as + | SelectedEqual> + | undefined; + + const select = useCellsStateSelector( + target, + toTargetKey(target), + hasSelector ? selector : undefined ); + return useSelectGraphState('useCells', select, isEqual); } diff --git a/packages/joint-react/src/hooks/use-cells.types.ts b/packages/joint-react/src/hooks/use-cells.types.ts new file mode 100644 index 0000000000..1e9a485fb8 --- /dev/null +++ b/packages/joint-react/src/hooks/use-cells.types.ts @@ -0,0 +1,24 @@ +import { type mvc, type dia } from '@joint/core'; +import type { AnyCellRecord, CellId, Computed } from '../types/cell.types'; + +/** Selector over the resolved cells array (the array forms of `useCells`). */ +export type CellsSelector = ( + cells: ReadonlyArray> +) => Selected; + +/** + * What a cells hook is scoped to: one cell, several cells, or a JointJS + * collection. `undefined` is all cells, `null` is no cell. + */ +export type CellsTarget = CellId | null | undefined | readonly CellId[] | mvc.Collection; + +/** What a target resolves to: the cells, one cell, or nothing. */ +export type CellsSource = + | ReadonlyArray> + | Computed + | undefined; + +/** A selector of any `useCells` form; the overloads pair each target with its own shape. */ +export type AnyCellsSelector = ( + source: CellsSource +) => Selected; diff --git a/packages/joint-react/src/hooks/use-cells.utils.ts b/packages/joint-react/src/hooks/use-cells.utils.ts index c1d49c2019..f5a2771b54 100644 --- a/packages/joint-react/src/hooks/use-cells.utils.ts +++ b/packages/joint-react/src/hooks/use-cells.utils.ts @@ -1,129 +1,15 @@ -import { type mvc, type dia } from '@joint/core'; -import type { AnyCellRecord, CellId } from '../types/cell.types'; -import { isCollection } from '../utils/is'; - -/** Normalised arguments after dispatching by the runtime call shape. */ -export interface ParsedCellsArgs { - readonly targetId: CellId | undefined; - readonly ids: readonly CellId[] | undefined; - readonly isCollectionForm: boolean; - readonly arraySelector: ((cells: readonly Cell[]) => Selected) | undefined; - readonly cellSelector: ((cell: Cell | undefined) => Selected) | undefined; - readonly isEqual: ((a: Selected, b: Selected) => boolean) | undefined; -} - -/** Builds default parsed result with no selectors. */ -function defaultParsedArgs( - targetId: CellId | undefined, - ids?: readonly CellId[] -): ParsedCellsArgs { - return { - targetId, - ids, - isCollectionForm: false, - arraySelector: undefined, - cellSelector: undefined, - isEqual: undefined, - }; -} - -/** - * Parses the collection form: `useCells(collection)` or `useCells(collection, selector, isEqual?)`. - */ -function parseCollectionArgs( - argument2: unknown, - argument3: unknown -): ParsedCellsArgs { - return { - targetId: undefined, - ids: undefined, - isCollectionForm: true, - arraySelector: - typeof argument2 === 'function' - ? (argument2 as (cells: readonly Cell[]) => Selected) - : undefined, - cellSelector: undefined, - isEqual: - typeof argument3 === 'function' - ? (argument3 as (a: Selected, b: Selected) => boolean) - : undefined, - }; -} - -/** Parses the `useCells(selector, isEqual?)` form. */ -function parseSelectorFirstArgs( - selectorArgument: (cells: readonly Cell[]) => Selected, - isEqualArgument: unknown -): ParsedCellsArgs { - return { - targetId: undefined, - ids: undefined, - isCollectionForm: false, - arraySelector: selectorArgument, - cellSelector: undefined, - isEqual: - typeof isEqualArgument === 'function' - ? (isEqualArgument as (a: Selected, b: Selected) => boolean) - : undefined, - }; -} +import type { CellsTarget } from './use-cells.types'; /** - * Classifies the runtime arguments of {@link useCells} into a normalised shape so - * the hook body can stay flat and cheap to read. - * @param argument1 - first positional arg (id, ids, collection, or selector) - * @param argument2 - second positional arg (selector or isEqual depending on form) - * @param argument3 - third positional arg (isEqual when the form admits it) - * @returns the normalised input + * A stable stand-in for a target in a dependency list: ids passed inline are a + * new array on every render. Serialised rather than joined, so that `[1]` + * and `['1']` stay apart. Both arrays and scalar strings are tagged, so a + * literal id containing serialized ids or either prefix cannot alias an array. + * @param target - The target of a cells hook. + * @returns A tagged key for string/array targets, otherwise the target itself. + * @internal */ -export function parseUseCellsArgs( - argument1?: - | CellId - | null - | readonly CellId[] - | ((cells: readonly Cell[]) => Selected) - | mvc.Collection, - argument2?: - | ((cells: readonly Cell[]) => Selected) - | ((cell: Cell | undefined) => Selected) - | ((a: Selected, b: Selected) => boolean), - argument3?: (a: Selected, b: Selected) => boolean -): ParsedCellsArgs { - if (isCollection(argument1)) { - return parseCollectionArgs(argument2, argument3); - } - if (typeof argument1 === 'function') { - return parseSelectorFirstArgs(argument1, argument2); - } - - if (Array.isArray(argument1)) { - return { - targetId: undefined, - ids: argument1 as readonly CellId[], - isCollectionForm: false, - arraySelector: - typeof argument2 === 'function' - ? (argument2 as (cells: readonly Cell[]) => Selected) - : undefined, - cellSelector: undefined, - isEqual: typeof argument3 === 'function' ? argument3 : undefined, - }; - } - - // Single-cell form: an explicit id, or a nullish id treated as "no match" - // (the selector then receives `undefined`). A nullish id without a selector - // collapses to the no-arg full-array form. - const targetId: CellId | undefined = - argument1 === undefined || argument1 === null ? undefined : (argument1 as CellId); - if (typeof argument2 === 'function') { - return { - targetId, - ids: undefined, - isCollectionForm: false, - arraySelector: undefined, - cellSelector: argument2 as (cell: Cell | undefined) => Selected, - isEqual: typeof argument3 === 'function' ? argument3 : undefined, - }; - } - return defaultParsedArgs(targetId); +export function toTargetKey(target: CellsTarget): unknown { + if (Array.isArray(target)) return `ids:${JSON.stringify(target)}`; + return typeof target === 'string' ? `id:${target}` : target; } diff --git a/packages/joint-react/src/hooks/use-graph-state-reader.ts b/packages/joint-react/src/hooks/use-graph-state-reader.ts new file mode 100644 index 0000000000..3fe31f35dd --- /dev/null +++ b/packages/joint-react/src/hooks/use-graph-state-reader.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { useGraphStore } from './use-graph-store'; +import type { AnyCellRecord } from '../types/cell.types'; +import { createGraphStateReader, type GraphStateReader } from '../store/graph-state'; + +/** + * The reader behind {@link useGraphState} and {@link useOnGraphStateChange}, one + * per hook instance and per graph store. + * @param hookName - The hook the application called, named in dev warnings. + * @returns The reader of the closest graph store. + * @internal + */ +export function useGraphStateReader( + hookName: string +): GraphStateReader { + const store = useGraphStore(); + // Should React drop this memo, the new reader selects afresh: correct, at the + // cost of one new reference for a selected array or object. + return useMemo(() => createGraphStateReader(store, hookName), [store, hookName]); +} diff --git a/packages/joint-react/src/hooks/use-graph-state.ts b/packages/joint-react/src/hooks/use-graph-state.ts new file mode 100644 index 0000000000..4a5dc55d27 --- /dev/null +++ b/packages/joint-react/src/hooks/use-graph-state.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import type { AnyCellRecord, CellRecord } from '../types/cell.types'; +import type { GraphStateSelector, SelectedEqual } from '../store/graph-state'; +import { useGraphStateReader } from './use-graph-state-reader'; + +/** + * Subscribe to a value selected from the graph state: the cells and the + * sizing version. The component re-renders when the selected value changes. + * + * The hook subscribes to exactly what the selector reads. A selector that + * reads `state.getCell(id)` runs when that cell changes, one that reads + * `state.sizingVersion` when the element sizes settle, and one that reads + * `state.cells` on every graph change, including each frame of a drag. Read + * the narrowest part you need, and return a primitive or an existing record + * rather than a new object. + * + * The selector also runs when it is a new function, so define it outside the + * component, or memoise it, when it is not cheap. + * @template Cell - Input cell record shape (defaults to CellRecord); reads resolve to its Computed form. + * @template Selected - The selector's return type. + * @param selector - Derives the value from the graph state. + * @param isEqual - Equality test used to skip re-renders. Defaults to a shallow, + * array-aware comparison that falls back to `Object.is` for other values. + * @returns The selected value, the same reference for as long as it is equal. + * @group Hooks + * @example + * ```tsx + * import { useLayoutEffect } from 'react'; + * import { useGraphState, usePaper, selectIsGraphSizingComplete } from '@joint/react'; + * + * // Mount inside a : fit the paper once the element sizes are known. + * function FitOnMeasure() { + * const { paper } = usePaper(); + * const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + * useLayoutEffect(() => { + * if (isSizingComplete && paper) paper.transformToFitContent({ padding: 20 }); + * }, [isSizingComplete, paper]); + * return null; + * } + * ``` + * @example + * ```tsx + * import { useGraphState, type GraphState } from '@joint/react'; + * + * // A custom selector: combine the cells with the sizing version. + * const selectIsReady = (state: GraphState) => state.sizingVersion > 0 && state.cells.length > 0; + * + * function ReadyBadge() { + * return {useGraphState(selectIsReady) ? 'ready' : 'loading'}; + * } + * ``` + */ +export function useGraphState( + selector: GraphStateSelector, + isEqual?: SelectedEqual +): Selected { + return useSelectGraphState('useGraphState', selector, isEqual); +} + +/** + * {@link useGraphState} for a hook built on it, which its dev warnings name. + * @param hookName - The hook the application called. + * @param selector - Derives the value from the graph state. + * @param isEqual - Equality test used to skip re-renders. + * @returns The selected value, the same reference for as long as it is equal. + * @internal + */ +export function useSelectGraphState( + hookName: string, + selector: GraphStateSelector, + isEqual?: SelectedEqual +): Selected { + const reader = useGraphStateReader(hookName); + const getSnapshot = useCallback( + () => reader.read(selector, isEqual), + [reader, selector, isEqual] + ); + const selected = useSyncExternalStore(reader.subscribe, getSnapshot, getSnapshot); + // A committed selector may read other parts of the state than the one before + // it. The subscription follows it here, never while rendering: a render that + // React throws away must leave it where the committed one needs it. Declared + // after the store subscription, so on mount it runs once that is in place. + useEffect(() => { + reader.sync(selector, isEqual); + }, [reader, selector, isEqual]); + return selected; +} diff --git a/packages/joint-react/src/hooks/use-on-cells-change.ts b/packages/joint-react/src/hooks/use-on-cells-change.ts new file mode 100644 index 0000000000..6d434e4dda --- /dev/null +++ b/packages/joint-react/src/hooks/use-on-cells-change.ts @@ -0,0 +1,141 @@ +import type { dia, mvc } from '@joint/core'; +import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types'; +import type { SelectedEqual } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSelector, CellsTarget } from './use-cells.types'; +import { toTargetKey } from './use-cells.utils'; +import { useCellsStateSelector } from './use-cells-state-selector'; +import { useWatchGraphState, type OnGraphStateChange } from './use-watch-graph-state'; + +/** + * Callback of {@link useOnCellsChange}: receives the newly selected value and + * the one before it, absent on the first call. + * @group Types + * @example + * ```ts + * import type { OnCellsChange } from '@joint/react'; + * + * const logCount: OnCellsChange = (count, previousCount) => { + * console.info(`cells: ${previousCount ?? '-'} → ${count}`); + * }; + * ``` + */ +export type OnCellsChange = OnGraphStateChange; + +/** + * Runs a callback when a value selected from the cells changes, without + * re-rendering the component: {@link useCells} as an effect. The callback + * runs once when the hook mounts and then synchronously with each store change + * that changes the selected value, before React re-renders its subscribers. + * Like any effect, the mount call runs twice in development under StrictMode. + * Changing the watched id, ids or collection starts over: the callback runs + * again with the current value and no previous one. + * + * It takes the same selectors and equality functions as {@link useCells}. + * Keep the selector and `isEqual` cheap and return a primitive or an existing + * record: they run on every commit, including each frame of a drag. + * @title All cells + * @param selector - Derives the value to watch from all cells. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnCellsChange } from '@joint/react'; + * + * const selectCount = (cells: readonly unknown[]) => cells.length; + * + * function LogCellCount() { + * useOnCellsChange(selectCount, (count, previousCount) => console.info(count, previousCount)); + * return null; + * } + * ``` + */ +export function useOnCellsChange( + selector: CellsSelector, + onChange: OnCellsChange, + isEqual?: SelectedEqual +): void; +/** + * Runs a callback when a value selected from one cell changes. A missing cell + * (or a nullish id) passes `undefined` to the selector. + * @title One cell + * @param id - Id of the cell to watch. + * @param selector - Derives the value to watch from the cell. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnCellsChange, selectElementSize } from '@joint/react'; + * + * function LogSize({ id }: { id: string }) { + * useOnCellsChange(id, (cell) => cell && selectElementSize(cell), (size) => console.info(size)); + * return null; + * } + * ``` + */ +export function useOnCellsChange( + id: CellId | null | undefined, + selector: (cell: Computed | undefined) => Selected, + onChange: OnCellsChange, + isEqual?: SelectedEqual +): void; +/** + * Runs a callback when a value selected from several cells, or from the cells + * of a JointJS collection, changes. + * @title Several cells or a collection + * @param target - Ids of the cells to watch, or a `mvc.Collection` of cells. + * @param selector - Derives the value to watch from those cells. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnCellsChange } from '@joint/react'; + * + * const selectCount = (cells: readonly unknown[]) => cells.length; + * + * function LogSelection({ ids }: { ids: readonly string[] }) { + * useOnCellsChange(ids, selectCount, (count) => console.info(count)); + * return null; + * } + * ``` + */ +export function useOnCellsChange( + target: readonly CellId[] | mvc.Collection, + selector: CellsSelector, + onChange: OnCellsChange, + isEqual?: SelectedEqual +): void; +export function useOnCellsChange( + ...args: + | [CellsSelector, OnCellsChange, SelectedEqual?] + | [ + CellsTarget, + CellsSelector | ((cell: AnyCellRecord | undefined) => Selected), + OnCellsChange, + SelectedEqual?, + ] +): void { + // The target forms carry the selector and the callback one position later + // than the all-cells form. The overloads guarantee the shapes, which the + // union hides from TypeScript. + const [target, selector, onChange, isEqual] = ( + typeof args[0] === 'function' + ? [undefined, args[0], args[1], args[2]] + : [args[0] ?? null, args[1], args[2], args[3]] + ) as [ + CellsTarget, + AnyCellsSelector, + OnCellsChange, + SelectedEqual | undefined, + ]; + const targetKey = toTargetKey(target); + useWatchGraphState( + useCellsStateSelector(target, targetKey, selector), + onChange as OnCellsChange, + isEqual, + targetKey, + 'useOnCellsChange' + ); +} diff --git a/packages/joint-react/src/hooks/use-on-elements-measured.ts b/packages/joint-react/src/hooks/use-on-elements-measured.ts index e370c6e9e7..8e2bb584ba 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -52,6 +52,9 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; * every element is an ordinary change: an empty diagram fires nothing, a hook * mounted on one waits for elements, and the next event after elements return * is not initial. + * @deprecated Use {@link useOnGraphStateChange} with {@link selectGraphSizingVersion} + * to react to every settled change, or {@link useGraphState} with + * {@link selectIsGraphSizingComplete} to act once the sizes are known. * @title On the current paper * @param callback - Called each time element sizes are measured. * @group Hooks @@ -74,6 +77,9 @@ export function useOnElementsMeasured(callback: OnElementsMeasured): void; /** * Calls a callback when element sizes are measured, targeting a specific paper * instead of the surrounding context. Useful when several papers share one graph. + * @deprecated Use {@link useOnGraphStateChange} with {@link selectGraphSizingVersion} + * to react to every settled change, or {@link useGraphState} with + * {@link selectIsGraphSizingComplete} to act once the sizes are known. * @title On a specific paper * @param paperTarget - Which paper to watch: a registered paper id, a * `dia.Paper` instance, or a React ref to one. @@ -115,19 +121,23 @@ export function useOnElementsMeasured( useLayoutEffect(() => { if (!paperStore) return; const { paper } = paperStore; - // A new paper (or graph store) starts its own history, and so does a graph - // reset (the state goes back to `0`): the next event is the initial one. - let previousState = 0; + // This legacy hook starts a new history on mount or an actual graph reset. + // The public sizing version also clears when the last element is removed, + // which must not turn a later addition into an initial event here. + let isInitial = true; + const onReset = () => { + isInitial = true; + }; function handleChanges() { const state = measurement.getSizingVersion(); - const isInitial = previousState === 0; - previousState = state; if (state === 0) return; + const wasInitial = isInitial; + isInitial = false; // This runs inside the store's notification: an error must not keep the // other subscribers of this change from being notified. try { - callbackRef.current({ isInitial, paper, graph }); + callbackRef.current({ isInitial: wasInitial, paper, graph }); } catch (error) { rethrowLater(error); } @@ -140,11 +150,15 @@ export function useOnElementsMeasured( } // Subscribed before the replay, so a callback that resets the graph is // notified of it and reports the replacement diagram as initial. + graph.on('reset', onReset); const unsubscribe = measurement.subscribe(handleChanges); // Replay a pass that settled before this subscription (e.g. initial data // sync ran before this paperStore was available). A change still waiting // is delivered when it settles, as this hook's initial event. if (measurement.isSettled()) handleChanges(); - return unsubscribe; + return () => { + unsubscribe(); + graph.off('reset', onReset); + }; }, [paperStore, measurement, graph, callbackRef]); } diff --git a/packages/joint-react/src/hooks/use-on-graph-state-change.ts b/packages/joint-react/src/hooks/use-on-graph-state-change.ts new file mode 100644 index 0000000000..a89fdfd8ec --- /dev/null +++ b/packages/joint-react/src/hooks/use-on-graph-state-change.ts @@ -0,0 +1,44 @@ +import type { AnyCellRecord, CellRecord } from '../types/cell.types'; +import type { GraphStateSelector, SelectedEqual } from '../store/graph-state'; +import { useWatchGraphState, type OnGraphStateChange } from './use-watch-graph-state'; + +export type { OnGraphStateChange } from './use-watch-graph-state'; + +/** + * Runs a callback when a value selected from the graph state changes, without + * re-rendering the component: {@link useGraphState} as an effect. The callback + * runs once when the hook mounts and then synchronously with each store change + * that changes the selected value, before React re-renders its subscribers. + * Like any effect, the mount call runs twice in development under StrictMode. + * + * It takes the same selectors and equality functions as {@link useGraphState} + * and subscribes the same way, to what the selector reads. A new selector + * function is run when the component re-renders, and calls back only if it + * selects another value, so an inline selector is fine as long as it is cheap. + * @template Cell - Input cell record shape (defaults to CellRecord); reads resolve to its Computed form. + * @template Selected - The selector's return type. + * @param selector - Derives the value to watch from the graph state. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnGraphStateChange, selectGraphSizingVersion, useGraph } from '@joint/react'; + * + * // Re-run a layout each time the element sizes settle. + * function AutoLayout() { + * const { graph } = useGraph(); + * useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + * if (sizingVersion) runLayout(graph); + * }); + * return null; + * } + * ``` + */ +export function useOnGraphStateChange( + selector: GraphStateSelector, + onChange: OnGraphStateChange, + isEqual?: SelectedEqual +): void { + useWatchGraphState(selector, onChange, isEqual); +} diff --git a/packages/joint-react/src/hooks/use-watch-graph-state.ts b/packages/joint-react/src/hooks/use-watch-graph-state.ts new file mode 100644 index 0000000000..643d2cd31e --- /dev/null +++ b/packages/joint-react/src/hooks/use-watch-graph-state.ts @@ -0,0 +1,84 @@ +import { useLayoutEffect, useRef } from 'react'; +import type { AnyCellRecord } from '../types/cell.types'; +import type { GraphStateSelector, SelectedEqual } from '../store/graph-state'; +import { useGraphStateReader } from './use-graph-state-reader'; +import { useLatestRef } from './use-latest-ref'; +import { rethrowLater } from '../utils/scheduler'; + +/** + * Callback of {@link useOnGraphStateChange}: receives the newly selected value + * and the one before it, absent on the first call. + * @group Types + * @example + * ```ts + * import type { OnGraphStateChange } from '@joint/react'; + * + * const logVersion: OnGraphStateChange = (version, previousVersion) => { + * console.info(`sizing version: ${previousVersion ?? '-'} → ${version}`); + * }; + * ``` + */ +export type OnGraphStateChange = (value: Selected, previousValue?: Selected) => void; + +/** + * The effect behind {@link useOnGraphStateChange} and {@link useOnCellsChange}. + * The equality and the callback are read when they are needed, so neither has + * to be stable. A new selector is read at once and calls back if it selects + * another value; `watchKey` says what is watched, and a new one starts over. + * @param selector - Derives the value to watch from the graph state. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @param watchKey - Identifies what is watched; changing it runs the callback + * again with the current value and no previous one. + * @param hookName - The hook the application called, named in dev warnings. + * @internal + */ +export function useWatchGraphState( + selector: GraphStateSelector, + onChange: OnGraphStateChange, + isEqual?: SelectedEqual, + watchKey?: unknown, + hookName = 'useOnGraphStateChange' +): void { + const reader = useGraphStateReader(hookName); + const selectorRef = useLatestRef(selector); + const isEqualRef = useLatestRef(isEqual); + const onChangeRef = useLatestRef(onChange); + + const checkRef = useRef<() => void>(undefined); + + useLayoutEffect(() => { + // Never called while rendering, so the subscription can follow each read. + const read = () => reader.sync(selectorRef.current, isEqualRef.current); + // `read` returns the last value while it is equal, so identity is the test. + let previous = read(); + onChangeRef.current(previous); + const check = () => { + // This may run inside the store's notification: an error, of the selector + // or of the callback, must not keep the other subscribers of this change + // from being notified. + try { + const next = read(); + if (Object.is(next, previous)) return; + const before = previous; + previous = next; + onChangeRef.current(next, before); + } catch (error) { + rethrowLater(error); + } + }; + checkRef.current = check; + const unsubscribe = reader.subscribe(check); + return () => { + checkRef.current = undefined; + unsubscribe(); + }; + }, [reader, watchKey, selectorRef, isEqualRef, onChangeRef]); + + // A new selector or equality can change the selected value without a store + // change. Declared after the effect above, so on mount and on a new + // `watchKey` it finds nothing to do. + useLayoutEffect(() => { + checkRef.current?.(); + }, [selector, isEqual]); +} diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 368aabb4f4..661ab7222b 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -142,6 +142,30 @@ export { useOnElementsMeasured } from './hooks/use-on-elements-measured'; /** @group Types */ export type { ElementsMeasuredParams, OnElementsMeasured } from './hooks/use-on-elements-measured'; +/** + * useGraphState() + * @group Hooks + */ +export { useGraphState } from './hooks/use-graph-state'; +/** @group Types */ +export type { GraphState, GraphStateSelector } from './store/graph-state'; + +/** + * useOnGraphStateChange() + * @group Hooks + */ +export { useOnGraphStateChange } from './hooks/use-on-graph-state-change'; +/** @group Types */ +export type { OnGraphStateChange } from './hooks/use-on-graph-state-change'; + +/** + * useOnCellsChange() + * @group Hooks + */ +export { useOnCellsChange } from './hooks/use-on-cells-change'; +/** @group Types */ +export type { OnCellsChange } from './hooks/use-on-cells-change'; + /** * useOnPaperEvents() * @group Hooks @@ -191,7 +215,12 @@ export { selectCellParent, selectCellLayer, selectCellZIndex, -} from './selectors/cell-selectors'; +} from './selectors'; +/** + * Graph state selectors, pass to {@link useGraphState} / {@link useOnGraphStateChange} + * @group Selectors + */ +export { selectGraphSizingVersion, selectIsGraphSizingComplete } from './selectors'; // Data // ---- diff --git a/packages/joint-react/src/utils/__tests__/selector-utils.test.ts b/packages/joint-react/src/selectors/__tests__/selector-utils.test.ts similarity index 100% rename from packages/joint-react/src/utils/__tests__/selector-utils.test.ts rename to packages/joint-react/src/selectors/__tests__/selector-utils.test.ts diff --git a/packages/joint-react/src/selectors/__tests__/index.test.ts b/packages/joint-react/src/selectors/__tests__/store-selectors.test.ts similarity index 93% rename from packages/joint-react/src/selectors/__tests__/index.test.ts rename to packages/joint-react/src/selectors/__tests__/store-selectors.test.ts index 8180ffd7b5..7ed1161cf9 100644 --- a/packages/joint-react/src/selectors/__tests__/index.test.ts +++ b/packages/joint-react/src/selectors/__tests__/store-selectors.test.ts @@ -2,10 +2,10 @@ import { selectResetVersion, createSelectPaperVersion, selectGraphFeaturesVersion, -} from '../index'; +} from '../store-selectors'; import type { GraphStoreInternalSnapshot } from '../../store/graph-store'; -describe('selectors/index', () => { +describe('store-selectors', () => { const snapshot: GraphStoreInternalSnapshot = { papers: { 'paper-1': { version: 7 } as GraphStoreInternalSnapshot['papers'][string], diff --git a/packages/joint-react/src/selectors/graph-state-selectors.ts b/packages/joint-react/src/selectors/graph-state-selectors.ts new file mode 100644 index 0000000000..08056ba837 --- /dev/null +++ b/packages/joint-react/src/selectors/graph-state-selectors.ts @@ -0,0 +1,67 @@ +import type { GraphState } from '../store/graph-state'; + +/** + * Selects the sizing version of the diagram, a value to react to: `0` while the + * element sizes are not known, and otherwise a number that changes each time the + * sizes settle. That is the first pass, and each later addition, removal or + * re-measurement, once no element is still waiting to be measured. It is `0` + * again after a graph reset, or when no element is left, until the diagram + * settles anew. A value is never reused, so it works as an effect + * dependency; treat it as opaque beyond "zero or not". + * + * Sizes the application writes itself (`cell.resize()`, controlled `cells` + * sync) are not measurements and do not change it. + * + * Pass it to {@link useGraphState} or {@link useOnGraphStateChange}, or call it + * from your own selector. A hook that reads only the sizing version does no + * work on other graph changes (a drag, for example). + * @param state - The graph state. + * @returns The sizing version, `0` when nothing is measured yet. + * @group Selectors + * @example + * ```tsx + * import { useOnGraphStateChange, selectGraphSizingVersion, useGraph } from '@joint/react'; + * + * // Re-run a layout each time the element sizes settle. + * function AutoLayout() { + * const { graph } = useGraph(); + * useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + * if (sizingVersion) runLayout(graph); + * }); + * return null; + * } + * ``` + */ +export const selectGraphSizingVersion = (state: Pick): number => + state.sizingVersion; + +/** + * Selects whether the element sizes of the diagram are known: `false` until the + * sizes first settle, and again after a graph reset, or when no element is + * left, until the diagram settles anew. Use it when something should happen + * once per diagram, such as fitting the paper; use + * {@link selectGraphSizingVersion} to react to every later change too. + * + * Pass it to {@link useGraphState} or {@link useOnGraphStateChange}, or call it + * from your own selector. + * @param state - The graph state. + * @returns `true` once the element sizes are known. + * @group Selectors + * @example + * ```tsx + * import { useLayoutEffect } from 'react'; + * import { useGraphState, usePaper, selectIsGraphSizingComplete } from '@joint/react'; + * + * // Mount inside a : fit the paper once the sizes are known. + * function FitOnMeasure() { + * const { paper } = usePaper(); + * const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + * useLayoutEffect(() => { + * if (isSizingComplete && paper) paper.transformToFitContent({ padding: 20 }); + * }, [isSizingComplete, paper]); + * return null; + * } + * ``` + */ +export const selectIsGraphSizingComplete = (state: Pick): boolean => + state.sizingVersion > 0; diff --git a/packages/joint-react/src/selectors/index.ts b/packages/joint-react/src/selectors/index.ts index a141f876cb..b6d1c66e5f 100644 --- a/packages/joint-react/src/selectors/index.ts +++ b/packages/joint-react/src/selectors/index.ts @@ -1,25 +1,9 @@ -import type { GraphStoreInternalSnapshot } from '../store/graph-store'; - -// ── Public cell selectors ─────────────────────────────────────────────────── - +// Cell selectors (`selectCell*`, `selectElement*`), passed to `useCell` / +// `useCells` / `useOnCellsChange`. export * from './cell-selectors'; +// Graph state selectors (`select*Graph*`), passed to `useGraphState` / +// `useOnGraphStateChange`. +export * from './graph-state-selectors'; -// ── Internal selectors ────────────────────────────────────────────────────── - -export const selectResetVersion = (state: GraphStoreInternalSnapshot): number => state.resetVersion; - -/** - * Creates a selector for the version of a specific paper. - * @param id - The paper ID to select the version for. - */ -export function createSelectPaperVersion(id: string) { - return (snapshot: GraphStoreInternalSnapshot) => snapshot.papers[id]?.version; -} - -/** - * Selects the graph features version from the internal snapshot. - * Used to trigger re-renders when graph-level features change. - * @param state - */ -export const selectGraphFeaturesVersion = (state: GraphStoreInternalSnapshot): number => - state.graphFeaturesVersion; +// Internal selectors of the graph store snapshot. +export * from './store-selectors'; diff --git a/packages/joint-react/src/utils/selector-utils.ts b/packages/joint-react/src/selectors/selector-utils.ts similarity index 92% rename from packages/joint-react/src/utils/selector-utils.ts rename to packages/joint-react/src/selectors/selector-utils.ts index 82c20771c7..a78c635a13 100644 --- a/packages/joint-react/src/utils/selector-utils.ts +++ b/packages/joint-react/src/selectors/selector-utils.ts @@ -1,6 +1,5 @@ import type { ElementPosition, ElementSize } from '../types/cell.types'; - export const isStrictEqual = Object.is; export const identitySelector = (item: T) => item; @@ -10,10 +9,7 @@ export const identitySelector = (item: T) => item; * @param a * @param b */ -export function isShallowEqual( - a: object | undefined, - b: object | undefined -): boolean { +export function isShallowEqual(a: object | undefined, b: object | undefined): boolean { if (a === b) return true; if (!a || !b) return false; const objectA = a as Record; @@ -55,7 +51,7 @@ export function isPositionEqual(a?: ElementPosition, b?: ElementPosition): boole * @param b - second array * @returns true when both arrays have the same length and the same element refs */ -export function areArraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { +function areArraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { if (a === b) return true; if (a.length !== b.length) return false; for (const [index, value] of a.entries()) { diff --git a/packages/joint-react/src/selectors/store-selectors.ts b/packages/joint-react/src/selectors/store-selectors.ts new file mode 100644 index 0000000000..a852b40ef0 --- /dev/null +++ b/packages/joint-react/src/selectors/store-selectors.ts @@ -0,0 +1,19 @@ +import type { GraphStoreInternalSnapshot } from '../store/graph-store'; + +export const selectResetVersion = (state: GraphStoreInternalSnapshot): number => state.resetVersion; + +/** + * Creates a selector for the version of a specific paper. + * @param id - The paper ID to select the version for. + */ +export function createSelectPaperVersion(id: string) { + return (snapshot: GraphStoreInternalSnapshot) => snapshot.papers[id]?.version; +} + +/** + * Selects the graph features version from the internal snapshot. + * Used to trigger re-renders when graph-level features change. + * @param state + */ +export const selectGraphFeaturesVersion = (state: GraphStoreInternalSnapshot): number => + state.graphFeaturesVersion; diff --git a/packages/joint-react/src/state/data-mapping/cell-record-merge.ts b/packages/joint-react/src/state/data-mapping/cell-record-merge.ts index b8ccc98961..675857926c 100644 --- a/packages/joint-react/src/state/data-mapping/cell-record-merge.ts +++ b/packages/joint-react/src/state/data-mapping/cell-record-merge.ts @@ -1,6 +1,6 @@ import type { dia } from '@joint/core'; import type { ElementJSONInit, LinkJSONInit } from '../../types/cell.types'; -import { isShallowEqual, isPositionEqual, isSizeEqual } from '../../utils/selector-utils'; +import { isShallowEqual, isPositionEqual, isSizeEqual } from '../../selectors/selector-utils'; import { mapAttributesToElement } from './element-mapper'; import { mapAttributesToLink } from './link-mapper'; diff --git a/packages/joint-react/src/store/__tests__/graph-state.test.ts b/packages/joint-react/src/store/__tests__/graph-state.test.ts new file mode 100644 index 0000000000..dbc9d0d405 --- /dev/null +++ b/packages/joint-react/src/store/__tests__/graph-state.test.ts @@ -0,0 +1,385 @@ +import { dia, mvc } from '@joint/core'; +import { GraphStore } from '../graph-store'; +import { createGraphStateReader, type GraphState } from '../graph-state'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../measurement'; +import type { CellRecord } from '../../types/cell.types'; + +const element = (id: string, x = 0): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, + }) as CellRecord; + +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +const selectX = (id: string) => (state: GraphState) => state.getCell(id)?.position?.x; +const selectCount = (state: GraphState) => state.cells.length; +const selectState = (state: GraphState) => state; +const selectMeasured = (state: GraphState) => state.sizingVersion; +const selectVersionAndWidth = (state: GraphState) => + `${state.sizingVersion > 0}:${state.getCell('a')?.size?.width}`; +const selectIds = (state: GraphState) => state.cells.map((cell) => cell.id); +const selectPicked = (state: GraphState) => + state.getCells(['b', 'missing', 'a']).map((cell) => cell.id); +const selectAAndLate = (state: GraphState) => state.getCells(['a', 'late']); +/** Reads the cells only once the diagram is measured. */ +const selectCountOnceMeasured = (state: GraphState) => + state.sizingVersion > 0 ? state.cells.length : -1; +const selectAndThrow = (state: GraphState) => { + state.getCell('b'); + throw new Error('selector failed'); +}; +const selectMemberIds = (collection: mvc.Collection) => (state: GraphState) => + state.getCells(collection).map((cell) => cell.id); +const selectMembers = (collection: mvc.Collection) => (state: GraphState) => + state.getCells(collection); +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const MANY_IDS = Array.from({ length: 50 }, (_, index) => `n${index}`); +const MANY_ELEMENTS = MANY_IDS.map((id) => element(id)); +const createStore = () => new GraphStore({ initialCells: [element('a'), element('b', 50)] }); +/** Changes the graph and drains the store's microtask commit. */ +const commit = async (run: () => void) => { + run(); + await flush(); +}; + +describe('createGraphStateReader', () => { + let store: ReturnType; + const getElement = (id: string) => store.graph.getCell(id) as dia.Element; + + beforeEach(() => { + store = createStore(); + }); + afterEach(() => store.destroy(false)); + + describe('read', () => { + it('selects from all cells, one cell, several cells and the sizing version', async () => { + await flush(); + const reader = createGraphStateReader(store); + + expect(reader.read(selectCount)).toBe(2); + expect(reader.read(selectX('b'))).toBe(50); + expect(reader.read(selectX('missing'))).toBeUndefined(); + expect(reader.read(selectPicked)).toEqual(['b', 'a']); + expect(reader.read(selectMeasured)).toBe(store.measurement.getSizingVersion()); + }); + + it('does not run the selector again while what it read is unchanged', async () => { + const reader = createGraphStateReader(store); + const selector = jest.fn(selectX('a')); + + reader.read(selector); + reader.read(selector); + // Another cell changed: this selector read only `a`. + await commit(() => getElement('b').position(70, 0)); + reader.read(selector); + + expect(selector).toHaveBeenCalledTimes(1); + }); + + // Regression: the render-to-subscribe gap. Staleness is tested against the + // store, so a change is seen without any subscription delivering it. + it('sees a change that happened while nothing was subscribed', async () => { + const reader = createGraphStateReader(store); + const selector = selectX('a'); + expect(reader.read(selector)).toBe(0); + + await commit(() => getElement('a').position(30, 0)); + + expect(reader.read(selector)).toBe(30); + }); + + it('runs a new selector function even when the store did not change', () => { + const reader = createGraphStateReader(store); + + expect(reader.read(selectX('a'))).toBe(0); + expect(reader.read(selectX('b'))).toBe(50); + }); + + it('returns the previous reference while the new value is equal', async () => { + const reader = createGraphStateReader(store); + const before = reader.read(selectIds); + await commit(() => getElement('a').position(30, 0)); + + expect(reader.read(selectIds)).toBe(before); + }); + + it('uses a custom equality', async () => { + const reader = createGraphStateReader(store); + expect(reader.read(selectCount, isSameParity)).toBe(2); + await commit(() => store.graph.addCells([element('c'), element('d')])); + expect(reader.read(selectCount, isSameParity)).toBe(2); + await commit(() => store.graph.addCell(element('e'))); + expect(reader.read(selectCount, isSameParity)).toBe(5); + }); + + it('recovers after a selector that throws', () => { + const reader = createGraphStateReader(store); + expect(reader.read(selectX('a'))).toBe(0); + + expect(() => reader.read(selectAndThrow)).toThrow('selector failed'); + expect(reader.read(selectX('a'))).toBe(0); + }); + }); + + // The reader follows what a selector reads while it runs. A state kept and + // read later would silently never update, so it says so. + it('warns, and follows nothing, when the state is read outside of a selector', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const leaked = reader.read(selectState); + reader.subscribe(listener); + + expect(leaked.getCell('a')?.id).toBe('a'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('outside of its selector')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + describe('subscribe', () => { + it('subscribes only to what the selector read', async () => { + const { cells } = store.graphProjection; + const subscribeToAll = jest.spyOn(cells, 'subscribe'); + const subscribeById = jest.spyOn(cells, 'subscribeById'); + const subscribeToMeasurement = jest.spyOn(store.measurement, 'subscribe'); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + + reader.read(selectX('a')); + const unsubscribe = reader.subscribe(listener); + + expect(subscribeById).toHaveBeenCalledTimes(1); + expect(subscribeById).toHaveBeenCalledWith('a', expect.any(Function)); + expect(subscribeToAll).not.toHaveBeenCalled(); + expect(subscribeToMeasurement).not.toHaveBeenCalled(); + + await commit(() => getElement('b').position(70, 0)); + expect(listener).not.toHaveBeenCalled(); + await commit(() => getElement('a').position(30, 0)); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + await commit(() => getElement('a').position(40, 0)); + expect(listener).toHaveBeenCalledTimes(1); + }); + + // Regression: the sizing version was announced in the same scheduler round + // as the measured write, ahead of the projection commit of that write, so + // a selector reading cells next to the version saw the pre-measure sizes. + it('announces a new sizing version once the measured sizes are in the cells', async () => { + await flush(); + const reader = createGraphStateReader(store); + const seen: string[] = []; + reader.sync(selectVersionAndWidth); + reader.subscribe(() => seen.push(reader.sync(selectVersionAndWidth))); + + await commit(() => { + store.graph.startBatch('auto-size'); + getElement('a').set('size', { width: 99, height: 10 }, { [AUTO_SIZE_OPTION]: true }); + store.graph.stopBatch('auto-size'); + }); + await flush(); + + expect(seen).not.toContain('true:10'); + expect(seen.at(-1)).toBe('true:99'); + }); + + it('is not notified of a cell change when the selector reads the sizing version', async () => { + await flush(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMeasured); + reader.subscribe(listener); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + + await commit(() => store.graph.addCell(element('c'))); + expect(listener).toHaveBeenCalled(); + }); + + // Regression: the render-to-subscribe gap, with the subscription in place + // by the time of the next read. Nothing notified it of the change. + it('sees a change that happened between the read and the subscription', async () => { + const reader = createGraphStateReader(store); + const selector = selectX('a'); + expect(reader.read(selector)).toBe(0); + + await commit(() => getElement('a').position(30, 0)); + reader.subscribe(jest.fn()); + + expect(reader.read(selector)).toBe(30); + }); + + it('moves the subscription when the selector reads something else', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectX('a')); + reader.subscribe(listener); + + reader.sync(selectX('b')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + await commit(() => getElement('b').position(70, 0)); + expect(listener).toHaveBeenCalledTimes(1); + }); + + // Regression: a read alone moved the subscription. React reads while + // rendering, and a render it throws away (a transition, a suspended tree) + // left the committed selector without its notifications. + it('keeps the subscription where it is on a read that does not sync', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const committed = selectX('a'); + reader.read(committed); + reader.subscribe(() => { + listener(); + reader.read(committed); + }); + + // What a discarded render does: reads with another selector, and no more. + reader.read(selectX('b')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).toHaveBeenCalledTimes(1); + expect(reader.read(committed)).toBe(30); + }); + + it('follows a selector that reads conditionally', async () => { + const emptyStore = new GraphStore({}); + const reader = createGraphStateReader(emptyStore); + const listener = jest.fn(); + const selector = selectCountOnceMeasured; + expect(reader.read(selector)).toBe(-1); + reader.subscribe(() => { + listener(); + reader.read(selector); + }); + + // Measured now: the listener's read picks up the cells as a second source. + emptyStore.graph.addCells([element('a'), element('b')]); + await flush(); + expect(reader.read(selector)).toBe(2); + listener.mockClear(); + + emptyStore.graph.getCell('a').set('position', { x: 30, y: 0 }); + await flush(); + expect(listener).toHaveBeenCalled(); + emptyStore.destroy(false); + }); + + // Regression: every notification scanned all the watched cells, so one + // commit moving k of N watched cells cost k scans of N. + it('reads each watched cell a constant number of times when all of them change at once', async () => { + const ids = MANY_IDS; + store.graph.addCells(MANY_ELEMENTS); + await flush(); + const reader = createGraphStateReader(store); + const selector = jest.fn((state: GraphState) => state.getCells(ids)); + reader.read(selector); + // What a subscribed hook does: read again on every notification. + reader.subscribe(() => reader.read(selector)); + selector.mockClear(); + const getCell = jest.spyOn(store.graphProjection.cells, 'get'); + + await commit(() => { + store.graph.startBatch('move'); + for (const id of ids) getElement(id).position(5, 5); + store.graph.stopBatch('move'); + }); + + expect(selector).toHaveBeenCalledTimes(1); + // One lookup per notification and one per id in the selector run, not ids². + expect(getCell.mock.calls.length).toBeLessThanOrEqual(ids.length * 3); + }); + + it('is notified when a missing cell it asked for is added', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const selector = selectAAndLate; + expect(reader.read(selector)).toHaveLength(1); + reader.subscribe(listener); + + await commit(() => store.graph.addCell(element('late'))); + + expect(listener).toHaveBeenCalled(); + expect(reader.read(selector)).toHaveLength(2); + }); + }); + + describe('collections', () => { + it('reads the members of a collection, in its order, and follows membership', () => { + const collection = new mvc.Collection([getElement('b')]); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const selector = selectMemberIds(collection); + expect(reader.read(selector)).toEqual(['b']); + reader.subscribe(listener); + + collection.add(getElement('a')); + expect(listener).toHaveBeenCalledTimes(1); + expect(reader.read(selector)).toEqual(['b', 'a']); + + collection.remove(getElement('b')); + expect(reader.read(selector)).toEqual(['a']); + + collection.reset([]); + expect(reader.read(selector)).toEqual([]); + }); + + // Regression: membership was tracked by a counter the subscription bumped, + // so a change before the subscription existed was missed. + it('sees a membership change that happened while nothing was subscribed', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const selector = selectMemberIds(collection); + expect(reader.read(selector)).toEqual([]); + + collection.add(getElement('a')); + + expect(reader.read(selector)).toEqual(['a']); + }); + + it('is notified once for several cells added together', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMemberIds(collection)); + reader.subscribe(listener); + + collection.add([getElement('a'), getElement('b')]); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('reads a member the graph does not hold from the collection, as one stable record', () => { + const detached = new dia.Element({ id: 'clone', type: ELEMENT_MODEL_TYPE }); + const collection = new mvc.Collection([detached]); + const reader = createGraphStateReader(store); + const first = reader.read(selectMembers(collection)); + const second = reader.read(selectMembers(collection)); + + expect(first.map((cell) => (cell as CellRecord).id)).toEqual(['clone']); + expect(second[0]).toBe(first[0]); + }); + + it('stops listening to the collection on unsubscribe', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMemberIds(collection)); + const unsubscribe = reader.subscribe(listener); + + unsubscribe(); + collection.add(getElement('a')); + + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/joint-react/src/store/__tests__/measurement.test.ts b/packages/joint-react/src/store/__tests__/measurement.test.ts index 98a4ee79ad..ea95d3aa51 100644 --- a/packages/joint-react/src/store/__tests__/measurement.test.ts +++ b/packages/joint-react/src/store/__tests__/measurement.test.ts @@ -623,7 +623,7 @@ describe('measurement — graph events', () => { expect(version()).toBe(2); }); - it('keeps the version once the last element is removed', async () => { + it('clears the version when empty and does not reuse it when elements return', async () => { const { graph, version, add } = setup(); graph.addCells([sized('a'), unsized('z')]); await flush(); @@ -634,10 +634,10 @@ describe('measurement — graph events', () => { // A zero-sized element is still an element with a known size. expect(version()).toBe(2); - // An empty diagram is the same diagram: only a reset starts a new history. + // The public sizing state becomes incomplete on an empty diagram. graph.getCell('z').remove(); await flush(); - expect(version()).toBe(2); + expect(version()).toBe(0); add('b'); await flush(); @@ -682,10 +682,10 @@ describe('measurement — graph events', () => { expect(version()).toBe(2); expect(measurement.isSettled()).toBe(true); - // The version is kept, but it describes elements that are gone. + // No elements remain, so there is no measured state to replay. graph.getCell('a').remove(); await flush(); - expect(version()).toBe(2); + expect(version()).toBe(0); expect(measurement.isSettled()).toBe(false); }); @@ -799,7 +799,7 @@ describe('measurement — graph events', () => { graph.getCell('b').remove(); await flush(); - expect(measurement.getSizingVersion()).toBe(2); + expect(measurement.getSizingVersion()).toBe(0); }); it('does not track links', async () => { diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index 68a2ada1e1..77789e5e97 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -232,6 +232,12 @@ export function graphProjection< } } + // Cleared before anything is notified: a subscriber may change the graph + // from its callback, and those changes are recorded here for the next + // pass. Clearing afterwards would drop them and leave the store out of + // sync with the graph. + changes.clear(); + // Two commit modes, decided by `deferCommit` (see graph-changes): // // - Plain batches (interactive drags, `auto-size`, layout) and lone edits @@ -257,8 +263,6 @@ export function graphProjection< changed!.clear(); removed!.clear(); } - - changes.clear(); }, }); diff --git a/packages/joint-react/src/store/graph-state.ts b/packages/joint-react/src/store/graph-state.ts new file mode 100644 index 0000000000..6f2acef3a9 --- /dev/null +++ b/packages/joint-react/src/store/graph-state.ts @@ -0,0 +1,372 @@ +import { mvc, type dia } from '@joint/core'; +import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types'; +import type { GraphStore } from './graph-store'; +import type { ReadonlyContainer } from './state-container'; +import { toCellRecord } from '../state/data-mapping/cell-record-merge'; +import { isCollection } from '../utils/is'; +import { warnGraphStateReadOutsideSelector, warnUnstableSelector } from '../utils/dev-warnings'; +import { arrayAwareEqual } from '../selectors/selector-utils'; + +/** + * The state of a graph, as a selector of {@link useGraphState} or + * {@link useOnGraphStateChange} sees it. Read it inside the selector only: the + * hook subscribes to exactly what the selector read, so a selector that reads + * one cell is not run when another cell changes, and one that reads + * `sizingVersion` is not run on a drag. + * @template Cell - Input cell record shape (defaults to CellRecord); reads resolve to its Computed form. + * @group Types + * @example + * ```tsx + * import { useGraphState, type GraphState } from '@joint/react'; + * + * const selectCellCount = (state: GraphState) => state.cells.length; + * + * function CellCount() { + * return {useGraphState(selectCellCount)} cells; + * } + * ``` + */ +export interface GraphState { + /** All cells. A new array on every graph change, so a selector reading it runs on each of them. */ + readonly cells: ReadonlyArray>; + /** + * One cell by id, or `undefined` when the graph does not hold it. + * @param id - The id of the cell to read. + */ + readonly getCell: (id: CellId) => Computed | undefined; + /** + * The cells with the given ids, or the cells of a JointJS collection, in + * that order. Cells the graph does not hold are skipped, except the members + * of a collection, which are read from the collection itself. + * @param target - Cell ids, or a `mvc.Collection` of cells. + */ + readonly getCells: ( + target: readonly CellId[] | mvc.Collection + ) => ReadonlyArray>; + /** + * `0` while the element sizes are not known, otherwise a number that changes + * each time they settle. See {@link selectGraphSizingVersion}. + */ + readonly sizingVersion: number; +} + +/** + * Derives a value from the {@link GraphState}. + * @group Types + * @example + * ```ts + * import type { CellRecord, GraphStateSelector } from '@joint/react'; + * + * const selectElementCount: GraphStateSelector = (state) => + * state.cells.filter((cell) => cell.type === 'element').length; + * ``` + */ +export type GraphStateSelector = ( + state: GraphState +) => Selected; + +/** + * Equality test that tells whether a newly selected value is a change. + * @group Types + */ +export type SelectedEqual = (a: Selected, b: Selected) => boolean; + +const ALL_CELLS = Symbol('cells'); +const SIZING_VERSION = Symbol('sizingVersion'); +/** Membership and order of a collection; `sort` reorders without adding or removing. */ +const COLLECTION_EVENTS = 'update reset sort'; + +/** A part of the graph state a selector can read: all cells, the sizing version, a collection or one cell. */ +type Source = typeof ALL_CELLS | typeof SIZING_VERSION | mvc.Collection | CellId; + +/** The parts of a {@link GraphStore} the reader reads, whatever its cell shapes are. */ +interface GraphStateStore { + readonly graphProjection: { readonly cells: ReadonlyContainer }; + readonly measurement: Pick; +} + +interface Subscription { + readonly listener: () => void; + readonly unsubscribers: Map void>; +} + +const collectionIds = new WeakMap< + mvc.Collection, + { readonly models: readonly dia.Cell[]; readonly ids: readonly CellId[] } +>(); + +/** + * The ids of a collection's cells, the same array for as long as the + * membership, model identities and order are. Replacing a detached model with + * another of the same id must invalidate its cached record too. + * O(members) per call, nothing allocated while unchanged. + * @param collection - The collection to read. + */ +function getCollectionIds(collection: mvc.Collection): readonly CellId[] { + const { models } = collection; + const known = collectionIds.get(collection); + if (known?.models.length === models.length) { + let isSame = true; + for (const [index, model] of models.entries()) { + if (model !== known.models[index] || model.id !== known.ids[index]) { + isSame = false; + break; + } + } + if (isSame) return known.ids; + } + const ids = models.map((model) => model.id); + collectionIds.set(collection, { models: [...models], ids }); + return ids; +} + +/** + * The record of a collection member the graph does not hold (a `ui.Clipboard` + * clone, for example), one per cell so repeated reads share a reference. + * @param collection - The collection the cell is a member of. + * @param id - The id of the cell. + * @param detachedRecords - The current reader's detached model snapshots. + */ +function getDetachedRecord( + collection: mvc.Collection, + id: CellId, + detachedRecords: WeakMap +): AnyCellRecord | undefined { + const cell = collection.get(id); + if (!cell) return undefined; + let record = detachedRecords.get(cell); + if (!record) { + record = toCellRecord(cell); + detachedRecords.set(cell, record); + } + return record; +} + +/** + * Reads a selected value from a graph store and keeps track of what the + * selector read, so that it is run again, and its subscriber notified, only + * when one of those parts changes. One reader serves one hook instance. + * + * The staleness test compares what the selector saw with the store itself, not + * with what the subscription delivered, so a change that lands between a + * render and its subscription is not missed. + * @param store - The graph store to read. + * @param hookName - The hook the application called, named in dev warnings. + * @returns `read`, `sync` and `subscribe`, all identity-stable. + * @internal + */ +export function createGraphStateReader( + store: GraphStateStore, + hookName = 'useGraphState' +) { + // The store holds resolved (Computed) records of the application's own cell + // shape; the `Cell` generic is how the caller declares that shape. + const cells = store.graphProjection.cells as ReadonlyContainer>; + const { measurement } = store; + // Cache detached snapshots for this hook's lifetime only. A later hook must + // read the model's current attributes even when it reuses the collection. + const detachedRecords = new WeakMap(); + // What the last selector run read, with the value it saw. + const seen = new Map(); + let subscription: Subscription | undefined; + // The selector `seen` belongs to. Unset from the start of a run until it + // returns, so a selector that throws leaves nothing to trust. + let lastSelector: GraphStateSelector | undefined; + let lastEquality: SelectedEqual | undefined; + let selected: { readonly value: Selected } | undefined; + // True only while a selector runs: the state is not to be read at any other time. + let isSelecting = false; + // True while the subscription listens to exactly what `seen` holds. Then a + // change cannot go unreported, and `isDirty` says whether one was: a read + // needs no scan of `seen`. Without it, k changed cells out of N watched ones + // cost k scans of N per commit. + let isListeningToSeen = false; + let isDirty = false; + + const current = (source: Source): unknown => { + if (source === ALL_CELLS) return cells.getSnapshot(); + if (source === SIZING_VERSION) return measurement.getSizingVersion(); + if (typeof source === 'object') return getCollectionIds(source); + return cells.get(source); + }; + + const subscribeTo = (source: Source, notify: () => void): (() => void) => { + // Compares its own source only: once a selector run has seen the new + // values, the rest of the notifications of the same commit find no change. + const listener = () => { + if (!Object.is(current(source), seen.get(source))) isDirty = true; + notify(); + }; + if (source === ALL_CELLS) return cells.subscribe(listener); + if (source === SIZING_VERSION) return measurement.subscribe(listener); + if (typeof source === 'object') { + const controller = new mvc.Listener(); + controller.listenTo(source, COLLECTION_EVENTS, listener); + return () => controller.stopListening(); + } + return cells.subscribeById(source, listener); + }; + + const hasChanged = (): boolean => { + if (isListeningToSeen && !isDirty) return false; + for (const [source, value] of seen) { + if (!Object.is(current(source), value)) return true; + } + isDirty = false; + return false; + }; + + /** Makes the subscription listen to what the last run read, and to nothing else. */ + const syncSubscription = ({ listener, unsubscribers }: Subscription): void => { + for (const [source, unsubscribe] of unsubscribers) { + if (seen.has(source)) continue; + unsubscribe(); + unsubscribers.delete(source); + } + for (const source of seen.keys()) { + if (unsubscribers.has(source)) continue; + unsubscribers.set(source, subscribeTo(source, listener)); + // It may have changed between being read and being listened to. + isDirty = true; + } + isListeningToSeen = true; + }; + + /** Records what a selector read. Outside of a selector run nothing would follow it. */ + const see = (source: Source, value: unknown): void => { + if (isSelecting) seen.set(source, value); + else warnGraphStateReadOutsideSelector(); + }; + + const getCell = (id: CellId): Computed | undefined => { + const cell = cells.get(id); + see(id, cell); + return cell; + }; + + const state: GraphState = { + get cells() { + const snapshot = cells.getSnapshot(); + see(ALL_CELLS, snapshot); + return snapshot; + }, + get sizingVersion() { + const sizingVersion = measurement.getSizingVersion(); + see(SIZING_VERSION, sizingVersion); + return sizingVersion; + }, + getCell, + getCells(target) { + let collection: mvc.Collection | undefined; + let ids: readonly CellId[]; + if (isCollection(target)) { + collection = target; + ids = getCollectionIds(target); + see(target, ids); + } else { + ids = target; + } + const picked: Array> = []; + for (const id of ids) { + // A detached record is of the application's cell shape too, like the + // records of the container above. + const cell = + getCell(id) ?? + (collection && + (getDetachedRecord(collection, id, detachedRecords) as Computed | undefined)); + if (cell !== undefined) picked.push(cell); + } + return picked; + }, + }; + + const read = ( + selector: GraphStateSelector, + isEqual: SelectedEqual = arrayAwareEqual + ): Selected => { + if (selected && selector === lastSelector && isEqual === lastEquality && !hasChanged()) { + return selected.value; + } + lastSelector = undefined; + lastEquality = undefined; + isListeningToSeen = false; + seen.clear(); + // Stays set if the selector throws: a read outside of a selector then goes + // unreported until the next run, which is all that is lost. + isSelecting = true; + const next = selector(state); + isSelecting = false; + lastSelector = selector; + lastEquality = isEqual; + isDirty = false; + if (selected) { + if (isEqual(selected.value, next)) return selected.value; + warnUnstableSelector(hookName, selected.value, next, isEqual !== arrayAwareEqual); + } + selected = { value: next }; + return next; + }; + + return { + /** + * The value `selector` selects. The selector runs when it is a new function + * or when something its last run read has changed; otherwise, and whenever + * the new value is equal to the last one, the last value is returned as is. + * + * Safe to call while rendering: it leaves the subscription alone, so a + * render React throws away cannot move it. See {@link sync}. + * @param selector - derives the value from the graph state + * @param isEqual - tells whether a newly selected value is a change + * @returns the selected value, reference-stable while it is equal + */ + read, + /** + * Reads like {@link read}, then makes the subscription listen to what that + * run read. Call it with the selector that is in effect, outside of render: + * once it has committed, or when a notification arrives. + * @param selector - derives the value from the graph state + * @param isEqual - tells whether a newly selected value is a change + * @returns the selected value, reference-stable while it is equal + */ + sync( + selector: GraphStateSelector, + isEqual?: SelectedEqual + ): Selected { + const value = read(selector, isEqual); + if (subscription && lastSelector && !isListeningToSeen) syncSubscription(subscription); + return value; + }, + /** + * Calls `listener` when something the selector read may have changed. The + * listener is expected to read again with the selector in effect; the + * subscription then moves to whatever that run read. + * @param listener - called on a change, synchronously with the store + * @returns the unsubscribe function + */ + subscribe(listener: () => void): () => void { + const own: Subscription = { + listener: () => { + listener(); + // The listener has read with the selector in effect: follow it. Not + // after unsubscribing, which the listener itself may have done. + if (subscription === own && lastSelector && !isListeningToSeen) syncSubscription(own); + }, + unsubscribers: new Map(), + }; + subscription = own; + syncSubscription(own); + return () => { + for (const unsubscribe of own.unsubscribers.values()) unsubscribe(); + own.unsubscribers.clear(); + if (subscription !== own) return; + subscription = undefined; + isListeningToSeen = false; + }; + }, + }; +} + +/** Reader returned by {@link createGraphStateReader}. */ +export type GraphStateReader = ReturnType< + typeof createGraphStateReader +>; diff --git a/packages/joint-react/src/store/measurement.ts b/packages/joint-react/src/store/measurement.ts index b6da837b40..a9e8fa1e44 100644 --- a/packages/joint-react/src/store/measurement.ts +++ b/packages/joint-react/src/store/measurement.ts @@ -96,7 +96,7 @@ const roundToTwoDecimals = (value: number) => Math.round(value * 100) / 100; /** * Everything `@joint/react` knows about measuring elements, for one graph: * sizing elements from their rendered DOM node ({@link useMeasureElement}) and - * telling when sizes are settled ({@link useOnElementsMeasured}). + * telling when sizes are settled ({@link selectGraphSizingVersion}). * * The sizing version changes once per settled change. An element is outstanding from * arriving in the graph until it is accounted for: its React content committed @@ -150,9 +150,12 @@ export function createMeasurement(options: Options) { // measures, so there is nothing to wait for. if (isWaiting() && paperStores.size > 0) return; hasUndeliveredChange = false; - // An empty diagram has nothing to lay out. It is still the same diagram, - // so the version is kept: only a reset starts a new history. - if (elementCount === 0) return; + // Nothing is measured in an empty diagram. The public sizing selectors + // clear here; legacy hook initialization still follows actual graph resets. + if (elementCount === 0) { + if (version > 0) setVersion(0); + return; + } lastVersion += 1; setVersion(lastVersion); }; @@ -340,14 +343,13 @@ export function createMeasurement(options: Options) { return { /** * The sizing version: `0` while nothing is measured, otherwise a number - * that changes once per settled change. See {@link useOnElementsMeasured}. + * that changes once per settled change. See {@link selectGraphSizingVersion}. */ getSizingVersion: () => version, /** * Whether the current version describes the diagram as it is: it has * elements, none is waiting to be measured and no change waits for its - * delivery. An emptied diagram keeps a version that describes elements - * that are gone. + * delivery. An empty diagram has no settled element sizes. */ isSettled: () => elementCount > 0 && !isWaiting() && !hasUndeliveredChange, /** diff --git a/packages/joint-react/src/store/state-container.ts b/packages/joint-react/src/store/state-container.ts index b578a06fb2..b4d689041a 100644 --- a/packages/joint-react/src/store/state-container.ts +++ b/packages/joint-react/src/store/state-container.ts @@ -1,6 +1,6 @@ /* eslint-disable unicorn/prefer-spread */ import { simpleScheduler } from '../utils/scheduler'; -import { isStrictEqual } from '../utils/selector-utils'; +import { isStrictEqual } from '../selectors/selector-utils'; import type { CellId, AnyCellRecord } from '../types/cell.types'; /** diff --git a/packages/joint-react/src/utils/__tests__/scheduler.test.ts b/packages/joint-react/src/utils/__tests__/scheduler.test.ts index d64c815c8e..faf8a08057 100644 --- a/packages/joint-react/src/utils/__tests__/scheduler.test.ts +++ b/packages/joint-react/src/utils/__tests__/scheduler.test.ts @@ -95,4 +95,75 @@ describe('createScheduler', () => { expect(calls).toEqual(['batch-1', 'batch-2']); }); + // Regression: a callback that threw left the scheduler "flushing" forever, so + // every later callback, for every graph on the page, was silently dropped. + describe('a callback that throws', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not stop the other callbacks of the flush, nor later flushes', async () => { + const scheduler = createScheduler(); + const calls: string[] = []; + scheduler(() => { + throw new Error('boom'); + }); + scheduler(() => calls.push('same flush')); + await Promise.resolve(); + expect(calls).toEqual(['same flush']); + + scheduler(() => calls.push('later flush')); + await Promise.resolve(); + expect(calls).toEqual(['same flush', 'later flush']); + }); + + it('still surfaces the error, on a later task', async () => { + const scheduler = createScheduler(); + scheduler(() => { + throw new Error('boom'); + }); + await Promise.resolve(); + + expect(() => jest.runOnlyPendingTimers()).toThrow('boom'); + }); + }); + + // Regression: callbacks that keep scheduling more work were drained in one + // endless microtask, which freezes the page. The flush now yields. + describe('callbacks that keep scheduling each other', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('yields to the event loop and reports it, and carries on afterwards', async () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const scheduler = createScheduler(); + let runs = 0; + const reschedule = () => { + runs += 1; + if (runs < 1000) scheduler(reschedule); + }; + scheduler(reschedule); + await Promise.resolve(); + + // One flush did a bounded amount of work, then handed control back. + expect(runs).toBeGreaterThan(1); + expect(runs).toBeLessThan(1000); + expect(error).toHaveBeenCalledWith(expect.stringContaining('keeps scheduling')); + + // The rest is drained over later tasks, not lost. + for (let task = 0; task < 100 && runs < 1000; task += 1) { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + } + expect(runs).toBe(1000); + error.mockRestore(); + }); + }); }); diff --git a/packages/joint-react/src/utils/collection-subscription.ts b/packages/joint-react/src/utils/collection-subscription.ts deleted file mode 100644 index 14d267b3e5..0000000000 --- a/packages/joint-react/src/utils/collection-subscription.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { mvc, type dia } from '@joint/core'; -import type { AnyCellRecord, CellId } from '../types/cell.types'; -import type { ReadonlyContainer } from '../store/state-container'; - -/** - * Creates a self-managing subscription for the collection form of {@link useCells}. - * - * Subscribes to each cell ID in the container (per-ID granularity) AND to - * collection membership events (`add`/`remove`/`reset`). On membership - * change, resubscribes to the new set of IDs. - * - * All changes bump `versionRef` so `getSnapshot` returns a new value, - * causing `useSyncExternalStoreWithSelector` to re-evaluate `select`. - */ -export function subscribeToCollection( - collection: mvc.Collection, - container: ReadonlyContainer, - listener: () => void, - collectionIdsRef: { current: readonly CellId[] }, - versionRef: { current: number } -): () => void { - let cellUnsubs: Array<() => void> = []; - - const bumpAndNotify = () => { - versionRef.current++; - listener(); - }; - - const resubscribeCells = () => { - for (const unsub of cellUnsubs) unsub(); - const ids = collection.models.map((m) => m.id as CellId); - collectionIdsRef.current = ids; - cellUnsubs = ids.map((id) => container.subscribeById(id, bumpAndNotify)); - }; - - resubscribeCells(); - - const controller = new mvc.Listener(); - const onMembershipChange = () => { - resubscribeCells(); - bumpAndNotify(); - }; - controller.listenTo(collection, 'add remove reset', onMembershipChange); - - return () => { - for (const unsub of cellUnsubs) unsub(); - controller.stopListening(); - }; -} diff --git a/packages/joint-react/src/utils/dev-warnings.ts b/packages/joint-react/src/utils/dev-warnings.ts index 85ca3b68fa..692f260a2b 100644 --- a/packages/joint-react/src/utils/dev-warnings.ts +++ b/packages/joint-react/src/utils/dev-warnings.ts @@ -72,7 +72,7 @@ export function warnUnstableSelector( 'Options to fix:\n' + ' 1. Return primitives: (cells) => cells.map(c => c.id)\n' + ' 2. Return cell records directly: (cells) => cells.filter(predicate)\n' + - ' 3. Provide a custom isEqual: useCells(input, selector, shallowEqual)\n' + ' 3. Provide a custom isEqual as the last argument of the hook\n' ); } @@ -125,3 +125,40 @@ export function warnResizeOnAutoSizedElement(cellId: dia.Cell.ID): void { 'explicit size (e.g. from FreeTransform / Halo).' ); } + +/** + * Warns once when the graph state is read outside of the selector it was + * handed to (kept in a variable and read later, or after an `await`). The hook + * subscribes to what the selector reads while it runs, so such a read is not + * followed by any update. Dev-only, tree-shaken in production. + */ +export function warnGraphStateReadOutsideSelector(): void { + if (process.env.NODE_ENV === 'production') return; + const key = 'graph-state-read-outside-selector'; + if (WARNED.has(key)) return; + WARNED.add(key); + + console.warn( + '[useGraphState] The graph state was read outside of its selector. The hook follows only ' + + 'what the selector reads while it runs, so this read will not update.\n\n' + + 'Fix: read everything you need inside the selector and return it.' + ); +} + +/** + * Reports once that store callbacks keep scheduling each other, typically a + * change handler that changes what it listens to. The scheduler then spreads + * the work over later tasks instead of freezing the page. Dev-only, + * tree-shaken in production. + */ +export function warnSchedulerCascade(): void { + if (process.env.NODE_ENV === 'production') return; + const key = 'scheduler-cascade'; + if (WARNED.has(key)) return; + WARNED.add(key); + + console.error( + '[@joint/react] A store callback keeps scheduling more work each time it runs, ' + + 'for example a change handler that adds or removes cells. Guard it so it settles.' + ); +} diff --git a/packages/joint-react/src/utils/scheduler.ts b/packages/joint-react/src/utils/scheduler.ts index d09ccaf9e9..ed63804391 100644 --- a/packages/joint-react/src/utils/scheduler.ts +++ b/packages/joint-react/src/utils/scheduler.ts @@ -1,3 +1,8 @@ +import { warnSchedulerCascade } from './dev-warnings'; + +/** Rounds of cascading callbacks one flush drains before it yields to the event loop. */ +const MAX_CASCADE_ROUNDS = 100; + /** * Rethrows an error on a later task, so it still surfaces (to `window.onerror` * and the console) while the caller carries on. @@ -28,21 +33,34 @@ export function createScheduler(): (callback: () => void) => void { // Process cascading callbacks in the same flush so React sees // all store changes as a single batched update. - while (callbacks.size > 0) { + for (let round = 0; callbacks.size > 0 && round < MAX_CASCADE_ROUNDS; round += 1) { const pending = callbacks; callbacks = new Set(); for (const callback of pending) { - callback(); + // One scheduler serves every graph on the page: a callback that throws + // must not stop the others, nor leave the scheduler flushing forever. + try { + callback(); + } catch (error) { + rethrowLater(error); + } } } flushing = false; + if (callbacks.size === 0) return; + // Callbacks keep scheduling each other (a change handler that changes what + // it listens to). Draining them here would never return and freeze the + // page, so the rest runs on a later task. + scheduled = true; + setTimeout(flush); + warnSchedulerCascade(); }; return (callback: () => void): void => { callbacks.add(callback); - // If we're already inside a flush, the while-loop will pick up + // If we're already inside a flush, the flush loop will pick up // the new callback — no need to schedule another microtask. if (scheduled || flushing) { return; diff --git a/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx b/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx index 0001eebb98..7c45402e9b 100644 --- a/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx +++ b/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx @@ -5,7 +5,8 @@ import { useCellId, useGraph, useCells, - useOnElementsMeasured, + useOnGraphStateChange, + selectGraphSizingVersion, linkRoutingOrthogonal, type CellRecord, type Computed, @@ -193,7 +194,9 @@ function LayoutRunner() { } }, [graph]); - useOnElementsMeasured(runLayout); + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + if (sizingVersion) runLayout(); + }); return null; } @@ -261,11 +264,7 @@ function NodeCard({ title, owner }: Readonly) { }`} > {isEditing ? ( -
+
Title layoutGrid(graph, columns)); + // Re-run the grid layout every time the element sizes settle. + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + if (sizingVersion) layoutGrid(graph, columns); + }); const elementCount = useCells, number>((cells) => { let total = 0; @@ -69,7 +72,11 @@ function Main() { const addNode = useCallback(() => { nextIdRef.current += 1; - setCell({ id: `${nextIdRef.current}`, type: 'element', data: { label: `Node ${elementCount + 1}` } }); + setCell({ + id: `${nextIdRef.current}`, + type: 'element', + data: { label: `Node ${elementCount + 1}` }, + }); }, [elementCount, setCell]); return ( diff --git a/packages/joint-react/stories/examples/automatic-layout/story.tsx b/packages/joint-react/stories/examples/automatic-layout/story.tsx index 809479f3d2..859c0a2658 100644 --- a/packages/joint-react/stories/examples/automatic-layout/story.tsx +++ b/packages/joint-react/stories/examples/automatic-layout/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { description: 'Position measured elements into a responsive grid, re-running the layout whenever they resize.', - apiUrl: getAPILink('useOnElementsMeasured'), + apiUrl: getAPILink('useOnGraphStateChange'), code: codeRaw, }, }, diff --git a/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx b/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx index ce7ee179e8..97f335358b 100644 --- a/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx +++ b/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx @@ -1,4 +1,4 @@ -import type { CellRecord, LinkRecord, LinkStyle, OnElementsMeasured } from '@joint/react'; +import type { CellRecord, LinkRecord, LinkStyle } from '@joint/react'; import { type ElementRecord, GraphProvider, @@ -9,12 +9,13 @@ import { useCellId, useGraph, useMarkup, - useOnElementsMeasured, usePaper, selectElementSize, + useGraphState, + selectIsGraphSizingComplete, linkRoutingOrthogonal, } from '@joint/react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; import { dia, elementTools } from '@joint/core'; import { DirectedGraph } from '@joint/layout-directed-graph'; @@ -745,8 +746,13 @@ function Main() { return !model.prop('hidden'); }, []); - const handleElementsMeasured: OnElementsMeasured = useCallback(({ isInitial, paper, graph }) => { - if (!isInitial) return; + const { graph } = useGraph(); + const { paper } = usePaper(); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + + // Lay the tree out and fit it once the element sizes are known. + useLayoutEffect(() => { + if (!isSizingComplete || !paper) return; runLayout(graph); addExpandTools(paper); paper.transformToFitContent({ @@ -755,9 +761,7 @@ function Main() { verticalAlign: 'middle', horizontalAlign: 'middle', }); - }, []); - - useOnElementsMeasured(handleElementsMeasured); + }, [isSizingComplete, paper, graph]); const renderElement = useCallback((data: FTAData) => RenderFTAElement(data), []); diff --git a/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx b/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx index 7e21827974..629d03e14b 100644 --- a/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx +++ b/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx @@ -6,10 +6,12 @@ import { Paper, SVGText, useGraph, - useOnElementsMeasured, + usePaper, selectElementSize, + useGraphState, + selectIsGraphSizingComplete, } from '@joint/react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useLayoutEffect } from 'react'; // Colors — unified dark diagram palette. The status dot colors are random on // purpose: the color IS the status this demo renders. @@ -18,7 +20,6 @@ const SHAPE_FILL_COLOR = '#1c2836'; const STATUS_STROKE_COLOR = '#3c4f63'; const TEXT_COLOR = '#DDE6ED'; - const ShapeTypes = { rectangle: 'rectangle', circle: 'circle', @@ -209,8 +210,12 @@ function Main() { useInterval(setRandomStatuses); - useOnElementsMeasured(({ isInitial, paper }) => { - if (!isInitial) return; + const { paper } = usePaper(); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + + // The status lists attach to the element views, which exist once the sizes are known. + useLayoutEffect(() => { + if (!isSizingComplete || !paper) return; for (const element of graph.getElements()) { StatusList.add(element.findView(paper), 'root', 'status', { attribute: 'status', @@ -221,11 +226,9 @@ function Main() { }); } setRandomStatuses(); - }); + }, [isSizingComplete, paper, graph, setRandomStatuses]); - return ( - - ); + return ; } export default function App() { diff --git a/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx b/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx index 550c384c8d..6e337d2bd3 100644 --- a/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx +++ b/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { description: 'Attaches a custom list highlighter to each shape and refreshes its colored status dots on an interval.', - apiUrl: getAPILink('useOnElementsMeasured'), + apiUrl: getAPILink('selectIsGraphSizingComplete'), canvasHeight: 240, code: codeRaw, }, diff --git a/packages/joint-react/stories/examples/element-controls/code.tsx b/packages/joint-react/stories/examples/element-controls/code.tsx index d723f33157..1fabfb2e28 100644 --- a/packages/joint-react/stories/examples/element-controls/code.tsx +++ b/packages/joint-react/stories/examples/element-controls/code.tsx @@ -3,13 +3,14 @@ import { GraphProvider, useCell, Paper, - useOnElementsMeasured, + usePaper, type ElementRecord, selectElementSize, - type OnElementsMeasured, + useGraphState, + selectIsGraphSizingComplete, } from '@joint/react'; import { dia, elementTools, g } from '@joint/core'; -import { useCallback } from 'react'; +import { useLayoutEffect } from 'react'; const PRIMARY = '#ED2637'; const LIGHT = '#DDE6ED'; @@ -749,12 +750,13 @@ function addElementControls(paper: dia.Paper) { // Application Components // ---------------------------------------------------------------------------- function Main() { - const handleElementsMeasured: OnElementsMeasured = useCallback(({ isInitial, paper }) => { - if (!isInitial) return; - addElementControls(paper); - }, []); + const { paper } = usePaper(); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); - useOnElementsMeasured(handleElementsMeasured); + // The controls attach to the element views, which exist once the sizes are known. + useLayoutEffect(() => { + if (isSizingComplete && paper) addElementControls(paper); + }, [isSizingComplete, paper]); return ; } diff --git a/packages/joint-react/stories/examples/element-controls/story.tsx b/packages/joint-react/stories/examples/element-controls/story.tsx index 471b2c53bf..18ea33889a 100644 --- a/packages/joint-react/stories/examples/element-controls/story.tsx +++ b/packages/joint-react/stories/examples/element-controls/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { description: 'Drag custom elementTools.Control handles to reshape SVG nodes, re-rendering each React shape from its updated element data.', - apiUrl: getAPILink('useOnElementsMeasured'), + apiUrl: getAPILink('selectIsGraphSizingComplete'), code: codeRaw, canvasHeight: 640, }, diff --git a/packages/joint-react/stories/examples/flowchart/code.tsx b/packages/joint-react/stories/examples/flowchart/code.tsx index 8e9357a35f..6d3c01f1e0 100644 --- a/packages/joint-react/stories/examples/flowchart/code.tsx +++ b/packages/joint-react/stories/examples/flowchart/code.tsx @@ -2,10 +2,25 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import './index.css'; -import type { CellRecord, ElementRecord, LinkRecord, LinkLabel, TransformElementLayoutParams } from '@joint/react'; -import { GraphProvider, Paper, useMarkup, useMeasureElement, useOnElementsMeasured, linkRoutingOrthogonal } from '@joint/react'; +import type { + CellRecord, + ElementRecord, + LinkRecord, + LinkLabel, + TransformElementLayoutParams, +} from '@joint/react'; +import { + GraphProvider, + Paper, + useMarkup, + useMeasureElement, + usePaper, + useGraphState, + selectIsGraphSizingComplete, + linkRoutingOrthogonal, +} from '@joint/react'; import { dia, highlighters, linkTools } from '@joint/core'; -import { forwardRef, useRef, useState } from 'react'; +import { forwardRef, useLayoutEffect, useRef, useState } from 'react'; const unit = 4; const bevel = 2 * unit; @@ -28,7 +43,12 @@ type ElementData = { // top-left coincides with center, and `autoSizeOrigin="center"` preserves // that center through the first DOM measurement. const flowchartElements: Array> = [ - { id: 'start', type: 'element', data: { label: 'Start', type: 'start' }, position: { x: 60, y: 40 } }, + { + id: 'start', + type: 'element', + data: { label: 'Start', type: 'start' }, + position: { x: 60, y: 40 }, + }, { id: 'addToCart', type: 'element', @@ -251,7 +271,10 @@ const flowchartLinks: LinkRecord[] = [ }, ]; -const initialCells: ReadonlyArray> = [...flowchartElements, ...flowchartLinks]; +const initialCells: ReadonlyArray> = [ + ...flowchartElements, + ...flowchartLinks, +]; interface PropsWithClick { readonly onMouseEnter?: () => void; @@ -404,15 +427,19 @@ function RenderFlowchartElement(data: Readonly) { } function Main() { - useOnElementsMeasured(({ isInitial, paper }) => { - if (!isInitial) return; - paper.transformToFitContent({ + const { paper: fitPaper } = usePaper(); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + + // Fit the content once the element sizes are known. + useLayoutEffect(() => { + if (!isSizingComplete || !fitPaper) return; + fitPaper.transformToFitContent({ padding: 40, useModelGeometry: true, verticalAlign: 'middle', horizontalAlign: 'middle', }); - }); + }, [isSizingComplete, fitPaper]); return ( function MinimapRenderElement() { const { width, height } = useCell(selectElementSize); return ( - + ); } function MiniMap() { const minimapId = useId(); - useOnElementsMeasured(minimapId, ({ paper, graph }) => { - const contentArea = graph.getBBox(); - if (!contentArea) return; + const { graph } = useGraph(); + const { paper } = usePaper(minimapId); + const sizingVersion = useGraphState(selectGraphSizingVersion); + + // Keep the whole diagram in view each time the element sizes settle. + useLayoutEffect(() => { + const contentArea = sizingVersion > 0 && graph.getBBox(); + if (!contentArea || !paper) return; paper.transformToFitContent({ contentArea, verticalAlign: 'middle', horizontalAlign: 'middle', padding: 20, }); - }); + }, [sizingVersion, paper, graph]); return (
@@ -388,12 +389,7 @@ function ToolBar(props: Readonly) { Toggle minimap - diff --git a/packages/joint-react/stories/examples/measured-state/code.tsx b/packages/joint-react/stories/examples/measured-state/code.tsx new file mode 100644 index 0000000000..b505a9d6a6 --- /dev/null +++ b/packages/joint-react/stories/examples/measured-state/code.tsx @@ -0,0 +1,230 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +/* eslint-disable sonarjs/pseudo-random -- picks a demo node to resize, not security-sensitive */ +import { useRef, useState, type ReactNode } from 'react'; +import type { dia } from '@joint/core'; +import { + GraphProvider, + HTMLBox, + Paper, + useGraph, + useGraphState, + useOnGraphStateChange, + selectIsGraphSizingComplete, + selectGraphSizingVersion, + type CellRecord, + type ElementRecord, +} from '@joint/react'; + +interface NodeData { + readonly label: string; + readonly isLong?: boolean; +} + +const GAP = 24; +const ROW_WIDTH = 560; +const LOG_LIMIT = 9; +const LONG_SUFFIX = ' · with a much longer label'; + +const initialCells: ReadonlyArray> = [ + { id: 'n1', type: 'element', data: { label: 'Node 1' }, position: { x: GAP, y: GAP } }, + { id: 'n2', type: 'element', data: { label: 'Node 2' }, position: { x: 140, y: GAP } }, + { id: 'n3', type: 'element', data: { label: 'Node 3' }, position: { x: 260, y: GAP } }, +]; + +/** Flows the elements left to right by their measured width, wrapping at `ROW_WIDTH`. */ +function layoutRows(graph: dia.Graph) { + let x = GAP; + let y = GAP; + let rowHeight = 0; + for (const element of graph.getElements()) { + const { width, height } = element.size(); + if (x > GAP && x + width > ROW_WIDTH) { + x = GAP; + y += rowHeight + GAP; + rowHeight = 0; + } + element.position(x, y); + x += width + GAP; + rowHeight = Math.max(rowHeight, height); + } +} + +function renderElement({ label, isLong }: NodeData) { + // No size on the records: HTMLBox measures its content and writes the size back. + return {isLong ? label + LONG_SUFFIX : label}; +} + +type Source = 'isSizingComplete' | 'sizingVersion'; + +interface LogEntry { + readonly id: number; + readonly source: Source; + readonly text: string; +} + +const SOURCE_CLASS: Record = { + isSizingComplete: 'text-brand', + sizingVersion: 'text-accent', +}; + +/** + * Logs every change of the two measurement selectors. `useOnGraphStateChange` calls + * back without re-rendering this component; only the log state does. + */ +function useMeasurementLog(isAutoLayout: boolean) { + const { graph } = useGraph(); + const [entries, setEntries] = useState([]); + const nextIdRef = useRef(0); + + const log = (source: Source, text: string) => { + nextIdRef.current += 1; + const entry = { id: nextIdRef.current, source, text }; + setEntries((previous) => [entry, ...previous].slice(0, LOG_LIMIT)); + }; + + // 1. Once per diagram: false → true when the sizes first settle. + useOnGraphStateChange(selectIsGraphSizingComplete, (isSizingComplete, previous) => { + log( + 'isSizingComplete', + previous === undefined ? `${isSizingComplete} (mount)` : `${previous} → ${isSizingComplete}` + ); + }); + + // 2. Every settled change: first pass, add, remove, re-measure. The place to run a layout. + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + log('sizingVersion', sizingVersion ? `settled #${sizingVersion}` : '0 (nothing measured)'); + if (sizingVersion && isAutoLayout) layoutRows(graph); + }); + + return entries; +} + +function Controls({ onMove }: Readonly<{ onMove: () => void }>) { + const { graph, setCell, setCellData, removeCell, resetCells } = + useGraph>(); + const nextIdRef = useRef(initialCells.length); + + const addNode = () => { + nextIdRef.current += 1; + const id = `n${nextIdRef.current}`; + setCell({ id, type: 'element', data: { label: `Node ${nextIdRef.current}` } }); + }; + + const removeLast = () => { + const last = graph.getElements().at(-1); + if (last) removeCell(last); + }; + + const toggleLabel = () => { + const elements = graph.getElements(); + const element = elements[Math.floor(Math.random() * elements.length)]; + if (!element) return; + setCellData(element.id, (previous) => ({ ...previous, isLong: !previous.isLong })); + }; + + const moveAll = () => { + for (const element of graph.getElements()) element.translate(0, GAP); + onMove(); + }; + + return ( +
+ + + + + + +
+ ); +} + +function Section({ title, children }: Readonly<{ title: string; children: ReactNode }>) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Inspector({ entries, moves }: Readonly<{ entries: readonly LogEntry[]; moves: number }>) { + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + const sizingVersion = useGraphState(selectGraphSizingVersion); + return ( + + ); +} + +function Main() { + const [isAutoLayout, setIsAutoLayout] = useState(true); + const [moves, setMoves] = useState(0); + const entries = useMeasurementLog(isAutoLayout); + + return ( +
+
+ setMoves((count) => count + 1)} /> + + +
+ +
+ ); +} + +export default function App() { + return ( + +
+ + ); +} diff --git a/packages/joint-react/stories/examples/measured-state/story.tsx b/packages/joint-react/stories/examples/measured-state/story.tsx new file mode 100644 index 0000000000..d077e9facc --- /dev/null +++ b/packages/joint-react/stories/examples/measured-state/story.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { getAPILink } from '../../utils/get-api-documentation-link'; +import Code from './code'; +import codeRaw from './code?raw'; + +const meta = { + title: 'Examples/Measured state', + component: Code, + tags: ['example'], + parameters: { + showcase: { + description: + 'Watch selectIsGraphSizingComplete flip once on load, and selectGraphSizingVersion change on every settled add, remove or re-measure. Moving nodes fires nothing.', + apiUrl: getAPILink('useOnGraphStateChange'), + code: codeRaw, + canvasHeight: 480, + }, + }, +} satisfies Meta; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = {};