Skip to content

Run VirtualThought's size-release cleanup only on unmount - #4975

Merged
raineorshine merged 1 commit into
mainfrom
claude/virtualthought-unmount-cleanup
Aug 18, 2026
Merged

Run VirtualThought's size-release cleanup only on unmount#4975
raineorshine merged 1 commit into
mainfrom
claude/virtualthought-unmount-cleanup

Conversation

@raineorshine

Copy link
Copy Markdown
Contributor

Follow-up to #4969, which fixed useDelayedAutofocus latching an unmounted ref in a cleanup that React re-runs on every dependency change. This is the same mistake in VirtualThought, found by auditing the codebase for it.

Problem

VirtualThought releases its tracked size by calling onResize({ height: null }) in an effect cleanup, annotated:

// these should be memoized and not change for the life of the component, so this is effectively componentWillUnmount
[crossContextualKey, onResize, id],

onResize is not stable. TreeNode memoizes it as useCallback(props => setSize({ ...props, cliff }), [cliff, setSize]), and cliff changes whenever a thought's position in the tree changes. React runs the cleanup before every such re-run, so a thought releases its size while still mounted.

Instrumenting the cleanup and adding a sibling (which changes the preceding thought's cliff):

=== adding sibling to a1 (changes cliff of a1) ===
CLEANUP-FIRED |_fHZM6U06I_ej stillInDOM= true
  removeSize |_fHZM6U06I_ej
  setSize    |_fHZM6U06I_ej 20

Impact

No user-visible bug today. updateSize's dependencies are a superset of this effect's, so its body re-adds the entry in the same passive-effect flush — the trace above shows the removeSize/setSize pair. What this PR removes is the churn (an in-place delete on the sizes state object, then a rebuild) and a live trap in the size-tracking path: any future change that breaks the superset relationship, or that makes the removal observable, turns this into a layout bug.

Solution

Route the call through the existing useFreshCallback so the effect's dependency is stable and the cleanup runs only on unmount, while still invoking the latest onResize. The comment now describes why the indirection is needed instead of asserting a stability that does not hold.

Verification

  • Re-ran the instrumented trace: the mid-life fire is gone, and all four thoughts still fire on teardown (stillInDOM= false).
  • yarn lint:tsc
  • eslint src/components/VirtualThought.tsx
  • Full unit suite: 1731 passed, 0 failed.

No regression test is included — the current behavior is self-healing, so there is nothing externally observable to assert without instrumenting internals.

🤖 Generated with Claude Code

The effect that calls `onResize({ height: null })` was written as a
componentWillUnmount, with a comment asserting its dependencies "should be
memoized and not change for the life of the component". That is not true of
`onResize`: TreeNode memoizes it on `cliff`, which changes whenever a thought's
position in the tree changes. React runs the cleanup before every such re-run,
so a thought released its tracked size while it was still mounted.

Instrumenting the cleanup and adding a sibling (which changes the preceding
thought's cliff) shows the cleanup firing on a live thought, followed by
`removeSize` and an immediate `setSize` for the same key:

    CLEANUP-FIRED |_fHZM6U06I_ej stillInDOM= true
      removeSize |_fHZM6U06I_ej
      setSize    |_fHZM6U06I_ej 20

The re-add happens because `updateSize`'s dependencies are a superset, so its
effect body restores the entry in the same passive-effect flush. The visible
behavior is therefore unchanged today. What this removes is the churn — an
in-place `delete` on the `sizes` state object followed by a rebuild — and the
trap of a cleanup that claims to be unmount-only while running mid-life.

Route the call through `useFreshCallback` so the effect's dependency is stable
and the cleanup runs only on unmount, while still invoking the latest
`onResize`. Re-running the instrumented trace confirms the mid-life fire is gone
and all thoughts still release their size on teardown.

Co-Authored-By: Claude Opus 5 (unknown context) <noreply@anthropic.com>
@raineorshine
raineorshine merged commit 771f265 into main Aug 18, 2026
11 checks passed
@raineorshine
raineorshine deleted the claude/virtualthought-unmount-cleanup branch August 18, 2026 00:50
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.

1 participant