mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Claude Opus 4.8
parent
0e75eb2510
commit
4db708eb4a
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user