Skip to content

Commit 4db708e

Browse files
fix(core): register sub-composition timelines after async build + lint rule (#1638)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0e75eb2 commit 4db708e

7 files changed

Lines changed: 128 additions & 5 deletions

File tree

packages/core/src/lint/rules/gsap.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,47 @@ import { describe, it, expect } from "vitest";
33
import { lintHyperframeHtml } from "../hyperframeLinter.js";
44

55
describe("GSAP rules", () => {
6+
it("errors when window.__timelines is registered BEFORE the fonts.ready build", async () => {
7+
const html = `
8+
<html><body>
9+
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
10+
<script>
11+
window.__timelines = window.__timelines || {};
12+
var tl = gsap.timeline({ paused: true });
13+
window.__timelines["c1"] = tl;
14+
document.fonts.ready.then(function () {
15+
tl.from("#editor", { opacity: 0, duration: 0.5 }, 0);
16+
});
17+
</script>
18+
</body></html>`;
19+
const result = await lintHyperframeHtml(html);
20+
const finding = result.findings.find(
21+
(f) => f.code === "gsap_timeline_registered_before_async_build",
22+
);
23+
expect(finding).toBeDefined();
24+
expect(finding?.severity).toBe("error");
25+
});
26+
27+
it("does NOT error when window.__timelines is registered AFTER the fonts.ready build", async () => {
28+
const html = `
29+
<html><body>
30+
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
31+
<script>
32+
window.__timelines = window.__timelines || {};
33+
var tl = gsap.timeline({ paused: true });
34+
document.fonts.ready.then(function () {
35+
tl.from("#editor", { opacity: 0, duration: 0.5 }, 0);
36+
window.__timelines["c1"] = tl;
37+
});
38+
</script>
39+
</body></html>`;
40+
const result = await lintHyperframeHtml(html);
41+
const finding = result.findings.find(
42+
(f) => f.code === "gsap_timeline_registered_before_async_build",
43+
);
44+
expect(finding).toBeUndefined();
45+
});
46+
647
it("does NOT error when GSAP animates opacity on a clip element (by id)", async () => {
748
const html = `
849
<html><body>

packages/core/src/lint/rules/gsap.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,43 @@ export const gsapRules: LintRule<LintContext>[] = [
767767
return findings;
768768
},
769769

770+
// gsap_timeline_registered_before_async_build — registering window.__timelines[id]
771+
// BEFORE the timeline is built inside document.fonts.ready (or any async callback)
772+
// leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate
773+
// treats "key present" as "ready" and nests the child ONCE, while still empty — so the
774+
// animation never renders when this composition is mounted as a sub-composition.
775+
// Register only AFTER the build completes (the documented async-setup contract).
776+
({ scripts }) => {
777+
const findings: HyperframeLintFinding[] = [];
778+
for (const script of scripts) {
779+
const content = stripJsComments(script.content);
780+
const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/);
781+
if (regIdx < 0) continue;
782+
const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/);
783+
if (fontsReadyIdx < 0) continue;
784+
// Registering after the async boundary is the correct pattern — skip it.
785+
if (regIdx >= fontsReadyIdx) continue;
786+
// Confirm the build is actually deferred past the boundary (a tween/build call
787+
// appears after document.fonts.ready), i.e. the registered timeline starts empty.
788+
const tail = content.slice(fontsReadyIdx);
789+
if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail)) continue;
790+
findings.push({
791+
code: "gsap_timeline_registered_before_async_build",
792+
severity: "error",
793+
message:
794+
"window.__timelines is assigned BEFORE the timeline is built inside " +
795+
"document.fonts.ready. An empty timeline registered early gets nested empty " +
796+
"when this composition is used as a sub-composition (the readiness gate treats " +
797+
'"key present" as "ready" and never re-nests), so the animation renders blank.',
798+
fixHint:
799+
"Move the `window.__timelines[id] = tl;` assignment to the END of the " +
800+
"document.fonts.ready callback, after the tweens are added. Optionally call " +
801+
"window.__hfForceTimelineRebind() right after, to re-nest the populated timeline.",
802+
});
803+
}
804+
return findings;
805+
},
806+
770807
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
771808
// fallow-ignore-next-line complexity
772809
async ({ styles, scripts, tags }) => {

registry/blocks/code-diff/code-diff.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,12 +790,21 @@
790790
var root = document.getElementById("root");
791791
window.__timelines = window.__timelines || {};
792792
var tl = gsap.timeline({ paused: true });
793-
window.__timelines["code-diff"] = tl; // register synchronously
793+
// Build inside fonts.ready (glyph metrics must be final), then register.
794+
// Register ONLY after the timeline is fully built: the runtime's
795+
// sub-composition readiness gate treats "key present" as "ready" and
796+
// nests the child once. An empty timeline registered before this build
797+
// would be nested empty and never re-nested → blank render when used as
798+
// a sub-composition. See the contract in engine frameCapture.ts.
794799
document.fonts.ready.then(function () {
795800
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
796801
buildEffect(tl, surface, spec);
797802
var dur = parseFloat(root.dataset.duration) || tl.duration();
798803
tl.to({}, { duration: dur }, 0); // pad to full composition length
804+
window.__timelines["code-diff"] = tl; // register AFTER setup completes
805+
if (typeof window.__hfForceTimelineRebind === "function") {
806+
window.__hfForceTimelineRebind(); // re-nest now that we are populated
807+
}
799808
});
800809
}
801810

registry/blocks/code-highlight/code-highlight.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,12 +719,21 @@
719719
var root = document.getElementById("root");
720720
window.__timelines = window.__timelines || {};
721721
var tl = gsap.timeline({ paused: true });
722-
window.__timelines["code-highlight"] = tl; // register synchronously
722+
// Build inside fonts.ready (glyph metrics must be final), then register.
723+
// Register ONLY after the timeline is fully built: the runtime's
724+
// sub-composition readiness gate treats "key present" as "ready" and
725+
// nests the child once. An empty timeline registered before this build
726+
// would be nested empty and never re-nested → blank render when used as
727+
// a sub-composition. See the contract in engine frameCapture.ts.
723728
document.fonts.ready.then(function () {
724729
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
725730
buildEffect(tl, surface, spec);
726731
var dur = parseFloat(root.dataset.duration) || tl.duration();
727732
tl.to({}, { duration: dur }, 0); // pad to full composition length
733+
window.__timelines["code-highlight"] = tl; // register AFTER setup completes
734+
if (typeof window.__hfForceTimelineRebind === "function") {
735+
window.__hfForceTimelineRebind(); // re-nest now that we are populated
736+
}
728737
});
729738
}
730739

registry/blocks/code-morph/code-morph.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,12 +1190,21 @@
11901190
var root = document.getElementById("root");
11911191
window.__timelines = window.__timelines || {};
11921192
var tl = gsap.timeline({ paused: true });
1193-
window.__timelines["code-morph"] = tl; // register synchronously
1193+
// Build inside fonts.ready (glyph metrics must be final), then register.
1194+
// Register ONLY after the timeline is fully built: the runtime's
1195+
// sub-composition readiness gate treats "key present" as "ready" and
1196+
// nests the child once. An empty timeline registered before this build
1197+
// would be nested empty and never re-nested → blank render when used as
1198+
// a sub-composition. See the contract in engine frameCapture.ts.
11941199
document.fonts.ready.then(function () {
11951200
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
11961201
buildEffect(tl, surface, spec);
11971202
var dur = parseFloat(root.dataset.duration) || tl.duration();
11981203
tl.to({}, { duration: dur }, 0); // pad to full composition length
1204+
window.__timelines["code-morph"] = tl; // register AFTER setup completes
1205+
if (typeof window.__hfForceTimelineRebind === "function") {
1206+
window.__hfForceTimelineRebind(); // re-nest now that we are populated
1207+
}
11991208
});
12001209
}
12011210

registry/blocks/code-scroll/code-scroll.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1534,12 +1534,21 @@
15341534
var root = document.getElementById("root");
15351535
window.__timelines = window.__timelines || {};
15361536
var tl = gsap.timeline({ paused: true });
1537-
window.__timelines["code-scroll"] = tl; // register synchronously
1537+
// Build inside fonts.ready (glyph metrics must be final), then register.
1538+
// Register ONLY after the timeline is fully built: the runtime's
1539+
// sub-composition readiness gate treats "key present" as "ready" and
1540+
// nests the child once. An empty timeline registered before this build
1541+
// would be nested empty and never re-nested → blank render when used as
1542+
// a sub-composition. See the contract in engine frameCapture.ts.
15381543
document.fonts.ready.then(function () {
15391544
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
15401545
buildEffect(tl, surface, spec);
15411546
var dur = parseFloat(root.dataset.duration) || tl.duration();
15421547
tl.to({}, { duration: dur }, 0); // pad to full composition length
1548+
window.__timelines["code-scroll"] = tl; // register AFTER setup completes
1549+
if (typeof window.__hfForceTimelineRebind === "function") {
1550+
window.__hfForceTimelineRebind(); // re-nest now that we are populated
1551+
}
15431552
});
15441553
}
15451554

registry/blocks/code-typing/code-typing.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,12 +737,21 @@
737737
var root = document.getElementById("root");
738738
window.__timelines = window.__timelines || {};
739739
var tl = gsap.timeline({ paused: true });
740-
window.__timelines["code-typing"] = tl; // register synchronously
740+
// Build inside fonts.ready (glyph metrics must be final), then register.
741+
// Register ONLY after the timeline is fully built: the runtime's
742+
// sub-composition readiness gate treats "key present" as "ready" and
743+
// nests the child once. An empty timeline registered before this build
744+
// would be nested empty and never re-nested → blank render when used as
745+
// a sub-composition. See the contract in engine frameCapture.ts.
741746
document.fonts.ready.then(function () {
742747
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
743748
buildEffect(tl, surface, spec);
744749
var dur = parseFloat(root.dataset.duration) || tl.duration();
745750
tl.to({}, { duration: dur }, 0); // pad to full composition length
751+
window.__timelines["code-typing"] = tl; // register AFTER setup completes
752+
if (typeof window.__hfForceTimelineRebind === "function") {
753+
window.__hfForceTimelineRebind(); // re-nest now that we are populated
754+
}
746755
});
747756
}
748757

0 commit comments

Comments
 (0)