Skip to content

fix(GraphComponent): run onUnmounted callbacks on unmount - #393

Merged
draedful merged 2 commits into
v2from
fix/v2-on-unmounted
Oct 17, 2026
Merged

draedful merged 2 commits into
v2from
fix/v2-on-unmounted

Conversation

@draedful

@draedful draedful commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

GraphComponent.onUnmounted(cb) is public API, but its callbacks never ran.

GraphComponent.unmount() called super.unmount() first. EventedComponent.unmount() deletes the component's listener registry. Only after that did GraphComponent.unmount() dispatch "graph-component-unmounted", so no listener registered through onUnmounted() or addEventListener("graph-component-unmounted", …) was still there to receive it.

A second gap: EventedComponent._fireEvent / _dipping skip components with interactive: false, so even with the ordering fixed, callbacks on non-interactive components would never run.

Fix

  • GraphComponent.unmount() now does its own cleanup first: unsubscribe, release ports, destroy the hitbox, set mounted = false. Then it calls try { dispatchEvent(...) } finally { super.unmount() }. Listeners are cleared even if a callback throws.
  • EventedComponent: the interactivity gate is now a single _isMutedByInteractivity(cmp, type) helper used by both _fireEvent and _dipping. Only "graph-component-unmounted" is exempt from it (INTERACTIVITY_INDEPENDENT_EVENTS). Every other event behaves as before.

Subclass ordering is unchanged:

  • Block, Anchor, Group and CollapsibleGroup run their own cleanup and then call super.unmount().
  • BlockConnection calls super.unmount() and only then removes itself from the batch.

The event fires at the same point relative to subclass code as before. The only thing that now runs after the dispatch is listeners.delete(this). Nothing else in the monorepo uses onUnmounted or graph-component-unmounted yet.

Follow-up from review

  • Own unmount only: onUnmounted(cb) now runs cb only when event.detail.component === this. Before this, "graph-component-unmounted" events bubbling up from descendants also triggered a parent's callback. For example, a Block callback ran when one of its anchors was removed, and ran twice when the block was removed together with its anchors. A raw addEventListener("graph-component-unmounted", …) still receives events bubbling up from descendants.
  • Re-entrancy guard: a callback that synchronously unmounts its own component used to recurse until RangeError: Maximum call stack size exceeded. Now a re-entrant GraphComponent.unmount() returns early, and the outer call finishes the teardown and the dispatch.
    • The guard covers only GraphComponent.unmount. Subclass unmount() code runs again on re-entry, which is idempotent today.
    • Full idempotency belongs in CoreComponent.__unmount.
  • API note: onUnmounted now registers a wrapper listener. removeEventListener("graph-component-unmounted", cb) no longer removes a callback added with onUnmounted(cb). Use the remover function that onUnmounted returns. No code in the repo relies on the old behaviour.

Behaviour notes

  • The event still bubbles to ancestors, which are unmounted after their children.
  • An own listener that calls event.stopPropagation() stops ancestors from receiving the event.
  • If a listener throws, dispatch stops: the remaining listeners and ancestors are skipped, and the error propagates out of unmount(). That is how any error in unmount() already behaved, and EventedComponent dispatch has always worked this way.
    • Because callbacks now actually run, a throwing user callback can abort graph.unmount() or child removal partway.
    • Making the lifecycle resilient to that belongs in CoreComponent and is out of scope here.

Tests

GraphComponent.test.ts uses a real started Graph with a mounted Block, found by hit-test after waitUsableRectUpdate. Each graph is unmounted in afterEach. The tests check that:

  • removing the block via setEntities and scheduler ticks runs the callback exactly once with detail.component, and the listeners are cleared afterwards;
  • a component with interactive: false still gets its callback;
  • a cascading graph.unmount() runs the callback once;
  • the event still bubbles to an evented ancestor;
  • removing one of a block's anchors runs only that anchor's callback, not the block's; removing the block runs the block's callback exactly once;
  • a callback that unmounts its own component runs once, does not throw, and the listeners are cleared;
  • when a listener throws, Component.unmount throws but the listeners are still cleared.

The mounted tests failed before the fix (0 calls / did not throw). The non-interactive test fails when only the EventedComponent change is reverted.

Checks run, all passing: pnpm run typecheck, pnpm run test:type-configs, pnpm test (graph 337 passed), pnpm run lint (0 errors), node scripts/strict-diagnostics.mjs.

🤖 Generated with Claude Code

Summary by Sourcery

Fix GraphComponent unmount lifecycle notifications so registered callbacks run once for the owning component and cleanup remains reliable.

Bug Fixes:

  • Run GraphComponent unmount callbacks reliably, including for non-interactive components and during cascading graph teardown.
  • Ensure callbacks are limited to the component being unmounted and prevent re-entrant unmounts from dispatching repeatedly.
  • Preserve listener cleanup when unmount callbacks throw.

Enhancements:

  • Allow graph-component-unmounted events to bypass interactivity filtering while preserving existing filtering for other events.
  • Continue bubbling unmount events to evented ancestors and provide remover semantics for onUnmounted callbacks.

Tests:

  • Add coverage for mounted and cascading component removal, non-interactive components, event bubbling, descendant isolation, re-entrant unmounts, and listener cleanup after errors.

