Skip to content

fix(lint): see the grouped gsap.set that stages a whole scene at once - #3737

Merged
xuanruli merged 1 commit into
mainfrom
claude/gsap-set-array-hidden-targets
Sep 7, 2026
Merged

fix(lint): see the grouped gsap.set that stages a whole scene at once#3737
xuanruli merged 1 commit into
mainfrom
claude/gsap-set-array-hidden-targets

Conversation

@xuanruli

@xuanruli xuanruli commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The hole

extractStandaloneHiddenSelectors builds the hidden-selector set that both gsap_cold_seek_hidden_fromto_missing_reveal and gsap_from_opacity_noop work from. It could not read an array target — the shortest and most natural way to stage a scene:

gsap.set([header, divider, body, cta], { opacity: 0 });

Two independent gates blocked it:

  • the target group was ([^,]+?), which cannot contain a comma, so a multi-element array never matched at all;
  • a single-element array got past the regex and then failed the selector parse, which accepted only a quoted string literal or a known string alias.

So for every grouped hide the hidden set was empty, and the two error rules that ask "was this hidden element ever properly revealed?" had nothing to ask about. A comma-separated selector string (gsap.set("#a, #b", …)) was silently half-broken the same way.

The change

hiddenSetTargetSelectors resolves each part of a group on its own; a part that resolves to nothing drops out instead of voiding the whole group. A resolved string is split on commas too, so both grouping forms are handled by one path.

