Skip to content
Merged
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
57 changes: 57 additions & 0 deletions packages/lint/src/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,32 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
});

it("does NOT report overlapping_gsap_tweens for distinct unresolved-target tweens", async () => {
// Each tween targets a DIFFERENT element via a target the parser cannot resolve
// statically (a helper call). Both collapse to the `__unresolved__` sentinel, but
// they are not the same element, so an overlap must not be asserted between them.
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="s0"><div class="hl"><span class="w">a</span></div></div>
<div id="s1"><div class="hl"><span class="w">b</span></div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const a = pickWord(0);
const b = pickWord(1);
tl.to(a, { x: 100, duration: 1 }, 0);
tl.to(b, { x: 100, duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
expect(finding).toBeUndefined();
});

it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => {
const html = `
<html><body>
Expand Down Expand Up @@ -979,6 +1005,37 @@ describe("GSAP rules", () => {
expect(finding?.message).toContain("3.00s");
});

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
// hard kill on an unknown element, and a `tl.set("__unresolved__", ...)` hint is
// meaningless. The resolved-target exit in the same timeline is still flagged.
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
<div id="scene-a" class="clip" data-start="0" data-duration="3" data-track-index="0">
<h1 id="headline">First beat</h1>
</div>
<div id="scene-b" class="clip" data-start="3" data-duration="3" data-track-index="0">
<h1>Second beat</h1>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const el = pickWord(0);
tl.to(el, { opacity: 0, duration: 0.3 }, 2.7);
tl.to("#headline", { opacity: 0, duration: 0.3 }, 2.7);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const exitFindings = result.findings.filter((f) => f.code === "gsap_exit_missing_hard_kill");
expect(exitFindings).toHaveLength(1);
expect(exitFindings[0]?.selector).toBe("#headline");
});

it("does not warn when a boundary exit has a matching hard kill", async () => {
const html = `
<html><body>
Expand Down
12 changes: 12 additions & 0 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ type CompositionRange = {

const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;

// Sentinel the GSAP parser assigns to a tween whose target it cannot statically
// resolve to a concrete element (a computed variable, a helper call, etc.). It is
// NOT an identity: two distinct unresolved selectors are not the same element, so
// overlap analysis must never treat them as one.
const UNRESOLVED_TARGET = "__unresolved__";

// ── GSAP parsing utilities ─────────────────────────────────────────────────

function countClassUsage(tags: OpenTag[]): Map<string, number> {
Expand Down Expand Up @@ -546,6 +552,9 @@ export const gsapRules: LintRule<LintContext>[] = [
const left = gsapWindows[i];
if (!left) continue;
if (left.end <= left.position) continue;
// Unresolved targets are unknown elements: two of them are not provably
// the same element, so an overlap between them cannot be asserted.
if (left.targetSelector === UNRESOLVED_TARGET) continue;
for (let j = i + 1; j < gsapWindows.length; j++) {
const right = gsapWindows[j];
if (!right) continue;
Expand Down Expand Up @@ -573,6 +582,9 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_exit_missing_hard_kill
if (clipStartBoundaries.length > 0) {
for (const win of gsapWindows) {
// Unresolved targets are unknown elements: you cannot assert a missing
// hard kill on one, and a `tl.set("__unresolved__", ...)` hint is meaningless.
if (win.targetSelector === UNRESOLVED_TARGET) continue;
if (!isSceneBoundaryExit(win)) continue;
const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries);
if (boundary == null) continue;
Expand Down
Loading