diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index 1efc3865c9..90d2bc4431 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -673,6 +673,63 @@ describe("core rules", () => { expect(finding).toBeUndefined(); }); + describe("css_transition_used", () => { + it.each([ + ["transition", "opacity 0.5s ease"], + ["transition-duration", "0.5s"], + ["transition-delay", "100ms"], + ["transition-property", "opacity"], + ["-webkit-transition", "opacity 0.5s ease"], + ["-webkit-transition-duration", "0.5s"], + ])("errors for %s declarations in style blocks", async (property, value) => { + const result = await lintHyperframeHtml( + compositionWithBodyPrefix(``), + ); + const finding = result.findings.find((item) => item.code === "css_transition_used"); + + expect(finding).toMatchObject({ severity: "error", selector: ".card" }); + expect(finding?.message).toContain(property); + expect(finding?.fixHint).toContain("paused GSAP timeline"); + }); + + it("errors for an inline transition and identifies its element", async () => { + const result = await lintHyperframeHtml( + compositionWithBodyPrefix( + "", + '
', + ), + ); + const finding = result.findings.find((item) => item.code === "css_transition_used"); + + expect(finding).toMatchObject({ severity: "error", elementId: "card" }); + expect(finding?.snippet).toContain('id="card"'); + }); + + it.each([ + ["transition", "none"], + ["transition-property", "none"], + ["-webkit-transition", "none"], + ["-webkit-transition-property", "NONE"], + ])("allows %s: %s", async (property, value) => { + const result = await lintHyperframeHtml( + compositionWithBodyPrefix(``), + ); + + expect(result.findings.find((item) => item.code === "css_transition_used")).toBeUndefined(); + }); + + it("ignores custom properties that contain transition in their name", async () => { + const result = await lintHyperframeHtml( + compositionWithBodyPrefix( + '
', + "", + ), + ); + + expect(result.findings.find((item) => item.code === "css_transition_used")).toBeUndefined(); + }); + }); + describe("non_deterministic_code", () => { it("gives randomness guidance for crypto and clock guidance for wall time", async () => { const result = await lintHyperframeHtml(` diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index 42c69335d9..a45ee9a0c6 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -144,6 +144,37 @@ function ruleForcesOpacityZero(rule: postcss.Rule): boolean { return forcesOpacityZero; } +const CSS_TRANSITION_PROPERTY_PATTERN = /^(?:-webkit-)?transition(?:-[a-z][a-z-]*)?$/i; + +function isSeekUnsafeTransition(declaration: postcss.Declaration): boolean { + const property = declaration.prop.trim().toLowerCase(); + if (property.startsWith("--") || !CSS_TRANSITION_PROPERTY_PATTERN.test(property)) return false; + + const value = declaration.value.trim().toLowerCase(); + const disablesTransition = + [ + "transition", + "-webkit-transition", + "transition-property", + "-webkit-transition-property", + ].includes(property) && value === "none"; + return !disablesTransition; +} + +function cssTransitionFinding( + declaration: postcss.Declaration, + details: Pick, +): HyperframeLintFinding { + return { + code: "css_transition_used", + severity: "error", + message: `CSS declaration \`${declaration.prop}: ${declaration.value}\` runs on the browser clock and cannot be seeked deterministically across render workers.`, + fixHint: + "Keep the class or attribute swap for state; put the visual change on the paused GSAP timeline.", + ...details, + }; +} + function isStudioTimelineElement(tag: { raw: string; name: string }): boolean { if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) { return false; @@ -380,6 +411,55 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return findings; }, + // css_transition_used + ({ styles, tags }) => { + const findings: HyperframeLintFinding[] = []; + + for (const style of styles) { + if (!/transition/i.test(style.content)) continue; + let root: postcss.Root; + try { + root = postcss.parse(style.content); + } catch { + // The CSS syntax rule reports malformed style blocks separately. + continue; + } + root.walkDecls((declaration) => { + if (!isSeekUnsafeTransition(declaration)) return; + const selector = + declaration.parent?.type === "rule" ? declaration.parent.selector : undefined; + findings.push( + cssTransitionFinding(declaration, { + selector, + snippet: truncateSnippet(declaration.toString()), + }), + ); + }); + } + + for (const tag of tags) { + const inlineStyle = readDecodedAttr(tag.raw, "style"); + if (!inlineStyle || !/transition/i.test(inlineStyle)) continue; + let root: postcss.Root; + try { + root = postcss.parse(inlineStyle); + } catch { + continue; + } + root.walkDecls((declaration) => { + if (!isSeekUnsafeTransition(declaration)) return; + findings.push( + cssTransitionFinding(declaration, { + elementId: readDecodedAttr(tag.raw, "id") || undefined, + snippet: truncateSnippet(tag.raw), + }), + ); + }); + } + + return findings; + }, + // CSS selector safety ({ styles }) => { const findings: HyperframeLintFinding[] = [];