Skip to content

fix(react): keep owned useGraph graph alive in StrictMode - #390

Open
draedful wants to merge 2 commits into
v2from
fix/v2-use-graph-strict-mode
Open

draedful wants to merge 2 commits into
v2from
fix/v2-use-graph-strict-mode

Conversation

@draedful

@draedful draedful commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

useGraph created its graph in useMemo and called graph.unmount() in a useLayoutEffect cleanup when it owned the graph (no config.graph passed). React StrictMode replays effect cleanup and setup on mount, so the memoized graph was destroyed but still returned by the hook.

Repro (React 18.3.1): render(<StrictMode><App /></StrictMode>) where App calls useGraph({}) and renders <GraphCanvas graph={graph} renderBlock={() => <div />} />. Result: graph.layers.getLayers().length === 1 and no <canvas>. Without StrictMode, or with an external graph, the result is 5 layers and 3 canvases.

Fix (packages/graph-react/src/hooks/useGraph.ts)

  • Synchronous disposal by default. An owned graph is disposed synchronously in the effect cleanup when it is unmounted or replaced. Normal unmount errors still reach error boundaries.
  • Deferred only during the StrictMode replay window.
    • The setup records the graph as "just mounted" until the end of the current synchronous pass (queueMicrotask).
    • A cleanup inside that pass may be React 18's StrictMode replay. Its disposal is deferred with setTimeout(0) and cancelled by the replayed setup.
    • Pending disposals are stored in a WeakMap keyed by graph, so one graph's setup never cancels another graph's disposal.
  • Shared scheduler protection. Graph.unmount() stops the global scheduler that every graph shares, and that scheduler is not ref-counted.
    • The hook keeps a ref-counted registry of graphs held by mounted useGraph instances, both owned and external.
    • After every disposal and every setup, it restarts the scheduler (idempotent) while any registered graph is READY.
    • So disposing an old graph does not freeze a replacement graph or another running graph.
  • Ownership is remembered per hook instance. If the caller passes the hook's own graph back as config.graph, the graph stays owned. It is not disposed by that switch, and it is still disposed on unmount.
  • Passive effect. The disposal runs in a passive useEffect. In React 18, a Suspense boundary that hides already-shown content runs layout-effect cleanups but keeps passive effects, so the graph survives the hide.
  • External graphs (config.graph) are never disposed by the hook.

Behaviour notes for consumers

  • An owned graph that is mounted and unmounted in the same synchronous pass is disposed on setTimeout(0), for example renderHook(() => useGraph({})); unmount(); without an await in between.
    • With fake timers, advance them to see the disposal.
    • Errors thrown by graph.unmount() on this path are uncaught and do not reach an error boundary.
  • Disposal now runs in the passive cleanup rather than the layout cleanup. A graph that no useGraph holds, and that is started in the layout phase of the same commit, is now stopped by that disposal. Graphs held by useGraph are restarted.
  • In React 19, <Activity> also disconnects passive effects. The peer range is React 18 only.

Known limitation (documented in code)

A Graph built by a render that never commits never reaches the effect, so it is never disposed.

  • In development, React 18 StrictMode renders twice on mount, so one such Graph is created per mount. useMemo, useRef lazy init and useState initializers all run twice in React 18.3.1.
  • This matters because new Graph() registers its layer schedulers in globalScheduler in the constructor, and Graph.unmount() never removes them. That is a pre-existing core issue.
  • A ref-counted global scheduler in core would also let this hook drop its scheduler registry. Both are outside this PR.

Tests (useGraph.test.ts → useGraph.test.tsx for JSX)

Fail on the original hook and pass now:

  • the owned graph survives the StrictMode effect replay;
  • the owned graph survives a Suspense boundary hiding and revealing it;
  • remounting with a new key disposes the old graph and keeps the new one;
  • a running external graph stays running when another hook that held it unmounts in the same pass.

Pass on the original hook, and failed on the first version of this PR; they now guard against regressions:

  • unmounting disposes the owned graph synchronously;
  • a disposal error reaches the error boundary;
  • disposing a replaced graph does not stop the scheduler the new graph started, including a start in the layout phase, a replacement in the same pass, and the README pattern;
  • a running external graph is not frozen by an owned graph's disposal, whether it runs when the next owned graph mounts or on its timer;
  • an owned graph passed back as config.graph is not disposed.

Other guards: unmount under StrictMode disposes the committed graph; switching from an owned to an external graph disposes only the owned one; an external graph is never disposed, with and without StrictMode.

Checks