@draedful
draedful requested a review from Antamansid as a code owner October 16, 2026 14:02
@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Fixes GraphComponent unmount lifecycle notifications by dispatching before EventedComponent listener teardown, ensuring delivery for non-interactive components, and adding integration tests for removal, bubbling, exactly-once behavior, and throwing listeners.

File-Level Changes

Change Details Files
Reordered graph-component teardown so unmount notifications run before listener cleanup while guaranteeing cleanup afterward.
  • Performs subscriptions, ports, hitbox, and mounted-state cleanup before dispatching the lifecycle event.
  • Uses try/finally to invoke superclass cleanup even when a listener throws.
  • Preserves subclass ordering and event bubbling semantics while ensuring listeners are removed after dispatch.
packages/graph/src/components/canvas/GraphComponent/index.ts
Made unmount lifecycle notifications independent of component interactivity.
  • Centralized the interactivity check in _isMutedByInteractivity.
  • Exempted graph-component-unmounted from the interactivity gate for both direct dispatch and ancestor bubbling.
  • Retained existing muting behavior for all other events.
packages/graph/src/components/canvas/EventedComponent/EventedComponent.ts
Added integration coverage for lifecycle callback execution, propagation, and failure cleanup.
  • Tests mounted-component removal, graph teardown, non-interactive components, ancestor bubbling, exactly-once delivery, and listener clearing.
  • Uses real started graphs, hit-test readiness, scheduler updates, and cleanup after each test.
packages/graph/src/components/canvas/GraphComponent/GraphComponent.test.ts

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 2 issues

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

## Individual Comments

### Comment 1
<location path="packages/graph/src/components/canvas/GraphComponent/index.ts" line_range="327-329" />
<code_context>
     this.ports.clear();
     this.destroyHitBox();
     this.mounted = false;
-    this.dispatchEvent(new CustomEvent("graph-component-unmounted", { detail: { component: this } }));
+    // Dispatch before super.unmount(): EventedComponent.unmount() drops this component's listeners,
+    // so dispatching afterwards would never reach onUnmounted() callbacks.
+    try {
+      this.dispatchEvent(new CustomEvent("graph-component-unmounted", { detail: { component: this } }));
</code_context>
<issue_to_address>
**Unmount recurses until stack overflow**

When an unmount callback synchronously unmounts its own component, or teardown reaches it again before the current unmount finishes, `GraphComponent.unmount()` dispatches while the listener registry is still active, so the callback runs again on each re-entry until the stack overflows and teardown aborts.

Mark the component as unmounting before dispatch and prevent re-entrant unmounts from dispatching again.

Also at `packages/graph/src/components/canvas/GraphComponent/index.ts:331`.
</issue_to_address>

### Comment 2
<location path="packages/graph/src/components/canvas/GraphComponent/index.ts" line_range="329" />
<code_context>
     this.ports.clear();
     this.destroyHitBox();
     this.mounted = false;
-    this.dispatchEvent(new CustomEvent("graph-component-unmounted", { detail: { component: this } }));
+    // Dispatch before super.unmount(): EventedComponent.unmount() drops this component's listeners,
+    // so dispatching afterwards would never reach onUnmounted() callbacks.
</code_context>
<issue_to_address>
**Descendant removal runs parent cleanup**

When a descendant is removed while a mounted parent has an `onUnmounted` callback, the bubbling `graph-component-unmounted` event reaches the parent's callback, so parent cleanup runs while the parent is still mounted and can run again for each removed descendant.

Ensure the unmount event only invokes the callback for the component that is actually being unmounted.

Also at `packages/graph/src/components/canvas/GraphComponent/index.ts:331`.
</issue_to_address>

Sourcery assessment

Approval pending. 2 findings to address first.

Blocking findings: packages/graph/src/components/canvas/GraphComponent/index.ts:329, packages/graph/src/components/canvas/GraphComponent/index.ts:329


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

Comment thread packages/graph/src/components/canvas/GraphComponent/index.ts
Comment thread packages/graph/src/components/canvas/GraphComponent/index.ts

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

Sourcery assessment

Approved.

draedful and others added 2 commits October 18, 2026 00:49
Dispatch "graph-component-unmounted" before EventedComponent.unmount()
drops the component's listener registry, so callbacks registered via
onUnmounted() actually run. super.unmount() runs in a finally block,
so listeners are cleared even if a callback throws.

The lifecycle event also bypasses the interactivity gate, so
callbacks run for components with interactive: false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
onUnmounted() callbacks now run only for the component's own unmount,
not for "graph-component-unmounted" events bubbling up from
descendants (e.g. a Block's anchors). Raw addEventListener keeps
bubbling semantics.

A callback that synchronously unmounts its own component no longer
recurses until stack overflow: re-entrant GraphComponent.unmount()
calls return early and the outer call completes teardown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@draedful
draedful force-pushed the fix/v2-on-unmounted branch from d9e76c5 to 833168c Compare October 17, 2026 22:01
@draedful
draedful merged commit d7c7f8f into v2 Oct 17, 2026
7 checks passed
@gravity-ui gravity-ui Bot mentioned this pull request Oct 17, 2026
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