Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/react-autosize-external-resize.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
111 changes: 111 additions & 0 deletions packages/joint-react/src/store/__tests__/graph-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
31 changes: 31 additions & 0 deletions packages/joint-react/src/store/create-elements-size-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
},
};
}
21 changes: 21 additions & 0 deletions packages/joint-react/src/store/graph-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export class GraphStore<
private onIncrementalCellsChange?: OnIncrementalCellsChange<Element, Link>;
// 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<Element, Link>) {
const {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down