The target pattern is now an explicit alternation — bracketed list, one quoted string, or a bare paren-free expression. Keeping it paren-free matters: a comma-permissive target would let gsap.set(document.querySelectorAll(".row"), staggerVars) (whose vars are a variable, so there is no { to stop at) run past its own closing paren and consume the next gsap.set, losing the only hide the rule needed. There is a test for exactly that.

Two false positives the wider hidden set exposed

Both in gsap_fullscreen_overlay_starts_visible:

  1. a fromTo at 0 seats its from-vars immediately, exactly as from() does — so hidden from-vars there mean the overlay does start hidden. The rule was reading only the destination.
  2. an overlay hidden by a standalone gsap.set is what this rule's own fixHint prescribes, so it now consults the source-level hidden set.

Verification

Corpus A/B on 1,181 real compositions (extracted from production zips, same runner both sides, findings sorted and diffed):

  • 2 new findings, 0 removed, 0 changed.
  • Both new ones are true positives in one composition: gsap.set(['#clip1','#clip2','#clip3','#cta'], { opacity: 0 }) followed by fromTo('#clip2', { opacity: 1, x: 1080 }, { x: 0, … }). fromTo renders immediately by default, so the visible from-state lands at t=0 and undoes the hide — #clip2 and #clip3 are on screen from the first frame, stacked over #clip1.

Tests: 8 new cases — the three grouped forms and the comma-string form firing, the scaleX: 0 group and the callback-body group staying quiet, the paren-safety case, and the two overlay false positives. 557 passed. tsc --noEmit 0 errors, oxlint 0, oxfmt --check clean.

Scope

This closes the extractor hole. It does not close the neighbouring gap that an element with no reveal tween of its own is never examined at all, because these rules iterate tweens rather than hidden elements — that one is a bigger change and belongs on its own.

🤖 Generated with Claude Code

Hiding several elements in one call is the shortest way to stage a scene, and it was
the one form the hidden-selector extractor could not read: a multi-element array
failed the target regex, which forbade commas, and a single-element array then failed
the selector parse, which accepted only a quoted string or a known alias. So the two
error rules that ask whether a hidden element is ever properly revealed had an empty
hidden set for every grouped hide.

Each part of a group now resolves on its own, and a comma-separated selector string
resolves the same way. The target pattern stays paren-free so a set whose vars are a
variable cannot run past its own closing paren and swallow the next call.

Two false positives the wider hidden set exposed in the fullscreen-overlay rule: a
fromTo at 0 seats its from-vars immediately, so hidden from-vars there mean the
overlay does start hidden; and an overlay hidden by a standalone gsap.set is what
that rule's own fixHint prescribes.
@xuanruli
xuanruli force-pushed the claude/gsap-set-array-hidden-targets branch from 5ef5cca to 2f5eada Compare September 7, 2026 01:27
@xuanruli
xuanruli marked this pull request as ready for review September 7, 2026 01:29

@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.

:large_green_circle: 2f5eada51 COMMENTED

Clean, well-scoped fix. The extractor gap is real (grouped gsap.set([a,b,c], {opacity:0}) never populated the hidden-set), the new target grammar closes it without over-consuming, and the two gsap_fullscreen_overlay_starts_visible false-positive fixes are the direct consequence of the wider hidden-set landing under a rule whose own fixHint prescribes exactly one of those shapes. Test discriminators are the right ones. Non-blocker notes below.

Verified as OK

  • hiddenSetTargetSelectors in packages/lint/src/rules/gsap.ts handles the three shapes cleanly. Bracketed list → slice(1,-1).split(",") per part, each resolved via quote regex OR alias map, undefined parts drop out (so [] yields [] and the caller skips the call, and [header] now resolves via the alias map — the two forms called out in the body). Comma-string → resolved string is itself .split(",")d, so "#a, #b" fans out. Bare paren-free identifier → alias lookup only.
  • pattern in extractStandaloneHiddenSelectors — alt 3 [^,()[\]]+? excludes both parens and commas, so gsap.set(document.querySelectorAll(".row"), staggerVars) can't match as a bare target and the whole call is silently skipped, letting the next gsap.set still match. The keeps a later gsap.set hide visible when an earlier one has non-literal vars test asserts exactly that. Alt 1 \[[^[\]]*\] also disallows nested brackets, so a [foo[0], bar] shape can't slip through and mis-parse.
  • scaleX: 0 grouped test: the extractor's body regex is (?:opacity|autoAlpha)\s*:\s*0…, so scaleX: 0 doesn't populate the hidden-set at all — the exclusion is structural, not accidental.
  • Callback-body test: the existing indexInsideNonIifeRange + collectFunctionBodyRanges still guards, and the // keep IIFEs (they run at parse time) comment is preserved.
  • fromTo-at-0 fix in gsap_fullscreen_overlay_starts_visible: the new startsHiddenAtZero clause OR's in win.fromPropertyValues !== undefined && isHiddenGsapState(win.fromPropertyValues). The !== undefined guard keeps to() windows (no fromPropertyValues) untouched. Consistent with the pre-existing from() handling and with GSAP's immediateRender: true default on from/fromTo.
  • Standalone-set fix: authoredHiddenSelectors built once from all scripts, checked via selectors.some(s => authoredHiddenSelectors.has(s)) right after the startsHiddenAtZero clause — same shape the rule's own fixHint recommends, so the two rules no longer close a bounce-loop.
  • Blast radius across the three extractStandaloneHiddenSelectors call sites: the new consumer at the top of the gsapRules[0] handler is a suppress path (wider hidden-set ⇒ fewer overlay findings); gsap_cold_seek_hidden_fromto_missing_reveal / gsap_from_opacity_noop are the fire paths (wider hidden-set ⇒ more findings, matching the +2 corpus claim); gsap_timeline_set_initial_hide is another suppress path (alreadyHidden short-circuits before the finding). Corpus A/B "0 removed" only holds if no composition in the 1,181 tripped the suppress paths — plausible, but that's a corpus fact not a source fact.

Concerns (all non-blocking)

  • fromTo immediateRender is not scoped to t=0. GSAP defaults immediateRender: true on both from() and fromTo(), so a fromTo("#overlay", {opacity:0}, {opacity:1}, 5) also seats opacity=0 at initialization even though the tween starts at 5s. The new clause only trusts fromPropertyValues when win.position <= SCENE_BOUNDARY_EPSILON_SECONDS, matching the existing from() scoping. That's a scope-consistent choice, not a regression this PR introduced, but it leaves a residual FP surface for fromTo at t>0. Comment-only fix if you agree: mention in the block comment above startsHiddenAtZero that the <= epsilon gate is a conservative slice, not the semantic truth.
  • Standalone-set ordering isn't checked. authoredHiddenSelectors is a Set built from all scripts, so if a standalone gsap.set(overlay, {opacity:0}) were written after the overlay's reveal tween in JS source order, the finding is still suppressed. In practice both fire at initialization and the compete-at-init ordering is subtle enough that a positional index would be over-engineering here — worth a one-line comment that ordering isn't enforced, though.
  • Bracket parts are .split(",")d without quote awareness. ["#a, #b"] (a bracket-wrapped comma-string) splits to ["\"#a", " #b\""], both fail the quote regex, and the whole group drops silently. Same for mixes like [a, "b, c", d]. Under-report failure mode, not a false-positive one, so it's the safe side of the trade — but the docstring in hiddenSetTargetSelectors could note that quoted commas inside bracket parts aren't preserved.
  • Extractor now runs 3× per script. extractStandaloneHiddenSelectors is invoked from the top of gsapRules[0], from gsap_cold_seek_hidden_fromto_missing_reveal/gsap_from_opacity_noop, and from gsap_timeline_set_initial_hide. Sibling cachedExtractGsapWindows exists for the window extractor — a cachedExtractStandaloneHiddenSelectors would be a one-line add. Perf-only, non-blocker.
  • Body attributes both new findings to one composition described as gsap.set(['#clip1','#clip2','#clip3','#cta'],{opacity:0}) + fromTo('#clip2', …). gsap_cold_seek_hidden_fromto_missing_reveal only fires on tweens whose target is in the hidden-set, so a finding on #clip3 needs a #clip3 tween somewhere — the body's "on screen from the first frame, stacked over #clip1" reads like observed visual state, not a second rule-level fire on #clip3 specifically. Not a code concern; if you happen to have the two finding rows handy in the corpus diff, worth pasting them in the PR body just so future readers can trace the +2.

What I didn't verify

  • Corpus A/B numbers (1,181 compositions → 2 new / 0 removed / 0 changed) — no in-tree harness; taken from author's own reporting.
  • Local vitest run of packages/lint. CI's Test job (which runs bun run --filter '!@hyperframes/producer' test and therefore includes the lint package's vitest) is now green at HEAD, so I trust that.

