Skip to content

fix(studio): preserve playhead position on composition refresh#1000

Merged
miguel-heygen merged 3 commits into
mainfrom
feat/studio-preserve-playhead
May 21, 2026
Merged

fix(studio): preserve playhead position on composition refresh#1000
miguel-heygen merged 3 commits into
mainfrom
feat/studio-preserve-playhead

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 21, 2026

Summary

  • Fix playhead resetting to 0:00 whenever a code change triggers a composition refresh
  • Root cause: refreshKey was included in the Player's React key, causing a full teardown + remount that destroyed the adapter before refreshPlayer() could save the seek position
  • Fix: remove refreshKey from the Player key so refreshes use the lightweight iframe.src reload path, which correctly saves and restores the playhead via saveSeekPosition()pendingSeekRefinitializeAdapter()

Implementation

Single-file change in NLEPreview.tsx (-8 lines, +1 line):

  • Remove the refreshKey-triggered crossfade block that set retiringKey
  • Change activeKey from `${baseKey}:${refreshKey}` to just baseKey

The existing refreshPlayer() mechanism in NLELayout already handles seek preservation correctly — the bug was that the Player remount was racing ahead of it.

Test plan

  • Verified with agent-browser: play to 0:10, pause, touch file on disk → playhead stays at 0:10
  • Verified with agent-browser: play to 0:10, pause, edit index.html content → playhead stays at 0:10
  • Build passes (typecheck, lint, format)
  • Drill into sub-composition and back — Player remount still works (key changes via directUrl)
  • Undo/redo triggers refresh — playhead preserved

Closes #996

Stop NLEPreview from including refreshKey in the Player's React key.
Previously, a refreshKey change caused a full Player teardown + remount,
which destroyed the playback adapter before refreshPlayer() could save
the seek position — so the playhead always reset to 0:00.

Now refreshKey changes only trigger refreshPlayer()'s lightweight
iframe.src reload path, which correctly captures the current time via
saveSeekPosition() before reloading the iframe content.

Closes #996
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: COMMENT (would-be APPROVE)

🎉 1000th PR for hyperframes — solid choice of fix to land on this number. Clean one-line surgical fix at exactly the right scope.

Root-cause trace confirmed

The fix matches the failure mode described in #996:

  • Pre-fix: refreshKey change → activeKey changes (${baseKey}:${refreshKey}) → React sees a new key on <Player> → mounts a NEW Player instance at the new key while the OLD one moves to retiringKey and is unmounted 160ms later. The OLD Player holds the user's playhead state; the NEW Player initializes from 0. The crossfade was visually masking the playhead reset, not preventing it.
  • Post-fix: activeKey = baseKey (no refreshKey suffix) → same Player instance is retained across refreshes → NLELayout's useEffect on [refreshKey] (NLELayout.tsx:122-126) calls refreshPlayer(), which is the iframe.src-reload path that saveSeekPosition()s (useTimelinePlayer.ts:464-470) into pendingSeekRef and restores on initializeAdapter(). ✓

Sub-composition drilling and project switch still remount correctly (those go through directUrl / projectId, both of which still feed into baseKey).

Non-blocking observations — dead code left behind

The PR is correctly scoped to the one-line behavior fix, but it leaves several symbols unreachable that the original crossfade scaffolding required. Worth a follow-up cleanup PR (or scoped down into this one if you'd like — I'm fine either way):

  1. retiringKey state, retiringTimerRef, and handleNewPlayerLoad are unreachable.

    • setRetiringKey(oldKey) was the only path to a non-null value, and that block was removed.
    • The remaining setRetiringKey(null) (NLEPreview.tsx:228) lives inside handleNewPlayerLoad, which is only called when retiringKey is truthy — circular dead state.
    • The first <Player key={retiringKey}> rendering block (NLEPreview.tsx:415-424) and the style={retiringKey ? {...} : undefined} (line 440) become permanently inert.
    • ~30 lines. Knip/lint probably won't flag it (the hooks are technically "used"), but it's real cruft.
  2. getPreviewPlayerKey still accepts refreshKey in its signature (NLEPreview.tsx:27-36) but ignores it. The associated test (NLEPreview.test.ts:115-128 — "keeps the same player identity when only refreshKey changes") documents the deliberate ignore. Post-fix, the signature could simplify to ({ projectId, directUrl }) => directUrl ?? projectId and the test reduces to redundancy (getPreviewPlayerKey({...}) === activeKey). Optional.

  3. Stale comment in NLELayout.tsx:117-120: "avoiding the full web-component teardown + crossfade that the key-based path uses." After this PR, the key-based path (directUrl / projectId changes) doesn't crossfade either — the crossfade trigger was only the now-removed refreshKey block. Worth a one-line update in the cleanup pass.

Testing

