fix(studio): preserve active Player ref during cross-fade unmount#833
fix(studio): preserve active Player ref during cross-fade unmount#833virang-nimrobo wants to merge 2 commits into
Conversation
`NLEPreview` renders two `<Player>` instances during a refresh cross-fade (retiring + active) sharing the same forwarded `iframeRef`. The retiring Player's cleanup unconditionally nulled the ref, even though the active Player had already claimed it — leaving `iframeRef.current === null`. The null ref breaks the source filter in `useTimelinePlayer`'s message handler (`if (e.source && ourIframe && e.source !== ourIframe.contentWindow) return;`): when `ourIframe` is null the guard short-circuits and every sibling iframe's `hf-preview` postMessage (e.g. the sidebar composition thumbnails in `CompositionsTab`) flows into `processTimelineMessage`. Each sidebar iframe sends its own composition's clip set, so the studio oscillates `setElements` between the manifests of every visible thumbnail — remounting `CompositionThumbnail` on every cycle and visibly flickering the timeline for as long as the Player is on screen. Page refresh hides the bug because there's no retiring Player to clobber the ref. Fix: only null the forwarded ref when it still points at our own iframe.
|
@miguel-heygen found this bug while building the comment feature. I will raise the PR for the feature one post this is merged into main. |
jrusso1020
left a comment
There was a problem hiding this comment.
Verdict
Approve. The bug analysis is correct, the fix is the right shape (object-ref ownership check before clearing), and the test reproduces the exact lifecycle that broke. One non-blocking issue with the dropped function-ref branch and one minor durability concern worth flagging.
Verifying the diagnosis
The PR description nails it. Confirming from the source at this SHA:
NLEPreview.tsxrenders the retiring Player withoutrefand the active Player withref={iframeRef}. WhenrefreshKeybumps, the previously-active Player keeps its React identity (samekey) but is now re-rendered without arefprop — meanwhile the new Player mounts with the sharediframeRefand claims it via its asyncimport("@hyperframes/player").then(...).- Player's setup/cleanup lives inside
useMountEffect, so the cleanup function closes over therefvalue at initial mount, not at unmount. The retiring Player's cleanup therefore holds a reference toiframeReffrom when it was active, even though the JSX no longer passes that ref to it. - 160 ms later the retiring Player unmounts → old cleanup runs → old (unconditional) code did
iframeRef.current = null→ floodedprocessTimelineMessagebecause thee.source !== ourIframe.contentWindowguard short-circuits onourIframe === null.
The downstream consequence (sidebar CompositionThumbnail iframes broadcasting hf-preview timeline messages every ~333 ms, oscillating setElements, remount loops) is consistent with what I'd expect from that null-ref short-circuit in useTimelinePlayer's message handler.
Why the fix is right
- Check ownership before clearing.
if (objectRef.current === iframe) objectRef.current = null;is the standard pattern for shared refs across overlapping component lifetimes. It's also self-documenting — only the owner of the current ref value gets to null it. - Refresh path still works. When there's no overlap (no retiring Player),
objectRef.current === iframeis true at unmount → ref is cleared as before. Test 2 covers this explicitly. - Test 1 reproduces the exact failure mode. Mount-with-ref → re-render-with-retiring-plus-new-claiming-ref → unmount-retiring → assert ref still points at the new iframe. Without the fix this would fail; with the fix it passes. Good regression coverage.
Key Concerns (non-blocking)
1. Function-ref branch is silently dropped
Old code preserved the function-ref path:
if (typeof ref === "function") {
ref(null);
} else if (ref) { ... }New code drops it entirely:
if (typeof ref !== "function" && ref) { ... }The PR description says "Function-ref path falls through harmlessly — there is no in-tree consumer of <Player> that uses a function ref today." That's true today, but:
- The
forwardRef<HTMLIFrameElement, PlayerProps>signature accepts function refs, so external consumers (or future internal ones) could pass one and silently get a stale value on unmount. - The same ownership-tracking is possible for function refs by holding
const lastForwardedIframe = useRef<HTMLIFrameElement | null>(null);and only callingref(null)iflastForwardedIframe.current === iframe. Or — simpler — preserve the unconditionalref(null)for the function-ref path and only guard the object-ref path; the function-ref consumer was already responsible for its own ownership logic.
Suggest one of:
- Preserve
if (typeof ref === "function") ref(null);(status-quo for function refs). - Or add a tightening to a
React.RefObjecttype so function refs are a compile-time error:forwardRef<HTMLIFrameElement, PlayerProps>(...)already permits both, so a type-narrowing wrapper would be needed. - At minimum, a code comment noting the deliberate omission, since the PR description rationale won't survive in tree.
2. Root cause vs. symptom
The underlying issue is that Player's setup/cleanup uses useMountEffect (mount-once) and therefore captures stale closures over props that can change. This PR patches the specific symptom for ref, but the pattern remains for any future prop that's read in cleanup. If onLoad, onCompositionLoadingChange, or directUrl ever grow cleanup-time behavior, the same class of bug recurs.
Not blocking — refactoring to a useEffect([ref]) setup would force remount listeners on every parent re-render, which is heavier than the bug warrants. Worth a comment near the cleanup explaining "this closure captures props from initial mount; check ownership before destructive cleanup."
Test Coverage
Solid.
- Test 1 is a tight reproduction of the production bug — mount, retire+reclaim, unmount-retired, assert ref preserved.
- Test 2 is the inverse — confirms the default unmount-clears-ref path still works, so we haven't silently broken normal lifecycle.
- The
installHyperframesPlayerStubhelper (mockingdocument.createElementfor<hyperframes-player>) is heavyweight but necessary given Player creates the web component imperatively. It would be worth extracting into a shared test helper if more Player tests follow. - No test for the function-ref path — see concern #1. If you preserve the function-ref
ref(null), a third test asserting "function ref is called with null on unmount" would round it out.
Nits / Future
globalThis.IS_REACT_ACT_ENVIRONMENT = true;at module load is fine for this file but should ideally live in a vitest setup file if more tests start needingact()for React 19.flushMountedPlayertwo-Promise.resolve()pattern works becausevi.mock("@hyperframes/player", () => ({}))makes the dynamic import synchronously resolvable. If the mocked module gains async exports later this could become flaky.getPreviewPlayerKeyis exported but only consumed internally byNLEPreviewper the file I read. Worth checking if anything else references it; if not, drop the export.
Good catch on the root cause and good fix. Recommend merging.
|
@jrusso1020 CI failed during bun install because Puppeteer hit a transient 500 downloading Chrome 148.0.7778.97. Could you rerun the workflow job? |
Summary
NLEPreviewrenders two<Player>instances during a refresh cross-fade — a "retiring" one and a new active one — sharing the same forwardediframeRef. The retiring Player's cleanup unconditionally nulled the ref, even though the active Player had already claimed it. Result:iframeRef.current === nullfor the rest of the session.That null ref breaks the source filter in
useTimelinePlayer's message handler:```ts
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) return;
```
When
ourIframeis null the guard short-circuits, so every sibling iframe'shf-previewpostMessage flows intoprocessTimelineMessage. TheCompositionsTabsidebar mounts an<iframe>per composition, and each one's runtime broadcasts its own clip set every ~333 ms (postTimeline()on a 20-frame transport tick). The studio then oscillatessetElementsbetween every visible thumbnail's manifest, remountingCompositionThumbnailon every cycle and visibly flickering the timeline.Page refresh hides the bug because there's no retiring Player to clobber the ref.
Fix: only null the forwarded ref when it still points at our own iframe (object-ref path). Function-ref path falls through harmlessly — there is no in-tree consumer of
<Player>that uses a function ref today.Repro
refreshKey++while the studio is mounted (e.g. cancel an in-flight timeline-comment agent run, save a file via the editor, etc.).CompositionThumbnailre-fetches, network tab fills with hundreds of requests per minute.Test plan
main, confirm the fix on this branch eliminates it.iframeRef.currentstays valid after the 160 ms cross-fade window.🤖 Generated with Claude Code