fix(studio-server): motion paths animate when gsap has a query string or loads deferred - #5007
Conversation
… 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.
8b44dbb to
66512e3
Compare
Edit accuracy: accurate 1556 (base branch 1556), smooth 1379 of thoseThe gate passes. 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.
…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.
There was a problem hiding this comment.
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 iframesrcdoc, 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 atpreview.ts:179returns 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
asyncgsap still breaks motion paths (preview.ts:161).<script async src=".../gsap.min.js">is detected, but the plugin tag is emitted with noasync/defer, so it runs during parsing, usually before gsap arrives. Main does the same; it's a sibling of thedefercase this PR fixes. Addingasynchere wouldn't help (it has no ordering), so either note it or emit the plugin after gsap from anonload.- 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. - Nit:
typeis re-emitted astype="${gsap.type}"without escaping (:161), sotype='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, droppingtype, testing the rawsrcinstead of the URL path, inserting before the closing>, and swapping in a naive<scriptregex instead of the scanner. - survived: the count guard, the unclosed guard, and treating
asyncasdefer(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
left a comment
There was a problem hiding this comment.
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,oxlintandoxfmt --checkare clean.- I ran the head tests against the base
preview.ts: exactly the 6 new behavior tests fail there (query string, hash,defer,DEFER/TYPEin capitals, gsap in a comment, gsap in a template), so they exercise the fix. - I probed
injectMotionPathPluginIfNeededdirectly with 23 inputs. The plugin tag lands right after the real gsap tag for a query string, a hash,defer,DEFER/SRCin capitals,type="module", a spacedsrc = "...", a baregsap.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.mapfile,mygsap.js, a ScrollTrigger-only page, a gsap tag inside another script's string, an emptysrc, adata:URL, an unclosed script and an inlined gsap. - I mutated the head 16 ways. 10 are caught: raw
srcinstead of the URL path, no$anchor, not copyingdefer, not copyingtype,deferalways 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<scriptscan instead of the core scanner. 6 survive (below). One further mutant was equivalent, so I left it out of the count.
Non-blocking
asyncgsap 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.- Two guards have no tests. Removing the
starts.length !== scripts.lengthguard, or the unclosed-script guard (end === 0), leaves the suite green. An<xmp>or--!>input would pin the first. Somansh found the same two. - Three small survivors in the matcher and scanner. Making the URL regex case-sensitive (
GSAP.MIN.JS), dropping its leading/(somygsap.jswould match), and the closing-tag scan being case-sensitive or skipping the>all stay green. Amygsap.jsno-match case is the cheap one to add. typeis re-emitted unescaped.type="a"b"comes out astype="a"b". The value is author-controlled and preview-only, so it is cosmetic. Main did the same.- Behavior widened on purpose, worth knowing. A bare
gsap.min.jsand a./vendor/gsap.jsnow count as gsap core. Before this PR they did not. - 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)
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 adefergsap 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.jsorgsap.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 carrydefer, 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
deferand the URL forms.How
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)./gsap.jsor/gsap.min.js, decided by the URL parser, so a query string or hash never matters.typeanddefer, read with the HTML parser the server already uses (linkedom), attribute names compared case-insensitively, soDEFERcounts and an attribute value that merely contains the word "defer" does not.asyncgsap 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:<script src=".../gsap.min.js?v=1"><script defer src=".../gsap.min.js">, composition code onDOMContentLoadedTest 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/TYPEin capitals are read;deferinside 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.mapfile 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<scriptscan instead of the core scanner, the earlier regex) each turn their tests red.