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

* fix(studio-server): motion paths animate when gsap has a query string 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.

* fix(studio-server): finding the gsap tag stays fast on any preview HTML

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.

* refactor(studio-server): the gsap tag scan is small single-purpose steps 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.

* fix(studio-server): the gsap tag search reuses the core HTML scanner, 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.
This commit is contained in:
Miguel Ángel
2026-10-04 14:25:09 +00:00
committed by GitHub
parent 4a5e38eed6
commit 5cee25efcb
2 changed files with 144 additions and 9 deletions
@@ -292,6 +292,103 @@ describe("registerPreviewRoutes", () => {
expect(plugin?.getAttribute("type")).toBe(AFTER_FONTS_SCRIPT_TYPE);
});
async function previewScriptSrcs(bodyGsapTag: string) {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head></head><body><div id="card" class="clip"></div>
${bodyGsapTag}
<script>
window.__timelines = { index: gsap.timeline({ paused: true }).to("#card", { motionPath: { path: [{ x: 100, y: 50 }] } }) };
</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
return [...parseHTML(html).document.querySelectorAll("script[src]")];
}
it.each([
["a query string", "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js?v=1"],
["a hash", "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.js#core"],
])("puts the MotionPathPlugin right after a body gsap whose URL has %s", async (_, src) => {
const scripts = await previewScriptSrcs(`<script src="${src}"></script>`);
const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === src);
expect(gsapAt).toBeGreaterThan(-1);
expect(scripts[gsapAt + 1]?.getAttribute("src")).toContain(
"gsap@3/dist/MotionPathPlugin.min.js",
);
});
it("defers the MotionPathPlugin with a deferred body gsap, so it runs after gsap", async () => {
const scripts = await previewScriptSrcs(
`<script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`,
);
const gsapAt = scripts.findIndex((el) => el.getAttribute("src")?.endsWith("/gsap.min.js"));
const plugin = scripts[gsapAt + 1];
expect(plugin?.getAttribute("src")).toContain("MotionPathPlugin.min.js");
expect(plugin?.hasAttribute("defer")).toBe(true);
});
it("does not read defer out of another attribute value", async () => {
const scripts = await previewScriptSrcs(
`<script data-note="do not defer this" src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`,
);
const plugin = scripts.find((el) => el.getAttribute("src")?.includes("MotionPathPlugin"));
expect(plugin?.hasAttribute("defer")).toBe(false);
});
it("finds no gsap tag in a long run of gsap-like src text without stalling", async () => {
const scripts = await previewScriptSrcs(`<script src="${"/gsap.js#".repeat(50_000)}`);
expect(scripts.some((el) => el.getAttribute("src")?.includes("MotionPathPlugin"))).toBe(true);
});
it.each([
["a style block", `<style>/* <script src="x"></script> */ .a { color: red; }</style>`],
["an empty comment", `<!--> <p>text</p>`],
["a comment start inside an attribute", `<div title="<!--"></div>`],
])("still finds the live gsap tag after %s", async (_, before) => {
const live = "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js";
const scripts = await previewScriptSrcs(`${before}\n <script src="${live}"></script>`);
const liveAt = scripts.findIndex((el) => el.getAttribute("src") === live);
expect(scripts[liveAt + 1]?.getAttribute("src")).toContain("MotionPathPlugin");
});
it("ignores a gsap-named file that is not gsap core", async () => {
const scripts = await previewScriptSrcs(
`<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js.map"></script>`,
);
const plugin = scripts.findIndex((el) => el.getAttribute("src")?.includes("MotionPathPlugin"));
const map = scripts.findIndex((el) => el.getAttribute("src")?.endsWith(".map"));
expect(plugin).toBeLessThan(map);
});
it("reads DEFER and TYPE written in capitals", async () => {
const scripts = await previewScriptSrcs(
`<script TYPE="text/javascript" DEFER SRC="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`,
);
const plugin = scripts.find((el) => el.getAttribute("src")?.includes("MotionPathPlugin"));
expect(plugin?.hasAttribute("defer")).toBe(true);
expect(plugin?.getAttribute("type")).toBe("text/javascript");
});
it.each([
[
"a comment",
`<!-- a > b <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> -->`,
],
[
"a template",
`<template><template></template><script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script></template>`,
],
])("skips a gsap tag inside %s and follows the live one", async (_, inert) => {
const live = "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js?live";
const scripts = await previewScriptSrcs(`${inert}\n <script src="${live}"></script>`);
const liveAt = scripts.findIndex((el) => el.getAttribute("src") === live);
expect(scripts[liveAt + 1]?.getAttribute("src")).toContain("MotionPathPlugin");
});
it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
+47 -9
View File
@@ -1,4 +1,5 @@
import type { Hono } from "hono";
import { parseHTML } from "linkedom";
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { Readable } from "node:stream";
import { join, resolve } from "node:path";
@@ -12,7 +13,7 @@ import {
} from "@hyperframes/core/compiler";
import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
import { isProjectRootMissing, resolveWithinProject } from "../helpers/safePath.js";
@@ -154,18 +155,55 @@ function injectMotionPathPluginIfNeeded(html: string): string {
// core gsap script — which often lives at body-end, not <head>. Insert it
// directly after the gsap script tag; only fall back to <head> if none is found
// (e.g. gsap is inlined).
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
const match = html.match(gsapScript);
if (match) {
const version = match[0].match(/gsap@([\d.]+)/)?.[1];
const gsapTagType = match[0].match(/\stype=("[^"]*"|'[^']*')/i)?.[0] ?? "";
const pluginTag = `<script${gsapTagType} src="${motionPathPluginUrl(version)}"></script>`;
const end = html.indexOf(match[0]) + match[0].length;
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
const gsap = findGsapCoreScript(html);
if (gsap) {
const version = gsap.src.match(/gsap@([\d.]+)/)?.[1];
const ordering = `${gsap.type ? ` type="${gsap.type}"` : ""}${gsap.defer ? " defer" : ""}`;
const pluginTag = `<script${ordering} src="${motionPathPluginUrl(version)}"></script>`;
return html.slice(0, gsap.end) + "\n" + pluginTag + html.slice(gsap.end);
}
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
}
function isGsapCoreUrl(src: string): boolean {
const base = "http://localhost/";
return URL.canParse(src, base) && /\/gsap(\.min)?\.js$/i.test(new URL(src, base).pathname);
}
type GsapCoreScript = { src: string; type: string | null; defer: boolean; end: number };
function findGsapCoreScript(html: string): GsapCoreScript | null {
const scripts = [...parseHTML(html).document.querySelectorAll("script")];
// The scanner skips comments, raw text and templates, so its `<script` offsets line up with the DOM's scripts.
const starts = findStartTags(html, "script");
if (starts.length !== scripts.length) return null;
const at = scripts.findIndex((script) =>
isGsapCoreUrl(lowerCaseAttributes(script).get("src") ?? ""),
);
if (at === -1) return null;
const attributes = lowerCaseAttributes(scripts[at]!);
const end = scriptCloseEnd(html, starts[at]!);
if (end === 0) return null;
return {
src: attributes.get("src")!,
type: attributes.get("type") ?? null,
defer: attributes.has("defer"),
end,
};
}
function lowerCaseAttributes(el: Element): Map<string, string> {
return new Map([...el.attributes].map((a) => [a.name.toLowerCase(), a.value]));
}
/** Index just past the `</script>` that closes the script starting at `start`; 0 when it never closes. */
function scriptCloseEnd(html: string, start: number): number {
const close = /<\/script/gi;
close.lastIndex = start;
const closeAt = close.exec(html)?.index;
return closeAt === undefined ? 0 : html.indexOf(">", closeAt) + 1;
}
function injectStudioMotionDependencies(html: string, manifestContent: string): string {
const manifest = parseStudioMotionManifestContent(manifestContent);
if (!manifest.hasMotion) return html;