From 4db708eb4a570577df758ca227ddfce1f4e6cdfe Mon Sep 17 00:00:00 2001 From: WaterrrForever Date: Mon, 22 Jun 2026 22:50:04 +0800 Subject: [PATCH] fix(core): register sub-composition timelines after async build + lint rule (#1638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): register sub-composition timelines after async build + lint rule When a composition builds its GSAP timeline inside document.fonts.ready (or any async callback), registering window.__timelines[id] BEFORE the build leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate treats "key present" as "ready" and nests the child once — an empty timeline gets nested empty and is never re-nested, so the frame renders blank when used as a sub-composition. - registry/blocks/code-{diff,highlight,morph,scroll,typing}: register the timeline AFTER the fonts.ready build completes, then call window.__hfForceTimelineRebind() to re-nest now that it is populated. - core lint: add rule gsap_timeline_registered_before_async_build to flag the early-registration anti-pattern, with tests. Co-Authored-By: Claude Opus 4.8 (1M context) * test(studio): import commitGsapPositionFromDrag from its actual module The function was split out into gsapDragPositionCommit.ts in #1605, but the test kept importing it from ./gsapDragCommit, which no longer exports it — yielding 'is not a function' at runtime. Import from the correct module. Inherited main breakage (same fix as #1631/#1635); fixes the Test CI check on this branch independently of merge order. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/core/src/lint/rules/gsap.test.ts | 41 +++++++++++++++++++ packages/core/src/lint/rules/gsap.ts | 37 +++++++++++++++++ registry/blocks/code-diff/code-diff.html | 11 ++++- .../blocks/code-highlight/code-highlight.html | 11 ++++- registry/blocks/code-morph/code-morph.html | 11 ++++- registry/blocks/code-scroll/code-scroll.html | 11 ++++- registry/blocks/code-typing/code-typing.html | 11 ++++- 7 files changed, 128 insertions(+), 5 deletions(-) diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index 89a70b524..7898b6643 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -3,6 +3,47 @@ import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; describe("GSAP rules", () => { + it("errors when window.__timelines is registered BEFORE the fonts.ready build", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_timeline_registered_before_async_build", + ); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("does NOT error when window.__timelines is registered AFTER the fonts.ready build", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_timeline_registered_before_async_build", + ); + expect(finding).toBeUndefined(); + }); + it("does NOT error when GSAP animates opacity on a clip element (by id)", async () => { const html = ` diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 257d01af6..1a04efd9a 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -767,6 +767,43 @@ export const gsapRules: LintRule[] = [ return findings; }, + // gsap_timeline_registered_before_async_build — registering window.__timelines[id] + // BEFORE the timeline is built inside document.fonts.ready (or any async callback) + // leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate + // treats "key present" as "ready" and nests the child ONCE, while still empty — so the + // animation never renders when this composition is mounted as a sub-composition. + // Register only AFTER the build completes (the documented async-setup contract). + ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + for (const script of scripts) { + const content = stripJsComments(script.content); + const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/); + if (regIdx < 0) continue; + const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/); + if (fontsReadyIdx < 0) continue; + // Registering after the async boundary is the correct pattern — skip it. + if (regIdx >= fontsReadyIdx) continue; + // Confirm the build is actually deferred past the boundary (a tween/build call + // appears after document.fonts.ready), i.e. the registered timeline starts empty. + const tail = content.slice(fontsReadyIdx); + if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail)) continue; + findings.push({ + code: "gsap_timeline_registered_before_async_build", + severity: "error", + message: + "window.__timelines is assigned BEFORE the timeline is built inside " + + "document.fonts.ready. An empty timeline registered early gets nested empty " + + "when this composition is used as a sub-composition (the readiness gate treats " + + '"key present" as "ready" and never re-nests), so the animation renders blank.', + fixHint: + "Move the `window.__timelines[id] = tl;` assignment to the END of the " + + "document.fonts.ready callback, after the tweens are added. Optionally call " + + "window.__hfForceTimelineRebind() right after, to re-nest the populated timeline.", + }); + } + return findings; + }, + // gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever // fallow-ignore-next-line complexity async ({ styles, scripts, tags }) => { diff --git a/registry/blocks/code-diff/code-diff.html b/registry/blocks/code-diff/code-diff.html index bf482a877..03c5a940c 100644 --- a/registry/blocks/code-diff/code-diff.html +++ b/registry/blocks/code-diff/code-diff.html @@ -790,12 +790,21 @@ var root = document.getElementById("root"); window.__timelines = window.__timelines || {}; var tl = gsap.timeline({ paused: true }); - window.__timelines["code-diff"] = tl; // register synchronously + // Build inside fonts.ready (glyph metrics must be final), then register. + // Register ONLY after the timeline is fully built: the runtime's + // sub-composition readiness gate treats "key present" as "ready" and + // nests the child once. An empty timeline registered before this build + // would be nested empty and never re-nested → blank render when used as + // a sub-composition. See the contract in engine frameCapture.ts. document.fonts.ready.then(function () { tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0); buildEffect(tl, surface, spec); var dur = parseFloat(root.dataset.duration) || tl.duration(); tl.to({}, { duration: dur }, 0); // pad to full composition length + window.__timelines["code-diff"] = tl; // register AFTER setup completes + if (typeof window.__hfForceTimelineRebind === "function") { + window.__hfForceTimelineRebind(); // re-nest now that we are populated + } }); } diff --git a/registry/blocks/code-highlight/code-highlight.html b/registry/blocks/code-highlight/code-highlight.html index 70f7b66a6..1d6dd76e9 100644 --- a/registry/blocks/code-highlight/code-highlight.html +++ b/registry/blocks/code-highlight/code-highlight.html @@ -719,12 +719,21 @@ var root = document.getElementById("root"); window.__timelines = window.__timelines || {}; var tl = gsap.timeline({ paused: true }); - window.__timelines["code-highlight"] = tl; // register synchronously + // Build inside fonts.ready (glyph metrics must be final), then register. + // Register ONLY after the timeline is fully built: the runtime's + // sub-composition readiness gate treats "key present" as "ready" and + // nests the child once. An empty timeline registered before this build + // would be nested empty and never re-nested → blank render when used as + // a sub-composition. See the contract in engine frameCapture.ts. document.fonts.ready.then(function () { tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0); buildEffect(tl, surface, spec); var dur = parseFloat(root.dataset.duration) || tl.duration(); tl.to({}, { duration: dur }, 0); // pad to full composition length + window.__timelines["code-highlight"] = tl; // register AFTER setup completes + if (typeof window.__hfForceTimelineRebind === "function") { + window.__hfForceTimelineRebind(); // re-nest now that we are populated + } }); } diff --git a/registry/blocks/code-morph/code-morph.html b/registry/blocks/code-morph/code-morph.html index 23d0b808b..b79c58439 100644 --- a/registry/blocks/code-morph/code-morph.html +++ b/registry/blocks/code-morph/code-morph.html @@ -1190,12 +1190,21 @@ var root = document.getElementById("root"); window.__timelines = window.__timelines || {}; var tl = gsap.timeline({ paused: true }); - window.__timelines["code-morph"] = tl; // register synchronously + // Build inside fonts.ready (glyph metrics must be final), then register. + // Register ONLY after the timeline is fully built: the runtime's + // sub-composition readiness gate treats "key present" as "ready" and + // nests the child once. An empty timeline registered before this build + // would be nested empty and never re-nested → blank render when used as + // a sub-composition. See the contract in engine frameCapture.ts. document.fonts.ready.then(function () { tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0); buildEffect(tl, surface, spec); var dur = parseFloat(root.dataset.duration) || tl.duration(); tl.to({}, { duration: dur }, 0); // pad to full composition length + window.__timelines["code-morph"] = tl; // register AFTER setup completes + if (typeof window.__hfForceTimelineRebind === "function") { + window.__hfForceTimelineRebind(); // re-nest now that we are populated + } }); } diff --git a/registry/blocks/code-scroll/code-scroll.html b/registry/blocks/code-scroll/code-scroll.html index 1484f7c02..55517a332 100644 --- a/registry/blocks/code-scroll/code-scroll.html +++ b/registry/blocks/code-scroll/code-scroll.html @@ -1534,12 +1534,21 @@ var root = document.getElementById("root"); window.__timelines = window.__timelines || {}; var tl = gsap.timeline({ paused: true }); - window.__timelines["code-scroll"] = tl; // register synchronously + // Build inside fonts.ready (glyph metrics must be final), then register. + // Register ONLY after the timeline is fully built: the runtime's + // sub-composition readiness gate treats "key present" as "ready" and + // nests the child once. An empty timeline registered before this build + // would be nested empty and never re-nested → blank render when used as + // a sub-composition. See the contract in engine frameCapture.ts. document.fonts.ready.then(function () { tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0); buildEffect(tl, surface, spec); var dur = parseFloat(root.dataset.duration) || tl.duration(); tl.to({}, { duration: dur }, 0); // pad to full composition length + window.__timelines["code-scroll"] = tl; // register AFTER setup completes + if (typeof window.__hfForceTimelineRebind === "function") { + window.__hfForceTimelineRebind(); // re-nest now that we are populated + } }); } diff --git a/registry/blocks/code-typing/code-typing.html b/registry/blocks/code-typing/code-typing.html index 62edf975e..5a3075000 100644 --- a/registry/blocks/code-typing/code-typing.html +++ b/registry/blocks/code-typing/code-typing.html @@ -737,12 +737,21 @@ var root = document.getElementById("root"); window.__timelines = window.__timelines || {}; var tl = gsap.timeline({ paused: true }); - window.__timelines["code-typing"] = tl; // register synchronously + // Build inside fonts.ready (glyph metrics must be final), then register. + // Register ONLY after the timeline is fully built: the runtime's + // sub-composition readiness gate treats "key present" as "ready" and + // nests the child once. An empty timeline registered before this build + // would be nested empty and never re-nested → blank render when used as + // a sub-composition. See the contract in engine frameCapture.ts. document.fonts.ready.then(function () { tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0); buildEffect(tl, surface, spec); var dur = parseFloat(root.dataset.duration) || tl.duration(); tl.to({}, { duration: dur }, 0); // pad to full composition length + window.__timelines["code-typing"] = tl; // register AFTER setup completes + if (typeof window.__hfForceTimelineRebind === "function") { + window.__hfForceTimelineRebind(); // re-nest now that we are populated + } }); }