diff --git a/.changeset/react-on-cells-change.md b/.changeset/react-on-cells-change.md
new file mode 100644
index 0000000000..98e65952fa
--- /dev/null
+++ b/.changeset/react-on-cells-change.md
@@ -0,0 +1,6 @@
+---
+"@joint/react": minor
+---
+
+useOnCellsChange - add the hook, with the `selectMeasuredState`, `selectIsMeasured` and `selectElementsSizes` selectors
+`useOnElementsMeasured` is deprecated in favor of it.
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..316638df26 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 UseOnCellsChangeModule from '../../../hooks/use-on-cells-change';
+import type * as SelectorsModule from '../../../selectors';
import type * as GraphProviderModule from '../graph-provider';
import type * as PaperModule from '../../paper/paper';
@@ -88,8 +89,11 @@ 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');
+ useOnCellsChange,
+}: typeof UseOnCellsChangeModule = require('../../../hooks/use-on-cells-change');
+const {
+ selectMeasuredState,
+}: typeof SelectorsModule = require('../../../selectors');
const graphProviderV1: typeof GraphProviderModule = require('../graph-provider');
const paperV1: typeof PaperModule = require('../../paper/paper');
@@ -191,8 +195,8 @@ function Probe() {
mountSequence += 1;
return mountSequence;
});
- useOnElementsMeasured(({ isInitial }) => {
- measuredCalls.push(isInitial);
+ useOnCellsChange(selectMeasuredState, (version, previousMeasuredState) => {
+ if (version) measuredCalls.push(!previousMeasuredState);
});
const ids = useCellIds();
return h(
diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx
index 7556ab9dcc..a08a752b74 100644
--- a/packages/joint-react/src/components/graph/graph-provider.tsx
+++ b/packages/joint-react/src/components/graph/graph-provider.tsx
@@ -3,7 +3,7 @@ import React, { memo, useLayoutEffect, useRef } from 'react';
import { useImperativeApi } from '../../hooks/use-imperative-api';
import { GraphStoreContext } from '../../context';
import { GraphStore } from '../../store';
-import type { AutoSizeOrigin } from '../../store/graph-store';
+import type { AutoSizeOrigin } from '../../store/measurement';
import type { OnIncrementalCellsChange } from '../../store/graph-projection';
import type { ElementJSONInit, LinkJSONInit, CellInput } from '../../types/cell.types';
diff --git a/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx b/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx
index 8acc8e395d..4556108a9c 100644
--- a/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx
+++ b/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx
@@ -10,7 +10,9 @@ import { CellIdContext, GraphStoreContext, PaperStoreContext } from '../../../..
import { ELEMENT_MODEL_TYPE } from '../../../../mvc/element-model';
import type { CellRecord, CellId } from '../../../../types/cell.types';
-const RenderEmpty: ComponentType> = () => ;
+const RenderEmpty: ComponentType> = () => (
+
+);
const CELLS: readonly CellRecord[] = [
{
@@ -27,7 +29,11 @@ const CELLS: readonly CellRecord[] = [
* can re-mount SVG / HTML element items with `portalElement={null}` for the
* defensive guard branches.
*/
-function StoreCapture({ onCapture }: { readonly onCapture: (graph: unknown, paper: unknown) => void }) {
+function StoreCapture({
+ onCapture,
+}: {
+ readonly onCapture: (graph: unknown, paper: unknown) => void;
+}) {
const graphStore = useContext(GraphStoreContext);
const paperStore = useContext(PaperStoreContext);
if (graphStore && paperStore) onCapture(graphStore, paperStore);
@@ -63,11 +69,7 @@ describe('paper-element-item exports', () => {
value={capturedPaper as React.ContextType}
>
-
+
@@ -101,11 +103,7 @@ describe('paper-element-item exports', () => {
value={capturedPaper as React.ContextType}
>
-
+
@@ -143,18 +141,16 @@ describe('paper-element-item exports', () => {
value={capturedPaper as React.ContextType}
>
-
+
);
// Placeholder wrapper should still be created with id and zero geometry.
- const wrapper = portalTarget.querySelector('div[model-id="missing-cell-id"]') as HTMLDivElement | null;
+ const wrapper = portalTarget.querySelector(
+ 'div[model-id="missing-cell-id"]'
+ ) as HTMLDivElement | null;
expect(wrapper).toBeTruthy();
expect(wrapper?.style.width).toBe('0px');
expect(wrapper?.style.height).toBe('0px');
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 8c77af7460..f5bffd4a7a 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
@@ -27,20 +27,18 @@ export interface ElementItemProps {
readonly renderElement: ComponentType>;
/** The DOM element to portal into. */
readonly portalElement: SVGElement | HTMLElement | null;
- /** Whether all auto-sized elements have been measured. */
- readonly areElementsMeasured: boolean;
}
/**
* SVG element portal component. Subscribes only to the element's `data`
* slice, position and size are handled by JointJS's view transform and
- * never cause a React re-render here. Clears cached views after
- * measurement to force re-render with correct dimensions.
+ * never cause a React re-render here. Clears the cached view once its content
+ * is committed, so links resolve against the rendered magnets.
* @param props - render/portal props
* @internal
*/
function SVGElementItemComponent(props: ElementItemProps) {
- const { renderElement: RenderElement, portalElement, areElementsMeasured } = props;
+ const { renderElement: RenderElement, portalElement } = props;
const id = useCellId();
// Subscribe to just this element's `data` slice (missing-tolerant — the portal
// can mount before the record lands in the store, and briefly after removal).
@@ -54,13 +52,13 @@ function SVGElementItemComponent(props: ElementItemProps) {
// inside `renderElement` has already registered with the size observer
// (in HTML overlay mode this item follows `HTMLElementItem`, which renders
// the user content, in sibling order). O(1), and a no-op on re-runs.
- graphStore.markElementRendered(id);
+ graphStore.measurement.markRendered(id);
if (!paper) return;
graphStore.clearViewForElementAndLinks({
cellId: id,
paper,
});
- }, [id, graphStore, areElementsMeasured, paper]);
+ }, [id, graphStore, paper]);
if (!portalElement) {
return null;
diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx
similarity index 92%
rename from packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx
rename to packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx
index ac86c8f011..28de36de40 100644
--- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx
+++ b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx
@@ -1,5 +1,8 @@
/**
- * Specification for when `useOnElementsMeasured` delivers an event.
+ * Specification for when the measurement version (`selectMeasuredState`)
+ * changes, written as the events a `useOnCellsChange` 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
@@ -23,10 +26,11 @@ 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 { useOnElementsMeasured } from '../use-on-elements-measured';
+import { useOnCellsChange } from '../use-on-cells-change';
import { useGraphStore } from '../use-graph-store';
import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model';
-import { AUTO_SIZE_OPTION } from '../../store/graph-store';
+import { AUTO_SIZE_OPTION } from '../../store/measurement';
+import { selectMeasuredState } from '../../selectors';
import type { CellRecord } from '../../types/cell.types';
import type { dia } from '@joint/core';
@@ -101,8 +105,10 @@ function renderGraph(initialCells: CellRecord[]): Harness {
function Probe() {
const { graph: currentGraph } = useGraphStore();
graph = currentGraph;
- useOnElementsMeasured(PAPER_ID, ({ isInitial }) => {
- events.push({ isInitial });
+ // 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.
+ useOnCellsChange(selectMeasuredState, (version, previousMeasuredState) => {
+ if (version) events.push({ isInitial: !previousMeasuredState });
});
return null;
}
@@ -137,7 +143,7 @@ async function settleAndClear(harness: Harness) {
harness.events.length = 0;
}
-describe('useOnElementsMeasured — one event per settled change', () => {
+describe('selectMeasuredState — one event per settled change', () => {
it('delivers one event for the seed pass', async () => {
const harness = renderGraph([plain('a')]);
@@ -257,7 +263,7 @@ describe('useOnElementsMeasured — one event per settled change', () => {
});
});
-describe('useOnElementsMeasured — a graph reset starts a new measurement history', () => {
+describe('selectMeasuredState — 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.
@@ -314,7 +320,7 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo
// 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('useOnElementsMeasured — sizes written by the application', () => {
+describe('selectMeasuredState — 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 () => {
@@ -364,7 +370,7 @@ describe('useOnElementsMeasured — sizes written by the application', () => {
// 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('useOnElementsMeasured — an element that stays zero-sized', () => {
+describe('selectMeasuredState — 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')]);
diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx
similarity index 76%
rename from packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx
rename to packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx
index 326e43c1fd..7a16f69c4b 100644
--- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx
+++ b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx
@@ -1,5 +1,5 @@
/**
- * Scenarios beyond the #3520 specification in `use-on-elements-measured-events`:
+ * 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
@@ -9,8 +9,9 @@ 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 { useOnElementsMeasured } from '../use-on-elements-measured';
+import { useOnCellsChange } from '../use-on-cells-change';
import { useGraphStore } from '../use-graph-store';
+import { selectMeasuredState } 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';
@@ -87,8 +88,10 @@ function renderGraph(initialCells: CellRecord[], paperProps: Partial
function Probe() {
const { graph: currentGraph } = useGraphStore();
graph = currentGraph;
- useOnElementsMeasured(PAPER_ID, ({ isInitial }) => {
- events.push({ isInitial });
+ // 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.
+ useOnCellsChange(selectMeasuredState, (version, previousMeasuredState) => {
+ if (version) events.push({ isInitial: !previousMeasuredState });
});
return null;
}
@@ -116,7 +119,7 @@ async function settleAndClear(harness: Harness) {
// measurer, it becomes outstanding then.
const hideAnchor: PaperProps['cellVisibility'] = ({ model }) => model.id !== 'anchor';
-describe('useOnElementsMeasured — an element the paper does not render', () => {
+describe('selectMeasuredState — 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 });
@@ -140,7 +143,7 @@ describe('useOnElementsMeasured — an element the paper does not render', () =>
});
// What else ends the wait: the waiting element leaves the graph.
-describe('useOnElementsMeasured — a waiting element is removed', () => {
+describe('selectMeasuredState — 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);
@@ -160,11 +163,54 @@ describe('useOnElementsMeasured — a waiting element is removed', () => {
});
});
+// Regression: an element that stopped measuring before it was measured stayed
+// "waiting" forever and held back every later event.
+describe('selectMeasuredState — 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('selectMeasuredState — 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('useOnElementsMeasured — the content of an element changes', () => {
+describe('selectMeasuredState — the content of an element changes', () => {
class TestResizeObserver {
static readonly instances: TestResizeObserver[] = [];
readonly observed = new Set();
@@ -194,15 +240,25 @@ describe('useOnElementsMeasured — the content of an element changes', () => {
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();
- // StrictMode mounts the store twice; only the live store's observer has
- // the node registered by ``.
- const observer = TestResizeObserver.instances.find((instance) => instance.observed.size > 0);
- if (!observer) throw new Error('no ResizeObserver has the measured node registered');
- const [node] = observer.observed;
+ const { observer, node } = findMeasuredNode();
act(() => {
observer.report(node, 120, 40);
@@ -256,9 +312,7 @@ describe('useOnElementsMeasured — the content of an element changes', () => {
it('measures an element the application pre-sized to what it will measure', async () => {
const harness = renderGraph([pending('b')]);
await flush();
- const observer = TestResizeObserver.instances.find((instance) => instance.observed.size > 0);
- if (!observer) throw new Error('no ResizeObserver has the measured node registered');
- const [node] = observer.observed;
+ const { observer, node } = findMeasuredNode();
act(() => {
(harness.graph.getCell('b') as dia.Element).resize(120, 40);
diff --git a/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx
index e17973f75e..1ed53e8ac0 100644
--- a/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx
+++ b/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx
@@ -28,6 +28,7 @@ import { LINK_MODEL_TYPE } from '../../mvc/link-model';
import type { CellRecord } from '../../types/cell.types';
let capturedGraph: dia.Graph | null = null;
+let capturedStore: ReturnType | null = null;
let capturedPaper: dia.Paper | null = null;
const initialCells: readonly CellRecord[] = [
@@ -53,8 +54,10 @@ const initialCells: readonly CellRecord[] = [
function Probe() {
const nodeRef = useRef(null);
- const { graph } = useGraphStore();
+ const store = useGraphStore();
+ const { graph } = store;
const { paper } = usePaper();
+ capturedStore = store;
capturedGraph = graph;
capturedPaper = paper;
const size = useMeasureElement(nodeRef);
@@ -193,6 +196,29 @@ describe('useMeasureElement', () => {
});
});
+ // Regression: the paper can route the links before React commits a measured
+ // size, so the measured element clears its view once that size is committed.
+ // This used to be a sweep of every element on a global "measured" flag.
+ it('clears the view of a measured element when its measured size commits', async () => {
+ renderProbe();
+ await waitFor(() => expect(capturedPaper?.findViewByModel('el')).toBeDefined());
+ const clearView = jest.spyOn(capturedStore!, 'clearViewForElementAndLinks');
+ const element = capturedGraph!.getCell('el') as dia.Element;
+
+ await act(async () => {
+ element.position(5, 5);
+ await Promise.resolve();
+ });
+ expect(clearView).not.toHaveBeenCalled();
+
+ await act(async () => {
+ element.set('size', { width: 120, height: 60 }, { autoSize: true });
+ await Promise.resolve();
+ });
+ const clearedIds = clearView.mock.calls.map(([options]) => options.cellId);
+ expect(clearedIds).toEqual(['el']);
+ });
+
describe('with a real ResizeObserver mock', () => {
// Local ResizeObserver mock so we can deliver entries — the package-level
// setup in `__mocks__/jest-setup.ts` installs a stub that never fires.
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..b878c89566
--- /dev/null
+++ b/packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx
@@ -0,0 +1,578 @@
+import React from 'react';
+import { renderHook, act } from '@testing-library/react';
+import { 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 { DEFAULT_CELL_NAMESPACE } from '../../store/graph-store';
+import {
+ selectElementsSizes,
+ selectIsMeasured,
+ selectMeasuredState,
+} from '../../selectors';
+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 noop = () => {};
+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();
+ });
+});
+
+describe('selectMeasuredState', () => {
+ it('is 0 until the sizes settle, then changes once per settled change', async () => {
+ const onChange = jest.fn();
+ const { graph } = mount(() => useOnCellsChange(selectMeasuredState, 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('re-renders a useCells reader once per settled change, not per commit', async () => {
+ let renderCount = 0;
+ const { result } = renderHook(
+ () => {
+ renderCount += 1;
+ return { version: useCells(selectMeasuredState), graph: useGraphStore().graph };
+ },
+ { wrapper }
+ );
+ await act(async () => flush());
+ expect(result.current.version).toBe(1);
+ const rendersBefore = renderCount;
+
+ // A move is a commit, but not a measurement.
+ await commit(() => (result.current.graph.getCell('a') as dia.Element).position(9, 9));
+ expect(result.current.version).toBe(1);
+ expect(renderCount).toBe(rendersBefore);
+
+ await commit(() => result.current.graph.addCell(element('c')));
+ expect(result.current.version).toBe(2);
+ });
+
+ it('selectIsMeasured turns true once sizes settle and re-renders its reader only then', async () => {
+ let renderCount = 0;
+ const { result } = renderHook(
+ () => {
+ renderCount += 1;
+ return { isMeasured: useCells(selectIsMeasured), graph: useGraphStore().graph };
+ },
+ { wrapper }
+ );
+ await act(async () => flush());
+ expect(result.current.isMeasured).toBe(true);
+ const rendersBefore = renderCount;
+
+ // A later settled change bumps the version, but the diagram stays measured.
+ await commit(() => result.current.graph.addCell(element('c')));
+ expect(result.current.isMeasured).toBe(true);
+ expect(renderCount).toBe(rendersBefore);
+
+ // A reset to a diagram without sizes is not measured.
+ await commit(() => result.current.graph.resetCells([]));
+ expect(result.current.isMeasured).toBe(false);
+ });
+
+ // Regression: read through the cells, the selectors made every commit rebuild
+ // the cells array (O(n) per drag frame). Passed directly to a hook they read
+ // the measurement itself.
+ it.each([
+ [
+ 'useOnCellsChange(selectMeasuredState)',
+ () => useOnCellsChange(selectMeasuredState, () => {}),
+ ],
+ [
+ 'useCells(selectMeasuredState)',
+ () => {
+ useCells(selectMeasuredState);
+ },
+ ],
+ [
+ 'useCells(selectElementsSizes)',
+ () => {
+ useCells(selectElementsSizes);
+ },
+ ],
+ [
+ 'useCells(selectIsMeasured)',
+ () => {
+ useCells(selectIsMeasured);
+ },
+ ],
+ ])('%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();
+ });
+
+ 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(() => useOnCellsChange(selectMeasuredState, 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(
+ () => {
+ useOnCellsChange(selectMeasuredState, onChange);
+ return { isMeasured: useCells(selectIsMeasured), graph: useGraphStore().graph };
+ },
+ { wrapper }
+ );
+ await act(async () => flush());
+ expect(result.current.isMeasured).toBe(true);
+
+ await commit(() => {
+ result.current.graph.getCell('a').remove();
+ result.current.graph.getCell('b').remove();
+ });
+
+ expect(result.current.isMeasured).toBe(false);
+ expect(onChange.mock.calls.at(-1)).toEqual([0, 1]);
+ });
+
+ 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(() => useCells(selectIsMeasured), {
+ 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);
+ });
+
+ // The selectors have no cells to read: scoping them to an id, ids or a
+ // collection used to return the neutral value silently, on every commit.
+ it('rejects the selectors in the id, ids and collection forms', () => {
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
+ const ids = ['a'];
+
+ expect(() => renderHook(() => useCells(ids, selectMeasuredState), { wrapper })).toThrow(
+ 'all-cells form'
+ );
+ expect(() =>
+ renderHook(() => useOnCellsChange('a', selectIsMeasured, noop), { wrapper })
+ ).toThrow('all-cells form');
+
+ error.mockRestore();
+ });
+
+ // The selectors read the store through the hook. On their own they have no
+ // store to read: TypeScript rejects a call with cells, and at runtime they
+ // say so and return their neutral value.
+ it('returns the neutral value and warns when called outside a hook', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ expect(selectMeasuredState()).toBe(0);
+ expect(selectIsMeasured()).toBe(false);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('pass it directly'));
+
+ warn.mockRestore();
+ });
+});
+
+/** Reads the sizes and the graph, counting renders. */
+function mountSizes() {
+ let renderCount = 0;
+ const { result } = renderHook(
+ () => {
+ renderCount += 1;
+ return { sizes: useCells(selectElementsSizes), graph: useGraphStore().graph };
+ },
+ { wrapper }
+ );
+ const getElement = (id: string) => result.current.graph.getCell(id) as dia.Element;
+ return { result, getElement, renders: () => renderCount };
+}
+
+describe('selectElementsSizes', () => {
+ it('returns the size of every element by id', () => {
+ const { result } = mountSizes();
+
+ expect([...result.current.sizes]).toEqual([
+ ['a', { width: 10, height: 10 }],
+ ['b', { width: 10, height: 10 }],
+ ]);
+ });
+
+ it('keeps the same map, and does not re-render, while elements only move', async () => {
+ const { result, getElement, renders } = mountSizes();
+ const before = result.current.sizes;
+ const rendersBefore = renders();
+
+ await commit(() => getElement('a').position(40, 40));
+
+ expect(result.current.sizes).toBe(before);
+ expect(renders()).toBe(rendersBefore);
+ });
+
+ it('returns a new map when a size changes, whoever wrote it', async () => {
+ const { result, getElement } = mountSizes();
+ const initial = result.current.sizes;
+
+ await commit(() => getElement('a').resize(30, 20));
+ const afterResize = result.current.sizes;
+ expect(afterResize).not.toBe(initial);
+ expect(afterResize.get('a')).toEqual({ width: 30, height: 20 });
+ // The untouched snapshot is left as it was.
+ expect(initial.get('a')).toEqual({ width: 10, height: 10 });
+
+ await commit(() => getElement('b').set('size', { width: 5, height: 5 }, { autoSize: true }));
+ expect(result.current.sizes).not.toBe(afterResize);
+ expect(result.current.sizes.get('b')).toEqual({ width: 5, height: 5 });
+ });
+
+ it('follows elements being added, removed and reset', async () => {
+ const { result } = mountSizes();
+
+ await commit(() => result.current.graph.addCell(element('c')));
+ expect([...result.current.sizes.keys()]).toEqual(['a', 'b', 'c']);
+
+ await commit(() => result.current.graph.getCell('a').remove());
+ expect([...result.current.sizes.keys()]).toEqual(['b', 'c']);
+
+ await commit(() => result.current.graph.resetCells([element('x')] as never));
+ expect([...result.current.sizes.keys()]).toEqual(['x']);
+ });
+
+ it('re-renders once for several sizes changed together', async () => {
+ const { result, getElement, renders } = mountSizes();
+ const before = result.current.sizes;
+ let rendersForOneResize = 0;
+ {
+ const start = renders();
+ await commit(() => getElement('a').resize(11, 11));
+ rendersForOneResize = renders() - start;
+ }
+
+ const start = renders();
+ await commit(() => {
+ getElement('a').resize(30, 30);
+ getElement('b').resize(40, 40);
+ });
+
+ expect(renders() - start).toBe(rendersForOneResize);
+ expect(result.current.sizes).not.toBe(before);
+ expect(result.current.sizes.get('b')).toEqual({ width: 40, height: 40 });
+ });
+
+ it('calls a useOnCellsChange callback with the new and the previous sizes', async () => {
+ const onChange = jest.fn();
+ const { getElement } = mount(() => useOnCellsChange(selectElementsSizes, onChange));
+
+ await commit(() => getElement('a').resize(30, 20));
+
+ const [[sizes, previous]] = changesOf(onChange);
+ expect(sizes.get('a')).toEqual({ width: 30, height: 20 });
+ expect(previous.get('a')).toEqual({ width: 10, height: 10 });
+ });
+});
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 e1256086a0..789389c238 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
@@ -3,7 +3,7 @@ import { renderHook, waitFor, act } from '@testing-library/react';
import { paperRenderElementWrapper } from '../../utils/test-wrappers';
import { useOnElementsMeasured } from '../use-on-elements-measured';
import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model';
-import { AUTO_SIZE_OPTION } from '../../store/graph-store';
+import { AUTO_SIZE_OPTION } from '../../store/measurement';
import { useGraphStore } from '../use-graph-store';
import type { CellRecord } from '../../types/cell.types';
import type { ElementsMeasuredParams } from '../use-on-elements-measured';
@@ -49,12 +49,25 @@ const zeroSizeWrapper = paperRenderElementWrapper({
},
});
-const incrementMeasureState = (previous: number) => previous + 1;
-
-type MeasureStateRef = ReturnType['measureState'];
-
-const bumpMeasureFor = (measureState: MeasureStateRef) => () =>
- measureState.set(incrementMeasureState);
+/**
+ * Mounts the hook for `paperId` through `hookWrapper` and returns a getter of
+ * the live graph. The wrappers mount their children inside `renderElement`, so
+ * there is one hook instance per rendered element.
+ */
+function renderMeasuredProbe(
+ callback: jest.Mock,
+ hookWrapper: typeof wrapper = wrapper,
+ paperId = 'measured-effect-paper'
+) {
+ let graphRef: dia.Graph | undefined;
+ function Probe() {
+ graphRef = useGraphStore().graph;
+ useOnElementsMeasured(paperId, callback);
+ return null;
+ }
+ renderHook(() => Probe(), { wrapper: hookWrapper });
+ return () => graphRef as dia.Graph;
+}
describe('useOnElementsMeasured', () => {
it('fires callback with isInitial=true after seed cells are measured', async () => {
@@ -66,65 +79,62 @@ describe('useOnElementsMeasured', () => {
expect(initialCalls.length).toBeGreaterThan(0);
});
- it('subsequent measurement bumps fire callback with isInitial=false', async () => {
+ it('later settled changes fire the callback with isInitial=false', async () => {
const callback = jest.fn();
- let bumpMeasure: () => void = () => {};
- function Probe() {
- const { measureState } = useGraphStore();
- bumpMeasure = bumpMeasureFor(measureState);
- useOnElementsMeasured('measured-effect-paper', callback);
- return null;
- }
- renderHook(() => Probe(), { wrapper });
+ const getGraph = renderMeasuredProbe(callback);
await waitFor(() =>
expect(callback.mock.calls.some(([event]) => event.isInitial === true)).toBe(true)
);
callback.mockClear();
act(() => {
- bumpMeasure();
+ (getGraph().getCell('a') as dia.Element).set('size', { width: 70, height: 70 }, {
+ [AUTO_SIZE_OPTION]: true,
+ } as object);
});
- await flush();
- expect(callback).toHaveBeenCalled();
+ await waitFor(() => expect(callback).toHaveBeenCalled());
for (const [event] of callback.mock.calls) {
expect((event as ElementsMeasuredParams).isInitial).toBe(false);
}
});
- // Regression: ElementModel defaults to size {0,0}. The ResizeObserver
- // pipeline sets the real size via `cell.set('size', ..., { autoSize: true })`.
- // Previously, the `change:size` listener in graph-changes.ts skipped
- // measurement writes, so the measured size never reached the tracking
- // logic and `useOnElementsMeasured` never fired for elements that relied
- // on DOM measurement (e.g. the flowchart demo).
- it('fires callback when elements start at zero size and get measured', async () => {
+ // An element nothing measures is settled once the paper rendered it, whatever
+ // its size: zero is a legal size. A later measurement is one more change.
+ it('fires for a zero-sized element once it is rendered, then for its measurement', async () => {
const callback = jest.fn();
- let graphRef: dia.Graph | undefined;
+ const getGraph = renderMeasuredProbe(callback, zeroSizeWrapper, 'zero-size-paper');
- function Probe() {
- const store = useGraphStore();
- graphRef = store.graph;
- useOnElementsMeasured('zero-size-paper', callback);
- return null;
- }
+ await waitFor(() => expect(callback).toHaveBeenCalled());
+ expect(callback.mock.calls[0][0].isInitial).toBe(true);
+ callback.mockClear();
- renderHook(() => Probe(), { wrapper: zeroSizeWrapper });
+ act(() => {
+ const cell = getGraph().getCell('zero-el') as dia.Element;
+ cell.set('size', { width: 100, height: 60 }, { [AUTO_SIZE_OPTION]: true } as object);
+ });
- // Wait for render to complete and graph to be available.
- await waitFor(() => expect(graphRef).toBeDefined());
- await flush();
+ await waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
+ expect(callback.mock.calls[0][0].isInitial).toBe(false);
+ });
- // Initial size is {0,0} — callback should NOT have fired yet
- // (measureState only bumps when elementsMeasured.size > 0).
- expect(callback).not.toHaveBeenCalled();
+ it('reports isInitial again for the first event after a graph reset', async () => {
+ const callback = jest.fn();
+ const getGraph = renderMeasuredProbe(callback);
+ await waitFor(() => expect(callback).toHaveBeenCalled());
+ callback.mockClear();
- // Simulate ResizeObserver setting the real measured size.
act(() => {
- const cell = graphRef!.getCell('zero-el') as dia.Element;
- cell.set('size', { width: 100, height: 60 }, { [AUTO_SIZE_OPTION]: true } as object);
+ getGraph().resetCells([
+ {
+ id: 'x',
+ type: ELEMENT_MODEL_TYPE,
+ position: { x: 0, y: 0 },
+ size: { width: 9, height: 9 },
+ },
+ ]);
});
- await waitFor(() => expect(callback).toHaveBeenCalled());
+ await waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
expect(callback.mock.calls[0][0].isInitial).toBe(true);
});
@@ -132,7 +142,43 @@ describe('useOnElementsMeasured', () => {
// options, so an application's own resize bumped `measureState` like a
// measurement write and woke every subscriber.
describe('application resizes vs measurement writes', () => {
- function renderMeasuredProbe(callback: jest.Mock) {
+ it('does not fire when the application resizes an element', async () => {
+ const callback = jest.fn();
+ const getGraph = renderMeasuredProbe(callback);
+ const getElement = () => getGraph().getCell('a') as dia.Element;
+ 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 getGraph = renderMeasuredProbe(callback);
+ const getElement = () => getGraph().getCell('a') as dia.Element;
+ 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);
+ });
+ });
+
+ // 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();
@@ -146,7 +192,7 @@ describe('useOnElementsMeasured', () => {
it('does not fire when the application resizes an element', async () => {
const callback = jest.fn();
- const getElement = renderMeasuredProbe(callback);
+ const getElement = renderResizeProbe(callback);
await waitFor(() => expect(callback).toHaveBeenCalled());
callback.mockClear();
@@ -161,7 +207,7 @@ describe('useOnElementsMeasured', () => {
it('fires with isInitial=false for a measurement write', async () => {
const callback = jest.fn();
- const getElement = renderMeasuredProbe(callback);
+ const getElement = renderResizeProbe(callback);
await waitFor(() => expect(callback).toHaveBeenCalled());
callback.mockClear();
diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts
index 8693acb52f..0a7a4545d0 100644
--- a/packages/joint-react/src/hooks/index.ts
+++ b/packages/joint-react/src/hooks/index.ts
@@ -10,6 +10,7 @@ export * from './use-graph-store';
export * from './use-combined-ref';
export * from './use-stores';
export * from './use-markup';
+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-are-elements-measured.ts b/packages/joint-react/src/hooks/use-are-elements-measured.ts
deleted file mode 100644
index 998d68c011..0000000000
--- a/packages/joint-react/src/hooks/use-are-elements-measured.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useGraphStore } from './use-graph-store';
-import { useSyncExternalStore } from 'react';
-
-/**
- * Returns `true` when at least one element has been measured.
- */
-export function useAreElementsMeasured() {
- const { measureState } = useGraphStore();
- return useSyncExternalStore(
- (onStoreChange) => {
- // subscribe for both, elements size from container and also measure state changes.
- const unsubscribeMeasureState = measureState.subscribe(onStoreChange);
- return () => {
- unsubscribeMeasureState();
- };
- },
- () => {
- return measureState.get() > 0;
- }
- );
-}
diff --git a/packages/joint-react/src/hooks/use-cells.ts b/packages/joint-react/src/hooks/use-cells.ts
index 5f0631785b..0e94ce918a 100644
--- a/packages/joint-react/src/hooks/use-cells.ts
+++ b/packages/joint-react/src/hooks/use-cells.ts
@@ -4,7 +4,8 @@ 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 { areArraysShallowEqual, arrayAwareEqual } from '../selectors/selector-utils';
+import { getSelectorSource } from '../selectors/source-selector';
import { isCollection } from '../utils/is';
import { subscribeToCollection } from '../utils/collection-subscription';
import { parseUseCellsArgs } from './use-cells.utils';
@@ -27,12 +28,25 @@ type CellsResult =
type UnknownEqual = (a: unknown, b: unknown) => boolean;
/** Selector over the resolved cells array (the array forms of `useCells`). */
-type CellsSelector = (
+export type CellsSelector = (
cells: ReadonlyArray>
) => Selected;
+/** What a `useCells` call can be scoped to: one cell, several cells, or a JointJS collection. */
+type CellsTarget = CellId | null | readonly CellId[] | mvc.Collection;
+
/** Equality test that short-circuits a re-render when the selected value is unchanged. */
-type SelectedEqual = (a: Selected, b: Selected) => boolean;
+export type SelectedEqual = (a: Selected, b: Selected) => boolean;
+
+/** 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,
+];
// ── Module-scoped helpers ───────────────────────────────────────────────────
@@ -261,19 +275,25 @@ 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> {
+>(...args: CellsArguments): CellsResult, Selected> {
+ const { subscribe, getSnapshot, select, isEqual } = useCellsSource(...args);
+ return useSyncExternalStoreWithSelector(subscribe, getSnapshot, getSnapshot, select, isEqual);
+}
+
+/**
+ * The subscription behind {@link useCells}, without the render: what to
+ * subscribe to, the change token, and the cached selection for the given
+ * `useCells` arguments. Shared with {@link useOnCellsChange}, which listens
+ * to it directly instead of re-rendering.
+ * @param args - The `useCells` arguments: target and/or selector, then an equality function.
+ * @returns `subscribe`, `getSnapshot`, `select` and `isEqual`, identity-stable while the arguments are.
+ * @internal
+ */
+export function useCellsSource<
+ Cell extends AnyCellRecord = CellRecord,
+ Selected = ReadonlyArray>,
+>(...args: CellsArguments) {
+ const [argument1, argument2, argument3] = args;
const store = useGraphStore();
// The store holds resolved (Computed) records; the public `Cell` generic is the
// input record shape, so internally we work in `Computed`.
@@ -284,11 +304,7 @@ export function useCells<
const { targetId, ids, arraySelector, cellSelector, isEqual } = parseUseCellsArgs<
Computed,
Selected
- >(
- argument1,
- argument2,
- argument3
- );
+ >(argument1, argument2, argument3);
const hasSelector = arraySelector !== undefined || cellSelector !== undefined;
const arraySelectorRef = useRef(arraySelector);
@@ -326,8 +342,21 @@ export function useCells<
// ── Subscribe ──
+ // An all-cells selector with its own source (see `createSourceSelector`) is read
+ // from that source: no subscription to every commit, no cells array built.
+ const isAllCellsForm = targetId === undefined && !ids && !collectionArgument;
+ const selectorSource = isAllCellsForm ? getSelectorSource(arraySelector, store) : undefined;
+ // Not dev-only: in production the selector would return its fallback forever.
+ if (!isAllCellsForm && getSelectorSource(arraySelector ?? cellSelector, store)) {
+ throw new Error(
+ 'This selector reads the graph store, not the cells: use it in the all-cells form, ' +
+ 'without an id, ids or collection.'
+ );
+ }
+
const subscribe = useCallback(
(listener: () => void) => {
+ if (selectorSource) return selectorSource.subscribe(listener);
if (collectionArgument) {
return subscribeToCollection(
collectionArgument,
@@ -348,12 +377,13 @@ export function useCells<
return container.subscribe(listener);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [container, collectionArgument, targetId, idsKey]
+ [container, collectionArgument, targetId, idsKey, selectorSource]
);
// ── Snapshot ──
const getSnapshot = useCallback(() => {
+ if (selectorSource) return selectorSource.get();
// 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
@@ -363,23 +393,31 @@ export function useCells<
// 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]);
+ }, [container, collectionArgument, targetId, selectorSource]);
// ── Equality ──
+ // Read through a ref, like the selectors, so an inline `isEqual` does not make
+ // a new subscription on every render.
+ const isEqualRef = useRef(isEqual);
+ isEqualRef.current = isEqual;
+ const hasIsEqual = isEqual !== undefined;
const isEqualCallback = useMemo(() => {
- if (isEqual) return isEqual as unknown as UnknownEqual;
+ if (hasIsEqual) {
+ return (a, b) => (isEqualRef.current as unknown as UnknownEqual)(a, b);
+ }
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]);
+ }, [hasIsEqual, targetId, hasSelector]);
// ── Selector ──
const select = useCallback(
(): Result => {
+ if (selectorSource) return selectorSource.get() as Result;
const subscribedIds = collectionArgument ? collectionIdsRef.current : idsRef.current;
const next = computeNext, Selected>(
container,
@@ -396,7 +434,7 @@ export function useCells<
return cachedRef.current.value;
}
if (hasSelector && cachedRef.current.hasValue) {
- warnUnstableSelector('useCells', cachedRef.current.value, next, !!isEqual);
+ warnUnstableSelector('useCells', cachedRef.current.value, next, hasIsEqual);
}
// 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
@@ -405,14 +443,8 @@ export function useCells<
return next;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [container, collectionArgument, targetId, idsKey, isEqualCallback]
+ [container, collectionArgument, targetId, idsKey, isEqualCallback, selectorSource]
);
- return useSyncExternalStoreWithSelector(
- subscribe,
- getSnapshot,
- getSnapshot,
- select,
- isEqualCallback
- );
+ return { subscribe, getSnapshot, select, isEqual: isEqualCallback };
}
diff --git a/packages/joint-react/src/hooks/use-create-portal-paper.tsx b/packages/joint-react/src/hooks/use-create-portal-paper.tsx
index d14efb428f..0c6d45f06f 100644
--- a/packages/joint-react/src/hooks/use-create-portal-paper.tsx
+++ b/packages/joint-react/src/hooks/use-create-portal-paper.tsx
@@ -44,7 +44,6 @@ import {
SVGElementItem,
} from '../components/paper/render-element/paper-element-item';
import { createSelectPaperVersion } from '../selectors';
-import { useAreElementsMeasured } from './use-are-elements-measured';
import { LINK_MODEL_TYPE } from '../mvc/link-model';
import { subscribeToPaperEvents } from './use-on-paper-events';
import { useOnEvents } from './use-on-events';
@@ -209,7 +208,6 @@ export function useCreatePortalPaper(
}
const graphStore = useGraphStore();
- const areElementsMeasured = useAreElementsMeasured();
// The set of cell ids — stable across data-only commits (a drag does zero work
// here), a new reference only on add/remove. Partition by type for portals.
@@ -481,20 +479,11 @@ export function useCreatePortalPaper(
-
+
>
) : (
-
+
)}
);
@@ -503,7 +492,6 @@ export function useCreatePortalPaper(
}, [
version,
HTMLRendererContainer,
- areElementsMeasured,
elementIds,
hasRenderElement,
paperStore,
diff --git a/packages/joint-react/src/hooks/use-measure-element.tsx b/packages/joint-react/src/hooks/use-measure-element.tsx
index c8481d1e37..63fc07ab09 100644
--- a/packages/joint-react/src/hooks/use-measure-element.tsx
+++ b/packages/joint-react/src/hooks/use-measure-element.tsx
@@ -1,7 +1,7 @@
import { useContext, useLayoutEffect, type RefObject } from 'react';
import { CellIdContext } from '../context';
import { useGraphStore } from './use-graph-store';
-import type { TransformElementLayout } from '../store/create-elements-size-observer';
+import type { TransformElementLayout } from '../store/measurement';
import { usePaper } from './use-paper';
import type { ElementSize } from '../types/cell.types';
import { useCell } from './use-cell';
@@ -142,7 +142,8 @@ export function useMeasureElement(
options: MeasureElementOptions = EMPTY_OBJECT
): Required {
const { transform } = options;
- const { graph, setMeasuredNode } = useGraphStore();
+ const graphStore = useGraphStore();
+ const { graph, measurement } = graphStore;
const { paper } = usePaper();
const id = useContext(CellIdContext);
if (id === undefined) {
@@ -179,15 +180,18 @@ export function useMeasureElement(
paper.requestViewUpdate(view, paper.FLAG_MEASURE, view.UPDATE_PRIORITY);
}
- const clean = setMeasuredNode({ id, node: nodeRef.current, transform });
- return () => {
- // No class cleanup here: views aren't recycled today, so the view
- // is gone with the cell.
- clean();
- };
+ // No class cleanup: views aren't recycled today, so the view is gone with the cell.
+ return measurement.observe({ id, node: nodeRef.current, transform });
// transform is not a dependency because it doesn't change
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [nodeRef, graph, id, paper, setMeasuredNode]);
+ }, [nodeRef, graph, id, paper, measurement]);
+
+ // The paper may route the links before React commits a measured size (the
+ // application may flush the views from a measurement callback), so let
+ // them resolve again against the committed content.
+ useLayoutEffect(() => {
+ if (paper) graphStore.clearViewForElementAndLinks({ cellId: id, paper });
+ }, [graphStore, id, paper, size]);
return size;
}
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..64c95be803
--- /dev/null
+++ b/packages/joint-react/src/hooks/use-on-cells-change.ts
@@ -0,0 +1,140 @@
+import { useLayoutEffect } from 'react';
+import type { dia, mvc } from '@joint/core';
+import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types';
+import { useCellsSource, type CellsSelector, type SelectedEqual } from './use-cells';
+import { useLatestRef } from './use-latest-ref';
+import { rethrowLater } from '../utils/scheduler';
+
+/**
+ * Callback of {@link useOnCellsChange}: receives the newly selected value and
+ * the one before it, absent on the first call.
+ * @group Types
+ */
+export type OnCellsChange = (value: Selected, previousValue?: Selected) => void;
+
+/**
+ * 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.
+ * 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, selectMeasuredState, useGraph } from '@joint/react';
+ *
+ * // Re-run a layout each time the element sizes settle.
+ * function AutoLayout() {
+ * const { graph } = useGraph();
+ * useOnCellsChange(selectMeasuredState, (measuredState) => {
+ * if (measuredState) runLayout(graph);
+ * });
+ * 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?]
+ | [
+ CellId | null | undefined | readonly CellId[] | mvc.Collection,
+ CellsSelector | ((cell: AnyCellRecord | undefined) => Selected),
+ OnCellsChange,
+ SelectedEqual?,
+ ]
+): void {
+ // The target forms carry the callback one position later than the all-cells
+ // form. The overloads guarantee the shapes, which the union hides from TS.
+ const [onChange, sourceArguments] = (
+ typeof args[0] === 'function'
+ ? [args[1], [args[0], args[2]]]
+ : [args[2], [args[0], args[1], args[3]]]
+ ) as [OnCellsChange, Parameters>];
+ const { subscribe, select } = useCellsSource(...sourceArguments);
+ const onChangeRef = useLatestRef(onChange);
+
+ useLayoutEffect(() => {
+ // `select` returns the cached value while it is equal, so identity is the test.
+ let previous = select();
+ onChangeRef.current(previous);
+ return subscribe(() => {
+ const next = select();
+ if (next === previous) return;
+ const before = previous;
+ previous = next;
+ // This runs inside the store's notification: an error must not keep the
+ // other subscribers of this change from being notified.
+ try {
+ onChangeRef.current(next, before);
+ } catch (error) {
+ rethrowLater(error);
+ }
+ });
+ }, [subscribe, select, onChangeRef]);
+}
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 0715133e97..3f2dfff75a 100644
--- a/packages/joint-react/src/hooks/use-on-elements-measured.ts
+++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts
@@ -1,9 +1,10 @@
-import { useLayoutEffect, useRef } from 'react';
+import { useLayoutEffect } from 'react';
import type { dia } from '@joint/core';
import { usePaperStore, useResolvePaperId } from './use-paper';
import type { PaperTarget } from '../types';
import { useGraphStore } from './use-graph-store';
import { useLatestRef } from './use-latest-ref';
+import { rethrowLater } from '../utils/scheduler';
/**
* Payload passed to the {@link useOnElementsMeasured} callback after a
@@ -48,6 +49,9 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void;
* The callback receives {@link ElementsMeasuredParams}; `isInitial` is `true`
* for the first event after the hook mounts and again for the first event
* after a graph reset (`resetCells()`), which replaces the diagram.
+ * @deprecated Use {@link useOnCellsChange} with {@link selectMeasuredState} to
+ * react to every settled change, or {@link useCells} with
+ * {@link selectIsMeasured} to act once the sizes are known.
* @title On the current paper
* @param callback - Called each time element sizes are measured.
* @group Hooks
@@ -70,6 +74,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 useOnCellsChange} with {@link selectMeasuredState} to
+ * react to every settled change, or {@link useCells} with
+ * {@link selectIsMeasured} 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.
@@ -98,34 +105,36 @@ export function useOnElementsMeasured(
const isContextForm = typeof paperTargetOrCallback === 'function';
const paperTarget = isContextForm ? undefined : (paperTargetOrCallback as PaperTarget);
- const callback = isContextForm ? (paperTargetOrCallback as OnElementsMeasured) : (callbackArgument as OnElementsMeasured);
+ const callback = isContextForm
+ ? (paperTargetOrCallback as OnElementsMeasured)
+ : (callbackArgument as OnElementsMeasured);
const paperId = useResolvePaperId(paperTarget);
const paperStore = usePaperStore(paperId);
const callbackRef = useLatestRef(callback);
- const graphStore = useGraphStore();
- const { measureState, graph } = graphStore;
- // The `measureGeneration` this hook last reported; a generation it has not
- // seen yet (mount, graph reset) makes the next event `isInitial`.
- const reportedGenerationRef = useRef(-1);
+ const { measurement, graph } = useGraphStore();
useLayoutEffect(() => {
if (!paperStore) return;
const { paper } = paperStore;
- // A new paper (or graph store) starts its own measurement history, so its
- // first pass reports `isInitial: true` again — e.g. after a dev-server hot
- // reload re-created the store, `transformToFitContent()` callers re-fit.
- reportedGenerationRef.current = -1;
+ const { stateSource } = measurement;
+ // 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;
function handleChanges() {
- // A graph reset clears `measureState`; the reset diagram's own first
- // pass is reported once it settles.
- if (measureState.get() === 0) return;
- const { measureGeneration } = graphStore;
- const isInitial = reportedGenerationRef.current !== measureGeneration;
- reportedGenerationRef.current = measureGeneration;
- callbackRef.current({ isInitial, paper, graph });
+ const state = stateSource.get();
+ const isInitial = previousState === 0;
+ previousState = state;
+ if (state === 0) return;
+ // 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 });
+ } catch (error) {
+ rethrowLater(error);
+ }
// The user callback may have moved cells via cell.position()/cell.size().
// PaperView runs in async mode, so those updates would be queued for the
// next rAF — producing a one-frame flash where the element is visible at its
@@ -135,12 +144,7 @@ export function useOnElementsMeasured(
}
// Flush any measurement that happened before subscription (e.g. initial
// data sync ran before this paperStore was available).
- if (measureState.get() > 0) {
- handleChanges();
- }
- const unsubscribe = measureState.subscribe(handleChanges);
- return () => {
- unsubscribe();
- };
- }, [paperStore, graphStore, measureState, graph, callbackRef]);
+ handleChanges();
+ return stateSource.subscribe(handleChanges);
+ }, [paperStore, measurement, graph, callbackRef]);
}
diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts
index 9ae4148f34..8f99a47084 100644
--- a/packages/joint-react/src/index.ts
+++ b/packages/joint-react/src/index.ts
@@ -12,7 +12,7 @@ export { GraphProvider } from './components/graph/graph-provider';
/** @group Types */
export type { GraphProviderProps } from './components/graph/graph-provider';
/** @group Types */
-export type { AutoSizeOrigin } from './store/graph-store';
+export type { AutoSizeOrigin } from './store/measurement';
/** @group Types */
export type { IncrementalCellsChange } from './store/graph-projection';
@@ -132,10 +132,7 @@ export { useMeasureElement } from './hooks/use-measure-element';
/** @group Types */
export type { MeasureElementOptions } from './hooks/use-measure-element';
/** @group Types */
-export type {
- TransformElementLayout,
- TransformElementLayoutParams,
-} from './store/create-elements-size-observer';
+export type { TransformElementLayout, TransformElementLayoutParams } from './store/measurement';
/**
* useOnElementsMeasured()
@@ -145,6 +142,14 @@ export { useOnElementsMeasured } from './hooks/use-on-elements-measured';
/** @group Types */
export type { ElementsMeasuredParams, OnElementsMeasured } from './hooks/use-on-elements-measured';
+/**
+ * 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
@@ -194,7 +199,9 @@ export {
selectCellParent,
selectCellLayer,
selectCellZIndex,
-} from './selectors/cell-selectors';
+} from './selectors';
+/** @group Selectors */
+export { selectMeasuredState, selectIsMeasured, selectElementsSizes } from './selectors';
// Data
// ----
diff --git a/packages/joint-react/src/internal.ts b/packages/joint-react/src/internal.ts
index 99fb9fb8d5..c6cbbe17a3 100644
--- a/packages/joint-react/src/internal.ts
+++ b/packages/joint-react/src/internal.ts
@@ -27,7 +27,8 @@ export type { FeaturesContext as FeaturesContextType } from './context';
export { FeaturesProvider } from './components';
// Store Classes
-export { GraphStore, DEFAULT_CELL_NAMESPACE, AUTO_SIZE_OPTION } from './store/graph-store';
+export { GraphStore, DEFAULT_CELL_NAMESPACE } from './store/graph-store';
+export { AUTO_SIZE_OPTION } from './store/measurement';
export type { GraphStoreInternalSnapshot, PaperStoreState } from './store/graph-store';
export type { GraphStoreOptions } from './store/graph-store';
export { PaperStore } from './store/paper-store';
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__/source-selector.test.ts b/packages/joint-react/src/selectors/__tests__/source-selector.test.ts
new file mode 100644
index 0000000000..b9362b3d75
--- /dev/null
+++ b/packages/joint-react/src/selectors/__tests__/source-selector.test.ts
@@ -0,0 +1,30 @@
+import { createSourceSelector, getSelectorSource, type SelectorSource } from '../source-selector';
+import type { GraphStore } from '../../store/graph-store';
+
+const store = {} as GraphStore;
+const unsubscribe = () => {};
+const source: SelectorSource = { subscribe: () => unsubscribe, get: () => 7 };
+
+describe('source-selector', () => {
+ it('resolves the source of a selector made by createSourceSelector', () => {
+ const resolve = jest.fn(() => source);
+ const selector = createSourceSelector(resolve, 0);
+
+ expect(getSelectorSource(selector, store)).toBe(source);
+ expect(resolve).toHaveBeenCalledWith(store);
+ });
+
+ it('has no source for a plain selector or a missing one', () => {
+ expect(getSelectorSource(() => 1, store)).toBeUndefined();
+ expect(getSelectorSource(undefined, store)).toBeUndefined();
+ });
+
+ it('returns the fallback and warns when called directly', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const selector = createSourceSelector(() => source, 42);
+
+ expect(selector()).toBe(42);
+ expect(warn).toHaveBeenCalledTimes(1);
+ warn.mockRestore();
+ });
+});
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/index.ts b/packages/joint-react/src/selectors/index.ts
index a141f876cb..e2939c8f0e 100644
--- a/packages/joint-react/src/selectors/index.ts
+++ b/packages/joint-react/src/selectors/index.ts
@@ -1,25 +1,6 @@
-import type { GraphStoreInternalSnapshot } from '../store/graph-store';
-
-// ── Public cell selectors ───────────────────────────────────────────────────
-
+// Public selectors, passed to `useCell` / `useCells` / `useOnCellsChange`.
export * from './cell-selectors';
+export * from './measurement-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/selectors/measurement-selectors.ts b/packages/joint-react/src/selectors/measurement-selectors.ts
new file mode 100644
index 0000000000..83092ccab7
--- /dev/null
+++ b/packages/joint-react/src/selectors/measurement-selectors.ts
@@ -0,0 +1,105 @@
+import type { dia } from '@joint/core';
+import type { CellId } from '../types/cell.types';
+import { createSourceSelector } from './source-selector';
+
+/**
+ * Selects the measured state 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 directly to {@link useCells} or {@link useOnCellsChange} in their
+ * all-cells form. The hook then listens to the measurement only, so it does no
+ * work on other commits (a drag, for example).
+ * @returns The measured state, `0` when nothing is measured yet.
+ * @group Selectors
+ * @example
+ * ```tsx
+ * import { useOnCellsChange, selectMeasuredState, useGraph } from '@joint/react';
+ *
+ * // Re-run a layout each time the element sizes settle.
+ * function AutoLayout() {
+ * const { graph } = useGraph();
+ * useOnCellsChange(selectMeasuredState, (measuredState) => {
+ * if (measuredState) runLayout(graph);
+ * });
+ * return null;
+ * }
+ * ```
+ */
+export const selectMeasuredState = createSourceSelector(
+ ({ measurement }) => measurement.stateSource,
+ 0
+);
+
+/**
+ * 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 selectMeasuredState} to react to every later change too.
+ *
+ * Pass it directly to {@link useCells} or {@link useOnCellsChange} in their
+ * all-cells form.
+ * @returns `true` once the element sizes are known.
+ * @group Selectors
+ * @example
+ * ```tsx
+ * import { useLayoutEffect } from 'react';
+ * import { useCells, usePaper, selectIsMeasured } from '@joint/react';
+ *
+ * // Mount inside a : fit the paper once the sizes are known.
+ * function FitOnMeasure() {
+ * const { paper } = usePaper();
+ * const isMeasured = useCells(selectIsMeasured);
+ * useLayoutEffect(() => {
+ * if (isMeasured && paper) paper.transformToFitContent({ padding: 20 });
+ * }, [isMeasured, paper]);
+ * return null;
+ * }
+ * ```
+ */
+export const selectIsMeasured = createSourceSelector(
+ ({ measurement }) => measurement.isMeasuredSource,
+ false
+);
+
+const NO_SIZES: ReadonlyMap = new Map();
+
+/**
+ * Selects the size of every element, by id. The map is the same reference
+ * until a size changes, an element is added or removed, or the graph is reset,
+ * so a component reading it does not re-render while elements are only moved.
+ * It changes for every size, including one the application writes itself
+ * (`cell.resize()`, a resize tool).
+ *
+ * To run a layout when sizes are known, prefer {@link selectMeasuredState}: a
+ * layout that resizes elements would change this map again.
+ *
+ * Pass it directly to {@link useCells} or {@link useOnCellsChange} in their
+ * all-cells form. The hook then listens to size changes only, so it does no
+ * work on other commits (a drag, for example).
+ * @returns The element sizes, keyed by element id.
+ * @group Selectors
+ * @example
+ * ```tsx
+ * import { useCells, selectElementsSizes } from '@joint/react';
+ *
+ * function WidestElement() {
+ * const sizes = useCells(selectElementsSizes);
+ * let widest = 0;
+ * for (const { width } of sizes.values()) widest = Math.max(widest, width);
+ * return {widest};
+ * }
+ * ```
+ */
+export const selectElementsSizes = createSourceSelector(
+ ({ measurement }) => measurement.sizesSource,
+ NO_SIZES
+);
diff --git a/packages/joint-react/src/utils/selector-utils.ts b/packages/joint-react/src/selectors/selector-utils.ts
similarity index 95%
rename from packages/joint-react/src/utils/selector-utils.ts
rename to packages/joint-react/src/selectors/selector-utils.ts
index 82c20771c7..f9e3c1c728 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;
diff --git a/packages/joint-react/src/selectors/source-selector.ts b/packages/joint-react/src/selectors/source-selector.ts
new file mode 100644
index 0000000000..991dcab63d
--- /dev/null
+++ b/packages/joint-react/src/selectors/source-selector.ts
@@ -0,0 +1,47 @@
+import type { GraphStore } from '../store/graph-store';
+import { warnSourceSelectorCalled } from '../utils/dev-warnings';
+
+/**
+ * Where a selector's value comes from when it is not derived from the cells:
+ * `get` returns the selected value and `subscribe` fires when it may have changed.
+ */
+export interface SelectorSource {
+ readonly subscribe: (listener: () => void) => () => void;
+ readonly get: () => Selected;
+}
+
+const selectorSources = new WeakMap