From 676a99cc3e67d3e3db33fa9c9f7c139aaa914cc6 Mon Sep 17 00:00:00 2001 From: QiuLG <195722592+QiuLsG@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:29:03 +0800 Subject: [PATCH] fix(lint): flag static composition hosts without opt-out --- docs/reference/html-schema.mdx | 7 ++ packages/lint/src/project.test.ts | 20 ++++++ packages/lint/src/rules/composition.test.ts | 80 +++++++++++++++++++++ packages/lint/src/rules/composition.ts | 75 ++++++++++++------- 4 files changed, 155 insertions(+), 27 deletions(-) diff --git a/docs/reference/html-schema.mdx b/docs/reference/html-schema.mdx index 883888ad3d..2af697baff 100644 --- a/docs/reference/html-schema.mdx +++ b/docs/reference/html-schema.mdx @@ -297,6 +297,13 @@ A composition using GSAP must: HyperFrames controls seeking. Composition code describes how the visual state looks at a given time. +Every element with `data-composition-id` participates in timeline readiness. +For a static nested section, use an ordinary `id` instead, or add +`data-no-timeline` to declare that no timeline will be registered. Otherwise, +the renderer waits up to the player-ready timeout for that element's timeline. +`hyperframes lint` reports bare composition hosts that have neither a matching +timeline registration nor an explicit opt-out as `missing_data_no_timeline`. + ## Validate ```bash diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 6e0bcd6ca4..1f429f4c99 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -47,6 +47,26 @@ afterEach(() => { dirs = []; }); +describe("missing_data_no_timeline", () => { + it("surfaces bare nested composition hosts through project lint", async () => { + const project = makeProject(` +
+
+
+
+ +`); + + const { results, totalWarnings } = await lintProject(project); + const findings = results[0]?.result.findings.filter( + (finding) => finding.code === "missing_data_no_timeline", + ); + + expect(totalWarnings).toBe(2); + expect(findings?.map((finding) => finding.elementId)).toEqual(["alpha", "beta"]); + }); +}); + describe("external symlink assets", () => { it("does not report a shared asset addressed through an in-project symlink", async () => { const project = makeProject( diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 91452e58ac..eedc8a8836 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -924,6 +924,86 @@ describe("composition rules", () => { expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); }); + it("warns for each bare nested composition id without a timeline or opt-out", async () => { + const html = ` +
+
+
+
+ +`; + const result = await lintHyperframeHtml(html); + const findings = result.findings.filter((f) => f.code === "missing_data_no_timeline"); + + expect(findings).toHaveLength(2); + expect(findings.map((finding) => finding.elementId)).toEqual(["alpha", "beta"]); + expect(findings[0]?.message).toContain('Composition host "alpha"'); + expect(findings[0]?.fixHint).toContain("plain `id`"); + }); + + it("ignores a timeline registration that only appears in a script comment", async () => { + const html = ` +
+
+
+ +`; + const result = await lintHyperframeHtml(html); + + expect(result.findings.find((f) => f.code === "missing_data_no_timeline")?.message).toContain( + 'Composition host "static"', + ); + }); + + it("accepts nested hosts with registrations, sources, or explicit opt-outs", async () => { + const html = ` +
+
+
+
+
+
+ +`; + const result = await lintHyperframeHtml(html); + + expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); + }); + + it("does not guess which host a computed timeline key registers", async () => { + const html = ` +
+
+
+ +`; + const result = await lintHyperframeHtml(html); + + expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); + }); + + it("checks bare nested hosts inside a sub-composition file", async () => { + const html = ``; + const result = await lintHyperframeHtml(html, { isSubComposition: true }); + + expect(result.findings.find((f) => f.code === "missing_data_no_timeline")?.message).toContain( + 'Composition host "static-part"', + ); + }); + it("does not warn when there is no root composition-id", async () => { const html = `

hello

`; const result = await lintHyperframeHtml(html); diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 46b3cc084a..7781d0ca53 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -1,6 +1,7 @@ import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context"; import { findHtmlTag, + extractTimelineRegistryKeys, readAttr, readDecodedAttr, readJsonAttr, @@ -60,6 +61,8 @@ const HEAVY_OVERLAY_EXEMPT_TAGS = new Set([ const HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i; const INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i; +const COMPUTED_TIMELINE_REGISTRATION_PATTERN = + /window\.__timelines\s*\[(?!\s*["'])\s*[^\r\n\]]+\]\s*=/; function readTagTiming(rawTag: string) { return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) }); @@ -258,6 +261,12 @@ function isInsideInertTemplate(tag: OpenTag, tags: readonly OpenTag[]): boolean ); } +function hasComputedTimelineRegistration(scripts: readonly ExtractedBlock[]): boolean { + return scripts.some((script) => + COMPUTED_TIMELINE_REGISTRATION_PATTERN.test(stripJsCode(script.content)), + ); +} + export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries. ({ tags }) => { @@ -613,39 +622,51 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding }, // 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, rawSource, options }) => { - if (options.isSubComposition) return []; + // The producer polls window.__timelines[id] for every composition id in the + // rendered document. A timeline-free root or bare nested composition host + // therefore spends the full 45-second readiness budget unless it explicitly + // opts out with data-no-timeline. + ({ rootTag, rootCompositionId, tags, scripts, rawSource, options }) => { if (!rootCompositionId || !rootTag) return []; - // readAttr only matches valued attrs (attr="..."); data-no-timeline is - // typically boolean (no value). Strip quoted attribute values first to - // avoid matching attr names that appear inside other values - // (e.g. title="add data-no-timeline here"), then check with a boundary - // that rejects hyphenated variants (data-no-timeline-start has '-' next, - // not a word-break char). - const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""'); - if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return []; // Can't scan external script files for timeline registration; skip to avoid // false positives on compositions that register via a bundled JS file. if (/]*\bsrc\s*=/i.test(rawSource)) return []; - const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines[")); - if (registersTimeline) return []; - return [ - { + // A computed key may map to any authored id. When static analysis cannot + // resolve that key, stay silent rather than claim a registration is absent. + if (hasComputedTimelineRegistration(scripts)) return []; + + const registeredIds = new Set( + scripts.flatMap((script) => extractTimelineRegistryKeys(stripJsComments(script.content))), + ); + const findings: HyperframeLintFinding[] = []; + + for (const tag of tags) { + const compositionId = readDecodedAttr(tag.raw, "data-composition-id"); + if (!compositionId || registeredIds.has(compositionId)) continue; + if (readDecodedAttr(tag.raw, "data-no-timeline") !== null) continue; + + const isRoot = tag.index === rootTag.index; + if (isRoot && options.isSubComposition) continue; + if (!isRoot && isInsideInertTemplate(tag, tags)) continue; + if (readAttr(tag.raw, "data-composition-src") || readAttr(tag.raw, "data-composition-file")) { + continue; + } + + findings.push({ 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), - }, - ]; + message: isRoot + ? "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." + : `Composition host "${compositionId}" has neither a matching \`window.__timelines\` registration nor \`data-no-timeline\`. The producer waits up to 45 seconds for every \`data-composition-id\` before rendering.`, + elementId: readAttr(tag.raw, "id") || undefined, + fixHint: isRoot + ? 'Add `data-no-timeline` to the root element to skip the poll: `
`.' + : "If this is a static section, use a plain `id` instead of `data-composition-id`, or add `data-no-timeline`. Otherwise, register its timeline or mount it with `data-composition-src`.", + snippet: truncateSnippet(tag.raw), + }); + } + + return findings; }, // requestanimationframe_in_composition