diff --git a/.changeset/react-elements-measured-app-resize.md b/.changeset/react-elements-measured-app-resize.md new file mode 100644 index 0000000000..9ed255334c --- /dev/null +++ b/.changeset/react-elements-measured-app-resize.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useOnElementsMeasured - fix to fire exactly once per settled change, when no element is still waiting to be measured 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 98420cb199..8c77af7460 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 @@ -50,6 +50,11 @@ function SVGElementItemComponent(props: ElementItemProps) { const graphStore = useGraphStore(); const { paper } = usePaper(); useLayoutEffect(() => { + // Runs after the portal subtree's layout effects, so a `useMeasureElement` + // 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); if (!paper) return; graphStore.clearViewForElementAndLinks({ cellId: id, diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx new file mode 100644 index 0000000000..ac86c8f011 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -0,0 +1,405 @@ +/** + * Specification for when `useOnElementsMeasured` delivers an event. + * + * 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 { useOnElementsMeasured } from '../use-on-elements-measured'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../../store/graph-store'; +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; + useOnElementsMeasured(PAPER_ID, ({ isInitial }) => { + events.push({ isInitial }); + }); + 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('useOnElementsMeasured — 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('useOnElementsMeasured — 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('useOnElementsMeasured — 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('useOnElementsMeasured — 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__/use-on-elements-measured-settling.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx new file mode 100644 index 0000000000..326e43c1fd --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx @@ -0,0 +1,278 @@ +/** + * Scenarios beyond the #3520 specification in `use-on-elements-measured-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 { useOnElementsMeasured } from '../use-on-elements-measured'; +import { useGraphStore } from '../use-graph-store'; +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; + useOnElementsMeasured(PAPER_ID, ({ isInitial }) => { + events.push({ isInitial }); + }); + 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('useOnElementsMeasured — 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('useOnElementsMeasured — 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); + }); +}); + +// 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', () => { + 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; + }); + + /** 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; + + 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 = TestResizeObserver.instances.find((instance) => instance.observed.size > 0); + if (!observer) throw new Error('no ResizeObserver has the measured node registered'); + const [node] = observer.observed; + + 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-on-elements-measured.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx index b10d52ea14..e1256086a0 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,6 +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 { useGraphStore } from '../use-graph-store'; import type { CellRecord } from '../../types/cell.types'; import type { ElementsMeasuredParams } from '../use-on-elements-measured'; @@ -30,7 +31,7 @@ const wrapper = paperRenderElementWrapper({ /** * Wrapper with zero-size elements (ElementModel defaults). * Simulates the flowchart scenario where elements rely on - * ResizeObserver to set their real size via `fromMeasure`. + * ResizeObserver to set their real size via an `autoSize` write. */ const zeroSizeWrapper = paperRenderElementWrapper({ graphProviderProps: { @@ -91,12 +92,12 @@ describe('useOnElementsMeasured', () => { }); // Regression: ElementModel defaults to size {0,0}. The ResizeObserver - // pipeline sets the real size via `cell.set('size', ..., {fromMeasure: true})`. + // pipeline sets the real size via `cell.set('size', ..., { autoSize: true })`. // Previously, the `change:size` listener in graph-changes.ts skipped - // `fromMeasure` writes, so the measured-size never reached the tracking + // 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 via fromMeasure', async () => { + it('fires callback when elements start at zero size and get measured', async () => { const callback = jest.fn(); let graphRef: dia.Graph | undefined; @@ -120,10 +121,56 @@ describe('useOnElementsMeasured', () => { // Simulate ResizeObserver setting the real measured size. act(() => { const cell = graphRef!.getCell('zero-el') as dia.Element; - cell.set('size', { width: 100, height: 60 }, { fromMeasure: true } as object); + cell.set('size', { width: 100, height: 60 }, { [AUTO_SIZE_OPTION]: true } as object); }); await waitFor(() => expect(callback).toHaveBeenCalled()); expect(callback.mock.calls[0][0].isInitial).toBe(true); }); + + // 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 renderMeasuredProbe(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 = renderMeasuredProbe(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 = renderMeasuredProbe(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/use-on-elements-measured.ts b/packages/joint-react/src/hooks/use-on-elements-measured.ts index d481409ad0..0715133e97 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -12,7 +12,10 @@ import { useLatestRef } from './use-latest-ref'; * @expand */ export interface ElementsMeasuredParams { - /** True on the first measurement pass (at least one element has been sized). */ + /** + * True on the first measurement pass (at least one element has been sized), + * and again on the first pass after a graph reset (`resetCells()`). + */ readonly isInitial: boolean; /** The paper this hook is bound to (the surrounding `` context, or the paper passed via `paperTarget`). */ readonly paper: dia.Paper; @@ -28,13 +31,23 @@ export interface ElementsMeasuredParams { export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; /** - * Calls a callback when element sizes are measured or re-measured. + * Calls a callback once element sizes are known, so a layout can run on them. * - * Fires on the first measurement pass (at least one element has been sized) - * and again whenever an element is resized. + * Delivers one event per settled change: the first pass (at least one element + * has a size), and each later addition or re-measurement, once no element is + * still waiting. An added element waits until the paper has rendered it; if + * its content measures itself ({@link useMeasureElement}, {@link HTMLHost}), + * until that measurement arrives. A batch mixing plain and measured elements + * is one event, delivered when the last one is measured. An element the paper + * does not render (viewport culling, `cellVisibility`) and a zero-sized element + * nothing measures do not hold the event back. A size the application writes + * itself (`cell.resize()`, controlled `cells` sync) is not a measurement and + * never fires; listen to `change:size` with {@link useOnGraphEvents} to hear + * every size change. * - * The callback receives {@link ElementsMeasuredParams}; check `isInitial` to - * distinguish the first measurement from later ones. + * 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. * @title On the current paper * @param callback - Called each time element sizes are measured. * @group Hooks @@ -92,23 +105,26 @@ export function useOnElementsMeasured( const callbackRef = useLatestRef(callback); - const { measureState, graph } = useGraphStore(); - const wasMeasuredRef = useRef(false); + 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); 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. - wasMeasuredRef.current = false; + reportedGenerationRef.current = -1; function handleChanges() { - const value = measureState.get(); - const isMeasured = value > 0; - const isInitial = isMeasured && !wasMeasuredRef.current; - if (isInitial) { - wasMeasuredRef.current = true; - } + // 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 }); // 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 @@ -126,5 +142,5 @@ export function useOnElementsMeasured( return () => { unsubscribe(); }; - }, [paperStore, measureState, graph, callbackRef]); + }, [paperStore, graphStore, measureState, graph, callbackRef]); } diff --git a/packages/joint-react/src/store/__tests__/graph-changes.test.ts b/packages/joint-react/src/store/__tests__/graph-changes.test.ts index 051ba6fe03..f431b34dc6 100644 --- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts @@ -23,12 +23,16 @@ function setupWithSize() { const graph = createGraph(); const onChanges = jest.fn(); const onElementsSizeChange = jest.fn(); + const onElementRemove = jest.fn(); + const onReset = jest.fn(); const controller = graphChanges({ graph, onChanges, onElementsSizeChange, + onElementRemove, + onReset, }); - return { graph, onChanges, onElementsSizeChange, controller }; + return { graph, onChanges, onElementsSizeChange, onElementRemove, onReset, controller }; } function addElement(graph: dia.Graph, id: string, x = 10, y = 20, width = 100, height = 50) { @@ -417,7 +421,33 @@ describe('graphChanges', () => { ); }); - it('fires when ResizeObserver sets size via fromMeasure flag', () => { + it('fires onReset before the seed cells\' size notifications', () => { + const { graph, onElementsSizeChange, onReset } = setupWithSize(); + onReset.mockImplementation(() => { + expect(onElementsSizeChange).not.toHaveBeenCalled(); + }); + graph.resetCells([ + { id: 'a', type: 'element', position: { x: 0, y: 0 }, size: { width: 100, height: 50 } }, + ]); + + expect(onReset).toHaveBeenCalledTimes(1); + expect(onElementsSizeChange).toHaveBeenCalledTimes(1); + }); + + it('fires onElementRemove for elements only', () => { + const { graph, onElementRemove } = setupWithSize(); + addElement(graph, 'a'); + addElement(graph, 'b'); + addLink(graph, 'l1', 'a', 'b'); + + graph.getCell('l1').remove(); + expect(onElementRemove).not.toHaveBeenCalled(); + + graph.getCell('a').remove(); + expect(onElementRemove).toHaveBeenCalledWith('a'); + }); + + it('forwards the change:size options of a measurement write', () => { const { graph, onElementsSizeChange } = setupWithSize(); graph.resetCells([ { @@ -430,9 +460,15 @@ describe('graphChanges', () => { onElementsSizeChange.mockClear(); const cell = graph.getCell('a') as dia.Element; - cell.set('size', { width: 120, height: 60 }, { fromMeasure: true } as object); - - expect(onElementsSizeChange).toHaveBeenCalledWith('a', { width: 120, height: 60 }); + cell.set('size', { width: 120, height: 60 }, { autoSize: true } as object); + + // The `change:size` options are forwarded so the store can tell a + // measurement write from an application resize (#3514). + expect(onElementsSizeChange).toHaveBeenCalledWith( + 'a', + { width: 120, height: 60 }, + expect.objectContaining({ autoSize: true }) + ); }); it('does not fire for links on reset', () => { diff --git a/packages/joint-react/src/store/__tests__/graph-store-features.test.ts b/packages/joint-react/src/store/__tests__/graph-store-features.test.ts index ec9fe025be..5557aea252 100644 --- a/packages/joint-react/src/store/__tests__/graph-store-features.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store-features.test.ts @@ -426,6 +426,8 @@ describe('GraphStore.clearViewForElementAndLinks', () => { const fakePaper = { getCellView: jest.fn().mockReturnValue(fakeElementView), remove: jest.fn(), + // `destroy()` unsubscribes the store's `render:done` listener. + off: jest.fn(), } as unknown as dia.Paper; // Forcefully redirect paperStore.paper to point at the mock so the diff --git a/packages/joint-react/src/store/__tests__/graph-store.test.ts b/packages/joint-react/src/store/__tests__/graph-store.test.ts index d29c0052c4..142f41d60b 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -92,6 +92,32 @@ describe('GraphStore', () => { store.destroy(false); }); + it('bumps measureState once an unsized element is measured to the size it already has', async () => { + const initialCells: readonly CellRecord[] = [ + { id: 'a', type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 } } as CellRecord, + ]; + const store = new GraphStore({ initialCells }); + await flush(); + // Nothing has a size yet, so the seed pass is not delivered. + expect(store.measureState.get()).toBe(0); + // The application pre-sizes the waiting element: not a measurement. + (store.graph.getCell('a') as dia.Element).resize(120, 40); + await flush(); + expect(store.measureState.get()).toBe(0); + + // The observer measures the same size, so it writes nothing to the graph. + const node = document.createElement('div'); + store.setMeasuredNode({ id: 'a', node }); + const [callback] = (globalThis.ResizeObserver as jest.Mock).mock.calls.at(-1) as [ + ResizeObserverCallback, + ]; + const entry = { target: node, borderBoxSize: [{ inlineSize: 120, blockSize: 40 }] }; + callback([entry as unknown as ResizeObserverEntry], {} as ResizeObserver); + await flush(); + expect(store.measureState.get()).toBe(1); + store.destroy(false); + }); + it('seeds from dia.Cell instances in initialCells', () => { const element = new ElementModel({ id: 'dia-el', diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 8fd9f754d0..870c180aca 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -97,6 +97,12 @@ interface Options { readonly getElements: () => Map; /** Callback function called when a batch of elements needs to be updated */ readonly onBatchUpdate: (data: Record) => void; + /** + * An observed node was measured, whether or not the size differs from the + * model's (a measurement equal to the model size, or a zero size, writes + * nothing). Fires before `onBatchUpdate` for the same entries. + */ + readonly onElementMeasured?: (id: CellId) => void; } /** @@ -226,6 +232,7 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver resizeObserverOptions = DEFAULT_OBSERVER_OPTIONS, getCellTransform, onBatchUpdate, + onElementMeasured, getElements, } = options; @@ -266,6 +273,8 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver ); if (!observedElement) continue; + onElementMeasured?.(observedElement.id); + if (!borderBoxSize || borderBoxSize.length === 0) { continue; } diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index 22a6d2d1e1..e25fe70cf8 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -60,10 +60,29 @@ interface OnChangeOptions { readonly isReset?: boolean; } -interface Options { +/** + * Graph events the measurement bookkeeping in `GraphStore` listens to. Forwarded + * unchanged by `graphProjection`. + */ +export interface MeasurementListeners { + /** + * An element got a size. `changeOptions` are the options of the `change:size` + * event; `undefined` when the size arrives with the cell (`add` / `reset`). + */ + readonly onElementsSizeChange?: ( + id: CellId, + size: dia.Size, + changeOptions?: dia.Cell.Options + ) => void; + /** An element left the graph (not fired by a `reset`; see {@link onReset}). */ + readonly onElementRemove?: (id: CellId) => void; + /** The graph was reset; fires before the seed cells' `onElementsSizeChange` calls. */ + readonly onReset?: () => void; +} + +interface Options extends MeasurementListeners { readonly graph: dia.Graph; readonly onChanges: (options: OnChangeOptions) => void; - readonly onElementsSizeChange?: (id: CellId, size: { width: number; height: number }) => void; } interface JointJSEventOptions { @@ -78,7 +97,7 @@ interface JointJSEventOptions { * @returns Controller exposing updateGraph and destroy. */ export function graphChanges(options: Options) { - const { graph, onElementsSizeChange } = options; + const { graph, onElementsSizeChange, onElementRemove, onReset } = options; const changes = new Map>(); let batchDepth = 0; @@ -165,6 +184,7 @@ export function graphChanges(options: Options) { _collection: mvc.Collection, { isUpdateFromReact }: JointJSEventOptions ) => { + if (cell.isElement()) onElementRemove?.(cell.id); if (isUpdateFromReact) return; onCellEvent(cell, 'remove'); } @@ -176,6 +196,7 @@ export function graphChanges(options: Options) { if (eventOptions.isUpdateFromReact) return; isSyncedWithReact = true; changes.clear(); + onReset?.(); for (const cell of collection.models) { changes.set(cell.id, { type: 'add', data: cell }); // `reset` suppresses per-cell `add` events, so size notifications @@ -203,10 +224,14 @@ export function graphChanges(options: Options) { onChanges({ changes: layoutChanges, isInsideBatch: true, deferCommit: isDeferring() }); }); - controller.listenTo(graph, 'change:size', (cell: dia.Cell, newSize: dia.Size) => { - if (!onElementsSizeChange) return; - onElementsSizeChange(cell.id, newSize); - }); + controller.listenTo( + graph, + 'change:size', + (cell: dia.Cell, newSize: dia.Size, changeOptions: dia.Cell.Options = {}) => { + if (!onElementsSizeChange) return; + onElementsSizeChange(cell.id, newSize, changeOptions); + } + ); // Always-on batch tracking. A batch flagged with DEFER_COMMIT_BATCH_OPTION // defers its container commits until it closes, so a burst of edits (sync or diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index 85726b177e..02578ac446 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -1,6 +1,6 @@ import { type dia } from '@joint/core'; import type { ElementJSONInit, LinkJSONInit, CellId } from '../types/cell.types'; -import { graphChanges, type UpdateGraphOptions } from './graph-changes'; +import { graphChanges, type MeasurementListeners, type UpdateGraphOptions } from './graph-changes'; import { asReadonlyContainer, createContainer, type ContainerChangeSet } from './state-container'; import { mergeCellRecord, toCellRecord } from '../state/data-mapping/cell-record-merge'; @@ -36,10 +36,9 @@ export type OnIncrementalCellsChange { +> extends MeasurementListeners { readonly graph: dia.Graph; readonly onIncrementalCellsChange?: OnIncrementalCellsChange; - readonly onElementsSizeChange?: (id: CellId, size: dia.Size) => void; } /* eslint-disable sonarjs/cognitive-complexity -- graph→container projection @@ -56,7 +55,8 @@ export function graphProjection< Element extends ElementJSONInit = ElementJSONInit, Link extends LinkJSONInit = LinkJSONInit, >(options: GraphProjectionState) { - const { graph, onIncrementalCellsChange, onElementsSizeChange } = options; + const { graph, onIncrementalCellsChange, onElementsSizeChange, onElementRemove, onReset } = + options; const cells = createContainer(); @@ -124,6 +124,8 @@ export function graphProjection< const graphChangesController = graphChanges({ graph, onElementsSizeChange, + onElementRemove, + onReset, onChanges: ({ changes, isInsideBatch, deferCommit, isReset }) => { // Elements removed in this batch — swept once after the loop for link // records they may have stranded. diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 176a27a8a0..22d745f31f 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -13,6 +13,7 @@ import { LINK_MODEL_TYPE, LinkModel } from '../mvc/link-model'; import { isElementType, isLinkType } from '../utils/cell-type'; import { clearConnectedLinkViews } from './clear-view'; import { LAYOUT_UPDATE_EVENT } from './graph-changes'; +import { isPaperView } from '../mvc/paper'; import { createAtom, type Atom } from './state-container'; import type { IncrementalChange } from '../state/incremental.types'; import type { Feature } from '../types/feature.types'; @@ -109,12 +110,20 @@ export class GraphStore< public readonly graphProjection: GraphProjection; public readonly internalState: Atom; public readonly measureState: Atom = createAtom(0); + /** + * Incremented by every graph `reset`. `useOnElementsMeasured` reports + * `isInitial` for the first `measureState` bump of each generation. + */ + public measureGeneration = 0; public readonly graph: dia.Graph; public readonly autoSizeOrigin: AutoSizeOrigin; public paperStores = new Map(); public features: Record = {}; private observer: GraphStoreObserver; + /** Elements added to the graph that no paper has accounted for yet (see the constructor). */ + private readonly outstandingElements = new Set(); + private readonly scheduleMeasurementDelivery: () => void; private onIncrementalCellsChange?: OnIncrementalCellsChange; // dev-only `change:size` listener that warns about resizing auto-sized elements. private warnAutoSizeResize?: (cell: dia.Cell, size: dia.Size, opt?: AutoSizeOptions) => void; @@ -148,29 +157,73 @@ export class GraphStore< graphFeaturesVersion: 1, }); - const elementsMeasured = new Set(); - const onElementSizeChange = () => { - if (elementsMeasured.size > 0) { - this.measureState.set((previous) => previous + 1); - } + // Measurement bookkeeping behind `measureState` (see `useOnElementsMeasured`): + // one bump per settled change. An element is outstanding from arriving in + // the graph until it is accounted for: its React content committed without + // registering a measurer (`markElementRendered`), no paper renders it + // (`settleUnrenderedElements`, after a paper's render pass), or, when it did + // register one (`useMeasureElement`), the observer measured it. A zero size + // is never read as "waiting": it is a legal final size (a layout anchor). + // Sizes the application writes are not measurements and never bump (#3514). + const sizedElements = new Set(); + let hasUndeliveredChange = false; + const deliverMeasurement = () => { + if (!hasUndeliveredChange) return; + // Papers account for outstanding elements; without one nothing renders + // or measures, so there is nothing to wait for. + if (this.outstandingElements.size > 0 && this.paperStores.size > 0) return; + // "Measured" means at least one element has a size (`isInitial` contract). + if (sizedElements.size === 0) return; + hasUndeliveredChange = false; + this.measureState.set((previous) => previous + 1); }; + this.scheduleMeasurementDelivery = () => simpleScheduler(deliverMeasurement); this.graphProjection = graphProjection({ graph: this.graph, onIncrementalCellsChange: (changes) => { this.onIncrementalCellsChange?.(changes); }, - onElementsSizeChange: (id, size) => { + onElementsSizeChange: (id, size, changeOptions) => { if (size.width > 0 && size.height > 0) { - elementsMeasured.add(id); + sizedElements.add(id); } else { - elementsMeasured.delete(id); + sizedElements.delete(id); } - simpleScheduler(onElementSizeChange); + if (changeOptions === undefined) { + // The size arrived with the cell (`add` / `reset`): outstanding until + // a paper has rendered it, whatever the size says. + this.outstandingElements.add(id); + } else if (changeOptions[AUTO_SIZE_OPTION]) { + this.outstandingElements.delete(id); + } else { + return; + } + hasUndeliveredChange = true; + this.scheduleMeasurementDelivery(); + }, + onElementRemove: (id) => { + sizedElements.delete(id); + if (this.outstandingElements.delete(id)) this.scheduleMeasurementDelivery(); + }, + onReset: () => { + // The reset replaces the diagram: its first settled pass is a new + // `isInitial` for `useOnElementsMeasured`, and until then nothing is + // measured for `useAreElementsMeasured`. + sizedElements.clear(); + this.outstandingElements.clear(); + hasUndeliveredChange = false; + this.measureGeneration += 1; + this.measureState.set(0); }, }); this.observer = createElementsSizeObserver({ + onElementMeasured: (id) => { + // Settles an element whose measurement equals the size it already has + // (the application pre-sized it): the observer then writes nothing. + if (this.outstandingElements.delete(id)) this.scheduleMeasurementDelivery(); + }, getElements: () => { // The observer only cares about element-typed cells. Build a Map on // demand from the unified cells container — cold path, called only @@ -432,6 +485,52 @@ export class GraphStore< }; public setMeasuredNode = (options: SetMeasuredNodeOptions) => this.observer.add(options); + + /** + * An element's React content committed. Called by the element portal item + * after its subtree's layout effects, so an element that registered for + * measurement (`useMeasureElement`) stays outstanding until measured, while + * one that nothing measures is settled now. + * @param id - the rendered element + */ + public markElementRendered = (id: CellId) => { + if (!this.outstandingElements.has(id) || this.observer.has(id)) return; + this.outstandingElements.delete(id); + this.scheduleMeasurementDelivery(); + }; + + /** + * A paper finished a render pass (`render:done`): every view it mounts for + * this pass is in the DOM now. An outstanding element that no paper renders + * (viewport culling, `cellVisibility`, a hidden group) is not going to be + * measured, so it is settled with the size it has; one with a mounted view + * waits for its content to commit (`markElementRendered`). O(outstanding), + * which is empty between changes. + */ + public settleUnrenderedElements = () => { + let didSettle = false; + for (const id of this.outstandingElements) { + if (this.observer.has(id) || this.isRenderedByAnyPaper(id)) continue; + this.outstandingElements.delete(id); + didSettle = true; + } + if (didSettle) this.scheduleMeasurementDelivery(); + }; + + /** + * Whether some paper mounted the element's view and will render React + * content into it, which is the only way a measurer gets registered: the + * paper has a `renderElement` and the view has a portal node. + */ + private isRenderedByAnyPaper(id: CellId): boolean { + for (const paperStore of this.paperStores.values()) { + if (!paperStore.renderElement) continue; + const view = paperStore.getElementView(id); + if (!view?.el.isConnected || !isPaperView(view.paper)) continue; + if (view.paper.getCellViewPortalNode(view)) return true; + } + return false; + } public getPaperStore = (id: string) => { return this.paperStores.get(id); }; diff --git a/packages/joint-react/src/store/paper-store.ts b/packages/joint-react/src/store/paper-store.ts index b34af21f15..9ddf68b5da 100644 --- a/packages/joint-react/src/store/paper-store.ts +++ b/packages/joint-react/src/store/paper-store.ts @@ -104,6 +104,8 @@ export class PaperStore { /** Link changes pending flush, populated by clearView, flushed in afterRender. */ private pendingLinkChanges: Map> = new Map(); + /** The graph store's `render:done` listener, kept to unsubscribe an adopted paper. */ + private readonly settleUnrenderedElements: () => void; constructor(options: PaperStoreOptions) { const { @@ -178,6 +180,11 @@ export class PaperStore { // prop-update effect runs (or when the store is used without the hook). this.nativeCellVisibility = this.paper.options.cellVisibility; + // After each render pass the graph store learns which added elements no + // paper renders, so they do not hold `useOnElementsMeasured` open. + this.settleUnrenderedElements = graphStore.settleUnrenderedElements; + this.paper.on('render:done', this.settleUnrenderedElements); + if (transform !== undefined) { this.paper.matrix(toSVGMatrix(transform)); } @@ -266,6 +273,7 @@ export class PaperStore { * Should be called when the paper is being removed from the graph store. */ public destroy = () => { + this.paper.off('render:done', this.settleUnrenderedElements); // An adopted paper is owned by its creator (e.g. ``), which // removes it itself — removing it here would kill a paper still in use, // breaking the next adoption (notably under React StrictMode remounts).