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
This commit is contained in:
Miguel Ángel
2026-04-22 16:10:38 +02:00
committed by GitHub
parent 29b6274ebc
commit d740f5ce42
6 changed files with 188 additions and 10 deletions
+36
View File
@@ -142,6 +142,42 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("does NOT require a local GSAP script for sub-compositions", () => {
const html = `<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title">Hello</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".title", { opacity: 0, duration: 1 });
window.__timelines["intro"] = tl;
</script>
</div>
</template>`;
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 = `<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title">Hello</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".title", { opacity: 0, duration: 1 });
window.__timelines["intro"] = tl;
</script>
</div>
</template>`;
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 = `
<html><body>
+4 -2
View File
@@ -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(`<script ${s.attrs}>`, "src") || "")
.filter(Boolean);
const canInheritGsapFromHost =
options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
const usesGsap = allScriptTexts.some((t) =>
/gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t),
@@ -455,7 +457,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
(t.length > 5000 && /\bgsap\b/i.test(t)),
);
if (!usesGsap || hasGsapScript || hasInlineGsap) return [];
if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost) return [];
return [
{
code: "missing_gsap_script",
+5 -1
View File
@@ -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);
});
});
+16 -6
View File
@@ -83,6 +83,15 @@ function seekMasterAndSiblingTimelinesDeterministically(
}
}
function activateSiblingTimelines(
registry: Record<string, RuntimeTimelineLike | undefined> | 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);
@@ -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 = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
<script>
(function(){
var __compId = "intro";
var __run = function() {
const label = "safe";
};
if (!__compId) { __run(); return; }
/* __HF_COMPILER_MOUNT_START__ */
var __selector = '[data-composition-id="intro"]';
var __attempt = 0;
var __tryRun = function() {
if (document.querySelector(__selector)) { __run(); return; }
if (++__attempt >= 8) { __run(); return; }
requestAnimationFrame(__tryRun);
};
__tryRun();
/* __HF_COMPILER_MOUNT_END__ */
})();
</script>
</body></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 = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
<script>
(function(){
var __compId = "intro";
var __run = function() {
function tick() {
requestAnimationFrame(tick);
}
tick();
};
if (!__compId) { __run(); return; }
/* __HF_COMPILER_MOUNT_START__ */
var __selector = '[data-composition-id="intro"]';
var __attempt = 0;
var __tryRun = function() {
if (document.querySelector(__selector)) { __run(); return; }
if (++__attempt >= 8) { __run(); return; }
requestAnimationFrame(__tryRun);
};
__tryRun();
/* __HF_COMPILER_MOUNT_END__ */
})();
</script>
</body></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"),
`<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080">
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0"></div>
</div>
</body></html>`,
);
writeFileSync(
join(compositionsDir, "intro.html"),
`<template id="intro-template">
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<div data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title">Hello</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["intro"] = gsap.timeline({ paused: true });
</script>
</div>
</template>`,
);
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;
}
});
});
+15 -1
View File
@@ -74,11 +74,23 @@ function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
}
const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\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();