From 4ad582606bd2c0da9e20c83faa1acb3b79fe6e47 Mon Sep 17 00:00:00 2001 From: Xuanru Li <157947275+xuanruli@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:49:45 -0700 Subject: [PATCH] feat(lint): flag relative-value second writers and tl.set initial hides (#2612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- docs/packages/lint.mdx | 13 + packages/lint/src/rules/gsap.test.ts | 373 +++++++++++++++++++++++++++ packages/lint/src/rules/gsap.ts | 224 +++++++++++++++- 3 files changed, 608 insertions(+), 2 deletions(-) diff --git a/docs/packages/lint.mdx b/docs/packages/lint.mdx index df9a36bce..e083d20b1 100644 --- a/docs/packages/lint.mdx +++ b/docs/packages/lint.mdx @@ -108,6 +108,19 @@ Detected issues include: - Deprecated attribute names - Missing composition dimensions (`data-width`, `data-height`) - Invalid `data-start` references to nonexistent clip IDs +- Seek-order hazards that render differently on cold render workers: relative tween + values (`"+=..."`) whose property has a second concurrent writer + (`gsap_relative_value_second_writer`), `repeatRefresh` combined with relative values + (`gsap_repeat_refresh_relative_value`), function-valued tween vars that measure the + DOM or misuse the index parameter (`gsap_function_value_hazard`), DOM measurement + reachable from timeline callbacks (`gsap_callback_dom_measurement`), and + non-deterministic values like `gsap.utils.random()` / `"random(...)"` + (`non_deterministic_code`) +- SVG draw-on pitfalls: GSAP `strokeDasharray` writes on elements whose CSS declares a + multi-component `stroke-dasharray` (`svg_drawon_css_dasharray_conflict`), + `getTotalLength()` on paths with no `d` yet (`svg_measure_before_path_d`), and + initial-state hides via `tl.set(..., 0)` that never render on frame 0 + (`gsap_timeline_set_initial_hide`) For a full list of what the linter catches and how to fix each issue, see [Common Mistakes](/guides/common-mistakes) and [Troubleshooting](/guides/troubleshooting). diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index a73d0c4e8..e00a16721 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -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 = ` + +
+
+
+
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+
+
+ +
+
+ +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+
+
+ +`; + 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 = ` + +
+ +`; + 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 = ` + + +
+
+
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 () => { diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index a1b5057f5..88e472bea 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -50,6 +50,7 @@ type GsapWindow = { propertyValues: Record; fromPropertyValues?: Record; 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 { 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): boolean { function extractStandaloneHiddenSelectors(script: string): Set { const selectors = new Set(); const source = stripJsComments(script); + const functionRanges = collectFunctionBodyRanges(source); const aliases = new Map(); 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 { 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 { return tagsByToken; } +function resolveSelectorTagIndexes( + selector: string, + tagsByToken: Map, +): Set { + const indexes = new Set(); + 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, +): 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[] = [ `${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[] = [ 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[] = [ 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 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