Skip to content

Commit d7c743f

Browse files
committed
feat(lint): flag late fromTo tweens that flash the authored state before starting
A timeline fromTo() renders with GSAP's default immediateRender: false. When its position is after the timeline start and the target's authored state is visible, the element sits fully rendered from t=0, then jumps to the from-vars when the tween begins — a visible flash on every playback and cold seek. In practice this breaks typewriter reveals, staggered line entrances, and any 'hidden until its beat' element. New gsap_fromto_flash_before_start rule fires when a fromTo targets a non-pre-hidden element at a position past the timeline start with state-changing from-vars. Exemptions: - authored hidden state (CSS opacity:0, standalone gsap.set, or a timeline set-to-hidden at the timeline start) - identity from-vars (e.g. tl.fromTo(el, { x: 0 }, ...)) — no jump - immediateRender: true — from-vars hold from load - tweens at the timeline start (zero flash window)
1 parent 9140c0e commit d7c743f

2 files changed

Lines changed: 202 additions & 0 deletions

File tree

packages/lint/src/rules/gsap.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3139,3 +3139,117 @@ describe("SVG draw-on rules", () => {
31393139
expect(finding).toBeUndefined();
31403140
});
31413141
});
3142+
3143+
describe("gsap_fromto_flash_before_start", () => {
3144+
const comp = (script: string, body = "", style = "") => `
3145+
<html><body>
3146+
<div data-composition-id="c1" data-width="1920" data-height="1080">${body}</div>
3147+
${style ? `<style>${style}</style>` : ""}
3148+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
3149+
<script>
3150+
window.__timelines = window.__timelines || {};
3151+
const tl = gsap.timeline({ paused: true });
3152+
${script}
3153+
window.__timelines["c1"] = tl;
3154+
</script>
3155+
</body></html>`;
3156+
3157+
it("errors when a late fromTo jumps from a non-identity from-var on a visible element", async () => {
3158+
const result = await lintHyperframeHtml(
3159+
comp(
3160+
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, ease: "steps(12)" }, 2);`,
3161+
`<div id="line">code</div>`,
3162+
),
3163+
);
3164+
const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
3165+
expect(finding).toBeDefined();
3166+
expect(finding!.severity).toBe("error");
3167+
expect(finding!.selector).toBe("#line");
3168+
});
3169+
3170+
it("errors when a late fromTo fades in an element that was never hidden", async () => {
3171+
const result = await lintHyperframeHtml(
3172+
comp(
3173+
`tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 1.5);`,
3174+
`<div id="hero">Hi</div>`,
3175+
),
3176+
);
3177+
const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
3178+
expect(finding).toBeDefined();
3179+
});
3180+
3181+
it("exempts a target hidden by authored CSS opacity:0", async () => {
3182+
const result = await lintHyperframeHtml(
3183+
comp(
3184+
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", opacity: 1, duration: 0.8 }, 2);`,
3185+
`<div id="line">code</div>`,
3186+
`#line { opacity: 0; }`,
3187+
),
3188+
);
3189+
expect(
3190+
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
3191+
).toBeUndefined();
3192+
});
3193+
3194+
it("exempts a target pre-hidden by a timeline set at the timeline start", async () => {
3195+
const result = await lintHyperframeHtml(
3196+
comp(
3197+
`tl.set("#line", { opacity: 0 }, 0);
3198+
tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
3199+
`<div id="line">code</div>`,
3200+
),
3201+
);
3202+
expect(
3203+
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
3204+
).toBeUndefined();
3205+
});
3206+
3207+
it("exempts a target pre-hidden by a standalone gsap.set", async () => {
3208+
const result = await lintHyperframeHtml(
3209+
comp(
3210+
`gsap.set("#line", { opacity: 0 });
3211+
tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
3212+
`<div id="line">code</div>`,
3213+
),
3214+
);
3215+
expect(
3216+
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
3217+
).toBeUndefined();
3218+
});
3219+
3220+
it("exempts identity from-vars (no jump)", async () => {
3221+
const result = await lintHyperframeHtml(
3222+
comp(
3223+
`tl.fromTo("#drift", { x: 0 }, { x: 100, duration: 3 }, 5);`,
3224+
`<div id="drift">Hi</div>`,
3225+
),
3226+
);
3227+
expect(
3228+
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
3229+
).toBeUndefined();
3230+
});
3231+
3232+
it("exempts immediateRender: true (from-vars hold from load)", async () => {
3233+
const result = await lintHyperframeHtml(
3234+
comp(
3235+
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, immediateRender: true }, 2);`,
3236+
`<div id="line">code</div>`,
3237+
),
3238+
);
3239+
expect(
3240+
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
3241+
).toBeUndefined();
3242+
});
3243+
3244+
it("exempts tweens at the timeline start (zero flash window)", async () => {
3245+
const result = await lintHyperframeHtml(
3246+
comp(
3247+
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 0);`,
3248+
`<div id="line">code</div>`,
3249+
),
3250+
);
3251+
expect(
3252+
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
3253+
).toBeUndefined();
3254+
});
3255+
});

