Skip to content

fix(lint): catch cold-seek opacity reveals#2503

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/cold-seek-opacity-lint
Jul 16, 2026
Merged

fix(lint): catch cold-seek opacity reveals#2503
miguel-heygen merged 3 commits into
mainfrom
fix/cold-seek-opacity-lint

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • flag fromTo() entrances whose target is authored hidden but whose destination omits opacity/autoAlpha
  • cover both CSS/inline hidden state and standalone gsap.set(..., { opacity: 0 })
  • explain why sequential snapshots can pass while cold render workers encode the target invisible

Why

CLI feedback reproduced a render-only divergence: a CSS-hidden element with fromTo({ opacity: 1, x: -60 }, { x: 0 }) appeared in check --snapshots, but was black in a two-worker encoded MP4. Each cold worker restored the authored opacity: 0, and the destination never established visibility.

Test plan

  • RED: both CSS-hidden and standalone-gsap.set repro tests failed before the rule
  • GREEN: pnpm --dir packages/lint exec vitest run src/rules/gsap.test.ts --environment node (92/92)
  • pnpm --dir packages/lint typecheck
  • oxfmt --check and oxlint on changed files

Visual evidence

Not applicable; this is a static lint diagnostic with regression tests for the reproduced render divergence.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at f2e65c16. Adds a new lint rule + supporting AST field; scope disjoint from #2496 (gsap_fullscreen_overlay_starts_visible).

Blockers

(none)

Concerns

(none)

Nits