Test plan in the PR body is comprehensive (file touch, content edit, build, sub-comp drill, undo/redo — 5 scenarios). No new automated test added, which is acceptable since the public surface is the React component lifecycle (hard to unit-test the seek-preservation chain without a real iframe). The existing getPreviewPlayerKey test still passes — and now exercises the actual activeKey (it didn't before, since activeKey included refreshKey while getPreviewPlayerKey didn't; the test was passing only by coincidence).

CI

All required green. 2 Windows checks still in_progress (pattern matches recent PRs).

— Rames Jusso

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved per James's go-ahead. 🎉 The 1000th hyperframes PR — fitting that it's a clean one-line surgical fix.

Findings from the prior COMMENT review stand as non-blocking follow-ups (dead crossfade scaffolding cleanup, getPreviewPlayerKey signature simplification, stale comment in NLELayout.tsx:117-120). None of them gate this merge.

Follow-up to the playhead-preservation fix: clean up the now-unreachable
crossfade infrastructure that was only triggered by refreshKey changes.

- Remove retiringKey state, retiringTimerRef, handleNewPlayerLoad
- Remove the retiring Player render block and conditional onLoad/style
- Drop refreshKey from getPreviewPlayerKey signature and NLEPreviewProps
- Stop passing refreshKey from NLELayout to NLEPreview
- Update NLELayout comment to reflect current iframe.src reload model
- Simplify getPreviewPlayerKey test
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 21, 2026

Fallow audit report

Found 24 findings.

Duplication (16)
Severity Rule Location Description
minor fallow/code-duplication packages/studio/src/components/editor/FileTree.tsx:20 Code clone group 1 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/sidebar/LeftSidebar.tsx:43 Code clone group 1 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/contexts/DomEditContext.tsx:16 Code clone group 2 (44 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/contexts/DomEditContext.tsx:63 Code clone group 3 (47 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/contexts/DomEditContext.tsx:64 Code clone group 2 (44 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/contexts/FileManagerContext.tsx:16 Code clone group 4 (40 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/contexts/FileManagerContext.tsx:53 Code clone group 5 (43 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/contexts/FileManagerContext.tsx:54 Code clone group 4 (40 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.ts:302 Code clone group 3 (47 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.ts:304 Code clone group 2 (44 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/hooks/useFileManager.ts:85 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useFileManager.ts:109 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useFileManager.ts:288 Code clone group 7 (31 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useFileManager.ts:374 Code clone group 7 (31 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useFileManager.ts:445 Code clone group 5 (43 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useFileManager.ts:447 Code clone group 4 (40 lines, 3 instances)
Health (8)
Severity Rule Location Description
critical fallow/high-crap-score packages/studio/src/App.tsx:53 'StudioApp' has CRAP score 756.0 (threshold: 30.0, cyclomatic 27)
major fallow/high-crap-score packages/studio/src/components/nle/NLELayout.tsx:95 'NLELayout' has CRAP score 63.6 (threshold: 30.0, cyclomatic 15)
critical fallow/high-crap-score packages/studio/src/components/sidebar/LeftSidebar.tsx:62 'LeftSidebar' has CRAP score 650.0 (threshold: 30.0, cyclomatic 25)
major fallow/high-crap-score packages/studio/src/hooks/useDomEditSession.ts:234 'syncSelectionFromDocument' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
minor fallow/high-crap-score packages/studio/src/hooks/useDomEditSession.ts:291 '<arrow>' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
minor fallow/high-crap-score packages/studio/src/hooks/useFileManager.ts:68 '<arrow>' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/hooks/useFileManager.ts:186 'openSourceForSelection' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
critical fallow/high-crap-score packages/studio/src/hooks/useFileManager.ts:229 'uploadProjectFiles' has CRAP score 182.0 (threshold: 30.0, cyclomatic 13)

Generated by fallow.

@miguel-heygen miguel-heygen force-pushed the feat/studio-preserve-playhead branch 3 times, most recently from 916f267 to d3cf649 Compare May 21, 2026 19:17
When the Code tab is active and a user clicks an element in the preview,
the code editor now auto-scrolls to the corresponding HTML source. This
removes the need for Alt+click — the existing click-to-source mechanism
fires automatically when the Code tab is already open.

- Add getTab() to LeftSidebarHandle for reading the active tab
- Add getSidebarTab getter to useDomEditSession
- Add effect that calls openSourceForSelection on selection change
  when Code tab is active
@miguel-heygen miguel-heygen force-pushed the feat/studio-preserve-playhead branch from d3cf649 to e2de547 Compare May 21, 2026 19:25
@miguel-heygen miguel-heygen merged commit 37329b0 into main May 21, 2026
34 of 35 checks passed
@miguel-heygen miguel-heygen deleted the feat/studio-preserve-playhead branch May 21, 2026 19:29
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.

feat(studio): Preserve playhead/current time on Studio refresh after code change

2 participants