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(`
+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 (/