fix(lint): check timeline registration before explicit font loads (#5363)

This commit is contained in:
Abdullah M. Nazif
2026-10-24 04:11:29 +00:00
committed by GitHub
parent 14c9eb4487
commit 191caf943a
2 changed files with 81 additions and 6 deletions
+73
View File
@@ -138,6 +138,79 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it.each([
["bracket / load", 'window.__timelines["c1"]', 'document.fonts.load("16px Inter")'],
["dot / ready", "window.__timelines.c1", "document.fonts.ready"],
["dot / load", "window.__timelines.c1", 'document.fonts.load("16px Inter")'],
["spaced dot / load", "window . __timelines . c1", 'document . fonts . load("16px Inter")'],
])("flags early registration with %s", async (_label, registry, fontWait) => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><h1 id="title">Text</h1></div>
<script>
const tl = gsap.timeline({ paused: true });
${registry} = tl;
${fontWait}.then(() => {
tl.to("#title", { x: 20, duration: 1 });
});
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.filter(
(finding) => finding.code === "gsap_timeline_registered_before_async_build",
),
).toEqual([expect.objectContaining({ severity: "error" })]);
});
it.each([
['window.__timelines["c1"]', "document.fonts.ready"],
['window.__timelines["c1"]', 'document.fonts.load("16px Inter")'],
["window.__timelines.c1", "document.fonts.ready"],
["window.__timelines.c1", 'document.fonts.load("16px Inter")'],
])("accepts %s registered after %s builds the timeline", async (registry, fontWait) => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><h1 id="title">Text</h1></div>
<script>
const tl = gsap.timeline({ paused: true });
const existing = ${registry};
${fontWait}.then(() => {
tl.to("#title", { x: 20, duration: 1 });
${registry} = tl;
});
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find(
(finding) => finding.code === "gsap_timeline_registered_before_async_build",
),
).toBeUndefined();
});
it("accepts a fonts.load callback that adds no deferred animation", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><h1 id="title">Text</h1></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 20, duration: 1 });
window.__timelines.c1 = tl;
document.fonts.load("16px Inter").then(() => console.log("fonts loaded"));
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find(
(finding) => finding.code === "gsap_timeline_registered_before_async_build",
),
).toBeUndefined();
});
it("does NOT error when GSAP animates opacity on a clip element (by id)", async () => {
const html = `
<html><body>
+8 -6
View File
@@ -1671,7 +1671,7 @@ export const gsapRules: LintRule<LintContext>[] = [
},
// gsap_timeline_registered_before_async_build — registering window.__timelines[id]
// BEFORE the timeline is built inside document.fonts.ready (or any async callback)
// BEFORE the timeline is built inside a font-ready 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.
@@ -1680,14 +1680,16 @@ export const gsapRules: LintRule<LintContext>[] = [
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/);
const regIdx = content.search(
/window\s*\.\s*__timelines\s*(?:\[[^\]]+\]|\.\s*[A-Za-z_$][\w$]*)\s*=\s*(?!=)/,
);
if (regIdx < 0) continue;
const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/);
const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*(?:ready\b|load\s*\()/);
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.
// appears after the font wait), i.e. the registered timeline starts empty.
const tail = content.slice(fontsReadyIdx);
if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail)) continue;
findings.push({
@@ -1695,12 +1697,12 @@ export const gsapRules: LintRule<LintContext>[] = [
severity: "error",
message:
"window.__timelines is assigned BEFORE the timeline is built inside " +
"document.fonts.ready. An empty timeline registered early gets nested empty " +
"document.fonts.ready or document.fonts.load(). 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 " +
"font-ready callback, after the tweens are added. Optionally call " +
"window.__hfForceTimelineRebind() right after, to re-nest the populated timeline.",
});
}