Skip to content

fix(studio): a preview reload survives its pane unmounting mid-load - #7590

Merged
miguel-heygen merged 4 commits into
mainfrom
fix/studio-shadow-reload-detached
Nov 12, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
fix/studio-shadow-reload-detached

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

What changes

A preview that never finishes reloading after an edit, when the preview pane unmounts while the reload is still loading.

useShadowPreviewReload loads a full-reload edit in a hidden shadow frame and gives it three 15 s budgets. When the pane holding the preview unmounted mid-load (for example a layout switch), the shadow Player removed its frame, but the hook kept timing that detached frame. It could never load, so after 45 s, and again after the retry, the edit was dropped with "Its page had got to: no-page", and the previous preview stayed on screen.

Now, when a budget runs out and the shadow's frame is no longer on the page, the reload pauses, the same way it already does for a hidden tab. When the pane mounts again and its new shadow Player hands over a fresh frame (setShadowIframeNode), the reload starts its budgets again from the beginning.

Why not clear the callback ref instead

Player.tsx never calls a callback ref with null on unmount, and its comment said the mutable-ref branch was "the only path used today". That is no longer true: both the shadow reload (setShadowIframeNode) and the stage's live ref pass callback refs. Calling them with null would break promotion. A promotion makes the shadow frame the live one directly, without calling the live Player's ref, so the retiring live Player's null would wipe the frame that was just promoted. The Player keeps leaving callback refs alone, the comment now says why, and the reload checks isConnected instead.

Trade-off: if the pane never comes back, the reload now stays pending instead of being dropped after 45 s. Nothing is waiting to be shown in that case, and a composition switch or the hook's unmount clears it, as before.

Tests

  • useTimelinePlayer.shadowReload.test.ts: two new cases.
    • A reload whose frame leaves the page is kept, then promoted once a new frame mounts.
    • A frame that mounts again gets its full budgets, then is dropped if it stays silent.
    • Both fail on main (the reload is dropped at 45 s).
  • fails without the fix: useTimelinePlayer.shadowReload.test.ts › "keeps a reload whose frame left the page, and promotes it once a new frame mounts" and › "gives a frame that mounts again its full budgets, then drops it if it stays silent". Both fail on main, where the reload is dropped at 45 s.
  • Player.test.ts: unmounting a Player never calls its callback ref with null, and its frame is off the page afterwards.
  • timelinePlayerTestHarness.ts: makeFakeIframe now attaches its frame to the page, as a mounted Player's is. All existing reload tests pass unchanged with it.
  • Results:
    • The two changed test files: 85/85, 3 runs in a row.
    • The whole studio package: 742 files, 8487 tests passed.
    • tsc --noEmit for studio is clean, with core's generated runtime built.
    • oxfmt --check and oxlint on the changed files are clean.
  • The local pre-commit lint, format, typecheck, fallow and commitlint steps were skipped (LEFTHOOK_EXCLUDE), because this worktree has no local install. The same checks ran on a separate machine with a full install, except fallow, whose CI comment covers it.

How the captures were made

Studio dev server with the studio-open test fixture, in headless Chrome:

  1. index.html gets a big "EDITED" banner, which starts a full reload.
  2. The reload's page request is held for 6 s, so the reload is still loading.
  3. The Preview group is closed ("Close group").
  4. After 100 s, past the three 15 s budgets and the retry, Preview is reopened from the Timeline group's Panel menu.

The screenshots are taken right after reopening. Only useShadowPreviewReload.ts differs between the two runs.

Before

main: the reload gave up twice while the pane was closed. Each time, Studio reports "The preview did not reload ... Its page had got to: no-page. The previous preview is still showing."

Before: two "did not reload" errors after reopening Preview

After

This branch: the reload waited while its frame was off the page, then loaded when Preview came back. No error.

After: the edit shows and nothing is reported

Size

Under 100 lines on its own: a single fix with its two tests, already approved by both reviewers. The lane has no other open OSS PR to carry it, and it should reach the next release.

