diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 23c488dd3a..6587c51596 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -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 = `
+
+ Text
+
+`;
+
+ 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 = `
+
+ Text
+
+`;
+
+ 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 = `
+
+ Text
+
+`;
+
+ 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 = `
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index fa11b63255..b6d222b81e 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -1671,7 +1671,7 @@ export const gsapRules: LintRule[] = [
},
// 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[] = [
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[] = [
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.",
});
}