Skip to content

fix(core): a composition mounted twice keeps each mount's own timeline - #5729

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/repeated-composition-mounts-keep-own-timeline
Oct 17, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
fix/repeated-composition-mounts-keep-own-timeline

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

When one composition file is mounted by two hosts, each mount now keeps its own timeline in preview and in export. Three ways to get there were broken:

  • Split a sub-composition on the timeline. The second half's host got a new id (scene-split), but its content still said scene. Its script then published its timeline under both ids and replaced the first half's. The first half froze at its first frame, and both hosts seeked one shared timeline, so which crop won depended on seek order (forward, back, forward could show a different source moment at the same output time).
  • Insert a project into another whose root has the same id (two CLI-made projects are both main). The inserted mount kept main and replaced the page root's timeline, so the host's own animation never ran.
  • Reuse a scene twice with the same id. This already worked (the runtime numbers repeats scene__hf1, scene__hf2) and is covered here so it stays that way.

How

  • wrapScopedCompositionScript (one place, used by both the preview loader and the render bundler): a mount still publishes its timeline under its runtime id, but it only takes the scene's authored id when that slot is empty or already its own. Inside a mount, reading window.__timelines.<scene> returns that mount's own timeline.
  • The runtime loader and the bundler count the page root as a mount of its own id (countPageRoot), so a mount sharing the root's id is numbered (main__hf1) instead of taking the root's slot.
  • Studio split and duplicate no longer rename a mount's composition id. Both halves keep the scene's id and the runtime numbers them. Already-saved scene-split projects play correctly through the runtime fix.
  • Studio insert keeps the inserted root's own composition id on the host and gives the host an element id that is unique against the page root too.

Verification

Each item has a test that fails on main:

Item Test On main
Split copy replaces the first half's timeline compositionScoping.test.ts "never replaces another mount's timeline under the scene's own id" fails
Split renames the mount sourceMutationSplitAndGroup.test.ts, 2 cases fail
Mount sharing the root's id (runtime) compositionLoader.test.ts "gives a mount that shares the page root's id its own runtime id" fails
Same (bundler) htmlBundler.test.ts, same name fails
Insert reuses the root's id compositionInsertion.test.ts "mounts a project whose root shares the parent root's id under its own host id" fails

The related core suites (loader, scoping, inline sub-compositions, scene swaps, bundler; 512 tests) and the studio-server helper suites (513 tests) pass 3 runs in a row. core and studio-server tsc, oxlint, oxfmt and the comment ratchet are clean.

End to end with the built CLI, against the published 0.8.166, on three small projects:

Project Check 0.8.166 This branch
Split at 3 s, linear bar snapshot bar x at 1 s / 2 s / 5 s / 7 s 0 / 0 / 900 / 1260 (first half frozen) 180 / 360 / 900 / 1260
Split, preview seeks 1, 5, 2, 5, 8, 1, 5 visible bar x first half stuck at 0 180, 900, 360, 900, 1440, 180, 900
Same scene on two hosts, second cropped by 2 s snapshot and preview correct correct
Inserted project sharing main HOST y / SCENE x at 2 s and 7 s HOST never moves 437 → 287 / 965 → 1265, mount numbered main__hf1

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Before and after in the Studio preview (live preview page, 0.8.166 vs this branch), from small fixture projects. Split section: a linear bar crosses the screen over the scene's 10 s, split at 3 s. Before, the first half's bar stays frozen at its first frame; after, it is at its 1 s position. At 5 s both versions agree, since the second half already played right. Inserted project: a project whose root is also main, mounted in another. Before, the host's own title never moves; after, it rises while the inserted scene slides.

Split section at 1 s, before and after

Split section at 5 s, before and after

Inserted project at 2 s, before and after

Inserted project at 7 s, before and after

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 170 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 (0)

Unstable (1)

  • textselect-none-px-r0-nested-z100: tracking 0, pressJump -, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0, pressJump -, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0, pressJump -, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@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.

Approving at 02653361.

Checked and fine

  • Scoping proxy set (core/src/compiler/compositionScoping.ts). A second mount no longer overwrites the scene's own id, and a mount that owns the slot can still publish again (the v1 → v2 case). Old saved scene-split projects work in either script order. A host whose id equals the scene's id gets no proxy, so the first half still holds the scene's slot.
  • countPageRoot feeds all three runtime-id paths: the loader (compositionLoader.ts), the bundler (htmlBundler.ts), and the producer's ProducerHostIdentityMap, which uses the bundler's counts. Preview and export therefore number the same way.
    • I checked the body's claim against source: a mount that shares the root's id gets a count above 1 and becomes main__hf1. The new loader and bundler tests assert that.
  • Single-mount projects are unchanged unless the mount shares the page root's id. A root that is itself a tracked host is skipped.
  • Studio split and duplicate (sourceMutation.ts) only rename elements that aren't mounts. A mount is an element with data-composition-src, or an empty host whose <id>-template exists. Both callers go through the same function.
  • Studio insert (compositionInsertion.ts) avoids the page root's id and composition id when it makes a host id.
  • Duplicate source ids from inserting the same project twice are intentional. duplicate_composition_id already skips data-composition-src mounts, so lint won't flag them.

Non-blocking

  • "Each mount reads its own timeline" holds only once that mount has written. When a mount's runtime key is still empty, the proxy's get falls back to the scene's id. Shipped blocks such as code-snippet-dark-modern do window.__timelines[id] = window.__timelines[id] || gsap.timeline(...). Mounted twice, the second mount reads the first mount's timeline and stores it under __hf2, so both mounts share one timeline. I modelled that by copying the proxy logic into a node script.
    • This isn't a regression, since main behaves the same.
    • Suggested follow-up: return only the runtime key while the proxy is active, and add an x = x || new test that mounts the block twice.
  • countPageRoot relies on document order (the first [data-composition-id] is the page root). A one-line comment saying so would help.

CI is the only run. Not armed or merged.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit c38e9f3 Oct 17, 2026
79 checks passed
@miguel-heygen
miguel-heygen deleted the fix/repeated-composition-mounts-keep-own-timeline branch October 17, 2026 01:07
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