From a0a11205bbdfd63957e4221985f43337c3aeafeb Mon Sep 17 00:00:00 2001 From: samuelgja Date: Wed, 26 Aug 2026 02:03:45 +0700 Subject: [PATCH] fix(joint-react): re-measure auto-sized elements after external size writes An external size write (controlled-mode sync, cell.resize) on a measured element could leave the model silently diverged from the DOM: the measurement pipeline does not re-fire when the DOM did not change, and its last-measurement dedup memory swallowed a delivery repeating the pre-write numbers. Under rapid state toggling (e.g. collapse/expand of a measured card) this left elements stuck at a stale measured size. The graph store now invalidates the cell's measurement state on any non-autoSize change:size: the dedup memory is cleared and the active node is re-observed, which forces the ResizeObserver to deliver the current layout so the measured size is re-asserted. Re-activating a node from the measurement stack also starts with a clean memory, closing the same hole in the multi-hook fallback path. --- .changeset/react-autosize-external-resize.md | 5 + .../create-elements-size-observer.test.ts | 53 +++++++++ .../src/store/__tests__/graph-store.test.ts | 111 ++++++++++++++++++ .../store/create-elements-size-observer.ts | 31 +++++ packages/joint-react/src/store/graph-store.ts | 21 ++++ 5 files changed, 221 insertions(+) create mode 100644 .changeset/react-autosize-external-resize.md diff --git a/.changeset/react-autosize-external-resize.md b/.changeset/react-autosize-external-resize.md new file mode 100644 index 0000000000..814c4d998b --- /dev/null +++ b/.changeset/react-autosize-external-resize.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +`useMeasureElement` - fix a measured element sticking at a stale size after its size is written externally (controlled-mode sync, `cell.resize()`); the measurement pipeline now re-asserts the measured size from the current layout 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 index fe8622e5b2..a95a22d290 100644 --- 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 @@ -508,6 +508,59 @@ describe('createElementsSizeObserver', () => { }); }); + describe('invalidate', () => { + it('clears the measurement memory so the next delivery of the same size is honored', () => { + const element = document.createElement('div'); + observer.add({ id: 'element-1', node: element }); + const resizeObserver = MockResizeObserver.getLastInstance()!; + + resizeObserver.triggerResize(element, 100, 50); + expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); + + // An external size write happened — the store invalidates the measurement. + observer.invalidate('element-1'); + + // The forced re-observation delivers the unchanged layout again; it must + // be honored, not deduped against the pre-invalidation measurement. + resizeObserver.triggerResize(element, 100, 50); + expect(mockOnBatchUpdate).toHaveBeenCalledTimes(2); + }); + + it('re-observes the active node to force a fresh delivery of the current layout', () => { + const element = document.createElement('div'); + observer.add({ id: 'element-1', node: element }); + const resizeObserver = MockResizeObserver.getLastInstance()!; + const unobserveSpy = jest.spyOn(resizeObserver, 'unobserve'); + const observeSpy = jest.spyOn(resizeObserver, 'observe'); + + observer.invalidate('element-1'); + + expect(unobserveSpy).toHaveBeenCalledWith(element); + expect(observeSpy).toHaveBeenCalledWith(element, expect.anything()); + expect(resizeObserver.isObserving(element)).toBe(true); + }); + + it('targets only the active node of a multi-entry stack', () => { + 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()!; + const unobserveSpy = jest.spyOn(resizeObserver, 'unobserve'); + + observer.invalidate('element-1'); + + // Identity check — `toHaveBeenCalledWith` matches structurally and the + // two empty divs are structurally equal. + expect(unobserveSpy).toHaveBeenCalledTimes(1); + expect(unobserveSpy.mock.calls[0][0]).toBe(nodeB); + }); + + it('is a no-op for an unobserved id', () => { + expect(() => observer.invalidate('unknown')).not.toThrow(); + }); + }); + describe('has', () => { it('should return true for registered elements', () => { const element = document.createElement('div'); 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..fc700f4435 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -340,4 +340,115 @@ describe('GraphStore', () => { store.destroy(false); }); }); + + describe('auto-size reassertion after external size writes', () => { + // The race this covers: the measurement pipeline writes sizes on its own + // (ResizeObserver) schedule, so an external size write (controlled-mode + // sync, `cell.resize`) landing between measurements would stick while the + // DOM disagrees — the observer does not re-fire when the DOM did not + // change, and its dedup memory would swallow a delivery repeating the + // last-measured numbers. Observed under rapid collapse/expand toggling of + // a measured card, where it left the model at a stale measured size. + interface ObserverInstance { + readonly observe: jest.Mock; + readonly unobserve: jest.Mock; + readonly disconnect: jest.Mock; + } + + // Hoisted so `setup`'s inner helpers stay within the nesting limit. + const noop = () => {}; + + const makeEntry = (target: Element, width: number, height: number): ResizeObserverEntry => + ({ + target, + borderBoxSize: [{ inlineSize: width, blockSize: height }], + contentBoxSize: [{ inlineSize: width, blockSize: height }], + devicePixelContentBoxSize: [{ inlineSize: width, blockSize: height }], + contentRect: {} as DOMRectReadOnly, + }) as ResizeObserverEntry; + + const setup = async () => { + const store = new GraphStore({}); + const element = new shapes.standard.Rectangle({ + id: 'measured', + position: { x: 0, y: 0 }, + size: { width: 100, height: 40 }, + }); + store.graph.addCell(element); + // The projection's cells container (read by the measurement pipeline's + // getElements) syncs on a microtask — flush before delivering entries. + await flush(); + const node = document.createElement('div'); + document.body.append(node); + store.setMeasuredNode({ id: 'measured', node }); + + // The global ResizeObserver is re-mocked in jest-setup's beforeEach, so + // the mock has seen exactly one construction: this store's observer. + // Assert that, so the test fails loudly if GraphStore ever grows more. + const resizeObserverMock = globalThis.ResizeObserver as unknown as jest.Mock; + expect(resizeObserverMock).toHaveBeenCalledTimes(1); + const callback = resizeObserverMock.mock.calls[0][0] as ResizeObserverCallback; + const instance = resizeObserverMock.mock.results[0].value as ObserverInstance; + + const deliver = (width: number, height: number) => + callback([makeEntry(node, width, height)], instance as unknown as ResizeObserver); + const resizeExternally = (width: number, height: number) => { + // Silence the dev-only "resized while in auto-size mode" warning. + const warn = jest.spyOn(console, 'warn').mockImplementation(noop); + element.resize(width, height); + warn.mockRestore(); + }; + const cleanup = () => { + node.remove(); + store.destroy(false); + }; + return { element, node, instance, deliver, resizeExternally, cleanup }; + }; + + it('honors the re-measured content size after an external resize', async () => { + const { element, deliver, resizeExternally, cleanup } = await setup(); + + deliver(300, 200); + expect(element.size()).toEqual({ width: 300, height: 200 }); + + resizeExternally(520, 400); + + // The content did not change, so the fresh (re-observed) measurement + // reports the same 300x200 — it must overwrite the external size, as + // documented ("the measured content size overrides the resize"). The + // stale dedup memory from before the resize must not swallow it. + deliver(300, 200); + expect(element.size()).toEqual({ width: 300, height: 200 }); + cleanup(); + }); + + it('re-observes the measured node on an external resize to force a fresh delivery', async () => { + const { node, instance, deliver, resizeExternally, cleanup } = await setup(); + + deliver(300, 200); + instance.unobserve.mockClear(); + instance.observe.mockClear(); + + resizeExternally(520, 400); + + // Real ResizeObserver semantics: unobserve() resets the last-reported + // size, so the following observe() forces a fresh delivery of the + // current layout even though the DOM did not change (observe() alone is + // a spec no-op for an already-observed target). + expect(instance.unobserve).toHaveBeenCalledWith(node); + expect(instance.observe).toHaveBeenCalledWith(node, expect.anything()); + cleanup(); + }); + + it('does not re-observe for measurement-pipeline (autoSize) writes', async () => { + const { element, instance, deliver, cleanup } = await setup(); + + deliver(300, 200); + instance.unobserve.mockClear(); + + element.set('size', { width: 310, height: 210 }, { autoSize: true }); + expect(instance.unobserve).not.toHaveBeenCalled(); + cleanup(); + }); + }); }); diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 8fd9f754d0..69cfc822d1 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -122,6 +122,18 @@ export interface GraphStoreObserver { * @returns True if the node is being observed */ readonly has: (id: CellId) => boolean; + /** + * Discards the cell's measurement state after its size was written from + * outside the measurement pipeline (controlled-mode sync, `cell.resize`). + * The pipeline must re-assert the measured size from the current layout, + * but on its own it stays silent: ResizeObserver does not re-fire when the + * DOM did not change, and the last-measurement dedup memory would swallow + * a delivery repeating the pre-write numbers. Re-observing the active node + * forces a fresh delivery of the current layout with the memory cleared, so + * the model can never be left silently diverged from the DOM. + * @param id - The ID of the cell whose measurement state to discard + */ + readonly invalidate: (id: CellId) => void; } /** @@ -243,6 +255,12 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver /** Starts observing the given element and registers it in the active DOM node lookup. */ function activateElement(observedElement: ObservedElement) { + // Start with a clean measurement memory: the model may have moved while + // the node was inactive (stack fallback) or was just written externally + // (invalidate), and stale lastWidth/lastHeight would swallow the forced + // initial delivery when it repeats the previously measured numbers. + observedElement.lastWidth = undefined; + observedElement.lastHeight = undefined; observer.observe(observedElement.node, resizeObserverOptions); activeObservedElementByDomNode.set(observedElement.node, observedElement); } @@ -370,5 +388,18 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver const stack = observedStacksByCellId.get(id); return !!stack && stack.length > 0; }, + invalidate(id: CellId) { + const stack = observedStacksByCellId.get(id); + const active = stack ? getActiveElement(stack) : undefined; + if (!active) return; + + // Re-observe: `unobserve()` resets the observer's last-reported size, + // so `observe()` forces a fresh delivery of the current layout even + // though the DOM itself did not change (calling `observe()` alone is a + // spec no-op for an already-observed target). `activateElement` clears + // the dedup memory so that delivery is honored. + deactivateElement(active); + activateElement(active); + }, }; } diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 70a3d0e808..6d08edffd3 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -118,6 +118,13 @@ export class GraphStore< 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; + // `change:size` listener that discards stale measurement state when an + // observed element's size is written from outside the measurement pipeline. + private invalidateMeasurementOnResize: ( + cell: dia.Cell, + size: dia.Size, + opt?: AutoSizeOptions + ) => void; constructor(public readonly config: GraphStoreOptions) { const { @@ -240,6 +247,19 @@ export class GraphStore< this.graphProjection.syncFromGraph(); } + // An external size write (controlled-mode sync, `cell.resize`) on an + // observed element would leave the model silently diverged from the DOM: + // the measurement pipeline does not re-fire on its own (the DOM did not + // change) and its dedup memory would swallow a delivery repeating the + // last-measured numbers. Invalidate the cell's measurement state so the + // pipeline re-asserts the measured size from the current layout. + // Our own measurement writes are marked with `autoSize` and skipped. + this.invalidateMeasurementOnResize = (cell, _size, opt) => { + if (opt?.[AUTO_SIZE_OPTION]) return; + this.observer.invalidate(cell.id); + }; + this.graph.on('change:size', this.invalidateMeasurementOnResize); + // 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 @@ -318,6 +338,7 @@ export class GraphStore< this.graphProjection.destroy(); this.internalState.clean(); this.observer.clean(); + this.graph.off('change:size', this.invalidateMeasurementOnResize); if (this.warnAutoSizeResize) { this.graph.off('change:size', this.warnAutoSizeResize); }