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
227 changes: 227 additions & 0 deletions packages/lint/src/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2886,6 +2886,233 @@ describe("SVG draw-on rules", () => {
expect(finding).toBeUndefined();
});

it("gsap_repeated_fromto_without_baseline: warns for repeated future fromTo state", async () => {
const html = `
<html><body>
<style>#ring { opacity: 0; }</style>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="ring"></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 });
tl.fromTo("#ring", { opacity: 0, scale: 1 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring", { opacity: 1, scale: 0.4 }, { opacity: 0, duration: 0.5 }, 10);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#ring");
});

it("gsap_repeated_fromto_without_baseline: accepts explicit immediateRender false", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="ring"></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 });
tl.fromTo("#ring", { opacity: 0 }, { opacity: 1, duration: 0.5, immediateRender: false }, 5);
tl.fromTo("#ring", { opacity: 1 }, { opacity: 0, duration: 0.5, immediateRender: false }, 10);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding).toBeUndefined();
});

it("gsap_repeated_fromto_without_baseline: accepts an earlier timeline set baseline", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="ring"></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 });
tl.set("#ring", { opacity: 0, scale: 1 }, 0);
tl.fromTo("#ring", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring", { opacity: 1 }, { opacity: 0, duration: 0.5 }, 10);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding).toBeUndefined();
});

it("gsap_repeated_fromto_without_baseline: rejects an earlier standalone set", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="ring"></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 });
gsap.set("#ring", { opacity: 0, scale: 1 });
tl.fromTo("#ring", { opacity: 0, scale: 1 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring", { opacity: 1, scale: 0.4 }, { opacity: 0, duration: 0.5 }, 10);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding?.severity).toBe("warning");
});

it("gsap_repeated_fromto_without_baseline: rejects a later incomplete standalone set", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="ring"></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 });
tl.fromTo("#ring", { opacity: 0, scale: 1 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring", { opacity: 1, scale: 0.4 }, { opacity: 0, duration: 0.5 }, 10);
gsap.set("#ring", { x: 0 });
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding?.severity).toBe("warning");
});

it("gsap_repeated_fromto_without_baseline: does not guess that a later standalone set is a timeline baseline", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="ring"></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 });
tl.fromTo("#ring", { opacity: 0, scale: 1 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring", { opacity: 1, scale: 0.4 }, { opacity: 0, duration: 0.5 }, 10);
gsap.set("#ring", { opacity: 0, scale: 1 });
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding?.severity).toBe("warning");
});

it("gsap_repeated_fromto_without_baseline: does not treat a deferred callback set as baseline", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="ring"></div><button id="reset"></button>
</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 });
document.getElementById("reset").addEventListener("click", () => {
gsap.set("#ring", { opacity: 0, scale: 1 });
});
tl.fromTo("#ring", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring", { opacity: 1 }, { opacity: 0, duration: 0.5 }, 10);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding?.severity).toBe("warning");
});

it("gsap_repeated_fromto_without_baseline: requires a timeline baseline to be authored first", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="ring"></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 });
tl.fromTo("#ring", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring", { opacity: 1 }, { opacity: 0, duration: 0.5 }, 10);
tl.set("#ring", { opacity: 0 }, 0);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);

expect(finding?.severity).toBe("warning");
});

it("gsap_repeated_fromto_without_baseline: accepts one future fromTo writer", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="ring"></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 });
tl.fromTo("#ring", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 5);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);

expect(
result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
),
).toBeUndefined();
});

it("gsap_repeated_fromto_without_baseline: keeps different selectors independent", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="ring-a"></div><div id="ring-b"></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 });
tl.fromTo("#ring-a", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 5);
tl.fromTo("#ring-b", { opacity: 1 }, { opacity: 0, duration: 0.5 }, 10);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);

expect(
result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
),
).toBeUndefined();
});

// ── svg_measure_before_path_d ──────────────────────────────────────────────

it("svg_measure_before_path_d: ERROR when no d assignment exists anywhere", async () => {
Expand Down
56 changes: 53 additions & 3 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ type GsapWindow = {
propertyValues: Record<string, string | number>;
fromPropertyValues?: Record<string, string | number>;
overwriteAuto: boolean;
immediateRender: boolean;
/** Explicit immediateRender option; undefined keeps the GSAP method default. */
immediateRender?: boolean;
method: string;
/** True for an off-timeline `gsap.set(...)` (applied once at load). */
global?: boolean;
Expand Down Expand Up @@ -150,6 +151,7 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
const cycleCount = infiniteRepeat ? 1 : repeat > 0 ? repeat + 1 : 1;
const effectiveDuration =
animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
const immediateRender = unwrapRaw(animation.extras?.immediateRender);
windows.push({
targetSelector: animation.targetSelector,
targetIdentity: animation.targetIdentity,
Expand All @@ -162,7 +164,8 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
propertyValues: animation.properties,
fromPropertyValues: animation.fromProperties,
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
immediateRender: unwrapRaw(animation.extras?.immediateRender) === "true",
immediateRender:
immediateRender === "true" ? true : immediateRender === "false" ? false : undefined,
method: animation.method,
global: animation.global,
raw: synthesizeWindowRaw(parsed.timelineVar, animation),
Expand Down Expand Up @@ -1108,6 +1111,53 @@ export const gsapRules: LintRule<LintContext>[] = [
}
}

// gsap_repeated_fromto_without_baseline
const fromToWindowsBySelector = new Map<string, GsapWindow[]>();
for (const win of gsapWindows) {
if (win.method !== "fromTo" || win.immediateRender === false) continue;
if (win.targetSelector === UNRESOLVED_TARGET) continue;
const windows = fromToWindowsBySelector.get(win.targetSelector) ?? [];
windows.push(win);
fromToWindowsBySelector.set(win.targetSelector, windows);
}

const repeatedFromToGroups = [...fromToWindowsBySelector.values()].filter(
(windows) => windows.length >= 2,
);
for (const fromToWindows of repeatedFromToGroups) {
const firstFromTo = fromToWindows[0];
if (!firstFromTo) continue;
const selector = firstFromTo.targetSelector;
const firstFromToIndex = gsapWindows.indexOf(firstFromTo);
const firstFromToPosition = Math.min(...fromToWindows.map((win) => win.position));
const hasTimelineBaseline = gsapWindows
.slice(0, firstFromToIndex)
.some(
(candidate) =>
candidate.method === "set" &&
!candidate.global &&
candidate.targetSelector === selector &&
candidate.position <= firstFromToPosition,
);
if (hasTimelineBaseline) continue;

findings.push({
code: "gsap_repeated_fromto_without_baseline",
severity: "warning",
message:
`${fromToWindows.length} tl.fromTo() calls target "${selector}" with no stable baseline. ` +
`The last-authored fromTo "from" values become the element's resting state for any seek before ` +
`the first tween actually runs, because GSAP applies fromTo from-values at authoring time ` +
`(immediateRender), not at tween position.`,
selector,
fixHint:
`Add \`immediateRender: false\` to the destination vars of each future fromTo, or set a safe ` +
`resting state with an earlier \`tl.set("${selector}", { ... }, 0)\`. Pre-first-tween seeks must not ` +
`inherit whichever fromTo call happened to author last.`,
snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n")),
});
}

// gsap_exit_missing_hard_kill
if (clipStartBoundaries.length > 0) {
for (const win of gsapWindows) {
Expand Down Expand Up @@ -2362,7 +2412,7 @@ export const gsapRules: LintRule<LintContext>[] = [
const initialHolds = firstTweenIndex < 0 ? windows : windows.slice(0, firstTweenIndex);
for (const win of initialHolds) {
if (!isInstantHold(win) || win.position !== 0) continue;
if (win.global || win.immediateRender) continue;
if (win.global || win.immediateRender === true) continue;
if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
const targetTokens = [...targetedSelectorTokens(win.targetSelector)];
const hiddenByToken =
Expand Down
Loading