Skip to content

fix(core): mount parked scenes with layout so mount-time scripts don't reorder text - #7341

Open
miguel-heygen wants to merge 1 commit into
mainfrom
fix/parked-scene-mount-keeps-layout
Open

miguel-heygen wants to merge 1 commit into
mainfrom
fix/parked-scene-mount-keeps-layout

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

The preview mounts a parked scene with its host laid out (hidden by visibility:hidden !important) while the scene's scripts run, not under display:none. Mount-time tweens on inline word spans no longer move the spaces between words.

Why

In a user project, a karaoke line written as in the ocean of your showed in Studio as "inthe ocean ofyour" on first load, after a seek into the scene, and when playing into it. Seeking away and back fixed it. Renders were fine. The bug has been there since 0.8.200 and is still in 0.9.19.

Root cause, from #6189 (70187a1):

  • At boot, scenes away from the playhead are parked behind an empty stand-in. The stand-in is a leaf clip in flow on the first visibility pass, so the pass hides it with display:none (and visibility:hidden !important).
  • mountScene unparks the scene into a host that copies the stand-in's attributes, so the host gets that display:none too. moveDisplayHide carries the record over, but nothing restores the display until the scene is visible. The scene's scripts (runSceneScripts) then run on a host without layout.
  • An author script like gsap.fromTo(span, { y, scale }, …) renders its start values right away. GSAP's CSSPlugin (_getMatrix, same in 3.14.2 and 3.15.0) handles an element with no offsetParent and no width by appending it to <html> to measure it, then putting it back with parent.insertBefore(target, target.nextElementSibling). That anchor is the next element, not the next node, so the whitespace text node after the span ends up in front of it: [in " " the] becomes [" " in the]. The moved space collapses and the words run together.
  • On a later remount the host comes from a retired, already-absolutized host, so it has no display:none and GSAP never moves anything. That is why a seek "fixed" it.

Any author script that measures on mount (offsetWidth, getBoundingClientRect, text fitting) also read zeros there.

Related work

Refs #6189, which added the scene window. I found no open issue or PR for this.

How

In mountScene (packages/core/src/runtime/init.ts), right after unpark and moveDisplayHide, the host gets back the display it had before the visibility pass hid the stand-in (restoreDisplay). It is then hidden with visibility:hidden !important, which is the state a remount already starts in. The scripts run with the host laid out but not visible. Once the scene settles (settleChangedScenes), the visibility pass hides or shows it as usual, and it may still hide an in-flow leaf host by display.

  • Every mount of a parked scene goes through mountScene: the scene a seek or scrub lands in, the next one mounted ahead while playing or paused, and scenes Studio or the inspector hold. All of them get the fix. Swapping an edited scene that is already mounted imports a fresh host that never had the stand-in's style, so that path didn't have the bug.
  • perf(core): the preview mounts and draws only the scenes near the playhead #6189's perf intent holds. Parked scenes that aren't mounting stay as they are, display:none included. A scene far from the playhead still gets content-visibility: hidden from the far-scene skip once it settles. I checked in Chrome that content-visibility: hidden still gives offsetParent and a width to a script that asks, so it doesn't hit GSAP's fallback.
  • No data-hf-scene-no-swap needed.

Test plan

  • New init.swapScenes.test.ts test: "lays a parked scene out while its script runs, so a transform tween keeps the spaces between words". A parked scene with <div class="row"><span class="w"><span>a</span> <span>b</span></span> <span class="w">c</span></div> runs gsap.fromTo('.w', { y: 10 }, { y: 0 }) with core's real gsap. jsdom gives nothing an offsetParent, so the test defines one that, as in a browser, is null only under display:none. The test asserts that the row's child nodes keep their order (w, " ", w), that its text stays a b c, and that the row had layout while the script ran.
    • Fails on origin/main: expected [ ' ', 'w', 'w' ] to deeply equal [ 'w', ' ', 'w' ]. This is GSAP's reorder, reproduced.
    • Passes with the fix.
  • Updated "shows a scene mounted again after the visibility pass hid it by display and it unmounted". Its setup relied on the stand-in's display:none reaching the mounted host. The fix stops that, so the scene now stays in flow through an !important stylesheet rule, which lets the visibility pass hide the mounted host by display. The test still covers the retire-then-remount handoff: with moveDisplayHide(host, placeholder) removed from retireScene, it fails.
  • bun run test in packages/core: 277 files and 5874 tests pass, the pageBuild goldens included (none changed). bun run typecheck and lint:runtime-preview-guards pass, and so do oxlint/oxfmt, check-comment-citations and comment-ratchet.
  • Manual check on the user project with this branch's CLI (hyperframes preview, headless Chrome). For each row I compared the live child-node order with the authored markup:
Case origin/main this branch
First load at t=67.2, ch1 #c1-r3 [" " in the] " " [ocean] " " [" " of your] [in " " the] " " [ocean] " " [of " " your] (authored)
First load at t=95, v2 #v2-l28t " " No " " mistakes, " " my [heart] " " " " is beating No " " mistakes, " " my " " [heart] " " is " " beating (authored)
Load at 0, seek to 67.2 / 95 displaced, as above authored order
Load at 58, play into ch1 displaced authored order
  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable): no user-facing behavior change beyond the fix
  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

…t reorder text

A scene parked at boot mounts into a host that takes its stand-in's style,
including the display:none the visibility pass gave the empty stand-in. The
scene's scripts then ran with the host undisplayed. GSAP's CSSPlugin reads
a transform from an element without layout by moving it to <html> and
putting it back before its nextElementSibling, so a tween on inline word
spans moved each one past the whitespace text node after it, and the
preview showed "in the" as "inthe" until a later remount.

The mount now gives the host back its own display and hides it with
visibility:hidden !important while its scripts run; the visibility pass
decides how to hide or show it once the scene settles.
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 111 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)

This branch has not been deployed

No deployments
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.

1 participant