State at HEAD 2f5eada51: isDraft: false, mergeStateStatus: BLOCKED, mergeable: MERGEABLE, reviewDecision: REVIEW_REQUIRED. CI at HEAD: Typecheck, Build, Lint, Format, Test, Test:runtime contract, SDK unit+contract+smoke, Producer unit+integration, Preview parity, CLI smoke, Studio load smoke, Render on windows-latest, Tests on windows-latest: studio-core, Tests on windows-latest: studio-engine-cli, Fallow audit, Analyze (JS/TS + Python + Actions), CodeQL — all pass. hyperframes OSS runs dismiss_stale=false / require_last_push_approval=true, so any approval needs to pin to this exact SHA.

Review by Rames D Jusso

@jrusso1020 jrusso1020 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.

Approving at 2f5eada515f06094ef8816dee726ed52994482a5. Read the rule myself rather than riding the prior review; the PR body's claims hold up against source.

The two gates you describe are both real. Old pattern ([^,]+?) genuinely cannot span a comma, so gsap.set([a, b, c], {...}) never matched. And a single-element array did get past it and then die in the parse, because /^(["'])([^"']+)\1$/ on ['#a'] fails on the leading bracket and aliases.get("['#a']") misses. So the hidden set was empty for every grouped hide, and the two rules that consume it had nothing to work with. Verified both by reading the pre-image, not just the description.

The paren-free constraint is the load-bearing part of the new pattern and it is worth having a test pinned to it, which you do. [^,()[\]]+? cannot match document.querySelectorAll(".row"), so when the vars are a variable and there is no { to stop at, the match simply fails at that position instead of running past the closing paren and eating the next gsap.set. A comma-permissive target would have made the extractor lose the one hide the rule needed -- a silent, data-dependent false negative, which is the worst shape for a lint rule. Good instinct to close it explicitly.

hiddenSetTargetSelectors degrades in the safe direction. A part that resolves to nothing drops out rather than voiding the group, so a partially-parseable array still contributes what it can. The known limit is that bracket splitting is not quote-aware, so ["#a, #b"] splits into "#a and #b", neither of which parses as a quoted literal or an alias, and the whole entry drops. That under-reports -- a smaller hidden set means the reveal rules ask fewer questions and produce fewer findings, never more. Correct direction to fail in, and not worth code to fix.

One pre-existing limit that now matters slightly more, since the extractor reaches more call sites: the vars group is \{([\s\S]*?)\}, which is lazy and stops at the first }. gsap.set(x, { opacity: 0, transform: { x: 0 } }) still works because opacity precedes the nested object, but { transform: { x: 0 }, opacity: 0 } truncates before the opacity and misses the hide. Under-reports again, and it predates this PR -- noting it only because widening the target set makes it reachable from more places.

The two false-positive fixes read correctly. Or-ing fromPropertyValues into startsHiddenAtZero matches how fromTo seats its from-vars, and gating it on <= SCENE_BOUNDARY_EPSILON_SECONDS is a conservative slice of immediateRender rather than the full semantics -- fine, since erring toward "starts hidden" suppresses a finding rather than inventing one. The authoredHiddenSelectors check is sound: extractStandaloneHiddenSelectors skips non-IIFE callback bodies, so what survives is top-level and IIFE code that genuinely runs at parse time, which is the t=0 the rule is asking about. tl.set(...) correctly never matches.

On the corpus result: 2 new, 0 removed, 0 changed across 1,181 compositions is the right shape for this change -- the extractor only ever grows the hidden set, so anything but "0 removed" would have meant a regression somewhere. Worth stating in the PR body that the direction is structural, not just observed.

CI is green at this head.

-- Rames

@xuanruli
xuanruli merged commit b0ac581 into main Sep 7, 2026
47 checks passed
@xuanruli
xuanruli deleted the claude/gsap-set-array-hidden-targets branch September 7, 2026 02:00
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