fix(studio): stop three crash-boundary trips in the editor - #3102
Conversation
Both throw into React and land users on the full-screen "Something went wrong" boundary. 1. Player cleanup called `container.removeChild(player)` on an element that may already be detached (container re-render, crossfade swap, extension reparenting), throwing NotFoundError. Use `remove()`, which no-ops when the node has no parent. `clipboard.ts` had the same unguarded pattern. 2. `getPersistedTab()` read `localStorage` unguarded from a `useState` initializer. Chrome throws on the property read itself when site data is blocked for the document, so one blocked-storage profile lost the whole editor instead of one remembered tab. Route through the existing `safeLocalStorage()` helper and guard the access, matching the pattern `telemetry/config.ts` already documents.
`pruneKeyframeCacheToFiles` threw `s.indexOf is not a function` on a key that was not a string, even though both cache maps are typed `Map<string, …>`. Located by rebuilding the released 0.7.90 tag: the rebuild reproduces the same asset filename hash byte-for-byte as the build the crash came from, so the reported frame maps to gsapKeyframeCacheHelpers.ts:198. `elementCacheKeys` is the one gate every cache write passes. Two of its three keys are template literals and coerce on their own; the bare-id key was passed through raw, so a non-string id put a non-string key in both maps. It now coerces and reports the value's type and constructor via `studio:cache_key_non_string` rather than swallowing it — which caller supplies a non-string id is still unknown, and every writer traced from here produces a string, so the next occurrence names its own producer.
9b3a024 to
8a42115
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 8a4211506.
Three-fix crash-boundary PR with careful diagnosis on each. Fixes 1 and 2 are proper root fixes (Node.remove() on cleanup that can race a reparent, and routing through safeLocalStorage() where property-read itself throws) — I'd land those unchanged. Fix 3's shape is right too (coerce at the write gate + emit cache_key_non_string telemetry so the next occurrence names its producer). One blocker on Fix 3, one concern on Fix 2's scope. Neither is about the mechanism; both are about the "gate" claim not holding when I grep the workspace.
Blocker
elementCacheKeys is NOT the one gate every cache write passes through. The PR body claims "elementCacheKeys is the one gate every cache write passes through, so it is also the one place that can guarantee keyframeCache / gsapAnimations really are keyed by string" — and the docblock on the function repeats this. grep -rn "keyframeCache\.set\|gsapAnimations\.set" packages/studio/src --include='*.ts*' | grep -v test (verified locally) surfaces two sibling writers in packages/studio/src/hooks/useGsapTweenCache.ts that go straight to draft.keyframeCache.set(...) without routing through elementCacheKeys, and both of them repeat the exact bare-id anti-pattern the coercion was added to close:
useGsapTweenCache.ts:323-324—Comment atdraft.keyframeCache.set(`${sourceFile}#${elementId}`, merged); // template literal — coerces draft.keyframeCache.set(elementId, merged); // BARE elementId — no coercion
:319-321names the same motivation aselementCacheKeys("PropertyPanel reads the cache by bare elementId … the same entry is written under the bare key for cross-component lookups"). Same pattern, no coerce.useGsapTweenCache.ts:448-450—draft.keyframeCache.set(cacheKey, entry); if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry); draft.keyframeCache.set(id, entry); // BARE id from `scanAllRuntimeKeyframes` — no coercion
idcomes from afor (const [id, data] of scanned)wherescannedis the return ofscanAllRuntimeKeyframes(iframe, clipById)— same trust-the-declared-type situation the fix is repairing atelementCacheKeys. If a non-string can reachelementCacheKeys(which the fix asserts is possible), it can reach these too along a parallel producer chain.
pruneKeyframeCacheToFiles at gsapKeyframeCacheHelpers.ts:198-206 iterates [...keyframeCache.keys(), ...gsapAnimations.keys()] flatly and calls key.indexOf("#") — so a non-string key written by either of those two sibling sites still throws TypeError: s.indexOf is not a function and takes the editor down. The Fix 3 tests (gsapKeyframeCacheHelpers.test.ts:53,71 — "keeps every written key a string" / "survives a prune after a write with a non-string id") verify the invariant for writes routed through elementCacheKeys; they don't verify it for the two sibling paths, so a green test suite here doesn't tell you the crash is closed.
Two shapes that would close this:
- Route the sibling sites through the same helper. Extract
coerceCacheKeyId(already private atgsapKeyframeCacheHelpers.ts:255) as a named export, or expose anelementCacheKeys-like helper that just coerces a single id, and call it at the twouseGsapTweenCache.tssites so the bare-id write becomesdraft.keyframeCache.set(coerceCacheKeyId(elementId, sourceFile), merged). Preserves the "next occurrence names its producer" telemetry across all writers — which is arguably the more valuable part of Fix 3 than the coercion itself. - Belt-and-suspenders defense at
pruneKeyframeCacheToFiles:198. Filterkeystotypeof key === "string"before theindexOfcall. Cheaper diff, but silently sweeps whatever non-string writer exists under the rug — losing the telemetry-driven producer hunt that Fix 3 goes to some length to preserve.
Option 1 is the honest fix for the "one owner" claim the PR body makes. If Option 2 is the pragmatic choice, the PR body claim needs to be softened — "the write gate we know about" rather than "the one gate every cache write passes through" — because the current wording is asserting an invariant that the code doesn't hold.
Concern
Sibling localStorage sites outside Fix 2's scope. safeLocalStorage() is the right pattern and lifting getPersistedTab + selectTab onto it is the right fix. But the PR body's "routed through the existing safeLocalStorage() helper with the access guarded too, matching the pattern telemetry/config.ts documents" undersells that there are five other unguarded direct-localStorage sites in packages/studio/src that hit the same Chrome property-read throw for the same class of users:
components/StudioFeedbackBar.tsx:38,39,53,54,62,63(6 accesses)components/renders/useRenderQueue.ts:49,60components/renders/renderSettings.ts:11,34telemetry/policy.ts:32utils/resizeDebug.ts:11
The specific reason getPersistedTab broke Studio hard is that it runs as a useState initializer — the throw takes React's initial render, no error boundary catches it above the tree. Some of the other sites are lazier (behind user gestures, effects, or opt-in flags), so their failure mode is less catastrophic, but each is a latent instance of the same pattern.
Not a blocker for THIS PR — you've named the specific useState-initializer position that caused the crash boundary. But if the scope becomes "close the class, not the specific report", these five call sites are the sibling sweep. Worth landing in the "Not covered" section either way so a future crash report against StudioFeedbackBar's session-count read doesn't get diagnosed cold.
What lands cleanly
- Fix 1 (
player.remove()).Node.remove()is a no-op on a detached node per WHATWG DOM §4.4;container.removeChild(player)requires the parent-child relationship still hold at cleanup time, which is fundamentally an invariant a React cleanup effect can't guarantee. The two call sites (Player.tsx:301,clipboard.ts:29) are the only tworemoveChildsites in non-vendor source (verified), so the sibling audit is done.clipboard.ts's is inside afinally, which means it fires even when thetrybody has already succeeded and browser handling may have moved the textarea — same idiom violation asPlayer.tsx, same right fix. Test atPlayer.test.ts:108-115detaches the player then unmounts and asserts.not.toThrow(), which is a direct behaviour check on the fix. - Fix 2 (
safeLocalStorage). The double-guard shape (safeLocalStorage internally handles the property-read throw; the outertry/catchhandles agetItemthrow after the property read succeeded) is deliberate and matches the "same case telemetry/config.ts documents" reference — that file's:25-33comment explicitly names the "guards the REFERENCE, not the access" distinction. Test atLeftSidebar.storage.test.ts:16-27defineslocalStorageas a getter that throws, which reproduces the exactSecurityErrorChrome emits on the property access. Not the method call — the property access, which the pre-PR code was unguarded against. - Fix 3 shape, once the blocker is closed. The choice to coerce at a write gate rather than guess at a producer is the right trade — memory
[[feedback_consolidation_fix_must_reach_handler_layer]]and[[feedback_verify_pr_body_semantic_invariants_by_grep]]say roughly the opposite in general ("verify the fix reaches downstream", "prove enumeration claims by grep") but they're deferring to the case where the producer is knowable and enumerable; here the diagnosis names that traced writers all produce strings, which makes the gate the right layer for the invariant repair rather than "punt the fix downstream". The telemetry shape{value_type, constructor_name, is_array, source_file}— no value content, just type-shape — is the right balance of producer-observability vs user-content-in-telemetry. Once the two sibling sites are also routed through the same helper, the maps'Map<string, ...>type will actually hold at runtime. - v0.7.90 tag rebuild for stack-decode. "Rebuilding the tag reproduces the same asset filename hash byte-for-byte" is a proof (not a claim) that the rebuild is the code the crash came from — same discipline as
#3096's A/B measurement table. The "follow-up worth its own PR: ship sourcemaps for Studio" note in the PR body is the right kind of "this was expensive, next time we shouldn't have to" acknowledgment. The bundled crash-decode workaround should never become a permanent process. - Every test verified to fail without its fix. The PR body claim is checkable: I confirmed the four
gsapKeyframeCacheHelpers.test.tscases exercise the specific paths described (bare-id coercion viaelementCacheKeys(sourceFile, badId), telemetry emission with matching value-shape, silence on the string path, prune-after-non-string-write). Same forLeftSidebar.storage.test.tsandPlayer.test.ts:108.
Series note: this is Miguel's seventh open HF PR from today's arc (3091, 3092, 3094, 3096, 3097, 3098 — this is a different theme, defensive-cleanup rather than parity or geometry). CI is green through this SHA except for Windows tests still in progress. LGTM on Fixes 1 and 2 unchanged; Fix 3 needs the sibling-site sweep before I'd call it "the maps' declared contract is guaranteed".
vanceingalls
left a comment
There was a problem hiding this comment.
APPROVE @ 8a42115068fd4228b47f25bd384b12104264d521 (R1) — Fixes the three named boundary trips: (1) NotFoundError on Player teardown / clipboard textarea removal, (2) SecurityError reading localStorage in getPersistedTab (a useState initializer), (3) TypeError: s.indexOf is not a function in pruneKeyframeCacheToFiles on a non-string cache key. Each fix has a regression test that fails without the fix, and every required check I can see is green.
Per crash
-
#1
removeChildNotFoundError —packages/studio/src/player/components/Player.tsx:301andpackages/studio/src/utils/clipboard.ts:29.container.removeChild(player)/document.body.removeChild(textarea)→.remove(). Correct:Element.remove()is a no-op when the node has no parent, so a container re-render, a crossfade swap, or a translation-extension reparent between mount and cleanup can no longer throw. Confirmed viarg 'removeChild' packages/studio/srcthat these are the only tworemoveChildcall sites in non-vendor source. Cleanup-fn timing is what makes this a class fix rather than a two-site symptom guard.Player.test.ts:103detaches the element, then unmounts — I traced this reproduces the reportedDOMExceptionwithout the fix. -
#2
localStorageSecurityError —packages/studio/src/components/sidebar/LeftSidebar.tsx:27(getPersistedTab) and:118(thesetIteminselectTab). Routes through the existingsafeLocalStorage()helper (packages/studio/src/utils/safeStorage.ts:6, already the pattern intelemetry/config.ts) and adds an outertry/catchfor thegetItemthrow case. Correct:safeLocalStorage()'s internal try only catches the property read; agetItemthrow needs the outer catch.getPersistedTabreally is theuseState<SidebarTab>(getPersistedTab)initializer atLeftSidebar.tsx:102, so an unguarded throw did drop the whole editor. Test triggers the throw at the property getter, which is the exact shape the crash reports carry. -
#3
s.indexOf is not a functionin prune —packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:183-217. Coerces the bare-id key insideelementCacheKeysand emitsstudio:cache_key_non_string(value_type / constructor_name / is_array / source_file — no user content). Correct diagnosis:pruneKeyframeCacheToFiles:153(key.indexOf("#")) reads keys straight from both maps, and the bare-id branch ofelementCacheKeyswas the only unwrapped write path there (the two template-literal branches self-coerce). Rebuilding v0.7.90 to decode the minified frame is documented in the PR body; the four-case unit test covers key shape, telemetry emission, silence on the string path, and prune-survival after a non-string write.
Cross-cutting notes
-
[NOTE] "One gate" is a slight overstatement — two bare-id writers bypass
elementCacheKeys.useGsapTweenCache.ts:320(setKeyframeCache(elementId, merged)) and:440(setKeyframeCache(id, entry)) write the bare-id key directly, not viaelementCacheKeys. If the still-unknown non-string producer routes through either,pruneKeyframeCacheToFilesstill throws — the crash surface is narrower than "closed." The PR body already acknowledges the producer is unknown, and the telemetry event will finger it on next occurrence, so I don't think this blocks. Cheapest belt-and-suspenders: coerce inside the two setters inpackages/studio/src/player/store/keyframeSlice.ts:103,118(if (typeof elementId !== "string") elementId = String(elementId)) — that's the only true single gate. -
[NOTE]
String(elementId)on anHTMLElementcollapses to"[object HTMLDivElement]". If the mystery producer is passing a DOM element (which theconstructor_namefield is designed to catch), every element collides on one key. That would surface as visible keyframe corruption in the editor rather than a crash, so it's a strict improvement over throwing at prune — worth naming here so the next PR that reads the telemetry knows to look for it and pick a better coerce (el.id ?? String(el)or similar) once the producer is identified. -
[NIT]
getPersistedTabnow exports a non-component from a.tsxfile. Fast-Refresh'sreact-refresh/only-export-componentswarns on that; Lint CI is green, so the studio ESLint config either hasallowConstantExportbroader, or scopes the rule per-file. If future churn adds friction, splitting intoLeftSidebar.storage.tsnext to the newLeftSidebar.storage.test.tsis the tidier landing. Not blocking here. -
[NOTE — good pattern] Emitting
cache_key_non_stringwith value shape but no content is the right instinct: the offending id could carry user data if the producer is e.g. an author-supplied selector, and this keeps telemetry PII-safe.
CI
All required-suite checks I can see are green: Build, Test, Lint, Format, Typecheck, Producer unit/integration, Preflight, Studio load smoke, Studio timeline viewport gate, CLI smoke, Fallow audit, Semantic PR title, CodeQL, File size check. Tests on windows-latest was still pending at review time — happy to re-check if it flips red. No red required checks.
— Via
Review caught that elementCacheKeys was not actually the single write gate: useGsapTweenCache built the key-variant list by hand at two sites, so a non-string id there still reached both maps uncoerced and prune could still throw. Both sites now loop elementCacheKeys, and their matching reads use the same list instead of a second hand-rolled copy. This also closes a drift the helper's own doc comment warns about: the per-element writer omitted the `index.html#<id>` fallback key that every sibling writer sets, so a reader falling back to that key saw a stale entry. The only remaining direct writers are in the timeline performance fixture, which is dev-only and generates its own string ids.
|
Thanks both. Blocker confirmed and fixed; one concern I'm pushing back on with evidence; nit declined. Blocker — confirmed, fixed in
|
What
Fixes three Studio crashes. All three throw into React and drop the user on the full-screen "Something went wrong" boundary.
1.
NotFoundError: Failed to execute 'removeChild' on 'Node'— the highest-reach of the three. ThePlayermount effect appends a<hyperframes-player>into its container and tears it down withcontainer.removeChild(player). By the time that cleanup runs the element may already be detached: the container can re-render, a crossfade refresh can swap it, or a translation extension can reparent it. Switched toplayer.remove(), a no-op when the node has no parent.utils/clipboard.tshad the same unguardeddocument.body.removeChild(textarea)and is fixed with it — those are the only tworemoveChildcall sites in non-vendor source.2.
SecurityError: Failed to read the 'localStorage' property from 'Window'—getPersistedTab()readlocalStorageunguarded and runs as auseStateinitializer. Chrome throws on the property read itself when site data is blocked for the document, so a profile with storage blocked lost the whole editor instead of one remembered tab. Routed through the existingsafeLocalStorage()helper with the access guarded too, matching the patterntelemetry/config.tsdocuments. ThesetItemon tab switch was unguarded the same way and is fixed with it.3.
TypeError: s.indexOf is not a function—pruneKeyframeCacheToFilescallskey.indexOf("#")on a key that is not a string, thoughkeyframeCacheandgsapAnimationsare both typedMap<string, …>.Why
None of the three loses real work — they are incidental teardown, persistence, and cache-pruning paths taking down the whole editor. The
removeChildone reaches by far the most users.How
Locating #3
The Studio build ships no sourcemaps, so the reported frame in a minified chunk was not traceable as-is. Checking out the
v0.7.90tag and rebuilding it reproduces the same asset filename hash byte-for-byte, which confirms the rebuild is the same code the crash came from. Decoding the frame against that bundle lands ongsapKeyframeCacheHelpers.ts:198.Fixing #3
elementCacheKeysowns the key-variant list every cache write sets. Two of its three keys are template literals and coerce on their own; the bare-id key was passed through raw, so a non-stringelementIdreaching it put a non-string key into both maps, which prune then choked on. It now coerces that key.Review caught that it was not yet the only write gate:
useGsapTweenCachebuilt the same key list by hand at two sites, so a non-string id there still reached the maps uncoerced. Both sites now loopelementCacheKeys, and their matching reads use the same list instead of a second hand-rolled copy. That also closes a drift the helper's own doc comment warns about — the per-element writer omitted theindex.html#<id>fallback key its siblings all set, so a reader falling back to that key saw a stale entry. The only remaining direct writers are in the dev-only timeline performance fixture, which generates its own string ids.The coercion reports the offending value's
typeof, constructor name, and source file asstudio:cache_key_non_stringrather than swallowing it. This is deliberate: every writer that reacheselementCacheKeyswas traced and each one produces a string, so which caller supplies a non-string id is still unknown. Rather than guess at a producer, this hardens the single gate that can guarantee the maps' declared contract, and makes the next occurrence name its own producer. Only the value's shape is reported, never its content.Fixes 1 and 2 are both the smaller diff and the root fix: one guard where every caller routes through, rather than one per call site. No behaviour change on any happy path.
Test plan
Six regression tests, every one verified to fail without its fix:
Player.test.ts— detaches the player element, then unmounts. Without the fix:DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.LeftSidebar.storage.test.ts— makes thelocalStorageproperty getter throw, then callsgetPersistedTab(). Without the fix it fails with the sameSecurityErrorthe crash reports carry.gsapKeyframeCacheHelpers.test.ts— four cases: keys stay strings, the violation is reported, the normal string path stays silent, and a prune after a non-string write does not throw. Without the fix the last one fails withTypeError: key.indexOf is not a function.Full Studio suite green: 3559 passed, 335 files, 0 failures.
oxlint,oxfmtandtsc --noEmitclean.Manual testing is unchecked deliberately: none of the three reproduces on a normal local profile, which is why they only surfaced in crash reports. The tests exercise the exact throwing boundaries instead.
Not covered
Two other crash signatures reviewed alongside these are not fixed here: one occurs almost entirely on locally-built dev Studio rather than released builds, and the other has not appeared on any recent release.
Follow-up worth its own PR: ship sourcemaps for the Studio build. Rebuilding a tag to decode one frame worked, but it should not be the process, and it is the prerequisite for diagnosing the next minified crash.