🟡 extractStandaloneHiddenSelectors regex only recognizes string-literal selectors. /gsap\.set\s*\(\s*(["'])([^"']+)\1\s*,\s*\{([^{}]*)\}/g bails on:

  • gsap.set(elementRef, { opacity: 0 }) — variable / captured-node arg
  • gsap.set(\#${dynamicId}`, { opacity: 0 })` — template literal
  • Nested-object opts ({ opacity: 0, css: { will-change: … } }) — [^{}]* breaks on inner {

All uncommon; the two red-first tests cover the primary literal-selector shape and pass. Worth a follow-up only if a real cold-seek bug slips through with one of the shapes above.

Green notes

🟢 fromPropertyValues extraction wired throughextractGsapWindows reads animation.fromProperties from the parsed timeline node and surfaces it on the GsapWindow shape. Optional (fromPropertyValues?) so downstream rules that don't need from-vars stay unaffected.

🟢 Rule shape mirrors the failure mode. The check requires ALL of (a) selector hidden via CSS OR standalone gsap.set, (b) method === "fromTo", (c) from-vars visible (opacity:1 / autoAlpha:1), (d) destination has no opacity/autoAlpha. Only that intersection reproduces the cold-worker encoded-black case; each guard would independently disqualify a false positive.

🟢 Two red-first pins covering both hidden-state sources. One test asserts the finding fires for CSS-inline style="opacity: 0" + fromTo({opacity:1, ...}, {x:0, ...}); the other asserts it fires for standalone gsap.set("#card", {opacity: 0}) + the same fromTo. Both hidden-state entry paths through the unified hiddenSelectors set are exercised.

🟢 gsap_from_opacity_noop (existing rule) survives. The new fromTo branch continues before the old .from opacity-noop path, so both rules can fire for their respective patterns on the same script without one shadowing the other.

🟢 CSS-hidden-only-early-return removed cleanly. Pre-PR the rule bailed with if (cssOpacityZeroSelectors.size === 0) return findings; — that guard would have blocked the standalone gsap.set-hidden path from ever running. Its removal is what makes the new rule reachable when the ONLY hidden-state source is gsap.set.

🟢 Merge-order note lands correctly. Both #2496 and #2503 touch packages/lint/src/rules/gsap.ts, but at different sites (line ~682 in #2496 vs. lines ~156/197/1138+ in #2503). No logical conflict; a mechanical rebase should suffice if the second lands with drift.

Verdict framing

Clean new lint rule with a tight scope guard + two red-first pins. LGTM from my side.

Review by Rames D Jusso

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified at e656a905. Δ from f2e65c16: extractStandaloneHiddenSelectors widened to cover the three uncoded shapes from R1's nit.

Blockers

(none)

Concerns

(none)

Nits

(none)

Green notes

🟢 Variable-alias resolution — the new leading pass over (?:const|let|var)\s+NAME\s*=\s*"..."; builds an aliases map, and when gsap.set(target, …)'s target isn't a string/template literal, the code looks it up in the alias map. Covers the common const CARD_SELECTOR = "#card"; gsap.set(CARD_SELECTOR, {opacity: 0}) shape from the R1 nit.

🟢 Template literals in the selector position now match. The character class widened to (["'\]) in both the alias regex and the selector-literal check inside the loop — simple template literals like `` gsap.set(\#card`, …) resolve. Interpolated template literals (`\`#${dyn}\) still slip through, which is unavoidable at the lint layer without a real JS AST — acceptable.

🟢 Nested-object opts covered by the [\s\S]*?…\}\s*\) shape. The non-greedy body match extends past inner } closings because the pattern requires \}\s*\) afterwards. Traced against gsap.set(el, { opacity: 0, css: { will-change: "auto" } }) — the body capture includes the outer object, and the opacity check finds the top-level opacity: 0 correctly.

🟢 stripJsComments(script) hoisted to a source binding — the alias pass and the gsap.set pass share the same comment-stripped source, so a gsap.set in one line and its aliased declaration on another (both live code) resolve consistently.

Verdict framing

R1 nit closed on the three practical shapes. LGTM from my side.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at e656a9057 (force-push over f2e65c16 with fix(lint): resolve hidden selector aliases). Additive lens on top of Rames R1/R2 — RIGHT verdict aligned, one held-until-X pre-condition + a couple of coverage nits.

Blockers

(none — see held-until below)

Held-until-X

🟡 Format CI red at this head on packages/lint/src/rules/gsap.ts. Job 87483940588 (Format, head_sha=e656a9057, completed 22:08:08Z, conclusion=failure) — oxfmt --check emits packages/lint/src/rules/gsap.ts (4ms) — Format issues found in above 1 files. Old head f2e65c16 had Format=SUCCESS, so the fix-up commit introduced it. Lint=SUCCESS at the same SHA, so oxlint semantics are fine — pure whitespace / line-length. Preflight (lint + format), preview-regression, and regression are the same root cause fanning out. Blocks merge under mergeStateStatus=BLOCKED.

  • Fix: bun run format on the touched file (or oxfmt --write .), force-push. Likely candidate is the long matchAll(...) line inside extractStandaloneHiddenSelectors that runs past print-width.

Concerns

(none)

Nits

🟡 Class-defined CSS opacity:0 + id-targeted fromTo isn't cross-referenced. gsap.ts:1172-1180 stores the RAW CSS selector token (.hidden) in cssOpacityZeroSelectors. The inline-style pass at 1182-1189 already cross-populates both #id and .class when a tag has style="opacity:0", but the CSS-rule pass never resolves tags whose class matches a hidden-class. Shape that slips through:

<style>.hidden { opacity: 0 }</style>
<div id="card" class="hidden"></div>
<script>tl.fromTo("#card", { opacity: 1, x: -60 }, { x: 0 });</script>

hiddenSelectors has .hidden; cssKey for the fromTo is #card; no match → rule silent on a case that still cold-blacks. Follow-up (not blocker): mirror the inline-style expansion for CSS-rule matches — for each class selector with opacity:0 in a <style> block, walk tags whose class list contains it and add their #id to hiddenSelectors.

🟡 No explicit negative pin for the new rule. The two red-first tests at gsap.test.ts:1396 and :1418 assert the finding fires. The nearest surrounding negative (line 1378, does NOT error when gsap.fromTo({opacity:0}, {opacity:1})) checks only the absence of gsap_from_opacity_noop, not gsap_cold_seek_hidden_fromto_missing_reveal. Static-scanning shows the destination-has-opacity/autoAlpha branch behaves correctly, but a future refactor that flips a boolean would silently regress. One-line pin: fromTo(sel, { opacity: 1, x: -60 }, { opacity: 1, x: 0 }) on a CSS-hidden element → assert finding undefined.

🟡 autoAlpha on the from-vars visible-state path is untested. isVisibleGsapState treats opacity and autoAlpha symmetrically (oneValue([opacity, autoAlpha])), so the code is right — both red-first tests happen to use opacity:1. Optional additional pin: fromTo(sel, { autoAlpha: 1, x: -60 }, { x: 0 }) on a hidden element.

🟡 Alias-map has no scope awareness and requires trailing ;. extractStandaloneHiddenSelectors builds a flat aliases Map via (?:const|let|var)\s+NAME\s*=\s*QUOTE...QUOTE\s*;. Recording only: (a) const s = "#card" without semicolon slips through — Prettier-style codebases emit ; by convention; (b) same NAME declared in two scopes maps to a single last-seen selector. Full generality needs a JS AST — the memoed acceptable-at-lint-layer trade-off. No action.

Green notes

🟢 Rule shape mirrors the reported cold-worker failure mode exactly. The new branch at gsap.ts:1204-1221 requires the conjunction (a) sel ∈ hiddenSelectors (CSS opacity:0 OR standalone gsap.set opacity:0/autoAlpha:0 including variable / template-literal aliases), (b) win.method === "fromTo", (c) isVisibleGsapState(win.fromPropertyValues) — from-vars would make the target visible, (d) destination omits both opacity AND autoAlpha. That intersection is the failure surface: cold worker restores authored opacity:0 because the immediateRender path never runs the from-vars, so the destination stays black. Each guard independently disqualifies a false positive.

🟢 Tests exercise the real parse pipeline, not hand-authored AST fixtures. Both new tests call lintHyperframeHtml(html) on raw HTML strings — the production entry point. cachedExtractGsapWindows actually runs on stripJsComments(script). If the underlying parser regressed, the pins would fail.

🟢 No collision with gsap_from_opacity_noop or with #2496's overlay-visibility rule. The new fromTo branch pushes its finding and then continues, so the old .from opacity-noop path (1223-1241) never double-fires on the same window. The overlay-visibility rule that #2496 refines lives in a separate handler around gsap.ts:700-744, guarded by styleLooksFullFrameOverlay + reportedVisibleOverlayKeys — different selector-space, no shared state, no cascade.

🟢 CSS-hidden-only early-return removal is the load-bearing enabler. Pre-PR the rule bailed at if (cssOpacityZeroSelectors.size === 0) return findings; — that guard would have kept the new standalone-gsap.set path unreachable whenever no CSS opacity:0 existed. Its removal is why the second red-first test (standalone gsap.set, no CSS opacity:0) can produce a finding.

🟢 Force-push delta reviewed at e656a9057. Parent = old head f2e65c167, commit message = fix(lint): resolve hidden selector aliases. extractStandaloneHiddenSelectors widened to cover R1's three uncoded shapes (variable alias, template literal, nested-object opts). Traced against gsap.set(el, { opacity: 0, css: { will-change: "auto" } }): non-greedy [\s\S]*?\}\s*\) correctly walks past the inner } because the outer close is followed by ); outer opacity: 0 still detected. Rule branch itself is byte-unchanged from f2e65c16 — previous verification stands.

Verdict framing

RIGHT on correctness; held on Format CI. One bun run format + force-push converts this to APPROVE mechanically. The rule closes the field-signal cold-worker opacity-reveal failure at its exact intersection without opening false-positive risk on adjacent GSAP patterns.

Review by Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified at 17aa162c. Δ from e656a9057: oxfmt reformat only — long matchAll argument wrapped onto its own line, and the selector ternary collapsed to one line. Regex patterns + logic byte-identical (verified via git diff -w --ignore-blank-lines).

Verdict framing

Format-only push. R1 + R2 LGTM stand. All 40 required checks green.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE (RIGHT)

Re-verified at 17aa162c4924. Delta from e656a9057 is pure oxfmt reformat — packages/lint/src/rules/gsap.ts +4/-3 across a 7-line diff, one file. Splits the matchAll argument onto separate lines (line 204) and re-joins the alias-resolution ternary (line 213). Zero semantic change to regex bodies, alias map, or gsap.set traversal loop.

Held-until-X condition satisfied: Format lane is now SUCCESS at this head. My prior 4 non-blocker nits remain follow-ups — none block merge:

  • class-defined CSS opacity:0 + id-targeted fromTo cross-ref gap
  • missing explicit negative pin for the new rule
  • autoAlpha from-vars path untested
  • alias-map scope/semicolon caveat (acceptable at lint layer)

Rames R3 COMMENTED at same head concurred ("oxfmt reformat only").

Converting my earlier COMMENT-with-RIGHT to APPROVE stamp per the pre-condition being met. reviewDecision on this stamp: REVIEW_REQUIRED → APPROVED.

Review by Via

@miguel-heygen
miguel-heygen merged commit 55ee559 into main Jul 16, 2026
49 checks passed
@miguel-heygen
miguel-heygen deleted the fix/cold-seek-opacity-lint branch July 16, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants