mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(lint): flag relative-value second writers and tl.set initial hides (#2612)
## What Part 2 of the GSAP seek-safety rules (stacks on #2611): the two rules that touch existing catalog content and required reconciliation with an existing rule. - `gsap_relative_value_second_writer` (error) — a relative var value (`y: "-=15"`) on a property whose target has another writer **active at the relative tween's start**. The relative base is captured at tween init, which reads a different partial state per seek path: sequential seek inits it mid-entrance, a cold render worker inits it at the entrance's end state, and the element teleports at chunk boundaries (production case: all scene nodes jumping ~20px mid-scene). Writers that complete strictly before the start are safe (children render in start-time order within a seek pass — verified against gsap 3.15.0) and are not flagged; neither are single-writer relatives, `from()`/`fromTo()`, build-time `gsap.set`, or relative position parameters (`"+=0.5"`). Selector resolution bails on combinators and cross-composition scoping rather than guessing. Findings aggregate per tween pair and report the overlap window. - `gsap_timeline_set_initial_hide` (warning) — initial-state hiding via `tl.set(target, vars, 0)` on a paused timeline is not rendered while the playhead sits at exactly 0, so frame 0 shows the unhidden state (verified against gsap 3.15.0: opacity stays 1 after `tl.time(0)`, applies only past 0). Exempt when the target is already hidden by authored CSS/inline styles or a standalone `gsap.set()`, and only sets preceding every tween in source order qualify (mutated position variables resolve to their initial binding in the parser — outro hard-kills don't masquerade as position-0 sets). - Reconciliation: `gsap_fullscreen_overlay_starts_visible`'s fixHint previously recommended exactly the flagged `tl.set(sel, {opacity:0}, 0)` pattern; it now recommends authored CSS hiding or immediate `gsap.set()`. - Docs for the full rule family in `docs/packages/lint.mdx`. ## Corpus impact (the reason this is its own PR) These two rules are the ones that fire on repo-shipped content: - `gsap_relative_value_second_writer`: 4 errors in `gooey-metaball`, all genuine overlaps. Measured with gsap 3.15.0: ballD diverges **3.31 xPercent / 1.99 yPercent (~8px/5px at 240px ball size)** between sequential and cold seek — a permanent base offset that appears as a teleport at a chunk boundary. Real but modest; happy to fix the block in a follow-up (start the drift at the entrance's end, or use absolute `fromTo`). - `gsap_timeline_set_initial_hide`: 10 warnings across the catalog after the CSS-hidden exemption (down from 54 pre-narrowing); spot-checked as genuine frame-0 pops with no authored hide (e.g. `vfx-text-cursor` `#phrase-b`). Adversarially reviewed the same way as #2611 (393-composition corpus + gsap semantics experiments); FP classes fixed and locked as negative tests: precede-only second writers, descendant/cross-composition selector mis-joins, CSS-hidden re-assertions, mutated position variables. ## Tests Full `packages/lint` suite green at 440 tests including multi-composition roots; `tsc`, oxlint, fallow audit clean.
This commit is contained in:
@@ -1907,6 +1907,204 @@ describe("GSAP rules", () => {
|
||||
});
|
||||
|
||||
describe("GSAP seek-order safety rules", () => {
|
||||
// ── gsap_relative_value_second_writer ──────────────────────────────────────
|
||||
|
||||
it("gsap_relative_value_second_writer: flags a relative drift over an entrance tween writing the same property", async () => {
|
||||
// Distilled from a production composition: entrance writes y on .tech-node,
|
||||
// then an ambient drift uses y:"-=15" on one of those elements by id.
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="tech-node" id="node-gmail"></div>
|
||||
<div class="tech-node" id="node-crm"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('.tech-node', { opacity: 1, y: 0, duration: 1, ease: "power3.out" }, 2);
|
||||
tl.to('#node-gmail', { y: "-=15", duration: 3, repeat: 2, yoyo: true, ease: "sine.inOut" }, 2);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#node-gmail");
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: aggregates multiple relative props into ONE finding per tween pair", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="ball"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#ball', { xPercent: 50, yPercent: 0, duration: 0.55 }, 0.2);
|
||||
tl.to('#ball', { xPercent: "-=18", yPercent: "-=10", duration: 1 }, 0.7);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const findings = result.findings.filter((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(findings.length).toBe(1);
|
||||
expect(findings[0]?.message).toContain("xPercent");
|
||||
expect(findings[0]?.message).toContain("yPercent");
|
||||
expect(findings[0]?.message).toMatch(/between 0\.70s and 0\.75s/);
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: does NOT flag when the other writer is a build-time gsap.set (runs on every worker)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="card"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
gsap.set('#card', { y: 20 });
|
||||
tl.to('#card', { y: "+=10", duration: 2 }, 1);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: does NOT flag back-to-back non-overlapping relative tweens", async () => {
|
||||
// Notification-chain pattern: nudge away, then nudge back, sequentially.
|
||||
// The first tween completes before the second starts, so bases are
|
||||
// identical on every seek path.
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="toast"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#toast', { y: "+=10", duration: 0.4 }, 1);
|
||||
tl.to('#toast', { y: "-=10", duration: 0.4 }, 1.4);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: does NOT flag a writer that completes before the relative tween starts", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="chip"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#chip', { y: 0, duration: 0.5 }, 0);
|
||||
tl.to('#chip', { y: "-=15", duration: 2 }, 3);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: bails on descendant and composition-scoped selectors", async () => {
|
||||
// ".card-a .icon" and ".card-b .icon" are DIFFERENT elements; scoped
|
||||
// selectors across compositions are too. Token-based matching would
|
||||
// mis-join them — the rule must skip rather than guess.
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="a" data-width="1920" data-height="1080">
|
||||
<div class="card-a"><span class="icon"></span></div>
|
||||
<div class="card-b"><span class="icon"></span></div>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<div data-composition-id="b" data-width="1920" data-height="1080">
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('.card-a .icon', { y: 0, duration: 2 }, 0);
|
||||
tl.to('.card-b .icon', { y: "-=15", duration: 2 }, 1);
|
||||
tl.to('[data-composition-id="a"] .dot', { x: 100, duration: 2 }, 0);
|
||||
tl.to('[data-composition-id="b"] .dot', { x: "+=40", duration: 2 }, 1);
|
||||
window.__timelines["a"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: does NOT flag a single-writer relative value", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1080" data-height="1920"><div id="hub-core"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('#hub-core', { scale: 0 });
|
||||
tl.to('#hub-core', { y: "-=15", duration: 2, repeat: 1, yoyo: true, ease: "sine.inOut" }, 1.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: does NOT flag a relative POSITION parameter", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#a', { opacity: 0, duration: 1 }, 0);
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, "+=0.5");
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: does NOT flag relative values in from()/fromTo()", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('#a', { y: 10 }, 0);
|
||||
tl.from('#a', { y: "-=30", duration: 1 }, 0.5);
|
||||
tl.fromTo('#a', { y: 0 }, { y: "+=30", duration: 1 }, 2);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_relative_value_second_writer: does NOT flag when the relative writer has overwrite auto", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#a', { y: 100, duration: 2 }, 0);
|
||||
tl.to('#a', { y: "-=15", duration: 1, overwrite: "auto" }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_relative_value_second_writer");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── gsap_repeat_refresh_relative_value ─────────────────────────────────────
|
||||
|
||||
it("gsap_repeat_refresh_relative_value: flags repeatRefresh with a relative value in the same vars", async () => {
|
||||
@@ -2268,6 +2466,181 @@ describe("SVG draw-on rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── gsap_timeline_set_initial_hide ─────────────────────────────────────────
|
||||
|
||||
it("gsap_timeline_set_initial_hide: warns on tl.set hidden state at position 0", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1080" data-height="1920">
|
||||
<div class="floating-icon"></div><div id="hub-core"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('.floating-icon', { opacity: 0 });
|
||||
tl.set('#hub-core', { scale: 0 });
|
||||
tl.to('.floating-icon', { opacity: 1, duration: 1 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const findings = result.findings.filter((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(findings.length).toBe(2);
|
||||
expect(findings.every((f) => f.severity === "warning")).toBe(true);
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: does NOT warn on immediate gsap.set or mid-timeline sets", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div><div id="b"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
gsap.set('#a', { opacity: 0 });
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, 0.5);
|
||||
tl.set('#b', { opacity: 0 }, 3);
|
||||
tl.set('#a', { x: 40 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: does NOT warn when the target is already hidden by authored CSS", async () => {
|
||||
// Defensive re-assertion: frame 0 is hidden by CSS anyway.
|
||||
const html = `
|
||||
<html><body>
|
||||
<style>.card { opacity: 0; }</style>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="card"></div><div id="pin" style="opacity: 0"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('.card', { opacity: 0 }, 0);
|
||||
tl.set('#pin', { scale: 0 }, 0);
|
||||
tl.to('.card', { opacity: 1, duration: 1 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: does NOT warn when a standalone gsap.set already hides the target", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
gsap.set('#a', { opacity: 0 });
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('#a', { opacity: 0 }, 0);
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: does NOT exempt a gsap.set nested inside a callback", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div><button id="btn"></button></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('#a', { opacity: 0 }, 0);
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, 0.5);
|
||||
document.getElementById('btn').addEventListener('click', () => {
|
||||
gsap.set('#a', { opacity: 0 });
|
||||
});
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: does NOT warn when a load-time IIFE gsap.set already hides", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
(function () {
|
||||
window.__timelines = window.__timelines || {};
|
||||
gsap.set('#a', { opacity: 0 });
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('#a', { opacity: 0 }, 0);
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: does NOT warn when immediateRender is true", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set('#a', { opacity: 0, immediateRender: true }, 0);
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: warns on zero-duration tl.to at position 0", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#a', { opacity: 0, duration: 0 }, 0);
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("gsap_timeline_set_initial_hide: does NOT warn on mutated position variables resolved as 0", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
let outroStart = 0;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#a', { opacity: 1, duration: 1 }, 0);
|
||||
outroStart = 5;
|
||||
tl.set('#a', { opacity: 0 }, outroStart);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_set_initial_hide");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── svg_measure_before_path_d ──────────────────────────────────────────────
|
||||
|
||||
it("svg_measure_before_path_d: ERROR when no d assignment exists anywhere", async () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ type GsapWindow = {
|
||||
propertyValues: Record<string, string | number>;
|
||||
fromPropertyValues?: Record<string, string | number>;
|
||||
overwriteAuto: boolean;
|
||||
immediateRender: boolean;
|
||||
method: string;
|
||||
/** True for an off-timeline `gsap.set(...)` (applied once at load). */
|
||||
global?: boolean;
|
||||
@@ -168,6 +169,7 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
|
||||
propertyValues: animation.properties,
|
||||
fromPropertyValues: animation.fromProperties,
|
||||
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
|
||||
immediateRender: unwrapRaw(animation.extras?.immediateRender) === "true",
|
||||
method: animation.method,
|
||||
global: animation.global,
|
||||
raw: synthesizeWindowRaw(parsed.timelineVar, animation),
|
||||
@@ -211,6 +213,7 @@ function isHiddenGsapState(values: Record<string, string | number>): boolean {
|
||||
function extractStandaloneHiddenSelectors(script: string): Set<string> {
|
||||
const selectors = new Set<string>();
|
||||
const source = stripJsComments(script);
|
||||
const functionRanges = collectFunctionBodyRanges(source);
|
||||
const aliases = new Map<string, string>();
|
||||
for (const match of source.matchAll(
|
||||
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])([^"'`]+)\2\s*;/g,
|
||||
@@ -220,6 +223,8 @@ function extractStandaloneHiddenSelectors(script: string): Set<string> {
|
||||
const pattern = /gsap\.set\s*\(\s*([^,]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
// Skip callback/handler bodies; keep IIFEs (they run at parse time).
|
||||
if (indexInsideNonIifeRange(match.index, source, functionRanges)) continue;
|
||||
const target = (match[1] ?? "").trim();
|
||||
const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target);
|
||||
if (!selector) continue;
|
||||
@@ -605,6 +610,12 @@ function scanScriptsForRegexMatches(
|
||||
// geometry, per-init random values — renders differently per worker, visible as
|
||||
// position jumps or dead animation at chunk boundaries.
|
||||
|
||||
const RELATIVE_TWEEN_VALUE = /^[+-]=/;
|
||||
|
||||
function isRelativeTweenValue(value: string | number | undefined): boolean {
|
||||
return typeof value === "string" && RELATIVE_TWEEN_VALUE.test(value.trim());
|
||||
}
|
||||
|
||||
// DOM reads split by transform sensitivity. Transform-sensitive reads report
|
||||
// live animated geometry, so their result depends on the worker's own seek
|
||||
// order. Transform-invariant layout reads (intrinsic size, path geometry) give
|
||||
@@ -636,6 +647,58 @@ function indexTagsByToken(tags: OpenTag[]): Map<string, OpenTag[]> {
|
||||
return tagsByToken;
|
||||
}
|
||||
|
||||
function resolveSelectorTagIndexes(
|
||||
selector: string,
|
||||
tagsByToken: Map<string, OpenTag[]>,
|
||||
): Set<number> {
|
||||
const indexes = new Set<number>();
|
||||
for (const token of targetedSelectorTokens(selector)) {
|
||||
for (const tag of tagsByToken.get(token) ?? []) indexes.add(tag.index);
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
// A selector whose comma groups are each a single simple compound (no
|
||||
// combinators, no attribute selectors) — the only shape that resolves
|
||||
// faithfully through simple #id/.class tokens. Descendant selectors
|
||||
// (".card-a .icon") and composition-scoped selectors
|
||||
// ('[data-composition-id="a"] .dot') would mis-join across elements or
|
||||
// compositions, so token-based matching must bail on them.
|
||||
function selectorResolvesFaithfully(selector: string): boolean {
|
||||
return selector.split(",").every((group) => {
|
||||
const token = group.trim();
|
||||
if (!token || token.includes("[")) return false;
|
||||
return !/[\s>+~]/.test(token);
|
||||
});
|
||||
}
|
||||
|
||||
// Two GSAP targets provably hit the same element when their stable identities
|
||||
// are equal, or when their (faithfully resolvable) selectors resolve to
|
||||
// intersecting element sets — an id selector and a class selector can name the
|
||||
// same node. Selectors with combinators or attribute parts are skipped rather
|
||||
// than guessed at.
|
||||
function targetsShareElement(
|
||||
a: { selector: string; identity?: string },
|
||||
b: { selector: string; identity?: string },
|
||||
tagsByToken: Map<string, OpenTag[]>,
|
||||
): boolean {
|
||||
if (
|
||||
!targetHasNoStableIdentity(a.selector, a.identity) &&
|
||||
!targetHasNoStableIdentity(b.selector, b.identity) &&
|
||||
(a.identity ?? a.selector) === (b.identity ?? b.selector)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!selectorResolvesFaithfully(a.selector) || !selectorResolvesFaithfully(b.selector)) {
|
||||
return false;
|
||||
}
|
||||
const aTags = resolveSelectorTagIndexes(a.selector, tagsByToken);
|
||||
if (aTags.size === 0) return false;
|
||||
const bTags = resolveSelectorTagIndexes(b.selector, tagsByToken);
|
||||
for (const index of bTags) if (aTags.has(index)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Source from the delimiter at `openIndex` to its matching closer, inclusive. */
|
||||
function matchBalanced(
|
||||
source: string,
|
||||
@@ -940,6 +1003,25 @@ function indexInsideAnyRange(
|
||||
return ranges.some((range) => index > range.start && index < range.end);
|
||||
}
|
||||
|
||||
function isIifeBody(source: string, range: { start: number; end: number }): boolean {
|
||||
let j = range.end;
|
||||
while (j < source.length && /\s/.test(source[j]!)) j++;
|
||||
if (source[j] !== ")") return false;
|
||||
j++;
|
||||
while (j < source.length && /\s/.test(source[j]!)) j++;
|
||||
return source[j] === "(" || source.startsWith(".call", j) || source.startsWith(".apply", j);
|
||||
}
|
||||
|
||||
function indexInsideNonIifeRange(
|
||||
index: number,
|
||||
source: string,
|
||||
ranges: Array<{ start: number; end: number }>,
|
||||
): boolean {
|
||||
return ranges.some(
|
||||
(range) => index > range.start && index < range.end && !isIifeBody(source, range),
|
||||
);
|
||||
}
|
||||
|
||||
// Simple selectors whose authored CSS (style blocks or inline styles) sets
|
||||
// opacity to EXACTLY zero. The declaration regex is boundary-anchored so
|
||||
// `opacity: 0.98` never matches; it ends at `;` or end of input, which also
|
||||
@@ -1133,9 +1215,13 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
`${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
|
||||
selector,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
// gsap_timeline_set_initial_hide warns on `tl.set(..., 0)` initial hides
|
||||
// (a zero-duration set at 0 does not render at exactly t=0), so this hint
|
||||
// must not recommend that pattern — advise authored CSS or an immediate
|
||||
// gsap.set() instead, keeping the two rules' advice consistent.
|
||||
fixHint:
|
||||
`Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add ` +
|
||||
`\`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
|
||||
`Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add an immediate ` +
|
||||
`\`gsap.set("${selector}", { opacity: 0 })\` (outside the timeline) before the reveal tween.`,
|
||||
snippet: truncateSnippet(firstVisible.raw),
|
||||
});
|
||||
}
|
||||
@@ -1782,6 +1868,73 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_relative_value_second_writer — a relative tween value ("+=..."/"-=...") on a
|
||||
// property that another writer is still ACTIVE on when the relative tween starts.
|
||||
// The relative tween captures its base at tween INIT, which happens on first render:
|
||||
// the sequential path inits it mid-flight of the other writer, a cold render worker
|
||||
// landing later inits it with the other writer's end state — the same frame then
|
||||
// renders at two different positions (a visible snap at chunk boundaries).
|
||||
// GSAP renders children in start-time order within a single seek pass, so a writer
|
||||
// that completes strictly BEFORE the relative tween's start yields identical bases
|
||||
// on every seek path and is never flagged. Single-writer relative values are
|
||||
// seek-stable. from()/fromTo() resolve their values at build (immediateRender), so
|
||||
// they are exempt. The position PARAMETER ("+=0.5") is not a tween value — the
|
||||
// parser keeps it out of properties — so it can never be flagged here.
|
||||
async ({ scripts, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const tagsByToken = indexTagsByToken(tags);
|
||||
for (const script of scripts) {
|
||||
if (!/gsap\.timeline/.test(script.content)) continue;
|
||||
const windows = await cachedExtractGsapWindows(script.content);
|
||||
for (const win of windows) {
|
||||
if (win.method === "from" || win.method === "fromTo") continue;
|
||||
if (win.overwriteAuto) continue;
|
||||
if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
|
||||
const relativeProps = Object.entries(win.propertyValues)
|
||||
.filter(([, value]) => isRelativeTweenValue(value))
|
||||
.map(([prop]) => prop);
|
||||
if (relativeProps.length === 0) continue;
|
||||
const target = { selector: win.targetSelector, identity: win.targetIdentity };
|
||||
for (const other of windows) {
|
||||
if (other === win) continue;
|
||||
if (other.position > win.position || other.end <= win.position) continue;
|
||||
const sharedProps = relativeProps.filter((prop) => other.properties.includes(prop));
|
||||
if (sharedProps.length === 0) continue;
|
||||
if (
|
||||
!targetsShareElement(
|
||||
target,
|
||||
{ selector: other.targetSelector, identity: other.targetIdentity },
|
||||
tagsByToken,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const values = sharedProps
|
||||
.map((prop) => `${prop}: "${win.propertyValues[prop]}"`)
|
||||
.join(", ");
|
||||
const overlapEnd = Math.min(win.end, other.end);
|
||||
const formatTime = (t: number): string => (Number.isFinite(t) ? `${t.toFixed(2)}s` : "∞");
|
||||
findings.push({
|
||||
code: "gsap_relative_value_second_writer",
|
||||
severity: "error",
|
||||
message:
|
||||
`Relative value(s) ${values} on "${win.targetSelector}" start while another writer for the same ` +
|
||||
`propert${sharedProps.length > 1 ? "ies" : "y"} is active between ${formatTime(win.position)} and ${formatTime(overlapEnd)}. ` +
|
||||
"Relative tweens capture their base at tween init: the sequential path inits mid-flight of the other " +
|
||||
"writer, a cold render worker landing later inits with its end state — the same frame renders at two " +
|
||||
"different positions (snap at chunk boundaries).",
|
||||
selector: win.targetSelector,
|
||||
fixHint:
|
||||
`Use absolute values for ${sharedProps.join(", ")}, or a fromTo() with explicit endpoints, so every seek ` +
|
||||
"path resolves the same state. Single-writer relative values are safe; the conflict is the second writer.",
|
||||
snippet: truncateSnippet(`${win.raw}\n${other.raw}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_repeat_refresh_relative_value — repeatRefresh re-resolves the tween's values
|
||||
// on every repeat iteration, so a relative value ACCUMULATES per cycle. A cold render
|
||||
// worker seeking non-linearly into iteration N skips the accumulation a sequential
|
||||
@@ -2080,6 +2233,73 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_timeline_set_initial_hide — a zero-duration tl.set(...) at position 0 inside
|
||||
// the paused timeline does NOT render while the playhead sits exactly at 0 (verified
|
||||
// against this repo's GSAP: tl.time(0) leaves the target untouched; only a seek past
|
||||
// 0 applies it). Frame 0 therefore shows the UN-hidden state, then the element pops
|
||||
// hidden on frame 1 — and only for the worker that renders frame 0. Targets already
|
||||
// hidden by authored CSS/inline styles or by a standalone gsap.set are exempt: the
|
||||
// tl.set is then a defensive re-assertion and frame 0 is hidden anyway.
|
||||
//
|
||||
// Only sets that precede every tween in source order qualify: the parser resolves a
|
||||
// mutated position variable (`var t = 0; ...; tl.set(sel, vars, t)`) to its INITIAL
|
||||
// binding, so late hard-kills can masquerade as position-0 sets. Genuine
|
||||
// initial-state hides are authored before the timeline's tweens.
|
||||
async ({ scripts, styles, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
|
||||
const tagsByToken = indexTagsByToken(tags);
|
||||
for (const script of scripts) {
|
||||
if (!/gsap\.timeline/.test(script.content)) continue;
|
||||
const windows = await cachedExtractGsapWindows(script.content);
|
||||
const alreadyHidden = new Set([
|
||||
...cssHiddenSelectors,
|
||||
...extractStandaloneHiddenSelectors(script.content),
|
||||
]);
|
||||
const isInstantHold = (win: GsapWindow): boolean =>
|
||||
win.method === "set" ||
|
||||
((win.method === "to" || win.method === "fromTo") && win.end === win.position);
|
||||
const firstTweenIndex = windows.findIndex((win) => !isInstantHold(win));
|
||||
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 (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
|
||||
const targetTokens = [...targetedSelectorTokens(win.targetSelector)];
|
||||
const hiddenByToken =
|
||||
targetTokens.length > 0 && targetTokens.every((token) => alreadyHidden.has(token));
|
||||
const resolvedTags = targetTokens.flatMap((token) => tagsByToken.get(token) ?? []);
|
||||
const hiddenByElement =
|
||||
resolvedTags.length > 0 &&
|
||||
resolvedTags.every((tag) =>
|
||||
tagSimpleSelectors(tag).some((token) => alreadyHidden.has(token)),
|
||||
);
|
||||
if (hiddenByToken || hiddenByElement) continue;
|
||||
const offset = win.propertyValues["strokeDashoffset"];
|
||||
const hidesByOffset = numberValue(offset) !== null && !zeroValue(offset);
|
||||
const hides =
|
||||
isHiddenGsapState(win.propertyValues) ||
|
||||
zeroValue(win.propertyValues["scale"]) ||
|
||||
hidesByOffset;
|
||||
if (!hides) continue;
|
||||
findings.push({
|
||||
code: "gsap_timeline_set_initial_hide",
|
||||
severity: "warning",
|
||||
message:
|
||||
`Initial hidden state for "${win.targetSelector}" is set via tl.set(...) at position 0 inside the paused ` +
|
||||
"timeline. A zero-duration set at 0 does not render while the playhead sits exactly at 0, so frame 0 " +
|
||||
"shows the un-hidden state.",
|
||||
selector: win.targetSelector,
|
||||
fixHint:
|
||||
"Use gsap.set(...) (immediate, outside the timeline) for initial states, or author the hidden state " +
|
||||
"directly in CSS/attributes.",
|
||||
snippet: truncateSnippet(win.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// svg_measure_before_path_d — getTotalLength() on a <path> that has no static `d`
|
||||
// attribute in the HTML. In Chrome getTotalLength() on a d-less path returns 0,
|
||||
// silently killing dash animations (offset 0 == length 0 == nothing to draw). If a
|
||||
|
||||
Reference in New Issue
Block a user