This commit is contained in:
Miguel Ángel
2026-08-30 02:16:17 -07:00
committed by GitHub
2 changed files with 202 additions and 0 deletions
+114
View File
@@ -3071,3 +3071,117 @@ describe("SVG draw-on rules", () => {
});
});
});
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
View File
@@ -1745,6 +1745,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