Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/lint/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,26 @@ afterEach(() => {
dirs = [];
});

describe("missing_data_no_timeline", () => {
it("surfaces bare nested composition hosts through project lint", async () => {
const project = makeProject(`<!doctype html><html><body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<section id="alpha" data-composition-id="alpha"></section>
<section id="beta" data-composition-id="beta"></section>
</div>
<script>window.__timelines["root"] = gsap.timeline({ paused: true });</script>
</body></html>`);

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(
Expand Down
80 changes: 80 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!DOCTYPE html><html><body>
<div data-composition-id="root" data-width="320" data-height="180" data-duration="5">
<section id="alpha" data-composition-id="alpha"></section>
<section id="beta" data-composition-id="beta"></section>
</div>
<script>window.__timelines["root"] = gsap.timeline({ paused: true });</script>
</body></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 = `<!DOCTYPE html><html><body>
<div data-composition-id="root" data-width="320" data-height="180" data-duration="5">
<section data-composition-id="static"></section>
</div>
<script>
window.__timelines["root"] = gsap.timeline({ paused: true });
// window.__timelines["static"] = timeline;
</script>
</body></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 = `<!DOCTYPE html><html><body>
<div data-composition-id="root" data-width="320" data-height="180" data-duration="5">
<section data-composition-id="registered"></section>
<section data-composition-id="static" data-no-timeline></section>
<section data-composition-id="source" data-composition-src="source.html"></section>
<section data-composition-id="compiled" data-composition-file="compiled.html"></section>
</div>
<script>
window.__timelines.root = gsap.timeline({ paused: true });
window.__timelines.registered = gsap.timeline({ paused: true });
</script>
</body></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 = `<!DOCTYPE html><html><body>
<div data-composition-id="root" data-width="320" data-height="180" data-duration="5">
<section data-composition-id="scene"></section>
</div>
<script>
const compositionId = "scene";
window.__timelines[compositionId] = (gsap.timeline({ paused: true }));
</script>
</body></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 = `<template>
<div data-composition-id="scene" data-width="320" data-height="180" data-duration="5">
<section data-composition-id="static-part"></section>
</div>
</template>`;
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 = `<!DOCTYPE html><html><body><p>hello</p></body></html>`;
const result = await lintHyperframeHtml(html);
Expand Down
75 changes: 48 additions & 27 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context";
import {
findHtmlTag,
extractTimelineRegistryKeys,
readAttr,
readDecodedAttr,
readJsonAttr,
Expand Down Expand Up @@ -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) });
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 (/<script\b[^>]*\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: `<div data-composition-id="..." data-no-timeline ...>`.',
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: `<div data-composition-id="..." data-no-timeline ...>`.'
: "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
Expand Down