All of these pass:

  • pnpm run typecheck
  • pnpm run test:type-configs
  • pnpm test: graph 329 passed and 6 todo, devtools 11 passed, graph-react 159 passed and 4 skipped
  • pnpm run lint: one existing warning in the storybook customConnectionLabel story
  • node scripts/strict-diagnostics.mjs

pnpm run test:package-contract passed on the first commit; the second commit changes only useGraph internals and tests.

Review

Sourcery findings:

  1. A deferred unmount() stopped the scheduler a replacement graph had started. Valid regression; fixed (synchronous disposal plus scheduler restart).
  2. A graph passed back as config.graph was disposed. Valid regression; fixed (per-instance ownership).
  3. Graphs from renders that never commit leak scheduler registrations. Pre-existing core issue; documented, not fixable in the hook without losing first-render availability.
  4. Errors from an async teardown bypass error boundaries. Fixed for normal unmounts. The same-pass case is documented.

Independent review rounds:

  • The initial implementation went through 3 rounds; the Suspense P2 was fixed there.
  • The Sourcery follow-up went through 5 rounds, which found and fixed P2s with tests:
    • a same-pass replacement;
    • a start in the layout phase and the README pattern;
    • a running external or already-mounted graph.
  • The last two rounds found no P2 or higher.

🤖 Generated with Claude Code

useGraph unmounted its owned graph synchronously in a layout-effect
cleanup. React StrictMode replays effect cleanup and setup, so the
memoized graph was destroyed while the hook kept returning it (one
layer, no canvases with GraphCanvas).

Disposal of an owned graph is now deferred by one macrotask and
cancelled when the effect sets up again for the same graph. Pending
disposals are keyed by graph, so a key change or Fast Refresh never
cancels another graph's disposal. The effect is passive so a React 18
Suspense boundary that hides shown content does not dispose the graph.
External graphs are never disposed by the hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@draedful
draedful requested a review from Antamansid as a code owner October 16, 2026 14:00
@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes premature destruction of internally created graphs by pairing ownership with graph creation, deferring owned-graph disposal through a per-graph timer in a passive effect, and canceling that disposal during StrictMode replay. Comprehensive tests cover StrictMode, Suspense, keyed remounts, graph switching, unmounting, and preservation of externally owned graphs.

File-Level Changes

Change Details Files
Make owned-graph lifecycle management resilient to React StrictMode and Suspense effect behavior.
  • Capture graph ownership alongside graph creation so cleanup decisions remain stable.
  • Move disposal to a passive effect and defer it by one macrotask.
  • Cancel pending disposal during setup to preserve graphs across StrictMode effect replay.
  • Track pending timers per graph with a WeakMap to prevent cross-graph cancellation during remounts or graph switches.
  • Continue leaving externally supplied graphs undisposed.
  • Document the known leak for graphs created by renders that never commit.
packages/graph-react/src/hooks/useGraph.ts
Expand lifecycle regression coverage for owned and external graphs across React rendering modes.
  • Rename the test file to TSX and add StrictMode survival coverage.
  • Verify graph preservation when Suspense hides and reveals committed content.
  • Verify disposal on unmount, keyed remount, and owned-to-external graph transitions.
  • Verify external graphs remain alive with and without StrictMode.
  • Add a helper to flush deferred macrotask disposal and update existing configuration coverage.
packages/graph-react/src/hooks/useGraph.test.ts
packages/graph-react/src/hooks/useGraph.test.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gravity-ui-bot

Copy link
Copy Markdown
Contributor

Preview is ready.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="packages/graph-react/src/hooks/useGraph.ts" line_range="86" />
<code_context>
-  expect(graph.graphConstants.camera.SPEED).toBe(2);
-  expect(graph.graphConstants.camera.PAN_SPEED).toBe(1);
-  unmount();
-  graph.unmount();
-});
</code_context>
<issue_to_address>
**Replacement graph stops rendering**

When an owned graph is disposed after its replacement has started, `graph.unmount()` stops the shared global scheduler, so the replacement graph remains mounted in READY state but receives no scheduled updates and stops rendering.

Prevent disposal of an old graph from stopping the shared scheduler while a replacement graph is using it.
</issue_to_address>

