diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 51f90ff22..865d66a6d 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1005,6 +1005,32 @@ describe("GSAP rules", () => { expect(finding?.message).toContain("3.00s"); }); + it("gsap_exit_missing_hard_kill points at the inner-wrapper pattern when the exiting selector is a clip element", async () => { + // Regression: a tl.set hard kill on a clip-classed selector is exactly what + // gsap_animates_clip_element then errors on — the two rules must not give + // contradictory advice for a crossfading scene that is itself class="clip". + const html = ` + +
+
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill"); + expect(finding).toBeDefined(); + expect(finding?.fixHint).toContain("clip element"); + expect(finding?.fixHint).toContain("inner"); + expect(finding?.fixHint).not.toContain('tl.set("#scene-a"'); + }); + it("does NOT report gsap_exit_missing_hard_kill for an unresolved-target boundary exit", async () => { // The exit tween targets an element via a value the parser cannot resolve (a helper // call), so it collapses to the `__unresolved__` sentinel. You cannot assert a missing @@ -1353,6 +1379,32 @@ describe("GSAP rules", () => { expect(finding?.elementId).toBe("scene1"); }); + it("scene_layer_missing_visibility_kill points at the inner-wrapper pattern when the scene element is a clip", async () => { + // Same contradiction as gsap_exit_missing_hard_kill above, via the older + // id-pattern-based rule: `tl.set("#scene1", { visibility: "hidden" }, ...)` + // on a class="clip" scene element is exactly what gsap_animates_clip_element + // then errors on. + const html = ` + +
+
+
+
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill"); + expect(finding).toBeDefined(); + expect(finding?.fixHint).toContain("clip element"); + expect(finding?.fixHint).toContain("inner"); + expect(finding?.fixHint).not.toContain('tl.set("#scene1"'); + }); + it("scene_layer_missing_visibility_kill: DOES fire when kill is only in a comment (stripJsComments guard)", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 010c7a5e1..1e7fa2182 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -629,6 +629,20 @@ export const gsapRules: LintRule[] = [ ); if (hasHardKill) continue; + // A tl.set hard kill on the exiting selector itself is the fix — unless + // that selector IS a clip element, in which case gsap_animates_clip_element + // (below) errors on that exact tl.set: the framework already owns + // visibility/display on clip elements. Point at the inner-wrapper + // pattern instead so the two rules' advice doesn't contradict. + const exitClipInfo = + clipIds.get(win.targetSelector) || clipClasses.get(win.targetSelector); + const fixHint = exitClipInfo + ? `"${win.targetSelector}" is a clip element — the framework already manages its visibility. ` + + "Wrap the scene's content in an inner non-clip
, move the exit tween and the hard kill " + + `(\`tl.set("", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\`) onto that wrapper instead.` + : `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` + + "after the exit tween."; + findings.push({ code: "gsap_exit_missing_hard_kill", severity: "error", @@ -636,9 +650,7 @@ export const gsapRules: LintRule[] = [ `GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` + "without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.", selector: win.targetSelector, - fixHint: - `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` + - "after the exit tween.", + fixHint, snippet: truncateSnippet(win.raw), }); } @@ -1014,6 +1026,18 @@ export const gsapRules: LintRule[] = [ const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`); const hasKill = killPattern.test(content); if (!hasKill) { + // A tl.set on "#id" is only safe advice when the scene element isn't + // itself a clip — otherwise gsap_animates_clip_element errors on that + // exact tl.set, since the framework already owns visibility/display on + // clip elements. Point at the inner-wrapper pattern instead. + const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean); + const isClip = classes.includes("clip"); + const fixHint = isClip + ? `"#${id}" is a clip element — the framework already manages its visibility. ` + + "Wrap the scene's content in an inner non-clip
, move the exit tween and the hard kill " + + '(`tl.set("", { visibility: "hidden" }, )`) onto that wrapper instead.' + : `Add \`tl.set("#${id}", { visibility: "hidden" }, )\` after the scene's exit tweens.`; + findings.push({ code: "scene_layer_missing_visibility_kill", severity: "error", @@ -1021,7 +1045,7 @@ export const gsapRules: LintRule[] = [ message: `Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` + "When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.", - fixHint: `Add \`tl.set("#${id}", { visibility: "hidden" }, )\` after the scene's exit tweens.`, + fixHint, }); } }