fix(lint): flag repeated fromTo state leaks (#3699)

* fix(lint): flag repeated fromTo state leaks

* fix(lint): require a timeline baseline for repeated fromTo
This commit is contained in:
Miguel Ángel
2026-09-08 18:09:15 +00:00
committed by GitHub
parent 131336f5dc
commit e1ff7e6f53
2 changed files with 280 additions and 3 deletions
+227
View File
@@ -3050,6 +3050,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 () => {
+53 -3
View File
@@ -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;
@@ -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,
@@ -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),
@@ -1124,6 +1127,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) {
@@ -2381,7 +2431,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 =