fix(theme): cancel mesh-gradient timers on teardown (#5160) - #5273
Conversation
`Gradient` owns three async chains that outlive the canvas React gives it: the `waitForCssVars` rAF retry loop (up to 200 frames), the `animate` rAF loop, and the 3s deferred `isLoaded` class. `disconnect()` cancelled none of them, so every `<MeshGradient />` unmount — a theme switch, a backdrop change, window teardown — left callbacks queued against a canvas React had already removed. The `isLoaded` timeout then read `this.el.parentElement.classList` on a detached node, which is the "Cannot read properties of null (reading 'classList')" family Sentry grouped across five bundle hashes (27 events / 13 users). PR tinyhumansai#5171 guarded the expression; this removes the reason it was ever reached. Each async entry point now records its handle and bails on `destroyed`: - `disconnect()` latches `destroyed`, clears the `isLoaded` timeout and the scroll-end debounce, and cancels both rAF handles. Idempotent. - `init()` / `waitForCssVars()` / `play()` / `animate()` return early once disconnected, so a frame already dispatched in the current tick cannot restart the retry chain or dereference the absent `mesh`. - `pause()` cancels the queued frame (which its documented contract in `MeshGradient.tsx` already claimed) and no longer assumes `conf` exists — the wrapper calls it right after `disconnect()` on gradients whose WebGL init bailed early. - `resize()` ignores events that arrive without a live `minigl`/`mesh`. Regression tests in `app/src/lib/meshGradient.test.ts` drive the lifecycle directly (no WebGL): five of the nine fail against the previous implementation. Teardown decisions are logged under `[MeshGradient]`.
There was a problem hiding this comment.
M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
📝 WalkthroughWalkthrough
ChangesGradient teardown safety
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/lib/meshGradient.js`:
- Around line 710-713: Store the initial requestAnimationFrame handle in
this.animateRaf within init() so disconnect() can cancel it; update
app/src/lib/meshGradient.js lines 710-713 accordingly. Add a test in
app/src/lib/meshGradient.test.ts lines 118-126 that stubs rendering setup,
initializes a live gradient, disconnects it, and asserts cancellation of the
stored frame handle.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bab96ceb-d6ea-43bf-b4f6-eb27e68282d3
📒 Files selected for processing (3)
app/src/lib/meshGradient.d.tsapp/src/lib/meshGradient.jsapp/src/lib/meshGradient.test.ts
…ncels it
`init()` scheduled its first `animate` frame without storing the handle, so
`disconnect()` — which only cancels `this.animateRaf` — had nothing to cancel
and that opening frame outlived teardown. Every other scheduling site
(`animate`, `play`, the `!playing` branch) already assigns to `animateRaf`;
this one was the outlier, and it is the same leak class this change set exists
to close.
Assign the handle and add a regression test that stubs the WebGL setup so
`init()` reaches its `requestAnimationFrame`, then asserts `disconnect()`
cancels that specific handle. Verified failing before the fix ("expected [] to
include 1").
`initGradientColors` joins the lifecycle-internals block in `meshGradient.d.ts`
so the test can stub it — same rationale as the `init`/`initMesh`/`resize`
entries already there.
Addresses the CodeRabbit review on tinyhumansai#5273.
There was a problem hiding this comment.
M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
Summary
Gradient.disconnect()now cancels every pending async chain it owns — thewaitForCssVarsrAF retry loop, theanimaterAF loop, the 3s deferredisLoadedclass, and the scroll-end debounce — instead of leaking them past unmount.destroyedlatch thatinit()/waitForCssVars()/play()/animate()bail on, so a frame already dispatched in the current tick cannot restart the retry chain or dereference an absentmesh.pause()now actually cancels the queued frame (the contract its call site inMeshGradient.tsxalready documented) and stops assumingconfexists.resize()ignores events arriving without a liveminigl/mesh.app/src/lib/meshGradient.test.ts— 10 lifecycle regression tests, 6 of which fail against the previous implementation.init()now stores its openinganimateframe onanimateRaf, sodisconnect()can cancel it too (CodeRabbit review).Problem
<MeshGradient />(the Stripe-style WebGL backdrop) unmounts on every theme switch, backdrop change and window teardown. ItsGradientinstance owns three async chains that outlive the canvas React gave it:waitForCssVarsrAF retry loop (up to 200 frames),animaterAF loop,isLoadedclass.disconnect()cancelled none of them — it only removed theresizelistener (its scroll-listener block is dead code, sincescrollObserveris permanentlyundefined). So after unmount those callbacks kept firing against a canvas React had already removed, and the 3s timeout evaluatedthis.el.parentElement.classListon a detached node →parentElement === null→TypeError: Cannot read properties of null (reading 'classList').That matches the Sentry signature reported in #5160: a 2-frame stack (the outer frame is
browserApiErrorsIntegration'ssetTimeoutwrapper, whichservices/analytics.tsenables explicitly; the inner one is the anonymous arrow), thesrc/lib/meshGradientculprit on the one shortId that resolved, and 5 shortIds that are the same defect across 5 release bundle hashes rather than 5 distinct bugs — 27 events / 13 users.For completeness: no other unguarded
.classListread exists in the shipped bundle. The two remaining reads inapp/srcare ondocument.documentElement(never null), and no bundled runtime dependency readsclassListoff a nullable node — thedom-helpers/react-transition-grouppath is unreachable here becausereact-smoothnever calls into it.PR #5171 added
this.el && this.el.parentElement &&guards at the crash site. That stopped the throw but left the leak in place — timers and frames still running after teardown, one guard deletion away from re-opening.Solution
Make the lifecycle cancellable at the root rather than guarding the last dereference:
isLoadedTimeout,cssVarsRaf,animateRaf) on the instance.disconnect()latchesdestroyed, clears both timeouts, cancels both rAF handles, and stays idempotent.init()/waitForCssVars()/play()/animate()return early whendestroyed, covering a callback that was already dispatched beforedisconnect()ran.isLoadedtimeout keeps a detached-node check as a second line of defence for a teardown we never observed, and logs why it skipped.[MeshGradient]prefix.The existing
this.el && …guards from #5171 are retained — they are now unreachable in the normal path but cost nothing.Submission Checklist
app/src/lib/meshGradient.test.ts: happy path (canvas still mounted at 3s), plus disconnect-first, detached-without-disconnect, retry-loop-after-disconnect, frame-after-disconnect, pause/play, resize-after-teardown and double-disconnectmeshGradient.js; the.d.tsis type-only## RelatedCloses #NNNin the## RelatedsectionImpact
isLoadedwindow no longer marks its (already removed) wrapper as loaded.Gradientno longer holds a self-rescheduling rAF loop alive after unmount, which was burning frames indefinitely on repeated theme switches. Related in spirit to the MacBook Air在运行app时,计算机持续发烫 #3524 idle-GPU work.Related
reading 'checked') is a separate signature already guarded inservices/analyticsInteractions.tsby fix: Sentry triage — resolve 16 actionable issues across tauri-react, tauri-rust, core-rust #5171.AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
fix/5160-null-classlist1f151cf69Validation Run
pnpm --filter openhuman-app format:check—prettier --checkclean on all three changed filespnpm typecheck— clean (the earlier@xyflow/reactfailure was a stale local install of an unrelated dependency; resolved after a reinstall, and it never affectedsrc/lib/meshGradient*)vitest related src/lib/meshGradient.*→ 5 files / 66 tests passed;meshGradient.test.tsalone → 10 passed. The newinit()-frame test fails against the pre-fix lib ("expected [] to include 1")Validation Blocked
command:N/A — nothing is blocked. The previously-reportedpnpm typecheckfailure (Cannot find module '@xyflow/react') was a stale localnode_modules; it is clean after a reinstall.error:N/Aimpact:N/ABehavior Changes
Gradientperforms no further DOM or WebGL work.Parity Contract
pause()cancelling the queued frame brings the implementation in line with the contract already documented at itsMeshGradient.tsxcall site.this.el && …null guards from fix: Sentry triage — resolve 16 actionable issues across tauri-react, tauri-rust, core-rust #5171 are retained rather than replaced; the no-GPU fallback (MacBook Air在运行app时,计算机持续发烫 #3524,meshabsent → neverplay()) is untouched and re-pinned byMeshGradient.test.tsx, which still passes unchanged.Duplicate / Superseded PR Handling
Summary by CodeRabbit
Bug Fixes
Documentation
Tests