mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user