From cfef2c342372edf9377f593f9de41ed9118a4e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 00:48:51 -0400 Subject: [PATCH 1/4] fix: handle nested gsap sub-compositions --- packages/core/src/lint/rules/gsap.test.ts | 36 ++++++++++ packages/core/src/lint/rules/gsap.ts | 6 +- .../src/services/htmlCompiler.test.ts | 67 +++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 22 ++++-- 4 files changed, 123 insertions(+), 8 deletions(-) diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index 5995ac450..af1547d0b 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -142,6 +142,42 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("does NOT require a local GSAP script for sub-compositions", () => { + const html = ``; + + const result = lintHyperframeHtml(html, { isSubComposition: true }); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding).toBeUndefined(); + }); + + it("does NOT require a local GSAP script when a template composition is linted in isolation", () => { + const html = ``; + + const result = lintHyperframeHtml(html, { filePath: "compositions/intro.html" }); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding).toBeUndefined(); + }); + it("ERRORS when GSAP animates visibility on a clip element", () => { const html = ` diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 2e70c27d2..7a6c7b588 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -432,11 +432,13 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ }, // missing_gsap_script - ({ scripts }) => { + ({ scripts, rawSource, options }) => { const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content); const allScriptSrcs = scripts .map((s) => readAttr(` +`; + + const result = detectRenderModeHints(html); + + expect(result.recommendScreenshot).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does not recommend screenshot mode for nested compositions that hoist GSAP from a CDN script", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-render-mode-")); + const compositionsDir = join(projectDir, "compositions"); + mkdirSync(compositionsDir, { recursive: true }); + + writeFileSync( + join(projectDir, "index.html"), + ` + +
+
+
+`, + ); + writeFileSync( + join(compositionsDir, "intro.html"), + ``, + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(async () => { + return new Response( + "window.gsap = { timeline: function() { return { paused: true }; } }; function __ticker(){ requestAnimationFrame(__ticker); }", + { status: 200 }, + ); + }) as any; + + try { + const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + + expect(result.renderModeHints.recommendScreenshot).toBe(false); + expect(result.renderModeHints.reasons).toEqual([]); + expect(result.html).toContain("requestAnimationFrame(__ticker)"); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 0750814d1..263856b76 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -79,6 +79,10 @@ function stripJsComments(source: string): string { return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); } +function isCompilerInlinedGsapScript(source: string): boolean { + return /\/\*\s*inlined:\s+.*\bgsap(?:\.min)?\.js\b/i.test(source); +} + export function detectRenderModeHints(html: string): RenderModeHints { const reasons: RenderModeHint[] = []; const { document } = parseHTML(html); @@ -96,7 +100,9 @@ export function detectRenderModeHints(html: string): RenderModeHints { while ((scriptMatch = scriptPattern.exec(html)) !== null) { const attrs = scriptMatch[1] || ""; if (/\bsrc\s*=/i.test(attrs)) continue; - const content = stripJsComments(scriptMatch[2] || ""); + const rawContent = scriptMatch[2] || ""; + if (isCompilerInlinedGsapScript(rawContent)) continue; + const content = stripJsComments(rawContent); if (!/requestAnimationFrame\s*\(/.test(content)) continue; reasons.push({ code: "requestAnimationFrame", @@ -665,13 +671,17 @@ function inlineSubCompositions( }; if (!__compId) { __run(); return; } var __selector = '[data-composition-id="' + (__compId + '').replace(/"/g, '\\\\"') + '"]'; - var __attempt = 0; - var __tryRun = function() { + var __hfCompilerAttempts = 0; + var __hfCompilerSchedule = + typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame.bind(window) + : function(callback) { return setTimeout(callback, 16); }; + var __hfCompilerRunWhenMounted = function() { if (document.querySelector(__selector)) { __run(); return; } - if (++__attempt >= 8) { __run(); return; } - requestAnimationFrame(__tryRun); + if (++__hfCompilerAttempts >= 8) { __run(); return; } + __hfCompilerSchedule(__hfCompilerRunWhenMounted); }; - __tryRun(); + __hfCompilerRunWhenMounted(); })()`); } scriptEl.remove(); From f2828c0ea64068f96c2c5df77d73536782fc3923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 01:19:24 -0400 Subject: [PATCH 2/4] chore: narrow nested gsap fix to linter --- .../src/services/htmlCompiler.test.ts | 67 ------------------- .../producer/src/services/htmlCompiler.ts | 22 ++---- 2 files changed, 6 insertions(+), 83 deletions(-) diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 67274e9b2..8446c6d7f 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { collectExternalAssets, - compileForRender, detectRenderModeHints, inlineExternalScripts, } from "./htmlCompiler.js"; @@ -344,70 +343,4 @@ describe("detectRenderModeHints", () => { expect(result.recommendScreenshot).toBe(false); expect(result.reasons).toEqual([]); }); - - it("ignores requestAnimationFrame inside compiler-inlined GSAP scripts", () => { - const html = ` - -
- -`; - - const result = detectRenderModeHints(html); - - expect(result.recommendScreenshot).toBe(false); - expect(result.reasons).toEqual([]); - }); - - it("does not recommend screenshot mode for nested compositions that hoist GSAP from a CDN script", async () => { - const projectDir = mkdtempSync(join(tmpdir(), "hf-render-mode-")); - const compositionsDir = join(projectDir, "compositions"); - mkdirSync(compositionsDir, { recursive: true }); - - writeFileSync( - join(projectDir, "index.html"), - ` - -
-
-
-`, - ); - writeFileSync( - join(compositionsDir, "intro.html"), - ``, - ); - - const originalFetch = globalThis.fetch; - globalThis.fetch = mock(async () => { - return new Response( - "window.gsap = { timeline: function() { return { paused: true }; } }; function __ticker(){ requestAnimationFrame(__ticker); }", - { status: 200 }, - ); - }) as any; - - try { - const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); - - expect(result.renderModeHints.recommendScreenshot).toBe(false); - expect(result.renderModeHints.reasons).toEqual([]); - expect(result.html).toContain("requestAnimationFrame(__ticker)"); - } finally { - globalThis.fetch = originalFetch; - } - }); }); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 263856b76..0750814d1 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -79,10 +79,6 @@ function stripJsComments(source: string): string { return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); } -function isCompilerInlinedGsapScript(source: string): boolean { - return /\/\*\s*inlined:\s+.*\bgsap(?:\.min)?\.js\b/i.test(source); -} - export function detectRenderModeHints(html: string): RenderModeHints { const reasons: RenderModeHint[] = []; const { document } = parseHTML(html); @@ -100,9 +96,7 @@ export function detectRenderModeHints(html: string): RenderModeHints { while ((scriptMatch = scriptPattern.exec(html)) !== null) { const attrs = scriptMatch[1] || ""; if (/\bsrc\s*=/i.test(attrs)) continue; - const rawContent = scriptMatch[2] || ""; - if (isCompilerInlinedGsapScript(rawContent)) continue; - const content = stripJsComments(rawContent); + const content = stripJsComments(scriptMatch[2] || ""); if (!/requestAnimationFrame\s*\(/.test(content)) continue; reasons.push({ code: "requestAnimationFrame", @@ -671,17 +665,13 @@ function inlineSubCompositions( }; if (!__compId) { __run(); return; } var __selector = '[data-composition-id="' + (__compId + '').replace(/"/g, '\\\\"') + '"]'; - var __hfCompilerAttempts = 0; - var __hfCompilerSchedule = - typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" - ? window.requestAnimationFrame.bind(window) - : function(callback) { return setTimeout(callback, 16); }; - var __hfCompilerRunWhenMounted = function() { + var __attempt = 0; + var __tryRun = function() { if (document.querySelector(__selector)) { __run(); return; } - if (++__hfCompilerAttempts >= 8) { __run(); return; } - __hfCompilerSchedule(__hfCompilerRunWhenMounted); + if (++__attempt >= 8) { __run(); return; } + requestAnimationFrame(__tryRun); }; - __hfCompilerRunWhenMounted(); + __tryRun(); })()`); } scriptEl.remove(); From d1519e8ffc01d9b82b8d18a98073bd505d478542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 09:29:05 -0400 Subject: [PATCH 3/4] fix(core): keep nested render timelines active during export seek --- packages/core/src/runtime/player.test.ts | 6 +++++- packages/core/src/runtime/player.ts | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index d21b9c831..9ade8ced4 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -381,7 +381,7 @@ describe("createRuntimePlayer", () => { expect(deps.onRenderFrameSeek).toHaveBeenCalled(); }); - it("renderSeek rearms paused siblings before seeking the master timeline", () => { + it("renderSeek rearms paused siblings and keeps them active for export frames", () => { const { master, scene1, scene2, scene5 } = createNestedTimelineHarness(); const deps = createMockDeps(master); const player = createRuntimePlayer({ @@ -390,12 +390,16 @@ describe("createRuntimePlayer", () => { }); player.pause(); player.renderSeek(5); + expect(master.totalTime).toHaveBeenCalledWith(5, false); expect(scene1.time()).toBe(1.5); expect(scene2.time()).toBe(3.5); expect(scene5.time()).toBe(0); expect(scene1.play).toHaveBeenCalledTimes(1); expect(scene2.play).toHaveBeenCalledTimes(1); expect(scene5.play).toHaveBeenCalledTimes(1); + expect(scene1.pause).toHaveBeenCalledTimes(1); + expect(scene2.pause).toHaveBeenCalledTimes(1); + expect(scene5.pause).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index a1d5a354e..d14776b0f 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -83,6 +83,15 @@ function seekMasterAndSiblingTimelinesDeterministically( } } +function activateSiblingTimelines( + registry: Record | undefined | null, + master: RuntimeTimelineLike, +): void { + forEachSiblingTimeline(registry, master, (tl) => { + tl.play(); + }); +} + export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { return { _timeline: null, @@ -156,12 +165,13 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { // their animations advance. Without this, non-GSAP compositions freeze // on their initial frame. const quantized = timeline - ? seekMasterAndSiblingTimelinesDeterministically( - deps.getTimelineRegistry?.(), - timeline, - timeSeconds, - canonicalFps, - ) + ? (() => { + // Export seeks run frame-by-frame through the resolved root timeline. + // If nested siblings stay paused, GSAP collapses the root back to the + // authored master duration and later frames clamp incorrectly. + activateSiblingTimelines(deps.getTimelineRegistry?.(), timeline); + return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps); + })() : quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps); deps.onDeterministicSeek(quantized); deps.setIsPlaying(false); From e5ee1541c2b36db9409bf419a283af8a2bae4524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 09:50:32 -0400 Subject: [PATCH 4/4] fix(producer): ignore compiler mount bootstrap in render hints --- .../src/services/htmlCompiler.test.ts | 112 ++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 16 ++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 8446c6d7f..6653c84ce 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { collectExternalAssets, + compileForRender, detectRenderModeHints, inlineExternalScripts, } from "./htmlCompiler.js"; @@ -343,4 +344,115 @@ describe("detectRenderModeHints", () => { expect(result.recommendScreenshot).toBe(false); expect(result.reasons).toEqual([]); }); + + it("ignores compiler-generated nested mount wrappers when detecting requestAnimationFrame", () => { + const html = ` + +
+ +`; + + const result = detectRenderModeHints(html); + + expect(result.recommendScreenshot).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("still flags user-authored requestAnimationFrame inside nested composition scripts", () => { + const html = ` + +
+ +`; + + const result = detectRenderModeHints(html); + + expect(result.recommendScreenshot).toBe(true); + expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]); + }); + + it("does not recommend screenshot mode for nested compositions that hoist GSAP from a CDN script", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-render-mode-")); + const compositionsDir = join(projectDir, "compositions"); + mkdirSync(compositionsDir, { recursive: true }); + + writeFileSync( + join(projectDir, "index.html"), + ` + +
+
+
+`, + ); + writeFileSync( + join(compositionsDir, "intro.html"), + ``, + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(async () => { + return new Response( + "window.gsap = { timeline: function() { return { paused: true }; } }; function __ticker(){ requestAnimationFrame(__ticker); }", + { status: 200 }, + ); + }) as any; + + try { + const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + + expect(result.renderModeHints.recommendScreenshot).toBe(false); + expect(result.renderModeHints.reasons).toEqual([]); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 0750814d1..892c292de 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -74,11 +74,23 @@ function dedupeElementsById(elements: T[]): T[] { } const INLINE_SCRIPT_PATTERN = /]*)>([\s\S]*?)<\/script>/gi; +const COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */"; +const COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */"; function stripJsComments(source: string): string { return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); } +function stripCompilerMountBootstrap(source: string): string { + return source.replace( + new RegExp( + `${COMPILER_MOUNT_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${COMPILER_MOUNT_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, + "g", + ), + "", + ); +} + export function detectRenderModeHints(html: string): RenderModeHints { const reasons: RenderModeHint[] = []; const { document } = parseHTML(html); @@ -96,7 +108,7 @@ export function detectRenderModeHints(html: string): RenderModeHints { while ((scriptMatch = scriptPattern.exec(html)) !== null) { const attrs = scriptMatch[1] || ""; if (/\bsrc\s*=/i.test(attrs)) continue; - const content = stripJsComments(scriptMatch[2] || ""); + const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || "")); if (!/requestAnimationFrame\s*\(/.test(content)) continue; reasons.push({ code: "requestAnimationFrame", @@ -664,6 +676,7 @@ function inlineSubCompositions( } }; if (!__compId) { __run(); return; } + ${COMPILER_MOUNT_BLOCK_START} var __selector = '[data-composition-id="' + (__compId + '').replace(/"/g, '\\\\"') + '"]'; var __attempt = 0; var __tryRun = function() { @@ -672,6 +685,7 @@ function inlineSubCompositions( requestAnimationFrame(__tryRun); }; __tryRun(); + ${COMPILER_MOUNT_BLOCK_END} })()`); } scriptEl.remove();