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>
This commit is contained in:
WaterrrForever
2026-06-22 22:50:04 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0e75eb2510
commit 4db708eb4a
7 changed files with 128 additions and 5 deletions
+41
View File
@@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["c1"] = tl;
document.fonts.ready.then(function () {
tl.from("#editor", { opacity: 0, duration: 0.5 }, 0);
});
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
document.fonts.ready.then(function () {
tl.from("#editor", { opacity: 0, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
});
</script>
</body></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 = `
<html><body>
+37
View File
@@ -767,6 +767,43 @@ export const gsapRules: LintRule<LintContext>[] = [
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 }) => {