packages/lint/src/rules/gsap.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1818,6 +1818,94 @@ export const gsapRules: LintRule<LintContext>[] = [
18181818
return findings;
18191819
},
18201820

1821+
// gsap_fromto_flash_before_start — a fromTo() positioned after the timeline's start
1822+
// renders with GSAP's default immediateRender: false. The target therefore sits in
1823+
// its AUTHORED state from t=0, then jumps to the tween's from-vars when it begins:
1824+
// a visible flash/jump on every playback and every cold seek. (Real-world casualty:
1825+
// a "typewriter" whose lines were fully readable before each line "typed".)
1826+
// Exemptions: the authored state is already hidden (CSS opacity:0, standalone
1827+
// gsap.set, or a timeline set-to-hidden at the timeline start) — nothing to flash;
1828+
// the from-vars are the transform/opacity identity — no jump; immediateRender: true —
1829+
// GSAP applies the from-vars at build time and holds them; and tweens starting at
1830+
// the timeline start, whose flash window is zero.
1831+
async ({ scripts, styles, tags }) => {
1832+
const findings: HyperframeLintFinding[] = [];
1833+
const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
1834+
const TRANSFORM_IDENTITY: Record<string, number> = {
1835+
x: 0,
1836+
y: 0,
1837+
rotation: 0,
1838+
rotationX: 0,
1839+
rotationY: 0,
1840+
skewX: 0,
1841+
skewY: 0,
1842+
scale: 1,
1843+
scaleX: 1,
1844+
scaleY: 1,
1845+
};
1846+
for (const script of scripts) {
1847+
if (!/gsap\.timeline/.test(script.content)) continue;
1848+
const windows = await cachedExtractGsapWindows(script.content);
1849+
const preHidden = new Set([
1850+
...cssHiddenSelectors,
1851+
...extractStandaloneHiddenSelectors(script.content),
1852+
]);
1853+
// A timeline set-to-hidden at the timeline start pre-hides the target for every
1854+
// seek; sets at later positions leave [0, position) visible, so they do not.
1855+
const initialHiddenSets = new Set(
1856+
windows
1857+
.filter(
1858+
(w) =>
1859+
w.method === "set" &&
1860+
w.position <= SCENE_BOUNDARY_EPSILON_SECONDS &&
1861+
isHiddenGsapState(w.propertyValues),
1862+
)
1863+
.map((w) => w.targetSelector),
1864+
);
1865+
for (const win of windows) {
1866+
if (win.method !== "fromTo") continue;
1867+
if (win.immediateRender) continue;
1868+
if (win.position <= SCENE_BOUNDARY_EPSILON_SECONDS) continue;
1869+
if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
1870+
const sel = win.targetSelector;
1871+
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
1872+
if (preHidden.has(cssKey) || initialHiddenSets.has(sel)) continue;
1873+
const from = win.fromPropertyValues;
1874+
if (!from) continue;
1875+
const jumps = Object.entries(from).some(([prop, value]) => {
1876+
const identity = TRANSFORM_IDENTITY[prop];
1877+
if (identity !== undefined) {
1878+
const n = numberValue(value);
1879+
return n === null ? true : n !== identity;
1880+
}
1881+
if (prop === "opacity" || prop === "autoAlpha") {
1882+
const n = numberValue(value);
1883+
return n === null ? true : n !== 1;
1884+
}
1885+
return prop === "visibility" || prop === "display";
1886+
});
1887+
if (!jumps) continue;
1888+
findings.push({
1889+
code: "gsap_fromto_flash_before_start",
1890+
severity: "error",
1891+
message:
1892+
`"${sel}" is visible from t=0 in its authored state, then jumps to this ` +
1893+
`tween's from-vars at ${win.position.toFixed(2)}s (timeline fromTo defaults ` +
1894+
"to immediateRender: false) — a visible flash on every playback and cold seek.",
1895+
selector: sel,
1896+
fixHint:
1897+
`Hide the authored state until the tween starts (CSS \`opacity: 0\` on "${sel}" ` +
1898+
'with `opacity: 1` in the destination vars, or `tl.set("' +
1899+
sel +
1900+
'", { opacity: 0 }, 0)`), or pass `immediateRender: true` so the ' +
1901+
"from-vars hold from load.",
1902+
snippet: truncateSnippet(win.raw),
1903+
});
1904+
}
1905+
}
1906+
return findings;
1907+
},
1908+
18211909
// gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
18221910
// or using roundProps snaps motion to integer device pixels. On the seek-by-frame
18231911
// capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly

0 commit comments

Comments
 (0)