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

it("warns when repeated tl.fromTo calls target one selector without a tl.set baseline", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<div id="overlay"><div class="cell"></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 });
tl.fromTo("#overlay .cell", { opacity: 0, zIndex: 999 }, { opacity: 1, duration: 0.25 }, 1);
tl.fromTo("#overlay .cell", { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1, duration: 0.25 }, 3);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#overlay .cell");
});

it("does not warn when repeated tl.fromTo calls have an earlier tl.set baseline", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<div id="overlay"><div class="cell"></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 });
tl.set("#overlay .cell", { opacity: 0, scale: 1 }, 0);
tl.fromTo("#overlay .cell", { opacity: 0, zIndex: 999 }, { opacity: 1, duration: 0.25 }, 1);
tl.fromTo("#overlay .cell", { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1, duration: 0.25 }, 3);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
expect(finding).toBeUndefined();
});

it("does not warn for a single tl.fromTo call on a selector", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<div id="overlay"><div class="cell"></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 });
tl.fromTo("#overlay .cell", { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.25 }, 1);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
expect(finding).toBeUndefined();
});

it("does not warn when tl.fromTo calls target different selectors", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<div id="overlay">
<div class="cell-a"></div>
<div class="cell-b"></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 });
tl.fromTo("#overlay .cell-a", { opacity: 0, zIndex: 999 }, { opacity: 1, duration: 0.25 }, 1);
tl.fromTo("#overlay .cell-b", { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1, duration: 0.25 }, 3);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
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
51 changes: 51 additions & 0 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,57 @@ 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") continue;
if (win.targetSelector === UNRESOLVED_TARGET) continue;
const windows = fromToWindowsBySelector.get(win.targetSelector) ?? [];
windows.push(win);
fromToWindowsBySelector.set(win.targetSelector, windows);
}

const repeatedFromToSelectors = [...fromToWindowsBySelector.values()].filter(
(windows) => windows.length >= 2,
);
const standaloneSetSelectors =
repeatedFromToSelectors.length > 0
? new Set(
extractStandaloneGsapTransformCalls(stripJsComments(script.content))
.filter((call) => call.method === "set")
.map((call) => call.selector),
)
: new Set<string>();

for (const fromToWindows of repeatedFromToSelectors) {
const selector = fromToWindows[0]?.targetSelector;
if (!selector) continue;
const firstFromToPosition = Math.min(...fromToWindows.map((win) => win.position));
const hasTimelineBaseline = gsapWindows.some(
(candidate) =>
candidate.method === "set" &&
candidate.targetSelector === selector &&
candidate.position <= firstFromToPosition,
);
if (hasTimelineBaseline || standaloneSetSelectors.has(selector)) continue;

findings.push({
code: "gsap_repeated_fromto_without_baseline",
severity: "warning",
message:
`${fromToWindows.length} tl.fromTo() calls target "${selector}" with no earlier tl.set() 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 \`tl.set("${selector}", { ...safe resting values... }, 0)\` as an explicit baseline before ` +
`the first fromTo, so pre-first-tween seeks have a defined, deterministic resting state instead ` +
`of inheriting 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
Loading