Repository navigation
fix(GraphComponent): run onUnmounted callbacks on unmount - #393
Merged
Merged
Conversation
Reviewer's GuideFixes 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
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Contributor
|
Preview is ready. |
There was a problem hiding this comment.
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
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
force-pushed
the
fix/v2-on-unmounted
branch
from
October 17, 2026 22:01
d9e76c5 to
833168c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
GraphComponent.onUnmounted(cb)is public API, but its callbacks never ran.GraphComponent.unmount()calledsuper.unmount()first.EventedComponent.unmount()deletes the component's listener registry. Only after that didGraphComponent.unmount()dispatch"graph-component-unmounted", so no listener registered throughonUnmounted()oraddEventListener("graph-component-unmounted", …)was still there to receive it.A second gap:
EventedComponent._fireEvent/_dippingskip components withinteractive: 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, setmounted = false. Then it callstry { 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_fireEventand_dipping. Only"graph-component-unmounted"is exempt from it (INTERACTIVITY_INDEPENDENT_EVENTS). Every other event behaves as before.Subclass ordering is unchanged:
Block,Anchor,GroupandCollapsibleGrouprun their own cleanup and then callsuper.unmount().BlockConnectioncallssuper.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 usesonUnmountedorgraph-component-unmountedyet.Follow-up from review
onUnmounted(cb)now runscbonly whenevent.detail.component === this. Before this,"graph-component-unmounted"events bubbling up from descendants also triggered a parent's callback. For example, aBlockcallback ran when one of its anchors was removed, and ran twice when the block was removed together with its anchors. A rawaddEventListener("graph-component-unmounted", …)still receives events bubbling up from descendants.RangeError: Maximum call stack size exceeded. Now a re-entrantGraphComponent.unmount()returns early, and the outer call finishes the teardown and the dispatch.GraphComponent.unmount. Subclassunmount()code runs again on re-entry, which is idempotent today.CoreComponent.__unmount.onUnmountednow registers a wrapper listener.removeEventListener("graph-component-unmounted", cb)no longer removes a callback added withonUnmounted(cb). Use the remover function thatonUnmountedreturns. No code in the repo relies on the old behaviour.Behaviour notes
event.stopPropagation()stops ancestors from receiving the event.unmount(). That is how any error inunmount()already behaved, andEventedComponentdispatch has always worked this way.graph.unmount()or child removal partway.CoreComponentand is out of scope here.Tests
GraphComponent.test.tsuses a real startedGraphwith a mountedBlock, found by hit-test afterwaitUsableRectUpdate. Each graph is unmounted inafterEach. The tests check that:setEntitiesand scheduler ticks runs the callback exactly once withdetail.component, and the listeners are cleared afterwards;interactive: falsestill gets its callback;graph.unmount()runs the callback once;Component.unmountthrows 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
EventedComponentchange 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:
Enhancements:
Tests: