diff --git a/.changeset/react-elements-measured-app-resize.md b/.changeset/react-elements-measured-app-resize.md new file mode 100644 index 0000000000..6c5ef0a59c --- /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 (an addition, a removal or a re-measurement), when no element is still waiting to be measured diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index e94c72749d..c1d3cfd9ff 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, { useLayoutEffect } 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 d080ec118b..854b8ba7f6 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 @@ -29,8 +29,6 @@ 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; } /** @@ -89,24 +87,31 @@ function useUnresolvedElement(id: CellId): ElementRecord { /** * 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(); const data = useElementDataSnapshot(id); 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. Keyed + // on the portal target too: a same-id reset whose replacement view renders + // before React commits keeps this item mounted, with a new target. + graphStore.measurement.markRendered(id); if (!paper) return; graphStore.clearViewForElementAndLinks({ cellId: id, paper, }); - }, [id, graphStore, areElementsMeasured, paper]); + }, [id, graphStore, paper, portalElement]); if (!portalElement) { return null; 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..79bb7ef3d5 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); @@ -135,7 +138,8 @@ describe('useMeasureElement', () => { expect(() => render( - @@ -172,7 +176,11 @@ describe('useMeasureElement', () => { } render( - + ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -184,7 +192,11 @@ describe('useMeasureElement', () => { it('returns size record without registering when ref.current is null at mount', async () => { const { container } = render( - + ); await waitFor(() => { @@ -193,6 +205,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-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..94b23c8d09 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -0,0 +1,403 @@ +/** + * Specification for when `useOnElementsMeasured` delivers an event (#3520). + * + * 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/measurement'; +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..f9ba1169bb --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx @@ -0,0 +1,697 @@ +/** + * 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 { useRef } from 'react'; +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, type OnElementsMeasured } from '../use-on-elements-measured'; +import { useGraphStore } from '../use-graph-store'; +import { useMeasureElement } from '../use-measure-element'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../../store/measurement'; +import type { GraphStore } from '../../store/graph-store'; +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[]; + readonly getPaper: () => dia.Paper; + /** + * Mounts a second hook instance beside the first one, from now on, recording + * its events and calling `onMeasured` after each. + */ + readonly mountLateProbe: (onMeasured?: OnElementsMeasured) => RecordedEvent[]; +} + +/** Renders a graph with one hook instance mounted beside the paper. */ +function renderGraph( + initialCells: CellRecord[], + paperProps: Partial = {}, + isStrictMode = true +): Harness { + const events: RecordedEvent[] = []; + const lateEvents: RecordedEvent[] = []; + let onLateMeasured: OnElementsMeasured | undefined; + let graph: dia.Graph | undefined; + let graphStore: GraphStore | undefined; + + function Probe({ recorded }: Readonly<{ recorded: RecordedEvent[] }>) { + graphStore = useGraphStore(); + ({ graph } = graphStore); + useOnElementsMeasured(PAPER_ID, (params) => { + recorded.push({ isInitial: params.isInitial }); + if (recorded === lateEvents) onLateMeasured?.(params); + }); + return null; + } + + const renderTree = (hasLateProbe: boolean) => ( + + + + {hasLateProbe && } + + ); + const { rerender } = render(renderTree(false), { reactStrictMode: isStrictMode }); + + const getPaper = () => { + const paperStore = graphStore?.getPaperStore(PAPER_ID); + if (!paperStore) throw new Error(`Paper "${PAPER_ID}" is not mounted`); + return paperStore.paper; + }; + const mountLateProbe = (onMeasured?: OnElementsMeasured) => { + onLateMeasured = onMeasured; + rerender(renderTree(true)); + return lateEvents; + }; + + return { graph: graph as dia.Graph, events, getPaper, mountLateProbe }; +} + +/** 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; +} + +/** + * Switches the settled plain `a` to ``, which registers a measurer, + * together with `otherChange` in the same task. + */ +async function startMeasuring(harness: Harness, otherChange?: () => void) { + act(() => { + harness.graph.getCell('a').set('data', { measured: true }); + otherChange?.(); + }); + await flush(); +} + +// 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); + }); + + // Regression: a pass that only culls updates no view, so the paper fires no + // `render:done`, and the culled element waited for one forever. + it('delivers an addition the paper culls entirely', async () => { + const harness = renderGraph([plain('a')], { cellVisibility: hideAnchor }); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(anchor('anchor') as never); + }); + await flush(); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + }); +}); + +// 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); + }); +}); + +// Regression: an element that stopped measuring before it was measured stayed +// "waiting" forever and held back every later event. +describe('useOnElementsMeasured — 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('useOnElementsMeasured — 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', () => { + class TestResizeObserver { + static readonly instances: TestResizeObserver[] = []; + readonly observed = new Set(); + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + TestResizeObserver.instances.push(this); + } + observe(target: Element) { + this.observed.add(target); + } + unobserve(target: Element) { + this.observed.delete(target); + } + disconnect() { + this.observed.clear(); + } + /** What the browser reports after layout: the node's new border box. */ + report(target: Element, width: number, height: number) { + const entry = { target, borderBoxSize: [{ inlineSize: width, blockSize: height }] }; + this.callback([entry as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + } + } + + beforeEach(() => { + TestResizeObserver.instances.length = 0; + globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; + }); + + /** + * The observer holding the node `` registered. The paper observes + * its own host with another `ResizeObserver`, so the instance is found by + * the node: the measured one lives inside the element's `foreignObject`. + */ + function findMeasuredNode() { + for (const observer of TestResizeObserver.instances) { + for (const node of observer.observed) { + if (node.closest('foreignObject')) return { observer, node }; + } + } + throw new Error('no ResizeObserver has the measured node registered'); + } + + // Regression: with no paper mounted yet, the seed was delivered unmeasured, + // and a paper mounted later replayed that pass as initial, on placeholder sizes. + it('waits for the measurement when the paper mounts after the graph', async () => { + const events: Array = []; + function Probe() { + useOnElementsMeasured(PAPER_ID, ({ isInitial, graph }) => { + events.push({ isInitial, width: (graph.getCell('b') as dia.Element).size().width }); + }); + return null; + } + const initialCells = [pending('b')]; + const renderTree = (hasPaper: boolean) => ( + + {hasPaper && ( + <> + + + + )} + + ); + const { rerender } = render(renderTree(false)); + await flush(); + + rerender(renderTree(true)); + await flush(); + expect(events).toHaveLength(0); + + const { observer, node } = findMeasuredNode(); + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(events).toEqual([{ isInitial: true, width: 120 }]); + }); + + /** Mounts one measured element, lets it register, and measures it once. */ + async function mountMeasured() { + const harness = renderGraph([pending('b')]); + await flush(); + const { observer, node } = findMeasuredNode(); + + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + expect(harness.events).toEqual([{ isInitial: true }]); + harness.events.length = 0; + + const element = harness.graph.getCell('b') as dia.Element; + return { harness, observer, node, element }; + } + + it('delivers one event when the re-rendered content measures larger', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + // The application changes what `renderElement` shows; the browser lays the + // node out larger and the observer reports it. + act(() => { + element.set('data', { measured: true, label: 'a much longer label' }); + }); + await flush(); + expect(node.textContent).toBe('a much longer label'); + expect(harness.events).toHaveLength(0); + + act(() => { + observer.report(node, 240, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + expect(element.size()).toEqual({ width: 240, height: 40 }); + }); + + it('delivers no event when the re-rendered content measures the same', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + act(() => { + element.set('data', { measured: true, label: 'same size' }); + }); + await flush(); + expect(node.textContent).toBe('same size'); + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + expect(element.size()).toEqual({ width: 120, height: 40 }); + }); + + it('measures an element the application pre-sized to what it will measure', async () => { + const harness = renderGraph([pending('b')]); + await flush(); + const { observer, node } = findMeasuredNode(); + + act(() => { + (harness.graph.getCell('b') as dia.Element).resize(120, 40); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + // Same size as the model: the observer writes nothing, yet the element is + // measured now and the pass is delivered. + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: true }]); + }); + + describe('a settled element starts measuring', () => { + // Regression: the newly registered measurer did not hold delivery back, so + // the removal fired with the old size and the first report fired again. + it('delivers one event for a change made while its first report is due', async () => { + const harness = renderGraph([plain('a'), plain('c')]); + await settleAndClear(harness); + + await startMeasuring(harness, () => { + harness.graph.getCell('c').remove(); + }); + expect(harness.events).toHaveLength(0); + const { observer, node } = findMeasuredNode(); + + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + }); + + // Regression: a hook mounted before the first report replayed the old size. + it('does not replay to a hook mounted before its first report', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + await startMeasuring(harness); + const { observer, node } = findMeasuredNode(); + + const lateEvents = harness.mountLateProbe(); + await flush(); + expect(lateEvents).toHaveLength(0); + + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(lateEvents).toEqual([{ isInitial: true }]); + expect(harness.events).toEqual([{ isInitial: false }]); + }); + + it('delivers no event when its first report matches its size', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + await startMeasuring(harness); + const { observer, node } = findMeasuredNode(); + + act(() => { + observer.report(node, 50, 50); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + act(() => { + harness.graph.addCell(plain('d') as never); + }); + await flush(); + expect(harness.events).toEqual([{ isInitial: false }]); + }); + + it('delivers the waiting change once it stops measuring before its first report', async () => { + const harness = renderGraph([plain('a'), plain('c')]); + await settleAndClear(harness); + await startMeasuring(harness, () => { + harness.graph.getCell('c').remove(); + }); + expect(harness.events).toHaveLength(0); + + act(() => { + harness.graph.getCell('a').set('data', {}); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + }); + }); +}); + +// Only a reset (or a new mount) starts a new history: emptying the diagram is +// an ordinary change, however the elements come back. +describe('useOnElementsMeasured — the diagram is emptied and filled again', () => { + it('does not report the next event as initial, with a pass in between', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.getCell('a').remove(); + }); + await flush(); + act(() => { + harness.graph.addCell(plain('b') as never); + }); + await flush(); + + expect(harness.events.every(({ isInitial }) => !isInitial)).toBe(true); + expect(harness.events.length).toBeGreaterThan(0); + }); + + it('does not report the next event as initial, in the same task', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.clear(); + harness.graph.addCell(plain('b') as never); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + }); +}); + +describe('useOnElementsMeasured — a graph reset', () => { + // Regression: a same-id reset whose replacement views rendered before React + // committed kept the element item mounted with a new portal target, so its + // content never reported as rendered and the reset was never delivered. + it('delivers the reset when the replacement views render before React commits', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([plain('a')] as never); + harness.getPaper().updateViews(); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: true }]); + }); +}); + +// Regression: a hook mounted while a change was still waiting replayed the +// previous version at once, so its layout ran on the waiting element's +// placeholder size, then ran again when the measurement arrived. +describe('useOnElementsMeasured — mounted while a change is waiting', () => { + it('delivers nothing until the change settles, then one initial event', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + const lateEvents = harness.mountLateProbe(); + await flush(); + expect(lateEvents).toHaveLength(0); + + act(() => { + harness.graph + .getCell('b') + .set('size', { width: 80, height: 20 }, { [AUTO_SIZE_OPTION]: true }); + }); + await flush(); + + expect(lateEvents).toEqual([{ isInitial: true }]); + expect(harness.events).toEqual([{ isInitial: false }]); + }); +}); + +// Regression: the mount-time replay ran on an emptied diagram, whose kept +// version describes elements that are gone. +describe('useOnElementsMeasured — mounted on an emptied diagram', () => { + it('delivers nothing until elements return', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + act(() => { + harness.graph.getCell('a').remove(); + }); + await flush(); + + const lateEvents = harness.mountLateProbe(); + await flush(); + expect(lateEvents).toHaveLength(0); + + act(() => { + harness.graph.addCell(plain('b') as never); + }); + await flush(); + expect(lateEvents).toEqual([{ isInitial: true }]); + }); +}); + +// Regression: the mount-time replay ran before the subscription, so a callback +// that reset the graph missed the reset, and the replacement diagram's first +// event was reported as not initial. +describe('useOnElementsMeasured — a replayed callback resets the graph', () => { + it('reports the replacement diagram as initial', async () => { + // Without StrictMode: its second mount replays after the reset, which + // hides the missed notification. Production mounts once. + const harness = renderGraph([plain('a')], {}, false); + await settleAndClear(harness); + + let hasReset = false; + const lateEvents = harness.mountLateProbe(({ graph }) => { + if (hasReset) return; + hasReset = true; + graph.resetCells([plain('x')] as never); + }); + await flush(); + await flush(); + + // The replay, then the replacement diagram's first pass. + expect(lateEvents).toEqual([{ isInitial: true }, { isInitial: true }]); + }); +}); + +// With `useHTMLOverlay`, every element renders into one overlay container that +// never changes, so React keeps the content of an id removed and added back in +// one task, and with it the measured node and its registration. A browser +// reports a node once when it starts observing it, then only when its box +// changes: this mock does the same, with a fixed box. +describe('useOnElementsMeasured — the HTML overlay keeps a re-added element', () => { + const MEASURED_BOX = { inlineSize: 120, blockSize: 40 }; + + class FirstReportResizeObserver { + readonly observed = new Set(); + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + this.observed.add(target); + // Only element content: the paper observes its own host too. + if (!target.closest('[model-id]')) return; + queueMicrotask(() => { + if (!this.observed.has(target)) return; + const entry = { target, borderBoxSize: [MEASURED_BOX] }; + this.callback([entry as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + }); + } + unobserve(target: Element) { + this.observed.delete(target); + } + disconnect() { + this.observed.clear(); + } + } + + beforeEach(() => { + globalThis.ResizeObserver = FirstReportResizeObserver as unknown as typeof ResizeObserver; + }); + + function MeasuredCard() { + const nodeRef = useRef(null); + useMeasureElement(nodeRef); + return
card
; + } + const overlayProps: Partial = { useHTMLOverlay: true, renderElement: MeasuredCard }; + + // Regression: the re-added element waited for a report the browser never sent + // again, and held back this event and every later one. + it('delivers one event when an element is removed and added back in one task', async () => { + const harness = renderGraph([pending('b')], overlayProps); + await settleAndClear(harness); + const element = harness.graph.getCell('b') as dia.Element; + expect(element.size()).toEqual({ width: 120, height: 40 }); + + act(() => { + element.remove(); + harness.graph.addCell(element); + }); + await flush(); + await flush(); + expect(harness.events).toEqual([{ isInitial: false }]); + + act(() => { + harness.graph.addCell(plain('c') as never); + }); + await flush(); + expect(harness.events).toEqual([{ isInitial: false }, { isInitial: false }]); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.compiler.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.compiler.test.tsx new file mode 100644 index 0000000000..7070781a28 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.compiler.test.tsx @@ -0,0 +1,7 @@ +/** + * The `useOnElementsMeasured` specification and settling scenarios, run again + * under the `react-compiler` jest project: the hook and the measurement module + * it reads keep their contract once compiled by `babel-plugin-react-compiler`. + */ +import './use-on-elements-measured-events.test'; +import './use-on-elements-measured-settling.test'; 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..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,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/measurement'; 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: { @@ -48,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 () => { @@ -65,65 +79,144 @@ 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', ..., {fromMeasure: true})`. - // Previously, the `change:size` listener in graph-changes.ts skipped - // `fromMeasure` 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 () => { + // 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 }, { fromMeasure: 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); }); + + // 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', () => { + 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(); + graphRef = store.graph; + useOnElementsMeasured('measured-effect-paper', callback); + return null; + } + renderHook(() => Probe(), { wrapper }); + return () => graphRef!.getCell('a') as dia.Element; + } + + it('does not fire when the application resizes an element', async () => { + const callback = jest.fn(); + const getElement = renderResizeProbe(callback); + await waitFor(() => expect(callback).toHaveBeenCalled()); + callback.mockClear(); + + act(() => { + getElement().resize(80, 80); + }); + await act(async () => flush()); + await act(async () => flush()); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('fires with isInitial=false for a measurement write', async () => { + const callback = jest.fn(); + const getElement = renderResizeProbe(callback); + await waitFor(() => expect(callback).toHaveBeenCalled()); + callback.mockClear(); + + act(() => { + getElement().set('size', { width: 80, height: 80 }, { autoSize: true }); + }); + + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + expect(callback.mock.calls[0][0].isInitial).toBe(false); + }); + }); }); diff --git a/packages/joint-react/src/hooks/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-create-portal-paper.tsx b/packages/joint-react/src/hooks/use-create-portal-paper.tsx index a142cb4576..3d7614e0ba 100644 --- a/packages/joint-react/src/hooks/use-create-portal-paper.tsx +++ b/packages/joint-react/src/hooks/use-create-portal-paper.tsx @@ -43,7 +43,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'; @@ -217,7 +216,6 @@ export function useCreatePortalPaper( } const graphStore = useGraphStore(); - const areElementsMeasured = useAreElementsMeasured(); // Subscribe to cells-container size — only re-renders when cells are added or removed. // Partition the id list by type so element / link portals can be rendered separately. @@ -478,20 +476,11 @@ export function useCreatePortalPaper( - + ) : ( - + )} ); @@ -500,7 +489,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-elements-measured.ts b/packages/joint-react/src/hooks/use-on-elements-measured.ts index 1e2938eae9..e370c6e9e7 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 @@ -12,7 +13,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 event after the hook mounts, and again on the first + * event 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 +32,26 @@ 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, and each later + * addition, removal 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. Removing + * every element is an ordinary change: an empty diagram fires nothing, a hook + * mounted on one waits for elements, and the next event after elements return + * is not initial. * @title On the current paper * @param callback - Called each time element sizes are measured. * @group Hooks @@ -85,27 +102,35 @@ 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 { measureState, graph } = useGraphStore(); - const wasMeasuredRef = useRef(false); + const { measurement, graph } = useGraphStore(); useLayoutEffect(() => { if (!paperStore) return; const { paper } = paperStore; + // A new paper (or graph store) starts its own history, and so does a graph + // reset (the state goes back to `0`): the next event is the initial one. + let previousState = 0; function handleChanges() { - const value = measureState.get(); - const isMeasured = value > 0; - const isInitial = isMeasured && !wasMeasuredRef.current; - if (isInitial) { - wasMeasuredRef.current = true; + const state = measurement.getSizingVersion(); + 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); } - 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 // next rAF — producing a one-frame flash where the element is visible at its @@ -113,14 +138,13 @@ export function useOnElementsMeasured( // reflects the post-layout state. paper.updateViews(); } - // 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, measureState, graph, callbackRef]); + // Subscribed before the replay, so a callback that resets the graph is + // notified of it and reports the replacement diagram as initial. + const unsubscribe = measurement.subscribe(handleChanges); + // Replay a pass that settled before this subscription (e.g. initial data + // sync ran before this paperStore was available). A change still waiting + // is delivered when it settles, as this hook's initial event. + if (measurement.isSettled()) handleChanges(); + return unsubscribe; + }, [paperStore, measurement, graph, callbackRef]); } diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index d85db178f3..53806fc2c9 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() 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/store/__tests__/clear-view.test.ts b/packages/joint-react/src/store/__tests__/clear-view.test.ts index af789664e9..05c5b1ebc3 100644 --- a/packages/joint-react/src/store/__tests__/clear-view.test.ts +++ b/packages/joint-react/src/store/__tests__/clear-view.test.ts @@ -1,11 +1,5 @@ import { dia } from '@joint/core'; -import { - clearConnectedLinkViews, - executeClearViewForCell, - mergeClearViewValidators, - shouldClearLink, - type ClearViewCacheEntry, -} from '../clear-view'; +import { clearConnectedLinkViews, shouldClearLink } from '../clear-view'; import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; function createGraph(): dia.Graph { @@ -26,55 +20,6 @@ function createMockLinkView(): MockLinkView { }; } -describe('mergeClearViewValidators', () => { - it('returns incoming when there is no existing entry', () => { - const incoming: ClearViewCacheEntry = { onValidateLink: () => true }; - const result = mergeClearViewValidators(undefined, incoming); - expect(result).toBe(incoming); - }); - - it('takes precedence with no validator (clear all)', () => { - const existing: ClearViewCacheEntry = { onValidateLink: () => true }; - const incoming: ClearViewCacheEntry = {}; - const result = mergeClearViewValidators(existing, incoming); - expect(result.onValidateLink).toBeUndefined(); - }); - - it('creates a union when both have validators', () => { - const existingValidator = jest.fn().mockReturnValue(false); - const newValidator = jest.fn().mockReturnValue(true); - const result = mergeClearViewValidators( - { onValidateLink: existingValidator }, - { onValidateLink: newValidator } - ); - expect(typeof result.onValidateLink).toBe('function'); - const link = {} as dia.Link; - expect(result.onValidateLink!(link)).toBe(true); - expect(existingValidator).toHaveBeenCalledWith(link); - expect(newValidator).toHaveBeenCalledWith(link); - }); - - it('union short-circuits when existing returns true', () => { - const existingValidator = jest.fn().mockReturnValue(true); - const newValidator = jest.fn().mockReturnValue(false); - const result = mergeClearViewValidators( - { onValidateLink: existingValidator }, - { onValidateLink: newValidator } - ); - const link = {} as dia.Link; - expect(result.onValidateLink!(link)).toBe(true); - expect(newValidator).not.toHaveBeenCalled(); - }); - - it('keeps the existing "clear all" semantics when existing has no validator', () => { - const existing: ClearViewCacheEntry = {}; - const incoming: ClearViewCacheEntry = { onValidateLink: () => true }; - const result = mergeClearViewValidators(existing, incoming); - expect(result).toBe(existing); - expect(result.onValidateLink).toBeUndefined(); - }); -}); - function makeLink(sourceId: string, targetId: string): dia.Link { return new dia.Link({ type: 'standard.Link', @@ -84,7 +29,6 @@ function makeLink(sourceId: string, targetId: string): dia.Link { } describe('shouldClearLink', () => { - it('returns true when source matches', () => { const link = makeLink('a', 'b'); expect(shouldClearLink(link, 'a')).toBe(true); @@ -179,7 +123,9 @@ describe('clearConnectedLinkViews', () => { graph.addCell(link); // Override `findView` to simulate the no-view-registered case. - link.findView = jest.fn(() => undefined as unknown as dia.LinkView) as unknown as typeof link.findView; + link.findView = jest.fn( + () => undefined as unknown as dia.LinkView + ) as unknown as typeof link.findView; const paper = { findViewByModel: jest.fn(), @@ -226,74 +172,3 @@ describe('clearConnectedLinkViews', () => { expect(mockLinkView.requestConnectionUpdate).toHaveBeenCalledWith({ async: true }); }); }); - -describe('executeClearViewForCell', () => { - let graph: dia.Graph; - - beforeEach(() => { - graph = createGraph(); - }); - - it('iterates papers and skips ones without a paper instance', () => { - graph.addCell({ - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - }); - const papers = [{ paper: undefined }]; - expect(() => executeClearViewForCell(papers, graph, 'a')).not.toThrow(); - }); - - it('skips papers where the cell view is not present', () => { - graph.addCell({ - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - }); - - const getCellView = jest.fn(); - const paper = { getCellView } as unknown as dia.Paper; - - executeClearViewForCell([{ paper }], graph, 'a'); - expect(getCellView).toHaveBeenCalledWith('a'); - }); - - it('cleans the cell view nodes cache and clears connected link views', () => { - graph.addCell({ - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - }); - graph.addCell({ - id: 'b', - type: 'element', - position: { x: 50, y: 0 }, - size: { width: 10, height: 10 }, - }); - const link = new dia.Link({ - id: 'l1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }); - graph.addCell(link); - - const cleanNodesCache = jest.fn(); - const elementView = { cleanNodesCache } as unknown as dia.ElementView; - - const mockLinkView = createMockLinkView(); - link.findView = jest.fn().mockReturnValue(mockLinkView) as unknown as typeof link.findView; - - const paper = { - getCellView: jest.fn().mockReturnValue(elementView), - } as unknown as dia.Paper; - - executeClearViewForCell([{ paper }], graph, 'a'); - expect(cleanNodesCache).toHaveBeenCalledTimes(1); - expect(mockLinkView.requestConnectionUpdate).toHaveBeenCalled(); - }); -}); - diff --git a/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts b/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts deleted file mode 100644 index fe8622e5b2..0000000000 --- a/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -/* eslint-disable prefer-destructuring */ - -/* eslint-disable @typescript-eslint/no-require-imports */ -import type { dia } from '@joint/core'; -import type { CellId } from '../../types/cell.types'; -import type { GraphStoreObserver } from '../create-elements-size-observer'; - -// Mock ResizeObserver for testing - -let mockResizeObserverInstances: MockResizeObserver[] = []; - -class MockResizeObserver { - private callback: ResizeObserverCallback; - private observedElements = new Map(); - - constructor(callback: ResizeObserverCallback) { - this.callback = callback; - mockResizeObserverInstances.push(this); - } - - observe(target: Element) { - // Simulate an entry with initial size - const entry = this.createEntry(target, 100, 50); - this.observedElements.set(target, entry); - } - - unobserve(target: Element) { - this.observedElements.delete(target); - } - - disconnect() { - this.observedElements.clear(); - } - - // Test helper to simulate resize - triggerResize(target: Element, width: number, height: number) { - const entry = this.createEntry(target, width, height); - this.observedElements.set(target, entry); - this.callback([entry], this as unknown as ResizeObserver); - } - - // Test helper to trigger callback for all observed elements - triggerAllCallbacks() { - const entries = [...this.observedElements.values()]; - if (entries.length > 0) { - this.callback(entries, this as unknown as ResizeObserver); - } - } - - isObserving(target: Element): boolean { - return this.observedElements.has(target); - } - - get observedCount(): number { - return this.observedElements.size; - } - - private createEntry(target: Element, width: number, height: number): ResizeObserverEntry { - return { - target, - contentRect: { - width, - height, - top: 0, - left: 0, - bottom: height, - right: width, - x: 0, - y: 0, - toJSON: () => ({}), - }, - borderBoxSize: [{ inlineSize: width, blockSize: height }], - contentBoxSize: [{ inlineSize: width, blockSize: height }], - devicePixelContentBoxSize: [{ inlineSize: width, blockSize: height }], - } as ResizeObserverEntry; - } - - static getLastInstance(): MockResizeObserver | undefined { - return mockResizeObserverInstances.at(-1); - } - - static clearInstances() { - mockResizeObserverInstances = []; - } -} - -// Replace global ResizeObserver with mock -globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; - -describe('createElementsSizeObserver', () => { - let observer: GraphStoreObserver; - let mockOnBatchUpdate: jest.Mock; - let mockGetCellTransform: jest.Mock; - let mockGetElements: jest.Mock; - let mockElements: Map; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let createElementsSizeObserver: any; - - beforeEach(() => { - MockResizeObserver.clearInstances(); - - // Reset modules and reimport to ensure the mock is used - jest.resetModules(); - // Re-assign the mock after reset to ensure it's used - globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; - - // jest.resetModules() requires synchronous re-import; using `require` is the only - // option here. ESM `import()` is async and does not bypass module cache the same way. - // eslint-disable-next-line @typescript-eslint/no-var-requires - createElementsSizeObserver = require('../create-elements-size-observer').createElementsSizeObserver; - - mockElements = new Map([ - ['element-1', { size: { width: 1, height: 1 }, position: { x: 0, y: 0 } }], - ['element-2', { size: { width: 1, height: 1 }, position: { x: 0, y: 0 } }], - ]); - - mockOnBatchUpdate = jest.fn(); - mockGetCellTransform = jest.fn((id: CellId) => ({ - width: 1, - height: 1, - x: 0, - y: 0, - angle: 0, - element: { id } as dia.Element, - })); - mockGetElements = jest.fn(() => mockElements); - - observer = createElementsSizeObserver({ - onBatchUpdate: mockOnBatchUpdate, - getCellTransform: mockGetCellTransform, - getElements: mockGetElements, - }); - }); - - afterEach(() => { - observer.clean(); - }); - - describe('add', () => { - it('should register element with ResizeObserver', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - expect(observer.has('element-1')).toBe(true); - }); - - it('should return cleanup function that unregisters element', () => { - const element = document.createElement('div'); - - const cleanup = observer.add({ id: 'element-1', node: element }); - expect(observer.has('element-1')).toBe(true); - - cleanup(); - expect(observer.has('element-1')).toBe(false); - }); - - it('should handle multiple elements', () => { - const element1 = document.createElement('div'); - const element2 = document.createElement('div'); - - observer.add({ id: 'element-1', node: element1 }); - observer.add({ id: 'element-2', node: element2 }); - - expect(observer.has('element-1')).toBe(true); - expect(observer.has('element-2')).toBe(true); - }); - - it('should process ResizeObserver callback when element is added', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - // Trigger resize via ResizeObserver (simulates browser behavior) - const resizeObserver = MockResizeObserver.getLastInstance(); - expect(resizeObserver).toBeDefined(); - resizeObserver?.triggerResize(element, 100, 50); - - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1']).toBeDefined(); - expect(updateCall['element-1'].width).toBe(100); - expect(updateCall['element-1'].height).toBe(50); - }); - - it('should handle multiple elements with ResizeObserver', () => { - const element1 = document.createElement('div'); - const element2 = document.createElement('div'); - - observer.add({ id: 'element-1', node: element1 }); - observer.add({ id: 'element-2', node: element2 }); - - // Trigger resize for both elements - const resizeObserver = MockResizeObserver.getLastInstance(); - resizeObserver?.triggerResize(element1, 100, 50); - resizeObserver?.triggerResize(element2, 200, 100); - - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(2); - }); - }); - - describe('ResizeObserver callback', () => { - it('should process size changes from ResizeObserver', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - const resizeObserver = MockResizeObserver.getLastInstance(); - expect(resizeObserver).toBeDefined(); - - // Trigger initial resize - resizeObserver?.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - // Trigger resize to a different size - resizeObserver?.triggerResize(element, 200, 100); - - // Should be called again for the resize - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(2); - }); - - it('should not update if size has not changed significantly', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - const resizeObserver = MockResizeObserver.getLastInstance(); - - // Trigger initial resize - resizeObserver?.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - mockOnBatchUpdate.mockClear(); - - // Trigger resize with same size (within epsilon of 0.5) - resizeObserver?.triggerResize(element, 100.1, 50.1); - - // Should not trigger update because change is within epsilon - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('should use transform function when provided', () => { - const element = document.createElement('div'); - const transform = jest.fn(({ width, height }) => ({ - width: width + 20, - height: height + 20, - })); - - observer.add({ id: 'element-1', node: element, transform }); - - const resizeObserver = MockResizeObserver.getLastInstance(); - resizeObserver?.triggerResize(element, 100, 50); - - expect(transform).toHaveBeenCalled(); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(120); // 100 + 20 - expect(updateCall['element-1'].height).toBe(70); // 50 + 20 - }); - }); - - describe('stack behavior', () => { - it('should allow multiple registrations for the same cell ID', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - expect(observer.has('element-1')).toBe(true); - }); - - it('should only observe the active (latest) node via ResizeObserver', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // nodeA should be unobserved (deactivated when nodeB was added) - expect(resizeObserver.isObserving(nodeA)).toBe(false); - // nodeB should be observed (active) - expect(resizeObserver.isObserving(nodeB)).toBe(true); - }); - - it('should process resize only from the active node', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Trigger resize on the active node (nodeB) - resizeObserver.triggerResize(nodeB, 200, 100); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(200); - expect(updateCall['element-1'].height).toBe(100); - }); - - it('should fall back to previous node when active node is removed', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - const cleanupB = observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Remove the active node (nodeB) - cleanupB(); - - // nodeA should be re-activated - expect(resizeObserver.isObserving(nodeA)).toBe(true); - expect(resizeObserver.isObserving(nodeB)).toBe(false); - expect(observer.has('element-1')).toBe(true); - - // Trigger resize on the re-activated nodeA - resizeObserver.triggerResize(nodeA, 150, 75); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(150); - expect(updateCall['element-1'].height).toBe(75); - }); - - it('should remove non-active node without affecting active observation', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const cleanupA = observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Remove the non-active node (nodeA) - cleanupA(); - - // nodeB should still be active - expect(resizeObserver.isObserving(nodeB)).toBe(true); - expect(observer.has('element-1')).toBe(true); - - // Trigger resize on nodeB — should still work - resizeObserver.triggerResize(nodeB, 300, 150); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - }); - - it('should handle removing all entries one by one (3-deep stack)', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - const nodeC = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - const cleanupC = observer.add({ id: 'element-1', node: nodeC }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Remove C (active) → B becomes active - cleanupC(); - expect(resizeObserver.isObserving(nodeC)).toBe(false); - expect(resizeObserver.isObserving(nodeB)).toBe(true); - expect(observer.has('element-1')).toBe(true); - }); - - it('should fully remove cell ID after all stack entries are cleaned up', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const cleanupA = observer.add({ id: 'element-1', node: nodeA }); - const cleanupB = observer.add({ id: 'element-1', node: nodeB }); - - cleanupB(); - expect(observer.has('element-1')).toBe(true); - - cleanupA(); - expect(observer.has('element-1')).toBe(false); - }); - - it('should use the active node transform function, not a previous one', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const transformA = jest.fn(({ width, height }) => ({ - width: width + 10, - height: height + 10, - })); - const transformB = jest.fn(({ width, height }) => ({ - width: width + 50, - height: height + 50, - })); - - observer.add({ id: 'element-1', node: nodeA, transform: transformA }); - observer.add({ id: 'element-1', node: nodeB, transform: transformB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(nodeB, 100, 50); - - expect(transformA).not.toHaveBeenCalled(); - expect(transformB).toHaveBeenCalled(); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(150); // 100 + 50 - expect(updateCall['element-1'].height).toBe(100); // 50 + 50 - }); - - it('should use the previous transform after active node is removed', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const transformA = jest.fn(({ width, height }) => ({ - width: width + 10, - height: height + 10, - })); - const transformB = jest.fn(({ width, height }) => ({ - width: width + 50, - height: height + 50, - })); - - observer.add({ id: 'element-1', node: nodeA, transform: transformA }); - const cleanupB = observer.add({ id: 'element-1', node: nodeB, transform: transformB }); - - // Remove B, A becomes active with its own transform - cleanupB(); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(nodeA, 100, 50); - - expect(transformA).toHaveBeenCalled(); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(110); // 100 + 10 - expect(updateCall['element-1'].height).toBe(60); // 50 + 10 - }); - - it('cleanup should be idempotent', () => { - const nodeA = document.createElement('div'); - - const cleanup = observer.add({ id: 'element-1', node: nodeA }); - - cleanup(); - expect(observer.has('element-1')).toBe(false); - - // Second cleanup call should not throw or cause side effects - cleanup(); - expect(observer.has('element-1')).toBe(false); - }); - - it('should handle interleaved add/remove across different cell IDs', () => { - const node1A = document.createElement('div'); - const node1B = document.createElement('div'); - const node2A = document.createElement('div'); - - const cleanup1A = observer.add({ id: 'element-1', node: node1A }); - observer.add({ id: 'element-2', node: node2A }); - observer.add({ id: 'element-1', node: node1B }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // element-1 active is node1B, element-2 active is node2A - expect(resizeObserver.isObserving(node1B)).toBe(true); - expect(resizeObserver.isObserving(node2A)).toBe(true); - expect(resizeObserver.isObserving(node1A)).toBe(false); - - // Remove non-active node from element-1 - cleanup1A(); - expect(resizeObserver.isObserving(node1B)).toBe(true); - expect(observer.has('element-1')).toBe(true); - expect(observer.has('element-2')).toBe(true); - }); - }); - - describe('clean', () => { - it('should remove all observed elements', () => { - const element1 = document.createElement('div'); - const element2 = document.createElement('div'); - - observer.add({ id: 'element-1', node: element1 }); - observer.add({ id: 'element-2', node: element2 }); - - expect(observer.has('element-1')).toBe(true); - expect(observer.has('element-2')).toBe(true); - - observer.clean(); - - expect(observer.has('element-1')).toBe(false); - expect(observer.has('element-2')).toBe(false); - }); - - it('should clean all stacks including multi-entry stacks', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - const nodeC = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - observer.add({ id: 'element-2', node: nodeC }); - - observer.clean(); - - expect(observer.has('element-1')).toBe(false); - expect(observer.has('element-2')).toBe(false); - }); - }); - - describe('has', () => { - it('should return true for registered elements', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - - expect(observer.has('element-1')).toBe(true); - }); - - it('should return false for unregistered elements', () => { - expect(observer.has('non-existent')).toBe(false); - }); - }); - - describe('processSizeChange branches', () => { - it('skips when elements map does not contain the cell id', () => { - const element = document.createElement('div'); - mockElements.clear(); // ← getElements() returns an empty map - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('skips when cell transform already matches the measured size (within epsilon)', () => { - const element = document.createElement('div'); - mockGetCellTransform.mockImplementation((id: CellId) => ({ - width: 100, - height: 50, - x: 0, - y: 0, - angle: 0, - element: { id } as dia.Element, - })); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - }); - - describe('ResizeObserver entry edge cases', () => { - it('skips entries with no borderBoxSize', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Construct an entry with empty borderBoxSize manually - const callback = (resizeObserver as unknown as { callback: ResizeObserverCallback }) - .callback; - callback( - [ - { - target: element, - contentRect: {} as DOMRectReadOnly, - borderBoxSize: [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - } as ResizeObserverEntry, - ], - resizeObserver as unknown as ResizeObserver - ); - - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('skips entries with zero size (e.g. display:none)', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - - resizeObserver.triggerResize(element, 0, 0); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('skips entries whose target is not currently observed', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - const otherNode = document.createElement('div'); - - const callback = (resizeObserver as unknown as { callback: ResizeObserverCallback }) - .callback; - callback( - [ - { - target: otherNode, - contentRect: {} as DOMRectReadOnly, - borderBoxSize: [{ inlineSize: 100, blockSize: 50 }], - contentBoxSize: [{ inlineSize: 100, blockSize: 50 }], - devicePixelContentBoxSize: [{ inlineSize: 100, blockSize: 50 }], - } as ResizeObserverEntry, - ], - resizeObserver as unknown as ResizeObserver - ); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - }); -}); 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..5aefa16e49 100644 --- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts @@ -19,18 +19,6 @@ function setup() { return { graph, onChanges, controller }; } -function setupWithSize() { - const graph = createGraph(); - const onChanges = jest.fn(); - const onElementsSizeChange = jest.fn(); - const controller = graphChanges({ - graph, - onChanges, - onElementsSizeChange, - }); - return { graph, onChanges, onElementsSizeChange, controller }; -} - function addElement(graph: dia.Graph, id: string, x = 10, y = 20, width = 100, height = 50) { graph.addCell({ id, @@ -381,89 +369,6 @@ describe('graphChanges', () => { }); }); - describe('onElementsSizeChange', () => { - it('fires for each element when resetCells seeds cells with sizes', () => { - const { graph, onElementsSizeChange } = setupWithSize(); - graph.resetCells([ - { - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 100, height: 50 }, - }, - { - id: 'b', - type: 'element', - position: { x: 200, y: 0 }, - size: { width: 80, height: 40 }, - }, - { - id: 'l1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }, - ]); - - const elementCalls = onElementsSizeChange.mock.calls.filter( - ([id]) => id === 'a' || id === 'b' - ); - expect(elementCalls).toHaveLength(2); - expect(elementCalls).toEqual( - expect.arrayContaining([ - ['a', { width: 100, height: 50 }], - ['b', { width: 80, height: 40 }], - ]) - ); - }); - - it('fires when ResizeObserver sets size via fromMeasure flag', () => { - const { graph, onElementsSizeChange } = setupWithSize(); - graph.resetCells([ - { - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 0, height: 0 }, - }, - ]); - 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 }); - }); - - it('does not fire for links on reset', () => { - const { graph, onElementsSizeChange } = setupWithSize(); - graph.resetCells([ - { - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 100, height: 50 }, - }, - { - id: 'b', - type: 'element', - position: { x: 200, y: 0 }, - size: { width: 80, height: 40 }, - }, - { - id: 'l1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }, - ]); - - for (const [id] of onElementsSizeChange.mock.calls) { - expect(id).not.toBe('l1'); - } - }); - }); - describe('destroy', () => { it('stops listening to graph events', () => { const { graph, onChanges, controller } = setup(); 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..a93988adb8 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 @@ -343,7 +343,7 @@ describe('GraphStore observer wiring', () => { }); const node = document.createElement('div'); - const cleanup = store.setMeasuredNode({ id: 'a', node }); + const cleanup = store.measurement.observe({ id: 'a', node }); expect(typeof cleanup).toBe('function'); cleanup(); store.destroy(false); @@ -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 @@ -497,7 +499,7 @@ describe('GraphStore size observer integration', () => { }); const node = document.createElement('div'); - store.setMeasuredNode({ id: 'a', node }); + store.measurement.observe({ id: 'a', node }); const observer = MockResizeObserver.instances.at(-1)!; observer.triggerResize(node, 100, 50); @@ -520,7 +522,7 @@ describe('GraphStore size observer integration', () => { }); const node = document.createElement('div'); - store.setMeasuredNode({ + store.measurement.observe({ id: 'a', node, transform: ({ width, height }) => ({ width, height, x: 9, y: 11 }), @@ -560,7 +562,7 @@ describe('GraphStore size observer integration', () => { }); const node = document.createElement('div'); - store.setMeasuredNode({ id: 'a', node }); + store.measurement.observe({ id: 'a', node }); const observer = MockResizeObserver.instances.at(-1)!; observer.triggerResize(node, 50, 25); @@ -579,7 +581,7 @@ describe('GraphStore size observer integration', () => { size: { width: 1, height: 1 }, }); const node = document.createElement('div'); - store.setMeasuredNode({ id: 'ghost', node }); + store.measurement.observe({ id: 'ghost', node }); // Remove the cell — the projection no longer contains it, so // processSizeChange bails out before reaching getCellTransform. 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 09324d8cb7..075ad515db 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -8,6 +8,9 @@ import type { CellRecord } from '../../types/cell.types'; const createGraph = () => new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +const sizingVersion = (store: { + readonly measurement: { readonly getSizingVersion: () => number }; +}) => store.measurement.getSizingVersion(); describe('GraphStore', () => { describe('constructor', () => { @@ -61,7 +64,7 @@ describe('GraphStore', () => { store.destroy(true); }); - it('bumps measureState after initialCells seed (so useOnElementsMeasured can fire isInitial)', async () => { + it('bumps the measurement version after the initialCells seed', async () => { const initialCells: readonly CellRecord[] = [ { id: 'a', @@ -71,14 +74,14 @@ describe('GraphStore', () => { } as CellRecord, ]; const store = new GraphStore({ initialCells }); - // simpleScheduler defers the measureState bump to a microtask. + // simpleScheduler defers the bump to a microtask. await flush(); - expect(store.measureState.get()).toBeGreaterThan(0); + expect(sizingVersion(store)).toBeGreaterThan(0); store.destroy(false); }); - it('does not bump measureState when initialCells contain only links or zero-sized elements', async () => { - const initialCells: readonly CellRecord[] = [ + it('counts a zero-sized element as measured (zero is a legal size), but not a diagram of links only', async () => { + const zeroSized: readonly CellRecord[] = [ { id: 'zero', type: ELEMENT_MODEL_TYPE, @@ -86,9 +89,54 @@ describe('GraphStore', () => { size: { width: 0, height: 0 }, } as CellRecord, ]; + const store = new GraphStore({ initialCells: zeroSized }); + await flush(); + expect(sizingVersion(store)).toBe(1); + store.destroy(false); + + const linksOnly = new GraphStore({ + initialCells: [ + { + id: 'l', + type: LINK_MODEL_TYPE, + source: { x: 0, y: 0 }, + target: { x: 9, y: 9 }, + } as CellRecord, + ], + }); + await flush(); + expect(sizingVersion(linksOnly)).toBe(0); + linksOnly.destroy(false); + }); + + it('does not change the measured state for an application resize or an equal measurement', 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(); - expect(store.measureState.get()).toBe(0); + // No paper holds the seed back, so it is delivered at once. + expect(sizingVersion(store)).toBe(1); + // Its content commits, as a paper would render it: the element is settled, + // and it was still owed that, since the pass was delivered without it. + store.measurement.markRendered('a'); + await flush(); + expect(sizingVersion(store)).toBe(2); + // The application sizes the element: not a measurement. + (store.graph.getCell('a') as dia.Element).resize(120, 40); + await flush(); + expect(sizingVersion(store)).toBe(2); + + // The observer measures the same size, so it writes nothing to the graph. + const node = document.createElement('div'); + store.measurement.observe({ 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(sizingVersion(store)).toBe(2); store.destroy(false); }); @@ -195,7 +243,9 @@ describe('GraphStore', () => { it('fires with added/changed/removed summary', async () => { const snaps: Array> = []; const store = new GraphStore({}); - store.setOnIncrementalCellsChange((c) => snaps.push(snapshot(c as Parameters[0]))); + store.setOnIncrementalCellsChange((c) => + snaps.push(snapshot(c as Parameters[0])) + ); store.graph.addCell({ id: 'a', type: ELEMENT_MODEL_TYPE, @@ -213,7 +263,9 @@ describe('GraphStore', () => { it('reports links alongside elements in the unified pipeline', async () => { const snaps: Array> = []; const store = new GraphStore({}); - store.setOnIncrementalCellsChange((c) => snaps.push(snapshot(c as Parameters[0]))); + store.setOnIncrementalCellsChange((c) => + snaps.push(snapshot(c as Parameters[0])) + ); store.graph.addCells([ { id: 'a', @@ -268,6 +320,29 @@ describe('GraphStore', () => { }); }); + describe('removing a paper', () => { + // Regression: only papers hold an outstanding element back, and nothing + // re-checked the delivery when the last one went away, so the sizing + // version stayed `0` until the next graph change. + it('settles the elements that waited for the last paper', async () => { + const store = new GraphStore({}); + const { remove } = store.addPaper('p1', { paperOptions: {} }); + store.graph.addCell({ + id: 'a', + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 10, height: 10 }, + }); + await flush(); + expect(sizingVersion(store)).toBe(0); + + remove(); + await flush(); + expect(sizingVersion(store)).toBe(1); + store.destroy(false); + }); + }); + describe('destroy', () => { it('clears features, papers, and internal state', () => { const store = new GraphStore({}); @@ -291,7 +366,7 @@ describe('GraphStore', () => { const measure = (store: GraphStore, id: string) => { const node = document.createElement('div'); document.body.append(node); - store.setMeasuredNode({ id, node }); + store.measurement.observe({ id, node }); return () => node.remove(); }; diff --git a/packages/joint-react/src/store/__tests__/measurement.test.ts b/packages/joint-react/src/store/__tests__/measurement.test.ts new file mode 100644 index 0000000000..98a4ee79ad --- /dev/null +++ b/packages/joint-react/src/store/__tests__/measurement.test.ts @@ -0,0 +1,824 @@ +import { dia } from '@joint/core'; +import { createMeasurement, AUTO_SIZE_OPTION, type AutoSizeOrigin } from '../measurement'; +import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; +import type { PaperStore } from '../paper-store'; + +/** A ResizeObserver the test drives: `report()` is what the browser would deliver. */ +class TestResizeObserver { + static readonly instances: TestResizeObserver[] = []; + readonly observed = new Set(); + /** Every `observe()` call, in order: a browser reports each new observation once. */ + readonly observations: Element[] = []; + isDisconnected = false; + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + TestResizeObserver.instances.push(this); + } + observe(target: Element) { + this.observed.add(target); + this.observations.push(target); + } + unobserve(target: Element) { + this.observed.delete(target); + } + disconnect() { + this.isDisconnected = true; + this.observed.clear(); + } + report(target: Element, width: number, height: number, hasBox = true) { + const borderBoxSize = hasBox ? [{ inlineSize: width, blockSize: height }] : []; + const entry = { target, borderBoxSize } as unknown as ResizeObserverEntry; + this.callback([entry], this as unknown as ResizeObserver); + } +} + +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +const lastObserver = () => TestResizeObserver.instances.at(-1)!; +const NO_PAPERS: ReadonlyMap = new Map(); +const newNode = () => document.createElement('div'); + +type Transform = Parameters['observe']>[0]['transform']; + +/** + * A graph with its measurement. `add` puts a 1x1 element at (10, 20) unless + * sized otherwise; `writes` counts the batches the measurement writes in, one + * per ResizeObserver callback that changed something. + */ +function setup(autoSizeOrigin: AutoSizeOrigin = 'top-left') { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + const measurement = createMeasurement({ graph, autoSizeOrigin, paperStores: NO_PAPERS }); + /** The sizing version right now. */ + const version = () => measurement.getSizingVersion(); + const nodesById = new Map(); + const writes = jest.fn(); + graph.on('batch:stop', ({ batchName }: { batchName?: string }) => { + if (batchName === 'auto-size') writes(); + }); + const add = (id: string, width = 1, height = 1) => { + graph.addCell({ id, type: 'element', position: { x: 10, y: 20 }, size: { width, height } }); + return graph.getCell(id) as dia.Element; + }; + const observe = (id: string, node: HTMLElement = newNode(), transform?: Transform) => { + nodesById.set(id, [...(nodesById.get(id) ?? []), node]); + return measurement.observe({ id, node, transform }); + }; + /** The old observer's `has(id)`: the element has a registered node, so one of them is observed. */ + const has = (id: string) => + (nodesById.get(id) ?? []).some((node) => + TestResizeObserver.instances.some((instance) => instance.observed.has(node)) + ); + const size = (id: string) => (graph.getCell(id) as dia.Element).size(); + return { graph, measurement, version, add, observe, has, size, writes }; +} + +beforeEach(() => { + TestResizeObserver.instances.length = 0; + globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; +}); + +describe('measurement — observe', () => { + it('does not create a ResizeObserver until a node is observed', () => { + const { add, observe } = setup(); + add('element-1'); + expect(TestResizeObserver.instances).toHaveLength(0); + + observe('element-1'); + observe('element-1'); + expect(TestResizeObserver.instances).toHaveLength(1); + }); + + it('registers the element with the ResizeObserver', () => { + const { add, observe, has } = setup(); + add('element-1'); + + observe('element-1'); + + expect(has('element-1')).toBe(true); + expect(has('non-existent')).toBe(false); + }); + + it('returns a cleanup that unregisters the element', () => { + const { add, observe, has } = setup(); + add('element-1'); + + const cleanup = observe('element-1'); + expect(has('element-1')).toBe(true); + + cleanup(); + expect(has('element-1')).toBe(false); + }); + + it('handles multiple elements', () => { + const { add, observe, has } = setup(); + add('element-1'); + add('element-2'); + + observe('element-1'); + observe('element-2'); + + expect(has('element-1')).toBe(true); + expect(has('element-2')).toBe(true); + }); + + it('writes the reported size to the element', () => { + const { add, observe, size, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50); + + expect(writes).toHaveBeenCalledTimes(1); + expect(size('element-1')).toEqual({ width: 100, height: 50 }); + }); + + it('writes each of several elements in its own batch', () => { + const { add, observe, size, writes } = setup(); + add('element-1'); + add('element-2'); + const first = newNode(); + const second = newNode(); + observe('element-1', first); + observe('element-2', second); + + lastObserver().report(first, 100, 50); + lastObserver().report(second, 200, 100); + + expect(writes).toHaveBeenCalledTimes(2); + expect(size('element-1')).toEqual({ width: 100, height: 50 }); + expect(size('element-2')).toEqual({ width: 200, height: 100 }); + }); +}); + +describe('measurement — ResizeObserver callback', () => { + it('writes every size change', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50); + expect(writes).toHaveBeenCalledTimes(1); + + lastObserver().report(node, 200, 100); + expect(writes).toHaveBeenCalledTimes(2); + }); + + it('does not write when the size has not changed significantly', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + lastObserver().report(node, 100, 50); + writes.mockClear(); + + // Within the 0.5 tolerance. + lastObserver().report(node, 100.1, 50.1); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('tags the write as a measurement and rounds to two decimals', () => { + const { graph, add, observe, size } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node); + const onSize = jest.fn(); + graph.on('change:size', onSize); + + lastObserver().report(node, 100.004, 50); + + expect(size('element-1')).toEqual({ width: 100, height: 50 }); + expect(element.position()).toEqual({ x: 10, y: 20 }); + expect(onSize.mock.calls[0][2]).toEqual(expect.objectContaining({ [AUTO_SIZE_OPTION]: true })); + }); + + it('uses the transform when provided', () => { + const { add, observe, size } = setup(); + const element = add('element-1'); + const node = newNode(); + const transform = jest.fn(({ width, height }) => ({ width: width + 20, height: height + 20 })); + observe('element-1', node, transform); + + lastObserver().report(node, 100, 50); + + expect(transform).toHaveBeenCalledWith({ + x: 10, + y: 20, + angle: 0, + model: element, + width: 100, + height: 50, + id: 'element-1', + }); + expect(size('element-1')).toEqual({ width: 120, height: 70 }); + }); + + it('writes the position a transform returns', () => { + const { add, observe } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node, ({ width, height }) => ({ width, height, x: 9, y: 11 })); + + lastObserver().report(node, 80, 40); + + expect(element.position()).toEqual({ x: 9, y: 11 }); + }); + + it('keeps the center fixed with the center origin', () => { + const { add, observe } = setup('center'); + const element = add('element-1', 20, 20); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 40, 60); + + expect(element.position()).toEqual({ x: 0, y: 0 }); + }); + + it('skips an element that is not in the graph', () => { + const { observe, writes } = setup(); + const node = newNode(); + observe('element-1', node); + + expect(() => lastObserver().report(node, 100, 50)).not.toThrow(); + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips an element removed from the graph after it registered', () => { + const { add, observe, writes } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node); + element.remove(); + + expect(() => lastObserver().report(node, 100, 50)).not.toThrow(); + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips when the model already has the measured size (within the tolerance)', () => { + const { add, observe, writes } = setup(); + add('element-1', 100, 50); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips a report equal to the last one, even if the application resized meanwhile', () => { + const { add, observe, size, writes } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node); + lastObserver().report(node, 100, 50); + writes.mockClear(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + element.resize(300, 300); + lastObserver().report(node, 100, 50); + + expect(writes).not.toHaveBeenCalled(); + expect(size('element-1')).toEqual({ width: 300, height: 300 }); + warn.mockRestore(); + }); + + it('skips entries with no borderBoxSize', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50, false); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips entries with zero size (e.g. display:none)', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 0, 0); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips entries whose target is not currently observed', () => { + const { add, observe, writes } = setup(); + add('element-1'); + observe('element-1'); + + lastObserver().report(newNode(), 100, 50); + + expect(writes).not.toHaveBeenCalled(); + }); +}); + +describe('measurement — several nodes for one element', () => { + it('allows multiple registrations for the same element', () => { + const { add, observe, has } = setup(); + add('element-1'); + + observe('element-1'); + observe('element-1'); + + expect(has('element-1')).toBe(true); + }); + + it('observes only the latest node', () => { + const { add, observe } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + + observe('element-1', nodeA); + observe('element-1', nodeB); + + expect(lastObserver().observed.has(nodeA)).toBe(false); + expect(lastObserver().observed.has(nodeB)).toBe(true); + }); + + it('writes only what the latest node reports', () => { + const { add, observe, size, writes } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + observe('element-1', nodeA); + observe('element-1', nodeB); + + lastObserver().report(nodeA, 77, 77); + expect(writes).not.toHaveBeenCalled(); + + lastObserver().report(nodeB, 200, 100); + expect(writes).toHaveBeenCalledTimes(1); + expect(size('element-1')).toEqual({ width: 200, height: 100 }); + }); + + it('falls back to the previous node when the latest one is removed', () => { + const { add, observe, has, size, writes } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + observe('element-1', nodeA); + const cleanupB = observe('element-1', nodeB); + + cleanupB(); + + expect(lastObserver().observed.has(nodeA)).toBe(true); + expect(lastObserver().observed.has(nodeB)).toBe(false); + expect(has('element-1')).toBe(true); + + lastObserver().report(nodeA, 150, 75); + expect(writes).toHaveBeenCalledTimes(1); + expect(size('element-1')).toEqual({ width: 150, height: 75 }); + }); + + it('removes a node that is not the latest without affecting the latest', () => { + const { add, observe, has, writes } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const cleanupA = observe('element-1', nodeA); + observe('element-1', nodeB); + + cleanupA(); + + expect(lastObserver().observed.has(nodeB)).toBe(true); + expect(has('element-1')).toBe(true); + + lastObserver().report(nodeB, 300, 150); + expect(writes).toHaveBeenCalledTimes(1); + }); + + it('handles removing the latest of three nodes', () => { + const { add, observe, has } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const nodeC = newNode(); + observe('element-1', nodeA); + observe('element-1', nodeB); + const cleanupC = observe('element-1', nodeC); + + cleanupC(); + + expect(lastObserver().observed.has(nodeC)).toBe(false); + expect(lastObserver().observed.has(nodeB)).toBe(true); + expect(lastObserver().observed.has(nodeA)).toBe(false); + expect(has('element-1')).toBe(true); + }); + + it('fully unregisters the element after all its nodes are cleaned up', () => { + const { add, observe, has } = setup(); + add('element-1'); + const cleanupA = observe('element-1'); + const cleanupB = observe('element-1'); + + cleanupB(); + expect(has('element-1')).toBe(true); + + cleanupA(); + expect(has('element-1')).toBe(false); + expect(lastObserver().observed.size).toBe(0); + }); + + it('uses the transform of the latest node, not a previous one', () => { + const { add, observe, size } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const transformA = jest.fn(({ width, height }) => ({ width: width + 10, height: height + 10 })); + const transformB = jest.fn(({ width, height }) => ({ width: width + 50, height: height + 50 })); + observe('element-1', nodeA, transformA); + observe('element-1', nodeB, transformB); + + lastObserver().report(nodeB, 100, 50); + + expect(transformA).not.toHaveBeenCalled(); + expect(transformB).toHaveBeenCalled(); + expect(size('element-1')).toEqual({ width: 150, height: 100 }); + }); + + it('uses the previous transform after the latest node is removed', () => { + const { add, observe, size } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const transformA = jest.fn(({ width, height }) => ({ width: width + 10, height: height + 10 })); + const transformB = jest.fn(({ width, height }) => ({ width: width + 50, height: height + 50 })); + observe('element-1', nodeA, transformA); + const cleanupB = observe('element-1', nodeB, transformB); + + cleanupB(); + lastObserver().report(nodeA, 100, 50); + + expect(transformA).toHaveBeenCalled(); + expect(transformB).not.toHaveBeenCalled(); + expect(size('element-1')).toEqual({ width: 110, height: 60 }); + }); + + it('has an idempotent cleanup', () => { + const { add, observe, has } = setup(); + add('element-1'); + const cleanup = observe('element-1'); + + cleanup(); + expect(has('element-1')).toBe(false); + + expect(() => cleanup()).not.toThrow(); + expect(has('element-1')).toBe(false); + }); + + it('handles interleaved registrations across different elements', () => { + const { add, observe, has } = setup(); + add('element-1'); + add('element-2'); + const node1A = newNode(); + const node1B = newNode(); + const node2A = newNode(); + const cleanup1A = observe('element-1', node1A); + observe('element-2', node2A); + observe('element-1', node1B); + + expect(lastObserver().observed.has(node1B)).toBe(true); + expect(lastObserver().observed.has(node2A)).toBe(true); + expect(lastObserver().observed.has(node1A)).toBe(false); + + cleanup1A(); + expect(lastObserver().observed.has(node1B)).toBe(true); + expect(has('element-1')).toBe(true); + expect(has('element-2')).toBe(true); + }); +}); + +describe('measurement — destroy', () => { + it('stops observing every element', () => { + const { measurement, add, observe, has } = setup(); + add('element-1'); + add('element-2'); + observe('element-1'); + observe('element-2'); + expect(has('element-1')).toBe(true); + expect(has('element-2')).toBe(true); + + measurement.destroy(); + + expect(has('element-1')).toBe(false); + expect(has('element-2')).toBe(false); + expect(lastObserver().isDisconnected).toBe(true); + }); + + it('stops observing elements with several nodes, and a late cleanup is a no-op', () => { + const { measurement, add, observe, has } = setup(); + add('element-1'); + add('element-2'); + observe('element-1'); + const cleanupLatest = observe('element-1'); + observe('element-2'); + + measurement.destroy(); + + expect(has('element-1')).toBe(false); + expect(has('element-2')).toBe(false); + expect(() => cleanupLatest()).not.toThrow(); + expect(lastObserver().observed.size).toBe(0); + }); + + it('stops following the graph', async () => { + const { measurement, add, version } = setup(); + measurement.destroy(); + + add('element-1'); + await flush(); + + expect(version()).toBe(0); + }); +}); + +describe('measurement — dev warning', () => { + it('warns once when an observed element is resized by the application, never for a measurement', () => { + const { add, observe } = setup(); + const observed = add('warned'); + const plain = add('plain'); + const node = newNode(); + const cleanup = observe('warned', node); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + plain.resize(9, 9); + lastObserver().report(node, 100, 50); + expect(warn).not.toHaveBeenCalled(); + + observed.resize(200, 80); + observed.resize(220, 90); + expect(warn).toHaveBeenCalledTimes(1); + + // No longer observed: its size is the application's to set. + cleanup(); + warn.mockClear(); + observed.resize(10, 10); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +const sized = (id: string) => ({ id, type: 'element', size: { width: 100, height: 50 } }); +const unsized = (id: string) => ({ id, type: 'element', size: { width: 0, height: 0 } }); +const link = (id: string, source: string, target: string) => ({ + id, + type: 'standard.Link', + source: { id: source }, + target: { id: target }, +}); + +// What `graph-changes.ts` used to forward as callbacks, now heard by the module itself. +describe('measurement — graph events', () => { + it('tracks each element a reset seeds, and changes once for them', async () => { + const { graph, version } = setup(); + + graph.resetCells([sized('a'), sized('b'), link('l1', 'a', 'b')]); + await flush(); + expect(version()).toBe(1); + + // Both were tracked: with one of them gone the diagram is still measured. + graph.getCell('a').remove(); + await flush(); + expect(version()).toBe(2); + }); + + it('clears what it tracked before tracking the seed of a reset', async () => { + const { graph, version, add } = setup(); + add('a', 100, 50); + await flush(); + expect(version()).toBe(1); + + // Nothing is measured from the reset until its seed settles. + graph.resetCells([sized('x')]); + expect(version()).toBe(0); + await flush(); + // A value is never reused, so the pass after a reset is seen as a change. + expect(version()).toBe(2); + + // A reset to no elements stays unmeasured. + graph.resetCells([link('l', 'x', 'x')]); + await flush(); + expect(version()).toBe(0); + }); + + it('changes when an element is removed, and not when a link is', async () => { + const { graph, version } = setup(); + graph.addCells([sized('a'), sized('b'), link('l1', 'a', 'b')]); + await flush(); + expect(version()).toBe(1); + + graph.getCell('l1').remove(); + await flush(); + expect(version()).toBe(1); + + graph.getCell('a').remove(); + await flush(); + expect(version()).toBe(2); + }); + + it('keeps the version once the last element is removed', async () => { + const { graph, version, add } = setup(); + graph.addCells([sized('a'), unsized('z')]); + await flush(); + expect(version()).toBe(1); + + graph.getCell('a').remove(); + await flush(); + // A zero-sized element is still an element with a known size. + expect(version()).toBe(2); + + // An empty diagram is the same diagram: only a reset starts a new history. + graph.getCell('z').remove(); + await flush(); + expect(version()).toBe(2); + + add('b'); + await flush(); + expect(version()).toBe(3); + }); + + // Regression: the graph's `reset` event carries only the default layer's + // collection, so elements on other layers were not tracked after a reset. + it('tracks the elements a reset seeds on every layer', async () => { + const { graph, version } = setup(); + graph.addLayer({ id: 'top' }); + + graph.resetCells([{ ...sized('a'), layer: 'top' }]); + await flush(); + expect(version()).toBe(1); + + graph.getCell('a').remove(); + graph.addCell(sized('b')); + await flush(); + expect(version()).toBe(2); + }); + + it('is settled only while elements are measured and nothing waits', async () => { + const { graph, measurement, version, add, observe } = setup(); + // An empty diagram has no pass to replay. + expect(measurement.isSettled()).toBe(false); + + add('a'); + const node = newNode(); + observe('a', node); + expect(measurement.isSettled()).toBe(false); + + await flush(); + // With no paper nothing holds the change back, yet the element still waits. + expect(version()).toBe(1); + expect(measurement.isSettled()).toBe(false); + + // Its measurement is owed an event of its own, even when it writes nothing. + lastObserver().report(node, 1, 1); + expect(measurement.isSettled()).toBe(false); + await flush(); + expect(version()).toBe(2); + expect(measurement.isSettled()).toBe(true); + + // The version is kept, but it describes elements that are gone. + graph.getCell('a').remove(); + await flush(); + expect(version()).toBe(2); + expect(measurement.isSettled()).toBe(false); + }); + + it('is not settled while a settled element waits for its first report', async () => { + const { measurement, version, add, observe } = setup(); + add('a'); + measurement.markRendered('a'); + await flush(); + await flush(); + expect(version()).toBe(1); + expect(measurement.isSettled()).toBe(true); + + const node = newNode(); + observe('a', node); + expect(measurement.isSettled()).toBe(false); + + // A report of the size it already has is not a change. + lastObserver().report(node, 1, 1); + await flush(); + await flush(); + expect(version()).toBe(1); + expect(measurement.isSettled()).toBe(true); + }); + + // Regression: content that survives a same-id reset (the HTML overlay keeps + // its nodes) stayed observed, and a browser does not report an unchanged box + // again; its last report also hid the size from the replacement model. + it('observes the retained nodes again after a reset, against the new models', async () => { + const { graph, add, observe, size } = setup(); + add('a'); + const node = newNode(); + observe('a', node); + lastObserver().report(node, 100, 50); + expect(size('a')).toEqual({ width: 100, height: 50 }); + const observationCount = lastObserver().observations.length; + + graph.resetCells([{ id: 'a', type: 'element', size: { width: 1, height: 1 } }]); + expect(lastObserver().observations.slice(observationCount)).toEqual([node]); + + lastObserver().report(node, 100, 50); + expect(size('a')).toEqual({ width: 100, height: 50 }); + }); + + // Regression: removing and re-adding an id in one task can keep its rendered + // content (the HTML overlay keeps its nodes) and with it the registration, so + // the replacement waited for a report the browser never sent again. + it('observes a retained node again when its id is added back, against the new model', async () => { + const { graph, add, observe, size, version } = setup(); + add('a'); + const node = newNode(); + observe('a', node); + lastObserver().report(node, 100, 50); + await flush(); + const observationCount = lastObserver().observations.length; + + graph.getCell('a').remove(); + add('a'); + expect(lastObserver().observations.slice(observationCount)).toEqual([node]); + + lastObserver().report(node, 100, 50); + expect(size('a')).toEqual({ width: 100, height: 50 }); + await flush(); + expect(version()).toBeGreaterThan(1); + }); + + it('changes for a measurement write, told apart by the change:size options', async () => { + const { graph, version } = setup(); + graph.resetCells([unsized('a')]); + await flush(); + expect(version()).toBe(1); + + (graph.getCell('a') as dia.Element).set( + 'size', + { width: 120, height: 60 }, + { [AUTO_SIZE_OPTION]: true } + ); + await flush(); + + expect(version()).toBe(2); + }); + + it('never changes for a size the application writes', async () => { + const { version, add } = setup(); + const element = add('a'); + await flush(); + + element.resize(70, 70); + element.resize(0, 0); + await flush(); + + expect(version()).toBe(1); + }); + + // Regression: elements already in the graph when the measurement is created + // (an external graph) were never counted, so the state was wrong from then on. + it('counts the elements the graph already holds', async () => { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + graph.addCells([sized('a'), sized('b'), link('l1', 'a', 'b')]); + const measurement = createMeasurement({ + graph, + autoSizeOrigin: 'top-left', + paperStores: NO_PAPERS, + }); + await flush(); + expect(measurement.getSizingVersion()).toBe(1); + + graph.getCell('a').remove(); + await flush(); + // One element is left, so the diagram is still measured. + expect(measurement.getSizingVersion()).toBe(2); + + graph.getCell('b').remove(); + await flush(); + expect(measurement.getSizingVersion()).toBe(2); + }); + + it('does not track links', async () => { + const { graph, version } = setup(); + + graph.resetCells([link('l1', 'a', 'b')]); + graph.addCell(link('l2', 'a', 'b')); + await flush(); + + expect(version()).toBe(0); + }); + + it('changes once for a batch of elements, whatever their sizes', async () => { + const { graph, version, add } = setup(); + graph.addCell(unsized('zero')); + add('a'); + add('b'); + await flush(); + + expect(version()).toBe(1); + }); +}); diff --git a/packages/joint-react/src/store/clear-view.ts b/packages/joint-react/src/store/clear-view.ts index 0119e63547..2a5f92ad5e 100644 --- a/packages/joint-react/src/store/clear-view.ts +++ b/packages/joint-react/src/store/clear-view.ts @@ -2,51 +2,6 @@ import type { dia } from '@joint/core'; import type { CellId } from '../types/cell.types'; import type { IncrementalChange } from '../state/incremental.types'; -/** - * Cache entry for batched clearView updates. - */ -export interface ClearViewCacheEntry { - /** Callback to validate which links should be cleared */ - onValidateLink?: (link: dia.Link) => boolean; -} - -/** - * Merges two clearView validators. - * - If no validator (clear all links), that takes precedence - * - If both have validators, creates union validator - * @param existing - Existing validator - * @param incoming - New validator - * @returns Merged validator or undefined - */ -export function mergeClearViewValidators( - existing: ClearViewCacheEntry | undefined, - incoming: ClearViewCacheEntry -): ClearViewCacheEntry { - // No existing entry - use new - if (!existing) { - return incoming; - } - - const { onValidateLink } = incoming; - - // No validator means clear all links - this takes precedence - if (!onValidateLink) { - return { onValidateLink: undefined }; - } - - // If existing has validator, create union - if (existing.onValidateLink) { - const existingValidator = existing.onValidateLink; - const newValidator = onValidateLink; - return { - onValidateLink: (link: dia.Link) => existingValidator(link) || newValidator(link), - }; - } - - // Existing has no validator (clear all) - keep that behavior - return existing; -} - /** * Determines if a link should be cleared based on cell connection. * @param link - The link to check @@ -104,32 +59,3 @@ export function clearConnectedLinkViews( } return changes; } - -/** - * Executes clearView for a single cell across all papers. - * @param papers - Iterable of paper stores with paper property - * @param graph - The JointJS Graph instance - * @param cellId - The cell ID to clear - * @param onValidateLink - Optional callback to determine which links to keep - */ -export function executeClearViewForCell( - papers: Iterable<{ readonly paper?: dia.Paper }>, - graph: dia.Graph, - cellId: CellId, - onValidateLink?: (link: dia.Link) => boolean -): void { - for (const paperStore of papers) { - const { paper } = paperStore; - if (!paper) { - continue; - } - - const elementView = paper.getCellView(cellId); - if (!elementView) { - continue; - } - - elementView.cleanNodesCache(); - clearConnectedLinkViews(paper, graph, cellId, onValidateLink); - } -} diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts deleted file mode 100644 index 8fd9f754d0..0000000000 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ /dev/null @@ -1,374 +0,0 @@ -/** - * Element size observer with stack-based multi-hook support. - * - * Tracks DOM element sizes via ResizeObserver and syncs them to the graph. - * Multiple {@link useMeasureElement} hooks can target the same cell ID, only the - * most recently added (active) node is observed. When it unmounts, the - * previous node in the stack becomes active again. - * - * Internal data structures: - * - `observedStacksByCellId`, `Map` (last = active) - * - `activeObservedElementByDomNode`, `WeakMap` for O(1) lookup in the ResizeObserver callback - */ -import type { dia } from '@joint/core'; -import type { CellId } from '../types/cell.types'; -import type { ElementLayout } from '../types/cell.types'; -import type { ElementJSONInit } from '../types/cell.types'; - -const DEFAULT_OBSERVER_OPTIONS: ResizeObserverOptions = { box: 'border-box' }; -// Epsilon value to avoid jitter due to sub-pixel rendering -// especially on Safari -const EPSILON = 0.5; - -/** Element layout where width/height are required but x/y may be omitted. */ -type ElementLayoutOptionalXY = Pick & - Partial>; - -/** - * The element's measurement, passed to a {@link TransformElementLayout} callback. - * Carries the element's current `x`, `y`, and `angle` together with the freshly - * measured `width` and `height`, plus the underlying model and cell id. - * @expand - * @group Types - */ -export interface TransformElementLayoutParams extends Required { - /** The JointJS `dia.Element` instance being measured. */ - readonly model: dia.Element; - /** Id of the cell being measured. */ - readonly id: CellId; -} - -/** - * Adjusts a measured element layout before it is written to the graph. Receives - * the element's current geometry plus its newly measured size, and returns the - * `width`/`height` (and optionally `x`/`y`) to apply — use it to clamp sizes, - * snap to a grid, or reposition while auto-sizing. Pass it via the `transform` - * option of {@link useMeasureElement}. - * @example - * ```tsx - * import type { TransformElementLayout } from '@joint/react'; - * - * // Never let a measured element shrink below 80px wide. - * const transform: TransformElementLayout = ({ width, height }) => ({ - * width: Math.max(width, 80), - * height, - * }); - * ``` - * @see {@link TransformElementLayoutParams} - * @group Types - */ -export type TransformElementLayout = ( - params: TransformElementLayoutParams -) => ElementLayoutOptionalXY; - -/** - * Options for registering an element to be measured for size changes. - * @group Types - */ -export interface SetMeasuredNodeOptions { - /** The DOM node (HTML or SVG) to observe for size changes */ - readonly node: HTMLElement | SVGElement; - /** Optional callback to handle size updates before they're applied */ - readonly transform?: TransformElementLayout; - /** The ID of the cell in the graph that corresponds to this DOM node */ - readonly id: CellId; -} - -interface ObservedElement { - readonly id: CellId; - readonly node: HTMLElement | SVGElement; - readonly transform?: TransformElementLayout; - lastWidth?: number; - lastHeight?: number; - isMeasured: boolean; -} - -/** - * Options for creating an elements size observer. - */ -interface Options { - /** Options to pass to the ResizeObserver constructor */ - readonly resizeObserverOptions?: ResizeObserverOptions; - /** Function to get the current size of a cell from the graph */ - readonly getCellTransform: ( - id: CellId - ) => ElementLayoutOptionalXY & { model: dia.Element; angle: number }; - /** Function to get the elements from the container */ - readonly getElements: () => Map; - /** Callback function called when a batch of elements needs to be updated */ - readonly onBatchUpdate: (data: Record) => void; -} - -/** - * Observer interface for tracking element size changes. - * Uses ResizeObserver to automatically detect when DOM elements change size - * and updates the corresponding graph elements. - * @group Types - */ -export interface GraphStoreObserver { - /** - * Adds an element to be observed for size changes. - * @param options - Options for measuring the element - * @returns Cleanup function to stop observing - */ - readonly add: (options: SetMeasuredNodeOptions) => () => void; - /** - * Cleans up all observers and resources. - */ - readonly clean: () => void; - /** - * Checks if a node is currently being observed. - * @param id - The ID of the cell to check - * @returns True if the node is being observed - */ - readonly has: (id: CellId) => boolean; -} - -/** - * Rounds a number to two decimals. - * @param value - The value to round to two decimals - * @returns The rounded value - */ -function roundToTwoDecimals(value: number) { - return Math.round(value * 100) / 100; -} - -/** - * Options for processing a single element's size change. - */ -interface ProcessSizeChangeOptions { - readonly measuredWidth: number; - readonly measuredHeight: number; - readonly observedElement: ObservedElement; - readonly getCellTransform: Options['getCellTransform']; - readonly elements: Map; - readonly mutableLayouts: Record; -} - -/** - * Processes a size change for a single element. - * Returns true if the element was updated, false otherwise. - * @param options - The options containing size data and element references - * @returns True if the element was updated, false otherwise - */ -function processSizeChange(options: ProcessSizeChangeOptions): boolean { - const { - measuredWidth, - measuredHeight, - observedElement, - getCellTransform, - elements, - mutableLayouts, - } = options; - - if (!elements.has(observedElement.id)) { - return false; - } - - const currentCellTransform = getCellTransform(observedElement.id); - if ( - Math.abs(currentCellTransform.width - measuredWidth) <= EPSILON && - Math.abs(currentCellTransform.height - measuredHeight) <= EPSILON - ) { - return false; - } - - if ( - Math.abs((observedElement.lastWidth ?? 0) - measuredWidth) <= EPSILON && - Math.abs((observedElement.lastHeight ?? 0) - measuredHeight) <= EPSILON - ) { - return false; - } - - observedElement.lastWidth = measuredWidth; - observedElement.lastHeight = measuredHeight; - - const { transform } = observedElement; - if (transform) { - // Provide the full current transform to the callback - const { x = 0, y = 0, angle = 0, model } = currentCellTransform; - mutableLayouts[observedElement.id] = transform({ - x, - y, - angle, - model, - width: measuredWidth, - height: measuredHeight, - id: observedElement.id, - }); - } else { - // No transform provided, use measured size directly. Position is left unchanged. - mutableLayouts[observedElement.id] = { - width: measuredWidth, - height: measuredHeight, - }; - } - - return true; -} - -/** - * Creates an observer for element size changes using the ResizeObserver API. - * - * This function sets up automatic size tracking for DOM elements that correspond to graph elements. - * When a DOM element's size changes (e.g., due to content changes or CSS updates), the observer - * automatically updates the corresponding graph element's size. - * - * **Features:** - * - Uses ResizeObserver for efficient size tracking - * - Compares sizes with epsilon to avoid jitter from sub-pixel rendering - * - Supports custom size update handlers - * @param options - The options for creating the size observer - * @returns A GraphStoreObserver instance with methods to add/remove observers - */ -export function createElementsSizeObserver(options: Options): GraphStoreObserver { - const { - resizeObserverOptions = DEFAULT_OBSERVER_OPTIONS, - getCellTransform, - onBatchUpdate, - getElements, - } = options; - - // Stack per cell ID: last entry is the active (observed) node. - // When the active node unmounts, the previous one becomes active again. - const observedStacksByCellId = new Map(); - - // Maps only the active DOM node to its ObservedElement for O(1) lookup in the ResizeObserver callback. - const activeObservedElementByDomNode = new WeakMap(); - - /** Returns the active (last) element from the stack, or `undefined` if empty. */ - function getActiveElement(stack: readonly ObservedElement[]): ObservedElement | undefined { - return stack.at(-1); - } - - /** Starts observing the given element and registers it in the active DOM node lookup. */ - function activateElement(observedElement: ObservedElement) { - observer.observe(observedElement.node, resizeObserverOptions); - activeObservedElementByDomNode.set(observedElement.node, observedElement); - } - - /** Stops observing the given element and removes it from the active DOM node lookup. */ - function deactivateElement(observedElement: ObservedElement) { - observer.unobserve(observedElement.node); - activeObservedElementByDomNode.delete(observedElement.node); - } - - const observer = new ResizeObserver((entries) => { - let hasAnySizeChange = false; - const elements = getElements(); - const mutableLayouts: Record = {}; - - for (const entry of entries) { - const { target, borderBoxSize } = entry; - - const observedElement = activeObservedElementByDomNode.get( - target as HTMLElement | SVGElement - ); - if (!observedElement) continue; - - if (!borderBoxSize || borderBoxSize.length === 0) { - continue; - } - - const [size] = borderBoxSize; - const { inlineSize, blockSize } = size; - - const measuredWidth = roundToTwoDecimals(inlineSize); - const measuredHeight = roundToTwoDecimals(blockSize); - - // Skip zero-size entries. This happens when an element is hidden (display:none) - // or removed from the DOM. We must never propagate 0-size to the model. - if (measuredWidth <= 0 || measuredHeight <= 0) { - continue; - } - - const wasUpdated = processSizeChange({ - measuredWidth, - measuredHeight, - observedElement, - getCellTransform, - mutableLayouts, - elements, - }); - - if (wasUpdated) { - hasAnySizeChange = true; - } - } - - if (!hasAnySizeChange) { - return; - } - - onBatchUpdate(mutableLayouts); - }); - - return { - add({ id, node, transform }: SetMeasuredNodeOptions) { - const observedElement: ObservedElement = { - id, - node, - transform, - isMeasured: false, - }; - - let stack = observedStacksByCellId.get(id); - - if (!stack) { - stack = []; - observedStacksByCellId.set(id, stack); - } - - // Deactivate the current active node before pushing the new one - const previousActive = getActiveElement(stack); - if (previousActive) { - deactivateElement(previousActive); - } - - stack.push(observedElement); - activateElement(observedElement); - - return () => { - const currentStack = observedStacksByCellId.get(id); - if (!currentStack) return; - - const isActive = getActiveElement(currentStack) === observedElement; - - if (isActive) { - deactivateElement(observedElement); - currentStack.pop(); - - // Fall back to the previous node in the stack - const newActive = getActiveElement(currentStack); - if (newActive) { - activateElement(newActive); - } - } else { - // Remove non-active entry from the middle of the stack - const index = currentStack.indexOf(observedElement); - if (index !== -1) { - currentStack.splice(index, 1); - } - } - - if (currentStack.length === 0) { - observedStacksByCellId.delete(id); - } - }; - }, - clean() { - // cleanup all observed nodes and clear all stacks - for (const [, stack] of observedStacksByCellId.entries()) { - for (const { node } of stack) { - observer.unobserve(node); - } - } - observedStacksByCellId.clear(); - observer.disconnect(); - }, - has(id: CellId) { - const stack = observedStacksByCellId.get(id); - return !!stack && stack.length > 0; - }, - }; -} diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index 22a6d2d1e1..3d87a4d6e1 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -63,7 +63,6 @@ interface OnChangeOptions { interface Options { readonly graph: dia.Graph; readonly onChanges: (options: OnChangeOptions) => void; - readonly onElementsSizeChange?: (id: CellId, size: { width: number; height: number }) => void; } interface JointJSEventOptions { @@ -78,7 +77,7 @@ interface JointJSEventOptions { * @returns Controller exposing updateGraph and destroy. */ export function graphChanges(options: Options) { - const { graph, onElementsSizeChange } = options; + const { graph } = options; const changes = new Map>(); let batchDepth = 0; @@ -149,10 +148,6 @@ export function graphChanges(options: Options) { _collection: mvc.Collection, { isUpdateFromReact }: JointJSEventOptions ) => { - if (cell.isElement() && onElementsSizeChange) { - const size = cell.size(); - onElementsSizeChange(cell.id, size); - } if (isUpdateFromReact) return; onCellEvent(cell, 'add'); } @@ -178,13 +173,6 @@ export function graphChanges(options: Options) { changes.clear(); for (const cell of collection.models) { changes.set(cell.id, { type: 'add', data: cell }); - // `reset` suppresses per-cell `add` events, so size notifications - // for seed elements never reach `onElementsSizeChange` via the - // `add` listener. Mirror that path here so initial-measurement - // gating (e.g. `useOnElementsMeasured`) fires for seed cells. - if (onElementsSizeChange && cell.isElement()) { - onElementsSizeChange(cell.id, (cell as dia.Element).size()); - } } // Bypass the simpleScheduler wrapper used for normal cell events. // `reset` is a one-shot bulk operation and callers (e.g. GraphStore @@ -203,11 +191,6 @@ 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); - }); - // 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 // async) flushes as a single React update. Plain batches commit live. diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index b30de30043..a185f3c5d3 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -39,7 +39,6 @@ interface GraphProjectionState< > { 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,7 @@ export function graphProjection< Element extends ElementJSONInit = ElementJSONInit, Link extends LinkJSONInit = LinkJSONInit, >(options: GraphProjectionState) { - const { graph, onIncrementalCellsChange, onElementsSizeChange } = options; + const { graph, onIncrementalCellsChange } = options; const cells = createContainer(); @@ -78,7 +77,6 @@ export function graphProjection< const graphChangesController = graphChanges({ graph, - onElementsSizeChange, 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 70a3d0e808..4f287facf2 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -3,11 +3,7 @@ import type { ElementJSONInit, LinkJSONInit, CellId } from '../types/cell.types' import type { AddPaperOptions } from './paper-store'; import { PaperStore, getDefaultPaperState } from './paper-store'; -import { - createElementsSizeObserver, - type GraphStoreObserver, - type SetMeasuredNodeOptions, -} from './create-elements-size-observer'; +import { createMeasurement, type AutoSizeOrigin, type Measurement } from './measurement'; import { ELEMENT_MODEL_TYPE, ElementModel } from '../mvc/element-model'; import { LINK_MODEL_TYPE, LinkModel } from '../mvc/link-model'; import { isElementType, isLinkType } from '../utils/cell-type'; @@ -21,10 +17,9 @@ import { type GraphProjection, type OnIncrementalCellsChange, } from './graph-projection'; -import { simpleScheduler } from '../utils/scheduler'; import { cellInputToModel } from '../utils/normalize-cell-input'; import type { CellInput } from '../types/cell.types'; -import { warnDuplicatePapers, warnResizeOnAutoSizedElement } from '../utils/dev-warnings'; +import { warnDuplicatePapers } from '../utils/dev-warnings'; export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, @@ -32,18 +27,6 @@ export const DEFAULT_CELL_NAMESPACE: Record = { [LINK_MODEL_TYPE]: LinkModel, }; -/** - * `dia.Cell.set()` option key used to mark writes that originate from the - * auto-size / measurement pipeline. `change:size` listeners can read it to - * distinguish measurement writes from external ones (controlled-mode sync, - * `cell.resize`, interactive tools) and avoid feedback loops. Exported for - * plugin authors and `@joint/react-plus` via `@joint/react/internal`. - */ -export const AUTO_SIZE_OPTION = 'autoSize'; - -/** Option shape accepted by `dia.Cell.set()` writes from the auto-size pipeline. */ -type AutoSizeOptions = { [AUTO_SIZE_OPTION]?: boolean }; - /** * Paper snapshot is a simple version counter. * Incremented on every view mount/unmount change to trigger React re-renders. @@ -64,19 +47,6 @@ export interface GraphStoreInternalSnapshot { readonly graphFeaturesVersion: number; } -/** - * Reference point that stays fixed when an auto-sized element's measured size - * changes. Mirrors CSS `transform-origin` semantics. - * - * - `'top-left'` (default): element grows right/down, top-left stays put. - * - `'center'`: element grows symmetrically, geometric center stays put. - * - * Only affects writes from the {@link useMeasureElement} pipeline. Manual `cell.resize()`, - * interactive resize tools, and direct `cell.set('size', ...)` calls are unaffected. - * @group Types - */ -export type AutoSizeOrigin = 'top-left' | 'center'; - /** * Options for constructing a {@link GraphStore}: an optional existing `dia.Graph`, * cell namespace/model overrides, the auto-size origin, and `initialCells` used to @@ -108,16 +78,13 @@ export class GraphStore< > { public readonly graphProjection: GraphProjection; public readonly internalState: Atom; - public readonly measureState: Atom = createAtom(0); + /** Element measuring and its "sizes are settled" signal. See {@link createMeasurement}. */ + public readonly measurement: Measurement; public readonly graph: dia.Graph; - public readonly autoSizeOrigin: AutoSizeOrigin; public paperStores = new Map(); public features: Record = {}; - private observer: GraphStoreObserver; 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; constructor(public readonly config: GraphStoreOptions) { const { @@ -127,7 +94,6 @@ export class GraphStore< autoSizeOrigin = 'top-left', initialCells, } = config; - this.autoSizeOrigin = autoSizeOrigin; this.graph = graph ?? @@ -148,82 +114,17 @@ export class GraphStore< graphFeaturesVersion: 1, }); - const elementsMeasured = new Set(); - const onElementSizeChange = () => { - if (elementsMeasured.size > 0) { - this.measureState.set((previous) => previous + 1); - } - }; - this.graphProjection = graphProjection({ graph: this.graph, onIncrementalCellsChange: (changes) => { this.onIncrementalCellsChange?.(changes); }, - onElementsSizeChange: (id, size) => { - if (size.width > 0 && size.height > 0) { - elementsMeasured.add(id); - } else { - elementsMeasured.delete(id); - } - simpleScheduler(onElementSizeChange); - }, }); - - this.observer = createElementsSizeObserver({ - getElements: () => { - // The observer only cares about element-typed cells. Build a Map on - // demand from the unified cells container — cold path, called only - // when the ResizeObserver fires. - const map = new Map(); - for (const cell of this.graphProjection.cells.getAll()) { - if (cell.id === undefined) continue; - if (this.isElement(cell)) { - map.set(cell.id, cell); - } - } - return map; - }, - onBatchUpdate: (updatedElements) => { - this.graph.startBatch('auto-size'); - for (const [id, data] of Object.entries(updatedElements)) { - const model = this.graph.getCell(id); - if (!model?.isElement()) continue; - // `autoSize: true` marks writes that originate from the - // ResizeObserver pipeline so `change:size` listeners can tell our - // own writes apart from external ones (controlled-mode sync, - // direct `cell.resize`, etc.) and avoid feedback loops. - const attributes: dia.Cell.Attributes = { - size: { width: data.width, height: data.height }, - }; - - if (data.x !== undefined && data.y !== undefined) { - attributes.position = { x: data.x, y: data.y }; - } else if (this.autoSizeOrigin === 'center') { - // Center-anchored auto-size: keep the geometric center fixed. - const center = model.getCenter(); - attributes.position = { - x: center.x - data.width / 2, - y: center.y - data.height / 2, - }; - } - - model.set(attributes, { [AUTO_SIZE_OPTION]: true }); - // Top-left auto-size (default): don't write position — the cell's - // top-left stays put implicitly and it grows right/down. - } - this.graph.stopBatch('auto-size'); - }, - getCellTransform: (id) => { - const model = this.graph.getCell(id); - if (!model?.isElement()) throw new Error('Cell not found or not an element: ' + id); - return { - model, - ...model.size(), - ...model.position().toJSON(), - angle: model.angle(), - }; - }, + // Before the seed below, so the seed cells are tracked. + this.measurement = createMeasurement({ + graph: this.graph, + autoSizeOrigin, + paperStores: this.paperStores, }); if (initialCells && initialCells.length > 0) { @@ -239,19 +140,6 @@ export class GraphStore< // (e.g. stencil drag's cloneView). this.graphProjection.syncFromGraph(); } - - // dev only — warn when an auto-sized element (registered with the size - // observer because it renders without `useModelGeometry`) is resized by - // something other than the measurement pipeline. Such resizes are - // immediately overwritten by the measured content size. - if (process.env.NODE_ENV !== 'production') { - this.warnAutoSizeResize = (cell, _size, opt) => { - if (opt?.[AUTO_SIZE_OPTION]) return; // our own measurement write - if (!this.observer.has(cell.id)) return; // not auto-sized → resize is honored - warnResizeOnAutoSizedElement(cell.id); - }; - this.graph.on('change:size', this.warnAutoSizeResize); - } } public setOnIncrementalCellsChange = (callback: OnIncrementalCellsChange) => { @@ -317,10 +205,7 @@ export class GraphStore< this.paperStores.clear(); this.graphProjection.destroy(); this.internalState.clean(); - this.observer.clean(); - if (this.warnAutoSizeResize) { - this.graph.off('change:size', this.warnAutoSizeResize); - } + this.measurement.destroy(); if (!isGraphExternal) { this.graph.clear(); } @@ -412,6 +297,7 @@ export class GraphStore< const paperStore = this.paperStores.get(id); paperStore?.destroy(); this.paperStores.delete(id); + this.measurement.onPaperRemoved(); this.internalState.setState((previous) => { const newPapers: Record = {}; for (const [key, value] of Object.entries(previous.papers)) { @@ -431,7 +317,6 @@ export class GraphStore< return { paperStore, remove: () => this.removePaper(id) }; }; - public setMeasuredNode = (options: SetMeasuredNodeOptions) => this.observer.add(options); public getPaperStore = (id: string) => { return this.paperStores.get(id); }; diff --git a/packages/joint-react/src/store/index.ts b/packages/joint-react/src/store/index.ts index 8e10a0eea4..7685b561fe 100644 --- a/packages/joint-react/src/store/index.ts +++ b/packages/joint-react/src/store/index.ts @@ -1,3 +1,2 @@ export * from './graph-store'; export * from './paper-store'; -export * from './create-elements-size-observer'; diff --git a/packages/joint-react/src/store/measurement.ts b/packages/joint-react/src/store/measurement.ts new file mode 100644 index 0000000000..5595e7b2dd --- /dev/null +++ b/packages/joint-react/src/store/measurement.ts @@ -0,0 +1,413 @@ +import { mvc, type dia } from '@joint/core'; +import type { CellId, ElementLayout } from '../types/cell.types'; +import type { PaperStore } from './paper-store'; +import { simpleScheduler } from '../utils/scheduler'; +import { warnResizeOnAutoSizedElement } from '../utils/dev-warnings'; + +/** + * `dia.Cell.set()` option key used to mark writes that originate from the + * auto-size / measurement pipeline. `change:size` listeners can read it to + * distinguish measurement writes from external ones (controlled-mode sync, + * `cell.resize`, interactive tools) and avoid feedback loops. Exported for + * plugin authors and `@joint/react-plus` via `@joint/react/internal`. + */ +export const AUTO_SIZE_OPTION = 'autoSize'; + +/** + * Reference point that stays fixed when an auto-sized element's measured size + * changes. Mirrors CSS `transform-origin` semantics. + * + * - `'top-left'` (default): element grows right/down, top-left stays put. + * - `'center'`: element grows symmetrically, geometric center stays put. + * + * Only affects writes from the {@link useMeasureElement} pipeline. Manual `cell.resize()`, + * interactive resize tools, and direct `cell.set('size', ...)` calls are unaffected. + * @group Types + */ +export type AutoSizeOrigin = 'top-left' | 'center'; + +const OBSERVER_OPTIONS: ResizeObserverOptions = { box: 'border-box' }; +/** Tolerance that absorbs sub-pixel rendering jitter (notably on Safari). */ +const EPSILON = 0.5; + +/** A measured layout: `width`/`height` are required, `x`/`y` may be omitted. */ +type MeasuredLayout = Pick & + Partial>; + +/** + * The element's measurement, passed to a {@link TransformElementLayout} callback. + * Carries the element's current `x`, `y`, and `angle` together with the freshly + * measured `width` and `height`, plus the underlying model and cell id. + * @expand + * @group Types + */ +export interface TransformElementLayoutParams extends Required { + /** The JointJS `dia.Element` instance being measured. */ + readonly model: dia.Element; + /** Id of the cell being measured. */ + readonly id: CellId; +} + +/** + * Adjusts a measured element layout before it is written to the graph. Receives + * the element's current geometry plus its newly measured size, and returns the + * `width`/`height` (and optionally `x`/`y`) to apply — use it to clamp sizes, + * snap to a grid, or reposition while auto-sizing. Pass it via the `transform` + * option of {@link useMeasureElement}. + * @example + * ```tsx + * import type { TransformElementLayout } from '@joint/react'; + * + * // Never let a measured element shrink below 80px wide. + * const transform: TransformElementLayout = ({ width, height }) => ({ + * width: Math.max(width, 80), + * height, + * }); + * ``` + * @see {@link TransformElementLayoutParams} + * @group Types + */ +export type TransformElementLayout = (params: TransformElementLayoutParams) => MeasuredLayout; + +/** A DOM node whose size drives the size of the element `id`. */ +interface MeasuredNode { + readonly id: CellId; + readonly node: HTMLElement | SVGElement; + readonly transform?: TransformElementLayout; +} + +/** A registered {@link MeasuredNode} plus the last size it reported. */ +interface ObservedNode extends MeasuredNode { + lastWidth?: number; + lastHeight?: number; +} + +interface Options { + readonly graph: dia.Graph; + readonly autoSizeOrigin: AutoSizeOrigin; + /** The graph store's live paper registry: papers are what render and measure. */ + readonly paperStores: ReadonlyMap; +} + +const isSameSize = (a: number, b: number) => Math.abs(a - b) <= EPSILON; +const roundToTwoDecimals = (value: number) => Math.round(value * 100) / 100; + +/** + * Everything `@joint/react` knows about measuring elements, for one graph: + * sizing elements from their rendered DOM node ({@link useMeasureElement}) and + * telling when sizes are settled ({@link useOnElementsMeasured}). + * + * The sizing version changes once per settled change. An element is outstanding from + * arriving in the graph until it is accounted for: its React content committed + * without registering a node (`markRendered`), no paper renders it + * (`settleUnrendered`), the node it registered was measured, or it left the + * graph. A zero size is never read as "waiting": it is a legal final size. + * Sizes the application writes are not measurements and never bump. + * + * The `ResizeObserver` is created by the first `observe()`, so a diagram that + * never measures does not have one. + * @param options - the graph, the auto-size origin and the paper registry + * @returns the measurement controller owned by the graph store + */ +export function createMeasurement(options: Options) { + const { graph, autoSizeOrigin, paperStores } = options; + // `0` while nothing is measured; otherwise the latest of `lastVersion`, which + // only grows, so a reset followed by a pass never repeats a value. + let version = 0; + let lastVersion = 0; + const versionListeners = new Set<() => void>(); + const subscribe = (versionListener: () => void) => { + versionListeners.add(versionListener); + return () => { + versionListeners.delete(versionListener); + }; + }; + const setVersion = (next: number) => { + version = next; + for (const versionListener of versionListeners) versionListener(); + }; + let elementCount = 0; + const outstanding = new Set(); + // Settled elements that registered a node since: the first report may change + // their size, so it holds delivery back, but it is not a change by itself. + const awaitingFirstReport = new Set(); + let hasUndeliveredChange = false; + // Registered nodes per element: the last one is the observed one, and the + // previous one takes over again when it unmounts. + const nodesById = new Map(); + const observedByNode = new WeakMap(); + let resizeObserver: ResizeObserver | undefined; + + const isWaiting = () => outstanding.size > 0 || awaitingFirstReport.size > 0; + const deliver = () => { + if (!hasUndeliveredChange) return; + // Papers account for waiting elements; without one nothing renders or + // measures, so there is nothing to wait for. + if (isWaiting() && paperStores.size > 0) return; + hasUndeliveredChange = false; + // An empty diagram has nothing to lay out. It is still the same diagram, + // so the version is kept: only a reset starts a new history. + if (elementCount === 0) return; + lastVersion += 1; + setVersion(lastVersion); + }; + // One round later than the graph events that settle an element: the + // projection commits those events in the round they are queued in, and a + // subscriber of the new version reads the committed cells. + const deliverNextRound = () => simpleScheduler(deliver); + const scheduleDelivery = () => simpleScheduler(deliverNextRound); + /** A change the subscribers are owed an event for, once nothing is outstanding. */ + const markChanged = () => { + hasUndeliveredChange = true; + scheduleDelivery(); + }; + // Settling is a change in itself: a pass delivered while no paper was mounted + // leaves its elements outstanding, and the paper that renders them later owes + // the event for their sizes. + const settle = (id: CellId) => { + if (outstanding.delete(id)) markChanged(); + }; + /** + * Settles an element that nothing measures any more: it is not waiting for a + * measurement either. Checked after this tick, because an effect that re-runs + * (StrictMode) registers the node again right away. + */ + const settleOnceUnobserved = (id: CellId) => + simpleScheduler(() => { + if (nodesById.has(id)) return; + settle(id); + if (awaitingFirstReport.delete(id)) scheduleDelivery(); + }); + + /** + * Whether some paper mounted the element's view and renders React content + * into it, the only way a node gets registered. + */ + const isRendered = (id: CellId) => { + for (const { renderElement, paper } of paperStores.values()) { + if (!renderElement) continue; + const view = paper.getElementView(id); + if (view?.el.isConnected && paper.getCellViewPortalNode(view)) return true; + } + return false; + }; + + const write = (model: dia.Element, layout: MeasuredLayout) => { + const { width, height, x, y } = layout; + const attributes: dia.Cell.Attributes = { size: { width, height } }; + if (x !== undefined && y !== undefined) { + attributes.position = { x, y }; + } else if (autoSizeOrigin === 'center') { + // Keep the geometric center fixed. With 'top-left' the position is not + // written: the top-left stays put and the element grows right/down. + const center = model.getCenter(); + attributes.position = { x: center.x - width / 2, y: center.y - height / 2 }; + } + model.set(attributes, { [AUTO_SIZE_OPTION]: true }); + }; + + /** + * The write a reported box asks for, or `undefined` when there is none: a + * hidden (`display: none`) or detached node reports zero, which is never + * written, and a box equal to the model's size or to the last report is a no-op. + */ + const toWrite = (observed: ObservedNode, box: ResizeObserverSize | undefined) => { + if (!box) return; + const width = roundToTwoDecimals(box.inlineSize); + const height = roundToTwoDecimals(box.blockSize); + if (width <= 0 || height <= 0) return; + const { id, transform, lastWidth = 0, lastHeight = 0 } = observed; + const model = graph.getCell(id); + if (!model?.isElement()) return; + const size = model.size(); + if (isSameSize(size.width, width) && isSameSize(size.height, height)) return; + if (isSameSize(lastWidth, width) && isSameSize(lastHeight, height)) return; + observed.lastWidth = width; + observed.lastHeight = height; + const { x, y } = model.position(); + const params = { x, y, angle: model.angle(), model, width, height, id }; + return [model, transform ? transform(params) : { width, height }] as const; + }; + + const onResize: ResizeObserverCallback = (entries) => { + const writes: Array = []; + for (const { target, borderBoxSize } of entries) { + const observed = observedByNode.get(target); + if (!observed) continue; + // Measured, whether or not a size is written: a measurement equal to the + // model's size (the application pre-sized it) writes nothing. + settle(observed.id); + // A written size marks the change; an unchanged one only stops holding + // back a change that waited for this report. + if (awaitingFirstReport.delete(observed.id)) scheduleDelivery(); + const pending = toWrite(observed, borderBoxSize?.[0]); + if (pending) writes.push(pending); + } + if (writes.length === 0) return; + graph.startBatch('auto-size'); + for (const [model, layout] of writes) write(model, layout); + graph.stopBatch('auto-size'); + }; + + const startObserving = (observed: ObservedNode) => { + resizeObserver ??= new ResizeObserver(onResize); + resizeObserver.observe(observed.node, OBSERVER_OPTIONS); + observedByNode.set(observed.node, observed); + }; + const stopObserving = (observed: ObservedNode) => { + resizeObserver?.unobserve(observed.node); + observedByNode.delete(observed.node); + }; + /** + * Measures the element's registered node again, as if it had just registered. + * Content can outlive its model (a reset, or a removal and an addition of the + * same id in one task; the HTML overlay keeps its nodes), and a browser does + * not report an unchanged box again. The last report is forgotten too: the + * id now names a replacement model, which may have another size. + */ + const remeasure = (id: CellId) => { + const active = nodesById.get(id)?.at(-1); + if (!active) return; + active.lastWidth = undefined; + active.lastHeight = undefined; + stopObserving(active); + startObserving(active); + }; + const track = (element: dia.Element) => { + elementCount += 1; + outstanding.add(element.id); + remeasure(element.id); + markChanged(); + }; + + // An external graph may already hold elements. + for (const element of graph.getElements()) track(element); + + const listener = new mvc.Listener(); + listener.listenTo(graph, 'add', (cell: dia.Cell) => { + if (cell.isElement()) track(cell); + }); + listener.listenTo(graph, 'remove', (cell: dia.Cell) => { + if (!cell.isElement()) return; + // What is left has to be laid out again, so a removal is a change too. + elementCount -= 1; + outstanding.delete(cell.id); + awaitingFirstReport.delete(cell.id); + markChanged(); + }); + listener.listenTo(graph, 'reset', () => { + // The reset replaces the diagram: until its first settled pass nothing is measured. + elementCount = 0; + outstanding.clear(); + awaitingFirstReport.clear(); + hasUndeliveredChange = false; + if (version > 0) setVersion(0); + // `reset` suppresses the per-cell `add` events, and the collection it + // carries holds only the default layer's cells. + for (const element of graph.getElements()) track(element); + }); + listener.listenTo( + graph, + 'change:size', + (cell: dia.Cell, _size: dia.Size, changeOptions: dia.Cell.Options = {}) => { + if (changeOptions[AUTO_SIZE_OPTION]) { + outstanding.delete(cell.id); + markChanged(); + } else if (nodesById.has(cell.id)) { + // The measured content size overrides this resize. + warnResizeOnAutoSizedElement(cell.id); + } + } + ); + + return { + /** + * The sizing version: `0` while nothing is measured, otherwise a number + * that changes once per settled change. See {@link useOnElementsMeasured}. + */ + getSizingVersion: () => version, + /** + * Whether the current version describes the diagram as it is: it has + * elements, none is waiting to be measured and no change waits for its + * delivery. An emptied diagram keeps a version that describes elements + * that are gone. + */ + isSettled: () => elementCount > 0 && !isWaiting() && !hasUndeliveredChange, + /** + * Calls `versionListener` each time the sizing version changes. + * @param versionListener - called synchronously with the change + * @returns the unsubscribe function + */ + subscribe, + /** + * Sizes the element from `node`, for as long as the returned cleanup has not run. + * A settled element that starts measuring (its content switched to + * {@link HTMLHost}, or it scrolled into view) waits for the node's first report. + * @param measured - the element id, its DOM node and an optional transform + * @returns cleanup that stops measuring the node + */ + observe(measured: MeasuredNode) { + const { id } = measured; + if (!outstanding.has(id) && graph.getCell(id)) awaitingFirstReport.add(id); + const observed: ObservedNode = { ...measured }; + const nodes = nodesById.get(id) ?? []; + const active = nodes.at(-1); + if (active) stopObserving(active); + else nodesById.set(id, nodes); + nodes.push(observed); + startObserving(observed); + return () => { + const registered = nodesById.get(id); + const index = registered?.indexOf(observed) ?? -1; + if (!registered || index === -1) return; + const wasObserved = index === registered.length - 1; + registered.splice(index, 1); + if (!wasObserved) return; + stopObserving(observed); + const previous = registered.at(-1); + if (previous) startObserving(previous); + else { + nodesById.delete(id); + settleOnceUnobserved(id); + } + }; + }, + /** + * An element's React content committed. Called after its subtree's layout + * effects, so an element that registered a node stays outstanding until + * measured, while one that nothing measures is settled now. + * @param id - the rendered element + */ + markRendered(id: CellId) { + if (!nodesById.has(id)) settle(id); + }, + /** + * A paper finished a render pass (`render:done`, or `render:idle` after a + * pass that only culled): 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. O(outstanding), which is empty between + * changes. + */ + settleUnrendered() { + for (const id of outstanding) { + if (!nodesById.has(id) && !isRendered(id)) settle(id); + } + }, + /** + * A paper was removed. Only papers hold outstanding elements back, so with + * the last one gone the event is due. Checked on the next pass, not now: a + * paper re-created right away (StrictMode, a hot reload) keeps them waiting. + */ + onPaperRemoved: scheduleDelivery, + /** Stops listening to the graph and observing nodes. */ + destroy() { + listener.stopListening(); + resizeObserver?.disconnect(); + nodesById.clear(); + }, + }; +} + +/** Controller returned by {@link createMeasurement}. */ +export type Measurement = ReturnType; diff --git a/packages/joint-react/src/store/paper-store.ts b/packages/joint-react/src/store/paper-store.ts index b34af21f15..f10b2f7c29 100644 --- a/packages/joint-react/src/store/paper-store.ts +++ b/packages/joint-react/src/store/paper-store.ts @@ -11,6 +11,12 @@ import { simpleScheduler } from '../utils/scheduler'; import { toSVGMatrix } from '../utils/transform'; import type { PaperTransform } from '../components/paper/paper.types'; +/** + * The paper finished a pass: `render:done` follows a pass that updated a view, + * `render:idle` (the preset `autoFreeze`) one that only culled, which updates none. + */ +const RENDER_PASS_EVENTS = 'render:done render:idle'; + /** * Options for adding a new paper instance to the graph store. */ @@ -104,6 +110,8 @@ export class PaperStore { /** Link changes pending flush, populated by clearView, flushed in afterRender. */ private pendingLinkChanges: Map> = new Map(); + /** The measurement's render-pass listener, kept to unsubscribe an adopted paper. */ + private readonly settleUnrendered: () => void; constructor(options: PaperStoreOptions) { const { @@ -178,6 +186,9 @@ export class PaperStore { // prop-update effect runs (or when the store is used without the hook). this.nativeCellVisibility = this.paper.options.cellVisibility; + this.settleUnrendered = graphStore.measurement.settleUnrendered; + this.paper.on(RENDER_PASS_EVENTS, this.settleUnrendered); + if (transform !== undefined) { this.paper.matrix(toSVGMatrix(transform)); } @@ -266,6 +277,7 @@ export class PaperStore { * Should be called when the paper is being removed from the graph store. */ public destroy = () => { + this.paper.off(RENDER_PASS_EVENTS, this.settleUnrendered); // 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). diff --git a/packages/joint-react/src/utils/scheduler.ts b/packages/joint-react/src/utils/scheduler.ts index eb50c4cfe1..d09ccaf9e9 100644 --- a/packages/joint-react/src/utils/scheduler.ts +++ b/packages/joint-react/src/utils/scheduler.ts @@ -1,3 +1,14 @@ +/** + * Rethrows an error on a later task, so it still surfaces (to `window.onerror` + * and the console) while the caller carries on. + * @param error - The error a callback threw. + */ +export function rethrowLater(error: unknown): void { + setTimeout(() => { + throw error; + }); +} + /** * Creates a microtask-based scheduler that batches callbacks. * diff --git a/packages/joint-react/stories/examples/buttons-in-magnets/code.tsx b/packages/joint-react/stories/examples/buttons-in-magnets/code.tsx index 1dc47e3f2b..a32b246d0b 100644 --- a/packages/joint-react/stories/examples/buttons-in-magnets/code.tsx +++ b/packages/joint-react/stories/examples/buttons-in-magnets/code.tsx @@ -14,12 +14,18 @@ const SMOOTH_LINKS = linkRoutingSmooth({ mode: 'horizontal', straightWhenDisconn // Links may land on an element body, so a port button can be connected to the other card. const VALIDATE_CONNECTION: CanConnectOptions = { allowRootConnection: true }; -// Colors — unified dark diagram palette. The card body and border come from the -// shared `jj-node` class; only the chrome below is styled here. -const HEADER_COLOR = '#243445'; -const MUTED_TEXT_COLOR = '#93A4B3'; -const BUTTON_COLOR = '#2F4459'; +// Colors come from the theme's tokens, so the chrome below keeps its contrast +// with the card body and text that the shared `jj-node` class draws. +const HEADER_COLOR = 'var(--jj-color-light-300)'; +const MUTED_TEXT_COLOR = 'var(--jj-color-light-900)'; +const BUTTON_COLOR = 'var(--jj-color-primary)'; +const BUTTON_TEXT_COLOR = 'var(--jj-color-primary-contrast)'; +const FIELD_COLOR = 'var(--jj-color-surface)'; +const FIELD_BORDER = '1px solid var(--jj-color-border)'; +const DIVIDER = '1px solid var(--jj-color-border-subtle)'; const MAGNET_COUNT = 3; +// Tall enough for both cards; the paper fills the story canvas's width. +const PAPER_STYLE: React.CSSProperties = { height: 300 }; interface NodeData { readonly kind: 'body' | 'magnets'; @@ -58,7 +64,7 @@ const headerStyle: React.CSSProperties = { fontWeight: 600, fontSize: 12, background: HEADER_COLOR, - borderBottom: '1px solid rgba(128, 128, 128, 0.2)', + borderBottom: DIVIDER, }; const bodyStyle: React.CSSProperties = { @@ -72,8 +78,8 @@ const selectStyle: React.CSSProperties = { font: 'inherit', fontSize: 11, color: 'inherit', - background: 'rgba(0, 0, 0, 0.25)', - border: '1px solid rgba(128, 128, 128, 0.35)', + background: FIELD_COLOR, + border: FIELD_BORDER, borderRadius: 4, padding: '3px 6px', width: '100%', @@ -93,15 +99,15 @@ const rowStyle: React.CSSProperties = { fontSize: 12, cursor: 'crosshair', userSelect: 'none', - borderBottom: '1px solid rgba(128, 128, 128, 0.15)', + borderBottom: DIVIDER, }; const buttonStyle: React.CSSProperties = { font: 'inherit', fontSize: 11, - color: 'inherit', + color: BUTTON_TEXT_COLOR, background: BUTTON_COLOR, - border: '1px solid rgba(128, 128, 128, 0.35)', + border: '1px solid transparent', borderRadius: 4, padding: '3px 8px', cursor: 'pointer', @@ -111,8 +117,8 @@ const inputStyle: React.CSSProperties = { font: 'inherit', fontSize: 11, color: 'inherit', - background: 'rgba(0, 0, 0, 0.25)', - border: '1px solid rgba(128, 128, 128, 0.35)', + background: FIELD_COLOR, + border: FIELD_BORDER, borderRadius: 4, padding: '3px 8px', boxSizing: 'border-box', @@ -189,8 +195,8 @@ function BodyButtonNode({ name }: Readonly>) {

- Click the button to count; drag it to move the element without counting. The text - field selects text instead, and the paper ignores the dropdown outright. + Click the button to count; drag it to move the element without counting. The text field + selects text instead, and the paper ignores the dropdown outright.

@@ -224,8 +230,8 @@ function MagnetButtonsNode({ name }: Readonly>) { ))}

- Drag off a row, or off its button, to start a link. The text field starts none, and - the dropdown never reaches the paper at all. + Drag off a row, or off its button, to start a link. The text field starts none, and the + dropdown never reaches the paper at all.

@@ -243,7 +249,7 @@ function Main() { return (