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

describe("gsap_fromto_flash_before_start", () => {
const comp = (script: string, body = "", style = "") => `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">${body}</div>
${style ? `<style>${style}</style>` : ""}
<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 });
${script}
window.__timelines["c1"] = tl;
</script>
</body></html>`;

it("errors when a late fromTo jumps from a non-identity from-var on a visible element", async () => {
const result = await lintHyperframeHtml(
comp(
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, ease: "steps(12)" }, 2);`,
`<div id="line">code</div>`,
),
);
const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
expect(finding).toBeDefined();
expect(finding!.severity).toBe("error");
expect(finding!.selector).toBe("#line");
});

it("errors when a late fromTo fades in an element that was never hidden", async () => {
const result = await lintHyperframeHtml(
comp(
`tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 1.5);`,
`<div id="hero">Hi</div>`,
),
);
const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
expect(finding).toBeDefined();
});

it("exempts a target hidden by authored CSS opacity:0", async () => {
const result = await lintHyperframeHtml(
comp(
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", opacity: 1, duration: 0.8 }, 2);`,
`<div id="line">code</div>`,
`#line { opacity: 0; }`,
),
);
expect(
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
).toBeUndefined();
});

it("exempts a target pre-hidden by a timeline set at the timeline start", async () => {
const result = await lintHyperframeHtml(
comp(
`tl.set("#line", { opacity: 0 }, 0);
tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
`<div id="line">code</div>`,
),
);
expect(
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
).toBeUndefined();
});

it("exempts a target pre-hidden by a standalone gsap.set", async () => {
const result = await lintHyperframeHtml(
comp(
`gsap.set("#line", { opacity: 0 });
tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
`<div id="line">code</div>`,
),
);
expect(
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
).toBeUndefined();
});

it("exempts identity from-vars (no jump)", async () => {
const result = await lintHyperframeHtml(
comp(
`tl.fromTo("#drift", { x: 0 }, { x: 100, duration: 3 }, 5);`,
`<div id="drift">Hi</div>`,
),
);
expect(
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
).toBeUndefined();
});

it("exempts immediateRender: true (from-vars hold from load)", async () => {
const result = await lintHyperframeHtml(
comp(
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, immediateRender: true }, 2);`,
`<div id="line">code</div>`,
),
);
expect(
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
).toBeUndefined();
});

it("exempts tweens at the timeline start (zero flash window)", async () => {
const result = await lintHyperframeHtml(
comp(
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 0);`,
`<div id="line">code</div>`,
),
);
expect(
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
).toBeUndefined();
});
});
88 changes: 88 additions & 0 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1818,6 +1818,94 @@ export const gsapRules: LintRule<LintContext>[] = [
return findings;
},

// gsap_fromto_flash_before_start — a fromTo() positioned after the timeline's start
// renders with GSAP's default immediateRender: false. The target therefore sits in
// its AUTHORED state from t=0, then jumps to the tween's from-vars when it begins:
// a visible flash/jump on every playback and every cold seek. (Real-world casualty:
// a "typewriter" whose lines were fully readable before each line "typed".)
// Exemptions: the authored state is already hidden (CSS opacity:0, standalone
// gsap.set, or a timeline set-to-hidden at the timeline start) — nothing to flash;
// the from-vars are the transform/opacity identity — no jump; immediateRender: true —
// GSAP applies the from-vars at build time and holds them; and tweens starting at
// the timeline start, whose flash window is zero.
async ({ scripts, styles, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
const TRANSFORM_IDENTITY: Record<string, number> = {
x: 0,
y: 0,
rotation: 0,
rotationX: 0,
rotationY: 0,
skewX: 0,
skewY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
};
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await cachedExtractGsapWindows(script.content);
const preHidden = new Set([
...cssHiddenSelectors,
...extractStandaloneHiddenSelectors(script.content),
]);
// A timeline set-to-hidden at the timeline start pre-hides the target for every
// seek; sets at later positions leave [0, position) visible, so they do not.
const initialHiddenSets = new Set(
windows
.filter(
(w) =>
w.method === "set" &&
w.position <= SCENE_BOUNDARY_EPSILON_SECONDS &&
isHiddenGsapState(w.propertyValues),
)
.map((w) => w.targetSelector),
);
for (const win of windows) {
if (win.method !== "fromTo") continue;
if (win.immediateRender) continue;
if (win.position <= SCENE_BOUNDARY_EPSILON_SECONDS) continue;
if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
const sel = win.targetSelector;
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
if (preHidden.has(cssKey) || initialHiddenSets.has(sel)) continue;
const from = win.fromPropertyValues;
if (!from) continue;
const jumps = Object.entries(from).some(([prop, value]) => {
const identity = TRANSFORM_IDENTITY[prop];
if (identity !== undefined) {
const n = numberValue(value);
return n === null ? true : n !== identity;
}
if (prop === "opacity" || prop === "autoAlpha") {
const n = numberValue(value);
return n === null ? true : n !== 1;
}
return prop === "visibility" || prop === "display";
});
if (!jumps) continue;
findings.push({
code: "gsap_fromto_flash_before_start",
severity: "error",
message:
`"${sel}" is visible from t=0 in its authored state, then jumps to this ` +
`tween's from-vars at ${win.position.toFixed(2)}s (timeline fromTo defaults ` +
"to immediateRender: false) — a visible flash on every playback and cold seek.",
selector: sel,
fixHint:
`Hide the authored state until the tween starts (CSS \`opacity: 0\` on "${sel}" ` +
'with `opacity: 1` in the destination vars, or `tl.set("' +
sel +
'", { opacity: 0 }, 0)`), or pass `immediateRender: true` so the ' +
"from-vars hold from load.",
snippet: truncateSnippet(win.raw),
});
}
}
return findings;
},

// gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
// or using roundProps snaps motion to integer device pixels. On the seek-by-frame
// capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly
Expand Down
Loading