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 ? (
-