When the preview pane unmounted while a shadow reload was loading, its
Player removed the shadow frame but the reload kept timing it, so the
edit was dropped with "Its page had got to: no-page" and the previous
preview stayed. The reload now pauses while its frame is off the page
and starts its budgets again when the pane mounts a new frame.

The Player's comment on callback refs said the mutable-ref branch was
the only one in use; the shadow reload passes a callback ref, so the
comment now says why those are left alone.
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 103 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits · head ea2af6ba

  • blocker — PR body: there's no fails without the fix: line. The body says the two new useTimelinePlayer.shadowReload.test.ts cases "fail on main", but this lane requires a line in exactly that form. Add one that names at least one test, for example fails without the fix: useTimelinePlayer.shadowReload.test.ts › keeps a reload whose frame left the page, and promotes it once a new frame mounts.

I found no blocker in the code. The detach check runs only when a budget runs out. setShadowIframeNode restarts the budgets only for the current gen after the frame has left the page, and stopPendingShadow clears the new flag. Leaving callback refs alone in Player.tsx keeps promotion safe.

Scope

I read all 5 changed files end to end, plus the callers of setShadowIframeNode (NLEPreview.tsx:522, PreviewPane.tsx, NLEContext.tsx). There were no prior reviews. mergeStateStatus is BLOCKED, and this reviewer can't read check-runs.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at ea2af6ba63bf7e9b084b23a25ff0b44c01ff379d.

No code blocker found. The body now has the required fails without the fix: line for these two tests in useTimelinePlayer.shadowReload.test.ts:

  • “keeps a reload whose frame left the page, and promotes it once a new frame mounts”
  • “gives a frame that mounts again its full budgets, then drops it if it stays silent”
    I verified the claim: reverting only useShadowPreviewReload.ts to the merge-base version makes both named tests fail because the reload is dropped while its frame is detached. Restoring the head gives 85/85 passing tests across the changed shadow-reload and Player suites. The full workspace build also succeeds. All temporary edits were restored.

The pause/rearm state belongs to the current reload generation and is cleared by promotion, failure, composition reset and teardown. Hidden-tab handling remains in place; the passing suite includes “does not spend the budget while the tab is hidden, and promotes once it is visible” and the composition-switch refresh-reset case. Callback-ref cleanup is unchanged behavior: retiring Players do not null a frame their owner may have promoted. The new connected-frame harness and Player unmount test reflect that ownership. I read the synchronization, retry and preview-pane wiring; I did not independently repeat the author's full browser/layout capture or whole-package suite.

Pre-existing follow-up (useShadowPreviewReload.ts:338–345): a detach/remount before the next timeout tick is not observed, so the replacement can inherit the remaining old budget. I checked the merge-base setter/timer: a quick replacement also retains its budget there. This fix covers detachments seen by a timeout; it does not reset every iframe replacement. Resetting on a changed node identity could address that separately, with an early-remount boundary test.

Gate mechanics: Genesis's request for changes (review 5870197494) is still live at this exact head despite the body correction. This approval does not dismiss another reviewer's request for changes.

Need: yes, retain a reload while its pane is absent · Size: focused · Reuse: existing generation and visibility-budget lifecycle · Simpler: extend that lifecycle rather than changing callback-ref promotion ownership · Structure: fine (checked sync callbacks, retry owner and pane wiring; no split)
Risk: low — timeout bookkeeping changes; preview promotion and ref-cleanup behavior are retained.

Verdict: APPROVE
Reasoning: The stated detached-pane regression is fixed, and both named tests discriminate the reverted implementation from this head.

— Rames

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVE · 1 resolved, 0 open, 0 new · ea2af6ba → ea2af6ba (head unchanged). ✅ The body now has a fails without the fix: line that names both new useTimelinePlayer.shadowReload.test.ts cases.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 5a3e462 Nov 12, 2026
125 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-shadow-reload-detached branch November 12, 2026 07:42
@miguel-heygen miguel-heygen mentioned this pull request Nov 12, 2026
4 of 6 tasks
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.

2 participants