From ffb04457b14800f07e6dc1374d4519b1f99a9c90 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Mon, 22 Jun 2026 17:56:51 +0800 Subject: [PATCH 1/2] fix(core): register sub-composition timelines after async build + lint rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- 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 89a70b5247..7898b66430 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 257d01af69..1a04efd9a4 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 bf482a8779..03c5a940c3 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 70f7b66a6c..1d6dd76e94 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 23d0b808bd..b79c584392 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 1484f7c02f..55517a332c 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 62edf975e1..5a30750005 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 + } }); } From 4099b4c0bc30ee5acc7241d531ac3e1b0dbc879d Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Mon, 22 Jun 2026 18:12:37 +0800 Subject: [PATCH 2/2] test(studio): import commitGsapPositionFromDrag from its actual module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/studio/src/hooks/gsapDragCommit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts index fab857dd22..cc0cd4479c 100644 --- a/packages/studio/src/hooks/gsapDragCommit.test.ts +++ b/packages/studio/src/hooks/gsapDragCommit.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it, beforeEach } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { - commitGsapPositionFromDrag, commitStaticGsapPosition, commitStaticGsapRotation, parkPlayheadOnKeyframe, type GsapDragCommitCallbacks, } from "./gsapDragCommit"; +import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit"; import { usePlayerStore } from "../player/store/playerStore"; // Minimal selection whose element has no drag-baseline attributes (origX/Y = 0).