### Comment 2
<location path="packages/graph-react/src/hooks/useGraph.ts" line_range="76" />
<code_context>
+  // not disposed. In development React 18 StrictMode renders twice on mount, so one such Graph is
+  // created per mount; suspended first mounts and interrupted renders can create more.
+  useEffect(() => {
+    if (!owned) {
+      return undefined;
+    }
</code_context>
<issue_to_address>
**Reused graph gets unmounted**

When `config.graph` changes from `undefined` to the same graph previously created by this hook, `useEffect` returns on the `!owned` branch before clearing the pending disposal timer. The timer then calls `graph.unmount()` even though the hook still returns that graph as external, leaving the caller with a dead graph.

Clear any pending disposal for the graph before returning from the `!owned` branch.

Also at `packages/graph-react/src/hooks/useGraph.ts:77`.
</issue_to_address>

### Comment 3
<location path="packages/graph-react/src/hooks/useGraph.ts" line_range="52" />
<code_context>
-      layers: config.layers, // layers init only once
-    });
+    return {
+      graph: new Graph({
+        configurationName: config.name || "",
+        blocks: [],
</code_context>
<issue_to_address>
**Discarded graphs leak schedulers**

When react 18 development StrictMode invokes the initializer twice, or concurrent rendering abandons an initial render, the abandoned `Graph` never reaches the passive-effect cleanup, and its layer components' `Scheduler`s remain registered with the global scheduler; repeated mounts or render retries accumulate scheduler registrations.

Avoid creating the graph during render, or provide a safe cleanup path for graphs created by renders that never commit.
</issue_to_address>

### Comment 4
<location path="packages/graph-react/src/hooks/useGraph.ts" line_range="86" />
<code_context>
-  expect(graph.graphConstants.camera.SPEED).toBe(2);
-  expect(graph.graphConstants.camera.PAN_SPEED).toBe(1);
-  unmount();
-  graph.unmount();
-});
</code_context>
<issue_to_address>
**Teardown errors escape asynchronously**

When a layer teardown throws during disposal of an owned graph, `graph.unmount()` runs in the timer callback outside React's commit handling, so the teardown exception escapes as an uncaught asynchronous error instead of reaching an Error Boundary; teardown also stops partway.

Route disposal errors through React's error-handling path instead of allowing them to escape from the timer callback.
</issue_to_address>

Sourcery assessment

Approval pending. 4 findings to address first.

Blocking findings: packages/graph-react/src/hooks/useGraph.ts:86, packages/graph-react/src/hooks/useGraph.ts:76, packages/graph-react/src/hooks/useGraph.ts:52, packages/graph-react/src/hooks/useGraph.ts:86


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread packages/graph-react/src/hooks/useGraph.ts Outdated
Comment thread packages/graph-react/src/hooks/useGraph.ts
layers: config.layers, // layers init only once
});
return {
graph: new Graph({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Discarded graphs leak schedulers

When react 18 development StrictMode invokes the initializer twice, or concurrent rendering abandons an initial render, the abandoned Graph never reaches the passive-effect cleanup, and its layer components' Schedulers remain registered with the global scheduler; repeated mounts or render retries accumulate scheduler registrations.

Avoid creating the graph during render, or provide a safe cleanup path for graphs created by renders that never commit.

Prompt for AI agents
In `packages/graph-react/src/hooks/useGraph.ts` at line 52:

**Discarded graphs leak schedulers**

When react 18 development StrictMode invokes the initializer twice, or concurrent rendering abandons an initial render, the abandoned `Graph` never reaches the passive-effect cleanup, and its layer components' `Scheduler`s remain registered with the global scheduler; repeated mounts or render retries accumulate scheduler registrations.

Avoid creating the graph during render, or provide a safe cleanup path for graphs created by renders that never commit.

graph,
setTimeout(() => {
pendingDisposals.delete(graph);
graph.unmount();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Teardown errors escape asynchronously

When a layer teardown throws during disposal of an owned graph, graph.unmount() runs in the timer callback outside React's commit handling, so the teardown exception escapes as an uncaught asynchronous error instead of reaching an Error Boundary; teardown also stops partway.

Route disposal errors through React's error-handling path instead of allowing them to escape from the timer callback.

Prompt for AI agents
In `packages/graph-react/src/hooks/useGraph.ts` at line 86:

**Teardown errors escape asynchronously**

When a layer teardown throws during disposal of an owned graph, `graph.unmount()` runs in the timer callback outside React's commit handling, so the teardown exception escapes as an uncaught asynchronous error instead of reaching an Error Boundary; teardown also stops partway.

Route disposal errors through React's error-handling path instead of allowing them to escape from the timer callback.

Address review findings on the deferred disposal:

- Dispose an owned graph synchronously in the passive cleanup, and
  defer it only when the cleanup runs in the same synchronous pass as
  the graph's first setup (React 18 StrictMode replay). Normal unmount
  errors reach error boundaries again.
- Graph.unmount() stops the shared global scheduler. Track graphs held
  by mounted useGraph instances (ref-counted, owned and external) and
  restart the scheduler after every disposal and setup while any of
  them is running, so a replacement or another graph is not frozen.
- Remember the graph the hook created, so passing it back as
  config.graph keeps it owned instead of disposing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants