Skip to content

feat(react-virtual): add useVirtualizerSnapshot for React Compiler compatibility#1241

Open
kklem0 wants to merge 3 commits into
TanStack:mainfrom
kklem0:feat/use-virtualizer-snapshot
Open

feat(react-virtual): add useVirtualizerSnapshot for React Compiler compatibility#1241
kklem0 wants to merge 3 commits into
TanStack:mainfrom
kklem0:feat/use-virtualizer-snapshot

Conversation

@kklem0

@kklem0 kklem0 commented Jul 25, 2026

Copy link
Copy Markdown

🎯 Changes

Adds useVirtualizerSnapshot and useWindowVirtualizerSnapshot: variants of the existing hooks whose render-facing values arrive as immutable snapshot data, making consuming components compatible with React Compiler.

Problem

React Compiler ships a hardcoded knownIncompatible entry for useVirtualizer ("returns functions that cannot be memoized safely") and skips every component that calls it — correct, but it leaves large consuming components entirely un-memoized (#1119).

The root cause is structural: useVirtualizer returns one identity-stable instance whose getVirtualItems() / getTotalSize() read mutable internal state during render, with re-renders forced by a contentless reducer bump. A memoizing compiler keys caches on reactive inputs — and the only input it can see is the never-changing instance:

// what the compiler generates for a consumer (mimic, since the real hook is skip-listed):
if ($[0] !== virtualizer) {            // identity-stable → true once, ever
  const items = virtualizer.getVirtualItems();
  t0 = <div>{items.map(...)}</div>;
  $[0] = virtualizer; $[1] = t0;
} else {
  t0 = $[1];                           // ← the first render's items, forever (#736, #743)
}

directDomUpdates (#1187) mitigates scroll-path cost under the skip; this PR adds the complementary path: letting consumers actually compile.

Design

The snapshot hooks split the API along the exact line the compiler cares about:

  • Render-facing values become immutable snapshot data via useSyncExternalStore (official use-sync-external-store shim, so the >=16.8 peer range is preserved): virtualItems and totalSize change identity exactly when the computed geometry changes. The exported fields are readonly / ReadonlyArray.
  • Imperative APIs stay on the stable instance (snapshot.virtualizer): measureElement as a ref, scrollToIndex, measure, … — these were never the problem (called at commit/event time, identity-stable, no ref churn).

Because the compiler's skip is keyed on the imported property name, snapshot consumers compile with today's stable compiler (1.0.0) — no coordination required. Longer term this also gives the React team a delisting target: the snapshot contract is compatible by design, not by exemption.

Implementation notes:

  • Snapshot rebuilt lazily on onChange, with part-comparison — notifications that change nothing visible (e.g. isScrolling flips) publish the same reference and skip the re-render (slightly fewer renders than the current adapter).
  • User onChange chained; useFlushSync semantics preserved; SSR via getServerSnapshot.
  • Shared engine (useVirtualizerSnapshotBase) mirrors the existing useVirtualizerBase layering; virtual-core untouched.
  • directDomUpdates intentionally not supported on the snapshot hooks — it's the alternative strategy (bypass React on scroll), while this makes React rendering itself compiler-safe.

Tests

Real-world validation

Linked this branch into a production React Router app (24 locales, Capacitor iOS/Android) that had isolated all eight of its virtualized lists behind the compiler skip. Converting a list to the snapshot hooks: the component went from compilation skipped to fully compiled (_c(26)), with types and lint clean, and identical runtime behavior.

Alternative considered: useVirtualizer({ snapshot: true })

An opt-in flag on the existing hooks would unify the API surface, but:

  1. The compiler's skip is name-keyed and not options-aware, so flagged consumers would still be skipped — the flag can't deliver the compiler benefit that motivates the feature.
  2. The two modes need different internal hooks (useReducer+flushSync vs useSyncExternalStore), so a toggleable flag would require mounting both mechanisms unconditionally in the existing hot path, plus overloaded return types.
  3. Separate hooks follow the package's own precedent (useWindowVirtualizer is a hook, not a { window: true } flag).

Happy to restructure if you prefer the flag shape — the engine converts trivially.

Refs: #736, #743, #1119, #1187

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • New Features
    • Added React Compiler–compatible useVirtualizerSnapshot and useWindowVirtualizerSnapshot hooks with immutable, render-safe virtualItems and totalSize, plus a stable virtualizer for imperative scrolling/measurement.
  • Documentation
    • Updated React Virtual documentation with snapshot-hook usage guidance and React Compiler compatibility notes.
  • Bug Fixes
    • Improved virtualization updates in compiled React consumers, preventing incorrect initial/scroll rendering.
  • Tests
    • Added unit, compiler, and end-to-end coverage validating snapshot behavior and scroll updates under React Compiler.

…mpatibility

React Compiler skips any component calling useVirtualizer as a
known-incompatible API: the hook returns a stable instance whose
getVirtualItems()/getTotalSize() read mutable internal state during render,
so a memoizing compiler caches the first result keyed on the stable
instance and the list never updates (TanStack#736, TanStack#743). The skip keeps apps
correct but leaves every consuming component un-memoized (TanStack#1119).

useVirtualizerSnapshot (and useWindowVirtualizerSnapshot) return the
render-facing values as immutable snapshot data instead, via
useSyncExternalStore (official shim, preserving the React >=16.8 peer
range): virtualItems and totalSize change identity exactly when the
computed geometry changes, while the stable virtualizer instance rides
along for imperative APIs (measureElement, scrollToIndex, ...). Because the
compiler's skip is keyed on the imported property name, components
consuming the snapshot hooks compile - and memoize correctly - today.

- snapshot cache rebuilt lazily on onChange, with part-comparison so
  notifications that change nothing visible (isScrolling flips) publish
  the same reference and skip the re-render
- user onChange still chained; useFlushSync semantics preserved
- unit tests pin the identity contract (stable across unrelated
  re-renders, fresh on geometry change), StrictMode, and SSR via
  getServerSnapshot
- compiler-contract tests run babel-plugin-react-compiler in CI: snapshot
  consumers emit the _c() memo cache, useVirtualizer consumers stay
  skipped
- e2e: a compiled snapshot-hook page under the existing react-compiler
  build (TanStack#1187 infra) proving the TanStack#736 scenario now passes with the
  consumer compiled
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds useVirtualizerSnapshot and useWindowVirtualizerSnapshot, exposing immutable render snapshots with stable imperative virtualizer instances. The implementation uses useSyncExternalStore and includes compiler, unit, SSR, window-scrolling, documentation, and end-to-end regression coverage.

Changes

Snapshot virtualizer hooks

Layer / File(s) Summary
Snapshot API and subscription implementation
packages/react-virtual/src/index.tsx, packages/react-virtual/package.json
Adds snapshot types and hooks backed by useSyncExternalStore, with stable virtualizer references and element/window scrolling wrappers.
Snapshot and compiler validation
packages/react-virtual/tests/snapshot.test.tsx, packages/react-virtual/tests/compiler.test.ts
Covers rendering, identity stability, updates, StrictMode, SSR, window scrolling, and React Compiler compilation behavior.
React Compiler end-to-end regression coverage
packages/react-virtual/e2e/app/..., packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts
Adds a compiled demo and Playwright checks for initial virtualization, scrolling updates, row positioning, and rerenders.
Snapshot hook documentation and release entry
docs/framework/react/react-virtual.md, .changeset/hungry-donkeys-applaud.md
Documents snapshot APIs, compiler usage, imperative access, and the new release entry.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReactComponent
  participant useVirtualizerSnapshot
  participant Virtualizer
  participant useSyncExternalStore
  ReactComponent->>useVirtualizerSnapshot: call with virtualization options
  useVirtualizerSnapshot->>Virtualizer: create and update virtualizer
  Virtualizer->>useSyncExternalStore: publish onChange notification
  useSyncExternalStore-->>ReactComponent: provide virtualItems and totalSize snapshot
  ReactComponent->>Virtualizer: call scrollToIndex through snapshot.virtualizer
  Virtualizer->>useSyncExternalStore: publish updated visible items
  useSyncExternalStore-->>ReactComponent: rerender with new snapshot
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly captures the main compiler-compatibility snapshot hook addition, though it omits the window variant.
Description check ✅ Passed The description follows the template and includes detailed changes, checklist items, and release impact information.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/react-virtual/tests/snapshot.test.tsx

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): packages/react-virtual/tests/snapshot.test.tsx


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​use-sync-external-store@​1.5.01001006280100

View full report

@kklem0

kklem0 commented Jul 25, 2026

Copy link
Copy Markdown
Author

A note on the longer-term API direction, since the "Alternative considered" section above only covers why the flag shape doesn't work today — the fuller version of that question is whether useVirtualizer({ snapshot: true }) (or making snapshot the default) could work in coordination with the React team removing the knownIncompatible entry. I looked into what each path would actually require from the compiler:

1. Options-aware skipping (flag shape + compiler inspects the call) — the compiler's type provider maps module → property name → type/knownIncompatible; it has no visibility into call-site options. Making it options-aware is statically fragile by nature: it could only trust a literal snapshot: true at the call site — any spread or variable (useVirtualizer(opts)) is undecidable and must conservatively skip. So the flag shape structurally can't give reliable compilation even with compiler support, whereas distinct hook names are always statically decidable. That's the deep reason this PR uses separate hooks rather than just repo convention.

2. Delisting useVirtualizer outright — only sound once the old contract (identity-stable instance whose getVirtualItems() is read during render) no longer ships under that name, i.e. a breaking major where snapshot semantics become the default. And it would need version-gated type-provider entries, which don't exist today: the entry is identical and ungated in compiler 1.0.0 stable and in the current experimental builds. Version gating (or a library-side compatibility declaration) would be the concrete feature to request from the React team.

3. What works with the compiler as it ships today — separate hooks, no coordination needed: the skip is keyed on the imported property name, so snapshot consumers compile under stable 1.0.0 right now (pinned by the compiler-contract tests in this PR).

So a plausible roadmap, if there's appetite: separate hooks now (this PR) → if snapshot semantics prove out, consider making them the useVirtualizer default in the next major → at that point coordinate delisting/version-gating with the React team. The engine here (useVirtualizerSnapshotBase) supports that evolution without rework — the flag or a default switch is a thin layer over the same store.

Happy to open the corresponding discussion on the React side if that roadmap sounds right.

@kklem0
kklem0 marked this pull request as ready for review July 25, 2026 01:21

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/react-virtual/tests/snapshot.test.tsx (1)

167-185: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise window scroll updates.

This only verifies the initial window snapshot. Add an offset/scroll update assertion that confirms a changed window position publishes a new range; otherwise regressions in observeWindowOffset or the window wrapper remain untested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-virtual/tests/snapshot.test.tsx` around lines 167 - 185,
Extend the useWindowVirtualizerSnapshot test around WindowList to simulate a
window scroll or offset change after the initial render, then assert that the
rendered virtual-item range updates to reflect the new position. Keep the
existing Row 0 initial assertion and use the public window-offset update path so
observeWindowOffset and its wrapper are exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/framework/react/react-virtual.md`:
- Around line 39-45: Align the documented React virtualizer snapshot signatures
with the exported TypeScript API: at docs/framework/react/react-virtual.md lines
39-45, constrain TScrollElement and TItemElement to Element and update the
VirtualizerSnapshot wrapper to use TScrollElement extends Element | Window and
TItemElement extends Element; at lines 117-125, document TItemElement extends
Element instead of defaulting it to unknown.
- Around line 47-55: Update the exported VirtualizerSnapshot interface and its
matching documentation type to declare virtualItems as
ReadonlyArray<VirtualItem> rather than Array<VirtualItem>. Preserve the existing
immutable snapshot contract and useVirtualizerSnapshot behavior.

In `@packages/react-virtual/src/index.tsx`:
- Around line 293-318: Update VirtualizerSnapshot and its snapshot construction
path to expose readonly fields, and prevent consumers from mutating cached data.
When the core virtual-items array identity changes, create and cache a cloned,
frozen array; reuse it otherwise, and freeze the returned snapshot object while
preserving the existing virtualizer, totalSize, and memoization behavior.

---

Nitpick comments:
In `@packages/react-virtual/tests/snapshot.test.tsx`:
- Around line 167-185: Extend the useWindowVirtualizerSnapshot test around
WindowList to simulate a window scroll or offset change after the initial
render, then assert that the rendered virtual-item range updates to reflect the
new position. Keep the existing Row 0 initial assertion and use the public
window-offset update path so observeWindowOffset and its wrapper are exercised.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 33367ede-5cbe-47dd-8e67-977931c6c9c2

📥 Commits

Reviewing files that changed from the base of the PR and between deca524 and b38b92e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • .changeset/hungry-donkeys-applaud.md
  • docs/framework/react/react-virtual.md
  • packages/react-virtual/e2e/app/react-compiler-vite.config.ts
  • packages/react-virtual/e2e/app/snapshot-hook/index.html
  • packages/react-virtual/e2e/app/snapshot-hook/main.tsx
  • packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts
  • packages/react-virtual/package.json
  • packages/react-virtual/src/index.tsx
  • packages/react-virtual/tests/compiler.test.ts
  • packages/react-virtual/tests/snapshot.test.tsx

Comment thread docs/framework/react/react-virtual.md Outdated
Comment thread docs/framework/react/react-virtual.md
Comment thread packages/react-virtual/src/index.tsx Outdated
…atures

Review follow-up: VirtualizerSnapshot fields are now readonly and
virtualItems is a ReadonlyArray, making the immutability contract explicit
at the type level (the array reference is shared with the core, matching
getVirtualItems()). Docs signature blocks now show the exported generic
constraints instead of the loose legacy style.
@kklem0

kklem0 commented Jul 25, 2026

Copy link
Copy Markdown
Author

Re the CodeRabbit ESLint tool failures on the two new test files: packages/react-virtual/tsconfig.json includes only src, vite.config.ts, and playwright.config.ts — test files sit outside the type-aware project repo-wide (the pre-existing tests/index.test.tsx has the same status; it just wasn't linted here because only changed files are). The new tests follow the existing convention; happy to widen the tsconfig include in a separate change if that's preferred.

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

🧹 Nitpick comments (2)
packages/react-virtual/tests/snapshot.test.tsx (2)

117-140: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Also assert stable virtualizer identity after the gap update.

This test verifies stability after the count change but not after the second render-time geometry update. Add the same assertion for afterGapChange to protect the imperative instance contract.

Proposed assertion
 expect(afterGapChange.virtualItems).not.toBe(afterCountChange.virtualItems)
+expect(afterGapChange.virtualizer).toBe(afterCountChange.virtualizer)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-virtual/tests/snapshot.test.tsx` around lines 117 - 140, Add
an assertion in the render-time geometry update test after the gap rerender to
verify afterGapChange.virtualizer is the same object as
afterCountChange.virtualizer, preserving the existing virtualizer identity
contract.

198-216: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise a window-scroll update, not only initial rendering.

The current smoke test can pass even if useWindowVirtualizerSnapshot fails to publish updates from the window offset observer. Scroll the window and assert that the rendered range or snapshot changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-virtual/tests/snapshot.test.tsx` around lines 198 - 216,
Extend the useWindowVirtualizerSnapshot test to simulate scrolling the window
after the initial render and assert that the rendered virtual range or snapshot
updates accordingly. Keep the existing initial Row 0 assertion, and use the
test’s rendered WindowList output and window-scroll behavior to verify
observer-driven updates rather than only initial rendering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/react-virtual/tests/snapshot.test.tsx`:
- Around line 117-140: Add an assertion in the render-time geometry update test
after the gap rerender to verify afterGapChange.virtualizer is the same object
as afterCountChange.virtualizer, preserving the existing virtualizer identity
contract.
- Around line 198-216: Extend the useWindowVirtualizerSnapshot test to simulate
scrolling the window after the initial render and assert that the rendered
virtual range or snapshot updates accordingly. Keep the existing initial Row 0
assertion, and use the test’s rendered WindowList output and window-scroll
behavior to verify observer-driven updates rather than only initial rendering.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d32ff408-209d-48ef-8e60-70d3ca60118a

📥 Commits

Reviewing files that changed from the base of the PR and between 420a871 and e7fc535.

📒 Files selected for processing (3)
  • docs/framework/react/react-virtual.md
  • packages/react-virtual/src/index.tsx
  • packages/react-virtual/tests/snapshot.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/framework/react/react-virtual.md
  • packages/react-virtual/src/index.tsx

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.

1 participant