Skip to content

fix(joint-react): fire useOnElementsMeasured once per settled change - #3556

Merged
kumilingus merged 5 commits into
clientIO:masterfrom
samuelgja:fix/react-elements-measured-master
Oct 26, 2026
Merged

kumilingus merged 5 commits into
clientIO:masterfrom
samuelgja:fix/react-elements-measured-master

Conversation

@samuelgja

@samuelgja samuelgja commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

On master, useOnElementsMeasured calls back on every bump of a measureState counter. That counter is bumped by any element size change once some element has a non-zero size. So the callback fires:

  • too early: before elements that are still being measured have their size, so a layout runs on placeholder sizes;
  • too often: once per size change instead of once per change of the diagram;
  • for sizes the application writes itself, such as cell.resize();
  • never for a diagram of zero-sized elements;
  • with a stale isInitial after resetCells().

This PR ports the fix from dev (#3518 and its follow-up) to master. It does not bring dev's new state management, and it adds no new API.

src/store/measurement.ts (new, replaces create-elements-size-observer.ts)

  • createMeasurement({ graph, autoSizeOrigin, paperStores }), exposed as graphStore.measurement. It listens to the graph itself (add / remove / reset / change:size), owns the node registry, and creates its ResizeObserver on the first observe().
  • An element is outstanding from entering the graph until a positive signal settles it, never by reading its size:
    • its React content committed without registering a node: markRendered(), called from the element item's existing layout effect;
    • no paper renders it (viewport culling, cellVisibility): settleUnrendered() on each paper's render:done, or render:idle after a pass that only culled;
    • its registered node was measured;
    • it left the graph.
  • A settled element that starts measuring later (its content switches to <HTMLHost>, or it scrolls into view) waits for its node's first report too. That report holds back delivery and the mount replay, but it is a change only when it writes a size.
  • The sizing version changes once per settled change (an addition, a removal or a re-measurement), once nothing is outstanding.
    • It is delivered one scheduler round after the settling graph events, so the store already holds the measured sizes.
    • Removing the last paper re-checks the delivery. A pass delivered while no paper was mounted leaves its elements outstanding, and settling them later is a change of its own.
    • Sizes the application writes are not measurements.
    • A zero size is a legal final size.
    • Only a reset starts a new history (isInitial is reported again). It tracks the elements of every layer. A node that outlives its model (a reset, or the same id removed and added back in one task) is observed again, against the new model. Emptying the diagram is an ordinary change: an empty diagram fires nothing, and the version is kept.
  • graph-store.ts, graph-changes.ts and graph-projection.ts lose their measurement code. use-are-elements-measured.ts (internal) and two dead helpers in clear-view.ts are removed.

Hooks

  • useOnElementsMeasured: reads measurement, keeps its signature, and the JSDoc now states the contract. It subscribes before replaying, and on mount replays only a settled pass of a diagram that has elements; while a change waits, or the diagram is empty, the next delivery is the hook's initial event. A callback that throws is rethrown on a later task, so other subscribers of the same change are still notified.
  • The element item reports its content as rendered again when its portal target changes (a same-id reset whose views render before React commits).
  • useMeasureElement: registers through measurement.observe(). It clears the view cache of its own element when its size changes, replacing the sweep over every element on each measurement.

Tests

  • use-on-elements-measured-events.test.tsx (the 16-case spec from test(joint-react): specify how many events useOnElementsMeasured delivers #3520) and use-on-elements-measured-settling.test.tsx (an unrendered or entirely culled element, removal while waiting or settled, a content change that re-measures, emptying and refilling the diagram, a same-id reset, a hook or a paper mounted while a change waits, a hook mounted on an emptied diagram, a replayed callback that resets the graph, an HTML overlay element removed and added back in one task, a settled element that starts measuring).
    • 14 of the first 24 cases fail on master, and all 37 pass here. The cases added during review each failed before the fix they cover.
    • use-on-elements-measured.compiler.test.tsx runs both suites again under React Compiler.
  • src/store/__tests__/measurement.test.ts: the observer and graph-change tests, ported one to one, plus the settled-state cases (an empty graph keeps the version, a reset on a nondefault layer, nodes re-observed after a reset, isSettled(), including while a settled element waits for its first report).
  • graph-store.test.ts covers removing the last paper while elements wait.
  • Results in packages/joint-react:
    • yarn test (typecheck + knip + coverage + React 18): 1156 tests with coverage on React 19 and 1115 on React 18, all passing, above the coverage floors.
    • yarn lint: 0 errors.
  • Storybook (master): every story that uses the hook renders sized elements without overlap and without console errors: automatic-layout, element-controls, collapsible-subtrees, dynamic-status-icons, flowchart, introduction-demo, automatic-layout-storage. Adding a node re-runs the layout.

Changesets: @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.

stories/examples/buttons-in-magnets

  • The paper had no height (className="size-full" inside a container without one), so the story rendered empty. It now sets height: 300, like the other examples.
  • The card chrome used a dark palette over the light theme's dark text. It now uses the theme's tokens (--jj-color-primary, --jj-color-surface, ...).

Motivation and Context

The settled-change fix is on dev (#3518) but dev is not being released yet, and the hook is broken in the current release. This PR is the same fix on master.

Notes

  • Behaviour change beyond the review threads: papers now also settle on render:idle. A pass that only culls updates no view, so core fires no render:done, and an addition the paper culled entirely used to hold the event back until some unrelated render.
  • Not included from dev: the graph-state reader, the useCells changes and the scheduler hardening (feat(joint-react): add useGraphState and useOnGraphStateChange #3532). This PR touches only measuring.
  • Left for a follow-up: per-paper settling. With two papers on one graph where only one measures, the other one's commit settles an element early, so its hook can fire before the measurement and again after it.

Screenshots (if appropriate):

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved reset and subscription lifecycle defects can block measurement notifications, deliver them prematurely, or preserve stale initial-state history.

5 open issues
What changed in this PR

Ports the settled-measurement behavior from #3518 and the specification from #3520 to master, without introducing the newer graph-state API.

Changes:

  • Centralizes measurement tracking and delays notifications until outstanding elements settle.
  • Updates hooks and paper lifecycle integration, including targeted view-cache clearing.
  • Adds regression coverage, React Compiler coverage, and a patch changeset.
File Description
packages/​joint-react/​src/​utils/​scheduler.ts Adds deferred callback-error reporting.
packages/​joint-react/​src/​store/​paper-store.ts Connects render completion to measurement settling.
packages/​joint-react/​src/​store/​measurement.ts Adds the measurement controller and sizing history.
packages/​joint-react/​src/​store/​index.ts Removes the obsolete observer export.
packages/​joint-react/​src/​store/​graph-store.ts Delegates measurement tracking to the controller.
packages/​joint-react/​src/​store/​graph-projection.ts Removes measurement callback forwarding.
packages/​joint-react/​src/​store/​graph-changes.ts Removes legacy size notifications.
packages/​joint-react/​src/​store/​create-elements-size-observer.ts Removes the replaced observer implementation.
packages/​joint-react/​src/​store/​clear-view.ts Removes unused cache-clearing helpers.
packages/​joint-react/​src/​store/​__tests__/​measurement.test.ts Tests observer behavior and measurement state.
packages/​joint-react/​src/​store/​__tests__/​graph-store.test.ts Updates measurement tests and covers paper removal.
packages/​joint-react/​src/​store/​__tests__/​graph-store-features.test.ts Adapts integration tests to the controller.
packages/​joint-react/​src/​store/​__tests__/​graph-changes.test.ts Removes relocated measurement tests.
packages/​joint-react/​src/​store/​__tests__/​create-elements-size-observer.test.ts Removes the replaced observer suite.
packages/​joint-react/​src/​store/​__tests__/​clear-view.test.ts Removes tests for deleted helpers.
packages/​joint-react/​src/​internal.ts Preserves the internal auto-size option export.
packages/​joint-react/​src/​index.ts Redirects public measurement type exports.
packages/​joint-react/​src/​hooks/​use-on-elements-measured.ts Subscribes to settled changes and documents the contract.
packages/​joint-react/​src/​hooks/​use-measure-element.tsx Registers measurements and clears affected view caches.
packages/​joint-react/​src/​hooks/​use-create-portal-paper.tsx Removes global measurement-state propagation.
packages/​joint-react/​src/​hooks/​use-are-elements-measured.ts Removes the obsolete internal hook.
packages/​joint-react/​src/​hooks/​__tests__/​use-on-elements-measured.test.tsx Updates hook tests to use graph events.
packages/​joint-react/​src/​hooks/​__tests__/​use-on-elements-measured.compiler.test.tsx Runs measurement suites under React Compiler.
packages/​joint-react/​src/​hooks/​__tests__/​use-on-elements-measured-settling.test.tsx Covers culling, removal, and remeasurement.
packages/​joint-react/​src/​hooks/​__tests__/​use-on-elements-measured-events.test.tsx Adds the settled-event specification.
packages/​joint-react/​src/​hooks/​__tests__/​use-measure-element.test.tsx Tests targeted cache clearing after size commits.
packages/​joint-react/​src/​components/​paper/​render-element/​paper-element-item.tsx Signals committed element content to measurement tracking.
packages/​joint-react/​src/​components/​paper/​render-element/​__tests__/​paper-element-item.test.tsx Updates tests for simplified portal props.
packages/​joint-react/​src/​components/​graph/​graph-provider.tsx Redirects the auto-size origin type import.
.changeset/​react-elements-measured-app-resize.md Records the measurement fix as a patch release.

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Measurement registration and replay edge cases can still deliver callbacks prematurely or block later notifications.

2 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Track pending first reports after switching to HTMLHost

packages/​joint-react/​src/​store/​measurement.ts:343

When an already-settled plain element switches to <HTMLHost>, registering its node does not make it pending again. If another element is removed before that node's first ResizeObserver report, the removal delivers using the old size, then the measurement delivers a second event. Track pending first reports until measurement or cleanup so other changes wait, while preserving no-event behavior for unchanged sizes and StrictMode re-registrations.

5 resolved since last review

Review effort: Balanced

@kumilingus
kumilingus requested a balanced review from Copilot October 26, 2026 11:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

- store/measurement.ts replaces create-elements-size-observer.ts and the
  measurement bookkeeping in graph-store, graph-changes and graph-projection;
  it listens to the graph itself and creates its ResizeObserver lazily
- an element is outstanding from entering the graph until a positive signal
  settles it: its content committed without registering a node, no paper
  renders it (render:done), its node was measured, or it left the graph
- one event per settled change (an addition, a removal or a re-measurement),
  announced once the measured sizes are in the store; application-written
  sizes are not measurements; a zero size is a legal final size; a graph
  reset or removing the last element starts a new history (isInitial)
- useMeasureElement clears the view cache of its own element when its size
  changes, replacing the sweep on every measurement
@samuelgja
samuelgja force-pushed the fix/react-elements-measured-master branch from 6dba752 to 9e0b28d Compare October 26, 2026 12:52
@kumilingus
kumilingus requested a balanced review from Copilot October 26, 2026 13:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Newly registered measurers on settled elements do not block readiness, allowing layout callbacks before their first measurement arrives.

1 open issue

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A late-mounted hook can miss its initial callback when readiness returns without a sizing-version change.

1 open issue
1 resolved since last review

Review effort: Balanced

@kumilingus
kumilingus merged commit 15124e9 into clientIO:master Oct 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants