From 685cd75a92f6c48239fcffc9a9764d62ac6f6ba5 Mon Sep 17 00:00:00 2001 From: James Russo Date: Sun, 25 Oct 2026 02:37:08 +0000 Subject: [PATCH] fix(studio): load GSAP core when only a GSAP plugin is on the page (#6260) * fix(studio): load GSAP core when only a GSAP plugin is on the page The Studio preview checked for GSAP core by matching "gsap" anywhere in a script src, so the MotionPathPlugin it had just injected (served from gsap@x/dist/) counted as core. A composition with a motion path and Studio-edited motion but no GSAP script of its own then got the plugin and no core, and its animations did not run. Detect core by file name (gsap.js, gsap.min.js, or a vendored gsap-.min.js) on any host or path, and inject Studio motion's core before the MotionPathPlugin so the plugin can register onto it. Apply the same core-only check to the sub-composition preview fallback and the missing_gsap_script lint rule. Refs #6205 Co-Authored-By: Claude Opus 5.5 (1M context) * refactor(studio): keep GSAP core detection within the comment budget Co-Authored-By: Claude Opus 5.5 (1M context) * fix(studio): load GSAP core ahead of head plugins and only on a real core script Studio motion and sub-composition previews now insert GSAP core before the first GSAP plugin script in the head, so an authored MotionPathPlugin or CustomEase registers on core instead of running first. Core detection keys on a core script (src, inlined marker, license banner or library body), not on usage like gsap.registerPlugin() or gsap.defaults(). The missing_gsap_script lint rule drops the same usage-based and plugin-banner shortcuts. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- packages/lint/src/rules/gsap.test.ts | 65 ++++++++++++- packages/lint/src/rules/gsap.ts | 20 ++-- .../src/helpers/gsapCoreScript.test.ts | 97 +++++++++++++++++++ .../src/helpers/gsapCoreScript.ts | 57 +++++++++++ .../src/helpers/subComposition.test.ts | 15 +++ .../src/helpers/subComposition.ts | 6 +- .../studio-server/src/routes/preview.test.ts | 61 ++++++++++++ packages/studio-server/src/routes/preview.ts | 73 +++++++------- 8 files changed, 340 insertions(+), 54 deletions(-) create mode 100644 packages/studio-server/src/helpers/gsapCoreScript.test.ts create mode 100644 packages/studio-server/src/helpers/gsapCoreScript.ts diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 6587c51596..88352e98d6 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1008,9 +1008,42 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("reports missing_gsap_script when only a GSAP plugin script is loaded", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding?.severity).toBe("error"); + }); + + it.each(["/api/vendor/npm/gsap@3.15.0/dist/gsap.min.js", "assets/gsap-3.14.2.min.js"])( + "does not report missing_gsap_script for the local GSAP core %s", + async (src) => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_gsap_script")).toBeUndefined(); + }, + ); + it("does not report missing_gsap_script when GSAP is bundled inline", async () => { - // Simulate a large inline GSAP bundle (>5KB) with GreenSock marker - const fakeGsapLib = "/* GreenSock GSAP */" + " ".repeat(6000); + const fakeGsapLib = "/*!\n * GSAP 3.15.0\n * https://gsap.com\n */\n" + " ".repeat(6000); const html = `
@@ -1027,6 +1060,34 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it.each([ + [ + "registerPlugin", + "gsap.registerPlugin(MotionPathPlugin);", + '', + ], + ["defaults", 'gsap.defaults({ ease: "none" });', ""], + ["a large GreenSock comment", "/* GreenSock */" + " ".repeat(6000), ""], + [ + "an inlined plugin", + "/* inlined: https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js */", + "", + ], + ])("reports missing_gsap_script when the only inline GSAP is %s", async (_name, inline, tag) => { + const html = ` + +
+ ${tag} + + +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_gsap_script")?.severity).toBe("error"); + }); + it("does not report missing_gsap_script when producer inlined CDN script", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index b6d222b81e..3897031f39 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1401,18 +1401,18 @@ export const gsapRules: LintRule[] = [ const usesGsap = allScriptTexts.some((t) => /gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t), ); - const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src)); - // Detect GSAP bundled inline (no src attribute). Match: - // - Producer's CDN-inlining comment: /* inlined: ...gsap... */ - // - GSAP library internals: _gsScope, GreenSock, gsap.config - // - Large inline scripts (>5KB) that reference gsap (likely bundled library) + const isGsapCoreSrc = (src: string) => + /(^|\/)gsap(-\d+(\.\d+)*)?(\.min)?\.js$/i.test(src.split(/[?#]/)[0] ?? ""); + const hasGsapScript = allScriptSrcs.some(isGsapCoreSrc); + // Inline core is its inlining marker, license banner or library body, never usage like gsap.defaults(). const hasInlineGsap = allScriptTexts.some( (t) => - /\/\*\s*inlined:.*gsap/i.test(t) || - /\b_gsScope\b/.test(t) || - /\bGreenSock\b/.test(t) || - /\bgsap\.(config|defaults|version)\b/.test(t) || - (t.length > 5000 && /\bgsap\b/i.test(t)), + [...t.matchAll(/\/\*\s*inlined:\s*(\S+)\s*\*\//gi)].some((m) => + isGsapCoreSrc(m[1] ?? ""), + ) || + /\/\*!\s*\*\s*GSAP\s+\d/.test(t) || + /Missing plugin\? gsap\.registerPlugin\(\)/.test(t) || + /\b_gsScope\b/.test(t), ); if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost) return []; diff --git a/packages/studio-server/src/helpers/gsapCoreScript.test.ts b/packages/studio-server/src/helpers/gsapCoreScript.test.ts new file mode 100644 index 0000000000..52a17bef6d --- /dev/null +++ b/packages/studio-server/src/helpers/gsapCoreScript.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { htmlHasGsapCore, insertGsapCoreBeforeHeadPlugin } from "./gsapCoreScript"; + +const script = (src: string) => ``; +const PLUGIN = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js"; +const CORE = ``; + +describe("htmlHasGsapCore", () => { + it.each([ + "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js", + "https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.js", + "/api/vendor/npm/gsap@3.15.0/dist/gsap.min.js", + "./vendor/gsap.min.js", + "assets/gsap-3.14.2.min.js", + "gsap.min.js?v=2", + ])("finds GSAP core at %s", (src) => { + expect(htmlHasGsapCore(script(src))).toBe(true); + }); + + it.each([ + "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js", + "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/CustomEase.min.js", + "/api/vendor/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js", + "./vendor/gsap/ScrollTrigger.js", + ])("does not treat the plugin %s as GSAP core", (src) => { + expect(htmlHasGsapCore(script(src))).toBe(false); + }); + + it("reads core from a producer inlining marker, but not a plugin's", () => { + const inlined = (src: string) => ``; + expect(htmlHasGsapCore(inlined("https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"))).toBe( + true, + ); + expect( + htmlHasGsapCore(inlined("https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js")), + ).toBe(false); + }); + + it("reads core from its license banner, but not a plugin's", () => { + const banner = (name: string) => + ``; + expect(htmlHasGsapCore(banner("GSAP"))).toBe(true); + expect(htmlHasGsapCore(banner("MotionPathPlugin"))).toBe(false); + }); + + it.each([ + "gsap.registerPlugin(MotionPathPlugin);", + 'gsap.defaults({ ease: "none" }); gsap.config({ nullTargetWarn: false });', + 'gsap.to("#a", { x: 1 }); /* GreenSock */', + ])("does not treat the authored usage %s as GSAP core", (usage) => { + const html = ``; + expect(htmlHasGsapCore(html)).toBe(false); + }); + + it("reads core from its library body", () => { + const body = `function S(t,e){console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}`; + expect(htmlHasGsapCore(``)).toBe(true); + }); + + it("ignores GSAP core inside a