From d740f5ce427d6adb656bd616c7a91d9676b79946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 16:10:38 +0200 Subject: [PATCH] fix: nested GSAP sub-composition lint and render handling (#405) ## Summary - allow nested sub-composition files to inherit GSAP from their host without tripping `missing_gsap_script` - keep nested render seeks stable for sub-compositions without regressing producer baselines - stop producer render-hint detection from treating the compiler's own nested mount retry wrapper as user-authored `requestAnimationFrame()` usage ## Root Cause - the core linter treated template-based nested compositions like standalone root compositions, so it incorrectly required a local GSAP loader even when the host composition already provided GSAP - producer `detectRenderModeHints()` runs before CDN scripts are inlined, so nested GSAP exports were never failing because of the GSAP payload itself - the nested-only false positive came from the compiler-generated mount bootstrap that waits for the inlined sub-composition root with `requestAnimationFrame()` before running the hoisted inline script - preview and export seek paths also needed to stay split so the nested timeline re-arm behavior that stabilizes scrubbing does not collapse render baselines ## What Changed - lint: keep the nested GSAP false-positive fix and regression coverage for template sub-compositions - runtime: keep the render-seek behavior that preserves nested child offsets during export without changing preview scrubbing behavior - producer: mark compiler-owned mount bootstrap blocks and strip only those blocks before scanning inline scripts for raw `requestAnimationFrame()` - producer tests now cover both cases: compiler-generated wrappers are ignored, but real user-authored nested `requestAnimationFrame()` still opts into screenshot mode ## Validation - `bun test packages/core/src/lint/rules/gsap.test.ts` - `bun test packages/producer/src/services/htmlCompiler.test.ts` - `bunx oxfmt packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts` - `bunx oxlint packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts` - `bun run --filter @hyperframes/producer test --sequential chat style-11-prod` - `style-11-prod` passed locally - `chat` still shows local-only visual drift on this macOS/ARM workstation, but the render metadata now reports `renderModeHints.recommendScreenshot=false`, which is the concrete acceptance condition for `#402` - Docker CI-image repro is blocked locally by OrbStack x86/arm64 loader mismatch, so final regression confirmation is deferred to GitHub Actions Closes #392 Closes #402 --- packages/core/src/lint/rules/gsap.test.ts | 36 ++++++ packages/core/src/lint/rules/gsap.ts | 6 +- packages/core/src/runtime/player.test.ts | 6 +- packages/core/src/runtime/player.ts | 22 +++- .../src/services/htmlCompiler.test.ts | 112 ++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 16 ++- 6 files changed, 188 insertions(+), 10 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("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();