diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/core/src/lint/rules/composition.test.ts index 8ede1c0d8..b9beca01a 100644 --- a/packages/core/src/lint/rules/composition.test.ts +++ b/packages/core/src/lint/rules/composition.test.ts @@ -591,6 +591,44 @@ describe("composition rules", () => { }); }); + describe("missing_data_no_timeline", () => { + it("warns when root has no timeline registration and no data-no-timeline", async () => { + const html = ` +
+`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "missing_data_no_timeline"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("does not warn when data-no-timeline is present (boolean form)", async () => { + const html = ` +
+`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); + }); + + it("does not warn when a script registers window.__timelines[id]", async () => { + const html = ` +
+ +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); + }); + + it("does not warn when there is no root composition-id", async () => { + const html = `

hello

`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); + }); + }); + describe("root_composition_missing_data_duration (removed)", () => { // The rule was a static proxy for the runtime's loop-inflation Infinity // emission, but lint cannot observe GSAP timeline duration statically and diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts index b4b9c94db..5fe924bed 100644 --- a/packages/core/src/lint/rules/composition.ts +++ b/packages/core/src/lint/rules/composition.ts @@ -393,6 +393,31 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding return findings; }, + // missing_data_no_timeline + // The producer polls window.__timelines[id] with a 45-second timeout waiting + // for GSAP timeline registration. Compositions that never call + // window.__timelines[id] = tl stall for 45 s every render. Adding + // data-no-timeline to the root element tells the producer to skip the poll. + ({ rootTag, rootCompositionId, scripts }) => { + if (!rootCompositionId || !rootTag) return []; + if (/\bdata-no-timeline\b/.test(rootTag.raw)) return []; + const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines[")); + if (registersTimeline) return []; + return [ + { + code: "missing_data_no_timeline", + severity: "warning", + message: + "This composition has no `window.__timelines` registration but is missing `data-no-timeline`. " + + "The producer polls for timeline registration for up to 45 seconds before timing out, " + + "adding 45 s to every render.", + fixHint: + 'Add `data-no-timeline` to the root element to skip the poll: `
`.', + snippet: truncateSnippet(rootTag.raw), + }, + ]; + }, + // requestanimationframe_in_composition ({ scripts }) => { const findings: HyperframeLintFinding[] = [];