Skip to content

fix(core): a sub-composition follows its host clip when the host moves without a reload - #5058

Merged
miguel-heygen merged 10 commits into
mainfrom
dbulk/subcomp-replace
Oct 5, 2026
Merged

miguel-heygen merged 10 commits into
mainfrom
dbulk/subcomp-replace

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

A timing drag that touches a sub-composition host now moves the live tweens in place instead of re-running the composition script, and the scene stays where a fresh load would put it.

  • Runtime, host move: on a rebind, a sub-composition timeline the runtime nested into the root follows its host's new start. Before, the scene kept playing at the old time until a reload. A scene that a re-run root script placed itself is left where the script put it, as on a fresh load.
  • Studio, host clips: the guard that sent every host-clip timing edit to a re-run is gone; the in-place move from perf(studio): move the live tweens in place after a timeline drop #5042 covers it. A move is still refused when any other timeline (nested, or another registered one) animates an element the move touches.
  • Studio, re-printed scripts: the preview runs the composition script after the bundler re-prints it (comments stripped, quotes and numbers normalized, arrow parameters wrapped). perf(studio): move the live tweens in place after a timeline drop #5042 compared the two by text with numbers masked, so its fast path never matched a script that carried a comment, and every such drag re-ran the script. They are now compared by syntax tree, positions and raw text dropped, number values masked; a change to any operator, name, string or regular expression still re-runs.

Part of the manual-edit performance work (bar: no main-thread task over 8 ms on a one-clip drop).

Numbers

This PR ships for correctness: a scene follows its host clip without a reload. It does not make the first drag faster. Built Studio, headless Chrome, base is main at #5042.

Host-clip drag, 30-card fixture with a sub-composition (the one in the captures). Longest main-thread task after the drop:

Before (re-run) After (in place)
First drag, round 1 (n = 6 each, one missed drop dropped per side) median 61 ms, max 99 ms median 58.5 ms, max 71 ms
First drag, round 2 (n = 7 before, 6 after) median 59 ms, max 82 ms median 66.5 ms, max 82 ms
Repeat drag of the same clip: task holding the preview sync (n = 5 each, profiled) over 8 ms in 4 of 5 runs, 29 to 59 ms over 8 ms in 1 of 5 runs (30 ms); under 8 ms in the other 4
Repeat drag: main-thread time per drag, median 194 ms 178 ms
Repeat drag: tasks over 8 ms per drag, median 5 5

On a first drag the in-place path does more work than re-running this small script: its preview sync is 33 to 70 ms (two GSAP parses 20 to 37 ms, two shape compares 10 to 29 ms) against 13 to 43 ms for the re-run. A repeat drag compares only the newly saved script's shape, because the last two shapes are kept by script text; the parser's existing memo already covers the parse. The capture banners are single drops (68 ms before, 91 ms after on dark, wide); the rows above are the comparison.

One-clip drag on a 53 s launch project with 11 sub-compositions (5 alternating rounds, median, first drag): preview sync 75 ms before (re-run) and 61 ms after (in place); the task holding it 115 and 107 ms; 12 and 14 tasks over 8 ms per drag.

What the remaining long task is. Every drop still has a task far over the 8 ms bar on both sides. The largest is React re-rendering Studio after the drop (40 to 60 ms in the first task after release; Studio subscribes to the whole element list), and the next is the preview sync above. The next PRs in this series take those on: the React subscription, then parsing each script once per drop.

Before

Main: a drag of the scene host's clip, in a script with comments, rebuilds the timeline. The scene's dot is where a fresh load puts it.

Before, dark, wide
Before, dark, wide, the banner and preview at 2x
Before, light, wide
Before, dark, narrow
Before, light, narrow
Before, light, narrow, the moved clip at 2x

After

Every full screenshot is at 2x device pixels; the zooms crop the banner with the preview, and the moved clip. The preview frames are pixel-identical to Before by design; the banner says which path ran. The same drag moves the tweens in place; the scene follows its host, and its dot matches a fresh load (x 109 at 16.6 s wide, 113 at 16.7 s narrow). The light, narrow banner on Before reads 0 ms because the probe missed that drop's task; the table above has the measured numbers.

After, dark, wide
After, dark, wide, the banner and preview at 2x
After, light, wide
After, dark, narrow
After, light, narrow
After, light, narrow, the moved clip at 2x

Known limits

  • Replaying to the playhead after an in-place move fires the timeline's callbacks again, as the existing fresh bind does. That predates this PR.
  • A project with several inline scripts in one file is bundled into one preview script, so it still re-runs on every drag.
  • Some re-prints still miss the fast path and re-run: undefined printed as void 0, and constant folding such as "a" + "b".
  • The script compare parses both scripts and serializes their trees: 77 to 136 ms per compare on 130 to 140 KB scripts, run twice per drop. Parsing once per drop is the next PR in this series.
  • A script with import or top-level await does not parse as a classic script, so it always re-runs.

Test plan

  • Real GSAP: a scene clip at 1 s moved to 4 s, then a rebind: the scene starts at 4 s and the frame matches a fresh load. Red before the fix.
  • A scene that a re-run root script places at 2 s stays there through a rebind and a later host move. Red before the fix.
  • An unmoved scene whose start does not round exactly is left in place on a rebind.
  • Script comparison: matches esbuild's verbatim re-print of a script; tells apart scripts that differ only in an operator, a comparison, a compound assignment, a unary operator, a regular expression, a private name, an array hole, a comma or a semicolon.
  • The live-retime table's host cases move in place and equal a fresh load; an operator flip inside a tween's value re-runs.
  • Core runtime suite, Studio live-retime and timing-sync tests 3 runs in a row; each guard broken on purpose turns its test red; typecheck, lint, format, comment ratchet, Fallow.
  • Built Studio, light and dark, wide and narrow: a host-clip drag moves in place, and the scene's frame matches a fresh load.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 15:56

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

No blocker at 8b27458effc09ec7342492114f116a23ea52da60. Approving; the CI jobs were all green when I stamped (89 pass, 5 skipping).

What I checked (local, NODE_ENV=test)

  • New/changed files: init.subCompositionPlacement 3/3, scriptShape 15/15, gsapLiveRetime.cache 2/2, gsapLiveRetime.realGsap 37/37. Neighbours: core init.test 220, init.swapScenes 140, init.compositionLength 10, init.mediaClipIndex 7, init.timingResolver 2, init.flatGsap 1, init.positionEdits 1; studio timelineTimingSync 29. tsc --noEmit is clean in core, parsers and studio.
  • Mutations, each reverted: dropping the followHostStart call, the placed.parent !== root guard, or the 1e-6 tolerance guard fails the placement test. In gsapLiveRetime, dropping the shape cache, the LRU refresh, or a cache size of 1 fails the cache test; ignoring the registry, the nested timelines, or the own-timeline tween scan fails the real-GSAP cases; reverting the __raw: compare to text fails the multi-line reformatted case. In scriptShape, no number masking, always dropping raw, dropping shorthand for __proto__, or keeping start each fail a test.
  • Call sites: moveLiveTweens has one caller (timelineTimingSync.ts:137); scriptShape is used only in gsapLiveRetime.ts; no other reader of the removed autoNestedHostIds, withoutLayout, withoutNumbers or inOrHostsSubComposition. The shape cache is keyed by script text, so it cannot go stale across undo/redo or a script rewrite; the live script's text is still set to the saved script after an in-place move.
  • No secrets in the diff.

Non-blocking

  1. Three guards survive mutation (each is correct today, none is pinned): shape === null in planLiveRetime (I confirmed that without it a script with a top-level import is retimed in place instead of re-run), the !placed return in followHostStart (an author-added scene with no runtime entry would throw inside the swallowed try), and nested.parent !== root (getChildren(true, true, true) also lists deeper children). A one-line test each would keep them.
  2. scriptShape has no test for BigInt literals (removing that branch stays green).
  3. I did not run the browser numbers (first drag, repeat 41 → 30 ms); the PR says the first drag is not faster, and I take those as the author's.

Reviewed on 8b27458effc09ec7342492114f116a23ea52da60. This is a review verdict, not authorization to merge.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 5d19e23 Oct 5, 2026
169 of 170 checks passed
@miguel-heygen
miguel-heygen deleted the dbulk/subcomp-replace branch October 5, 2026 16:39
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