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