diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 97511a7cff..dee02f7919 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -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 = `
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 () => {
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index 6cfa54b17d..cc82192fc0 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -55,7 +55,8 @@ type GsapWindow = {
propertyValues: Record;
fromPropertyValues?: Record;
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 {
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 {
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),
@@ -1108,6 +1111,53 @@ export const gsapRules: LintRule[] = [
}
}
+ // gsap_repeated_fromto_without_baseline
+ const fromToWindowsBySelector = new Map();
+ 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) {
@@ -2362,7 +2412,7 @@ export const gsapRules: LintRule[] = [
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 =