Skip to content

fix(studio-server): motion paths animate when gsap has a query string or loads deferred - #5007

Merged
miguel-heygen merged 4 commits into
mainfrom
fix/studio-motionpath-after-any-gsap
Oct 4, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
fix/studio-motionpath-after-any-gsap

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Motion paths animate in Studio preview when the composition loads gsap from a URL with a query string or hash (gsap.min.js?v=1), or with a defer gsap tag. Before, Studio's MotionPathPlugin ran before gsap in both cases and the motion path did nothing ("Missing plugin? gsap.registerPlugin()").

Why

Studio adds MotionPathPlugin right after the composition's gsap tag, so the plugin registers onto gsap. It found that tag by text and only recognised URLs ending exactly in gsap.js or gsap.min.js; anything else sent the plugin to <head>, ahead of a body gsap. A deferred gsap was recognised, but the plugin tag did not carry defer, so it still ran during parsing, before gsap.

Related work

Builds on #4980 (merged), which made the plugin tag copy gsap's script type; this adds defer and the URL forms.

How

  • Studio finds the gsap tag with the core HTML scanner (findStartTags) paired with the parsed document, the way lazy preview images already do, so tags inside comments, templates and raw-text blocks (style, textarea, title) are never mistaken for the live one, and the search stays linear on any input. The earlier single pattern could backtrack polynomially (code scanning flagged it).
  • A script is gsap core when its URL path ends in /gsap.js or /gsap.min.js, decided by the URL parser, so a query string or hash never matters.
  • The plugin tag copies the gsap tag's type and defer, read with the HTML parser the server already uses (linkedom), attribute names compared case-insensitively, so DEFER counts and an attribute value that merely contains the word "defer" does not.
  • An async gsap is left as it is: no later tag can be ordered after an async script, and the composition's own scripts already race it.

Verification

Real Chrome 152, Studio preview, a composition with a motion-path tween and no manual registerPlugin, seeked to 1 s, 2 runs per case:

gsap tag before after
body <script src=".../gsap.min.js?v=1"> dot not moved, "Missing plugin" warning dot at (300, 150), no warning
<script defer src=".../gsap.min.js">, composition code on DOMContentLoaded dot not moved, "Missing plugin" warning dot at (300, 150), no warning

Test plan

  • routes/preview.test.ts: plugin follows a body gsap with a query string, with a hash; plugin is deferred after a deferred gsap; DEFER/TYPE in capitals are read; defer inside another attribute's value is not; a gsap tag inside a comment or a nested template is skipped for the live one; the live tag is still found after a style block, an empty comment, or a comment opener inside an attribute; a gsap .map file is not gsap core; a 450 KB run of /gsap.js# src text finishes. Red before each fix, green after (95/95, 3 runs). Mutants (case-sensitive names, raw path instead of URL path, a raw <script scan instead of the core scanner, the earlier regex) each turn their tests red.
  • Typecheck, lint, format, comment ratchet, Fallow audit.

Base automatically changed from fix/runtime-scripts-after-fonts to main October 4, 2026 10:37
… or loads deferred

Studio puts MotionPathPlugin right after the composition gsap tag. A gsap URL with a query string or hash was not recognised, so the plugin went to the head and ran before gsap; a deferred gsap kept the plugin undeferred, with the same result. The tag is now found with a query or hash, and the plugin copies its type and defer, read with the HTML parser the server already uses.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-motionpath-after-any-gsap branch from 8b44dbb to 66512e3 Compare October 4, 2026 10:39
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

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

The gsap tag pattern from the previous commit could backtrack polynomially on long runs of gsap-like src text. The preview now walks script tags with index searches, decides gsap core from the URL path, reads attributes case-insensitively, and skips tags inside comments and templates.
Comment thread packages/studio-server/src/routes/preview.ts Fixed
…eps with no whitespace regexes

Splits the scan so each step has one job (walk tags, read one script tag, find its closing tag, skip a comment or template), and skips whitespace by character instead of with \s* patterns, which code scanning reads as polynomial.
… so style blocks and odd comments cannot hide gsap

The hand-written walk missed raw-text bodies, empty comments and a comment opener inside an attribute value; any of those before the gsap tag sent the plugin to the head again. findStartTags already skips all of them and is what lazyPreviewImages pairs with the parsed document; the preview now does the same.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 4, 2026 13:17

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

Review at 8ebde352 (full PR).

This is a comment, not an approval. I found no blockers. The fix does what the body says: the gsap core tag is now matched on the URL path, so ?v=1 and #x don't matter, and the plugin tag copies gsap's type and defer, so a deferred gsap runs before the plugin. Pairing findStartTags offsets with linkedom's scripts is a sound way to skip comments, templates and raw text. Edge cases I probed against the head's scanner and linkedom:

  • inside <template>, <noscript>, <noembed>, an SVG <script>, an iframe srcdoc, and an attribute containing >/<script: offsets line up and the plugin lands right after gsap.
  • <xmp> and a --!> comment end: the two parsers disagree on the count (2 vs 1, 1 vs 0), so the guard at preview.ts:179 returns null and the plugin falls back to <head>. That's main's behaviour and fails safe; a browser does close a comment at --!>, linkedom doesn't.

Low

  1. async gsap still breaks motion paths (preview.ts:161). <script async src=".../gsap.min.js"> is detected, but the plugin tag is emitted with no async/defer, so it runs during parsing, usually before gsap arrives. Main does the same; it's a sibling of the defer case this PR fixes. Adding async here wouldn't help (it has no ordering), so either note it or emit the plugin after gsap from an onload.
  2. Two guards have no tests. Mutants that drop the count guard (:179) or the unclosed-script guard (end === 0, :186) leave the suite green. Without the count guard, the <xmp> input above lines gsap up with the <xmp> script's offset, and the plugin goes into raw text and never runs. One test with an <xmp> or --!> input would pin it.
  3. Nit: type is re-emitted as type="${gsap.type}" without escaping (:161), so type='text/x"y' gives a broken attribute. The value is author-controlled and preview-only, so this is cosmetic.

Mutation testing: I ran the 16 motion/gsap/plugin tests in preview.test.ts under bun test; vitest doesn't load this file on my box (the node: builtin issue). 5 of 8 mutants were caught:

  • caught: dropping defer, dropping type, testing the raw src instead of the URL path, inserting before the closing >, and swapping in a naive <script regex instead of the scanner.
  • survived: the count guard, the unclosed guard, and treating async as defer (expected, since nothing covers async).

CI at 8ebde352: 88 pass, 6 skipped, 0 failed. CodeQL left one COMMENTED review on the earlier head c574017e.

What I ran: read the diff and findStartTags in core htmlDocument.ts; ran a scratch probe (since deleted) over 16 HTML inputs through the head's scanner and linkedom; ran the targeted tests and 8 mutants one at a time, restoring the tree after each.

— Somu

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

Approving 8ebde352. Studio's preview now finds the composition's gsap tag by parsed URL path, so a query string or hash no longer sends MotionPathPlugin to <head> ahead of a body gsap, and the plugin tag copies the gsap tag's type and defer so a deferred gsap still runs first. It touches 2 files (preview.ts, preview.test.ts) and is not stacked: the 4 commits are all this PR, and main...8ebde352 shows the same 2 files.

What I verified (head tarball, deps built, NODE_ENV=test)

  • preview.test.ts: 95/95, 3 runs in a row, matching the PR body. tsc --noEmit, oxlint and oxfmt --check are clean.
  • I ran the head tests against the base preview.ts: exactly the 6 new behavior tests fail there (query string, hash, defer, DEFER/TYPE in capitals, gsap in a comment, gsap in a template), so they exercise the fix.
  • I probed injectMotionPathPluginIfNeeded directly with 23 inputs. The plugin tag lands right after the real gsap tag for a query string, a hash, defer, DEFER/SRC in capitals, type="module", a spaced src = "...", a bare gsap.min.js, ./vendor/gsap.js, a protocol-relative URL, and a gsap tag that follows a commented-out one. It falls back to <head> for a .map file, mygsap.js, a ScrollTrigger-only page, a gsap tag inside another script's string, an empty src, a data: URL, an unclosed script and an inlined gsap.
  • I mutated the head 16 ways. 10 are caught: raw src instead of the URL path, no $ anchor, not copying defer, not copying type, defer always on, case-sensitive attribute names, picking the last match, inserting after the start tag instead of the close tag, version read from the wrong place, and a raw <script scan instead of the core scanner. 6 survive (below). One further mutant was equivalent, so I left it out of the count.

Non-blocking

  1. async gsap still breaks motion paths. The tag is found, but the plugin tag is emitted with no ordering, so it can run before gsap arrives. The probe confirms it, and main does the same. The PR text already says it is left alone. A one-line note in the code, or a follow-up, covers it. Somansh raised the same point.
  2. Two guards have no tests. Removing the starts.length !== scripts.length guard, or the unclosed-script guard (end === 0), leaves the suite green. An <xmp> or --!> input would pin the first. Somansh found the same two.
  3. Three small survivors in the matcher and scanner. Making the URL regex case-sensitive (GSAP.MIN.JS), dropping its leading / (so mygsap.js would match), and the closing-tag scan being case-sensitive or skipping the > all stay green. A mygsap.js no-match case is the cheap one to add.
  4. type is re-emitted unescaped. type="a&quot;b" comes out as type="a"b". The value is author-controlled and preview-only, so it is cosmetic. Main did the same.
  5. Behavior widened on purpose, worth knowing. A bare gsap.min.js and a ./vendor/gsap.js now count as gsap core. Before this PR they did not.
  6. Not exercised: I did not re-run the Chrome 152 before/after table in the PR body.

CI. All checks at this head finished green (88 pass, 6 skipped, 0 failing, 0 pending), including the edit-accuracy gate (1556, same as base). CI is a reference; the verdict rests on the evidence above.

Reviewed on the PR head 8ebde352. The only other reviews on it are the code-scanning bot's comment (on an earlier head) and the earlier comment-only review from somanshreddy (Somu) at this head, which found no blockers. This is a review verdict, not authorization to merge or deploy beyond what the gate already does.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 5cee25e Oct 4, 2026
169 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-motionpath-after-any-gsap branch October 4, 2026 14:36
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.

4 participants