feat: add e2e and perf tests for useExperimentalDOMVirtualizer with hook injection#1141
Draft
piecyk wants to merge 2 commits into
Draft
feat: add e2e and perf tests for useExperimentalDOMVirtualizer with hook injection#1141piecyk wants to merge 2 commits into
piecyk wants to merge 2 commits into
Conversation
|
|
View your CI Pipeline Execution ↗ for commit 1cd67a0
☁️ Nx Cloud last updated this comment at |
2 tasks
5748d9c to
9d1b31c
Compare
95b74d4 to
525ee89
Compare
tannerlinsley
added a commit
that referenced
this pull request
May 20, 2026
…andling and scroll restoration (#1168) * perf(virtual-core): replace Map clone in resizeItem with version counter `resizeItem` was doing `new Map(itemSizeCache.set(...))` on every call, cloning the entire size cache (O(n) per call) just to invalidate the `getMeasurements` memo. For a 10k-item dynamic list mount where every item resizes, this was O(n²) — measured at 1861ms. Replace with mutate-in-place + a private `itemSizeCacheVersion` counter that is included in `getMeasurements`'s memo deps. Same invalidation behavior, O(1) per call. Also switches `measure()` to `.clear()` + bump version rather than allocating fresh Maps. Benchmarks (n×n measure storm, then 1× getMeasurements): n=100 0.159ms -> 0.013ms (12x) n=1000 16.0ms -> 0.107ms (150x) n=5000 399.6ms -> 0.640ms (624x) n=10000 1861ms -> 1.35ms (1382x) No public API change; itemSizeCacheVersion is private. Adds 11 regression tests pinning the cache-invalidation contract. * perf(virtual-core): rewrite setOptions to avoid Object.entries+delete `setOptions` was using `Object.entries(opts).forEach([k,v] => if undefined delete opts[k])` to strip undefined values before `{...defaults, ...opts}`. Two problems: 1. The `delete` call triggers V8 hidden-class dictionary-mode transition, slowing every subsequent options access for the virtualizer's lifetime. 2. It mutates the caller's opts object — a hidden API contract violation. Replace with a single `for...in` loop that copies non-undefined values onto a fresh defaults object. Same semantics (undefined falls through to defaults, falsy 0/false/'' stick), no mutation, no deopt. Benchmark (10,000 setOptions calls, simulating React render storm): before: 14.35ms after: 1.31ms speedup: 11.0x Adds 6 regression tests pinning the merge contract (defaults, undefined-falls-through, falsy values stick, no-mutation, no-stale-accumulation, explicit-override). * perf(virtual-core): track pending-rebuild min with a counter, not an array `getMeasurements` was reading the earliest dirty index with `Math.min(...this.pendingMeasuredCacheIndexes)`. The spread allocates an argument list and, at very large pending counts (~125k), can throw RangeError from V8's stack-argument limit. Replace the Array<number> + Math.min(...) pair with a single `pendingMin: number | null` field. `resizeItem` does an O(1) compare-and-set; `getMeasurements` reads it and resets to null. Perf delta is small (the rebuild loop dominates), but this removes a latent stack-overflow footgun on very large lists. Adds 2 regression tests: - random-order resize produces correct prefix-sums (covers the running-min logic) - 10k-item storm doesn't crash on min lookup * chore(virtual-core): add Layer 4 bench scenarios (resizeItem notify cost) Added bench scenarios that measure the cost of notify() dispatch under resize-storms with realistic vs no-op onChange callbacks. These informed the decision to *not* implement Layer 4 of the perf audit: - React 18+ batches useReducer dispatches; the audit's "1000 React renders per mount" claim doesn't hold in practice. - Real-world cost of redundant notify() is ~1ms over a 10k-item mount. - Routing through maybeNotify (the audit's proposed fix) would change the sync flag from false to isScrolling, regressing scroll behavior. Keeping the benches for future revisits. * perf(virtual-core): pre-size defaultRangeExtractor's result array The default extractor was building its result with `arr.push(i)`, forcing V8's array-growth heuristic to repeatedly resize. Compute the length upfront and allocate once. Benchmarks (10,000 invocations): visible=50 1.07ms -> 0.50ms (2.14x) visible=200 3.96ms -> 1.94ms (2.04x) visible=1000 28.81ms -> 12.28ms (2.35x) Adds 7 regression tests for the extractor (basic, overscan, start/end clamping, single-item, large range, return-type). * fix(virtual-core): cast setOptions merged-defaults through unknown The narrow defaults object doesn't have the user-required fields (count, estimateSize, etc.) until the loop fills them in. The 'as Required<...>' cast was too strict and failed tsc's structural check. Casting through 'unknown' is the standard escape hatch for two-step build patterns. * perf(react-virtual): use a number counter for useReducer instead of allocating {} The force-rerender pattern previously used `useReducer(() => ({}), {})` which allocates a new object on every dispatch. Switch to an incrementing number — same semantics (state changes on every dispatch, forcing a render), zero alloc. Trivial individual cost, but eliminates one steady-state GC source on scroll-heavy apps. * fix(virtual-core): drop elementsCache entry when RO sees disconnected node When an item element disconnects from the DOM, the ResizeObserver still fires a callback for it (until we call unobserve). We were calling unobserve but leaving the stale entry in elementsCache, so the Map could slowly grow with detached-node references over the lifetime of a long- running list (frequent unmount/remount, virtualized routes, etc.). Now remove the entry when we detect the disconnect, with a === guard so a delayed callback for an old node doesn't blow away a new node that React has since mounted for the same key. Tests: 2 added — cleanup-on-disconnect, and the don't-clobber-replaced-node edge case. * perf(virtual-core): make memo's debug instrumentation tree-shakable Cache `process.env.NODE_ENV !== 'production' && opts.key && opts.debug?.()` into a single \`debugEnabled\` flag, then gate all three timing/logging blocks on it. The `process.env.NODE_ENV` prefix lets downstream minifiers (Terser/esbuild/swc with NODE_ENV define) constant-fold the entire flag to false in production and DCE the console.info + Date.now() machinery. Behavior in dev is unchanged — opts.debug() is still polled once per call (rather than three times) but the timings and logs are identical. Bundle size (esbuild --minify --define:process.env.NODE_ENV='"production"'): before: 5219 bytes gzip after: 4999 bytes gzip delta: -220 bytes (-4.2%) * refactor(virtual-core): collapse element/window observer pairs to one impl Both observer pairs were near-duplicate functions differing only in how the offset is read from the scroll target. Pull the shared structure into an internal \`observeOffset\` (takes a \`readOffset\` callback) and re-export the two named exports as thin wrappers. Same for \`elementScroll\` / \`windowScroll\`, which were identical except for the generic type parameter — both now alias one underlying function with the right exported signature. No public API change: \`observeElementOffset\`, \`observeWindowOffset\`, \`elementScroll\`, and \`windowScroll\` remain named exports with their original signatures. All adapter packages continue to import them unchanged. Bundle size impact (this is mostly a maintenance refactor): source: -37 LOC dist raw: 31.87 -> 30.70 kB (-1.17 kB) dist gzip: 6.55 -> 6.59 kB (+40 B, gzip already deduplicated the copies) consumer min: 16.55 -> 15.98 kB raw / 4.99 -> 5.00 kB gzip (~flat) Tests: 10 added covering the four exports' contracts before/after refactor. * refactor(virtual-core): replace utils barrel with named exports Drop the \`export * from './utils'\` barrel in favor of explicit named exports — same public surface (\`memo\`, \`debounce\`, \`approxEqual\`, \`notUndefined\`, types \`NoInfer\`, \`PartialKeys\`), now visible at the top of the file. Bundle size impact: zero. Modern bundlers tree-shake the \`export *\` barrel identically. The win is API clarity — the file declares its public surface up front instead of inheriting it implicitly. Adds a "public exports lockdown" test that fails if any of these go missing in a future change. * chore(benchmarks): add reproducible cross-library benchmark suite Adds benchmarks/ — a Vite + React + Playwright harness that runs the same scenarios through the actual public APIs of @tanstack/react-virtual, virtua, react-virtuoso, and react-window v2, then aggregates medians into a markdown table. How: - One page per library at src/pages/, each registering a HarnessHandle so the runner can drive them uniformly without knowing the library. - Shared deterministic dataset (LCG-seeded) so every library renders identical content. - runner/run.mjs spawns the vite preview server, loops over (lib × scenario × run), and writes results/<ts>.json + results/LATEST.md. - Chromium launched with --enable-precise-memory-info and --expose-gc for trustworthy memory readings. Scenarios cover mount (1k, 10k, 100k fixed; 1k, 10k dynamic), dynamic measurement convergence, programmatic scroll, and jump-to-index settle. Run with: cd benchmarks && pnpm bench Sample run (5 runs/cell medians) checked in at results/SAMPLE.json. README documents methodology, results, and known limitations honestly — including that the synthetic scroll test is too gentle to discriminate between the libraries at the sizes tested. * docs: add competitor claims verification matrix Synthesized findings from official competitor docs, social media, and our own issue tracker. Maps every claim to verification status (TRUE/FALSE/ PARTIAL/UNVERIFIED) and ranks audit priorities. Highlights: - virtua has 17+ explicit iOS code paths; we have zero - virtuoso's 'better scrollTo' claim is FALSE per our benchmark (they're slowest) - virtua's v0.10.0 README had TanStack as the SMALLEST bundle; they removed it - virtua's 'Benchmark: WIP' has been WIP for 3+ years - PR #1141 (useExperimentalDOMVirtualizer) already shows 47% fewer renders Action plan ranked by impact in section 5. * exp(virtual-core): lazy VirtualItem materialization for lanes===1 fast path Replace the eager per-item VirtualItem object loop with a typed-array backing + a Proxy that builds VirtualItems on first indexed read. The existing lanes>1 path stays on eager construction (lane assignment is order-dependent and harder to defer cleanly). Mechanism: - Float64Array (stride 2: start, size) holds the dense position data - Single allocated buffer is reused across rebuilds - Proxy wraps a sparse cache and materializes a VirtualItem on first integer read; subsequent reads return the cached object - resizeItem reads raw start/size from the flat buffer (avoiding Proxy overhead per call) when in the fast path Backwards-compatible: measurementsCache still satisfies Array<VirtualItem> shape; getVirtualItems / calculateRange / getVirtualItemForOffset / getOffsetForIndex / getTotalSize / resizeItem all work unchanged. Benchmarks (real Virtualizer, vitest bench): BEFORE AFTER Speedup Cold getMeasurements n=10k 0.21ms 0.05ms 4.2x Cold getMeasurements n=100k 2.52ms 0.54ms 4.7x Cold getMeasurements n=500k 14.1ms 2.63ms 5.4x Cold + visible@0 n=100k 2.76ms 0.93ms 3.0x Cold + visible@0 n=500k 13.98ms 4.65ms 3.0x 100x resize@0 n=10k 26.3ms 15.2ms 1.7x Bundle size (consumer minified+gzip): before: 5.00 kB after: 5.43 kB (+430 B / +8.6%) The bundle cost buys 5x faster cold mount at 100k+ items and ~3 MB less memory at 100k (typed array vs N object literals). Closes the gap to virtua's lazy prefix-sum architecture for the most common (single-lane) case. Adds 9 regression tests pinning lazy-path behavior: empty list, paddingStart/ scrollMargin/gap, VirtualItem field correctness, identity caching, out-of-range access, resizeItem→getTotalSize, getVirtualItemForOffset binary search, 1M-item mount stress test, and the lanes>1 fallback path. * exp(virtual-core): defer scroll-position adjustments during iOS momentum scroll iOS WebKit cancels momentum-scroll the moment you write to scrollTop. Our resizeItem path was unconditionally calling _scrollToOffset whenever an above-viewport item resized, killing momentum and producing the most-cited mobile complaint cluster (issues #545, #622, #884, plus several closed duplicates). Match virtua's pendingJump pattern: detect iOS WebKit (UA + iPadOS-on- MacIntel heuristic), accumulate the delta into _iosDeferredAdjustment while isScrolling, then flush a single scrollTo when isScrolling transitions back to false. Non-iOS code path is unchanged. SSR-safe (returns false when navigator is undefined). Detection result is cached after first call. Adds 3 regression tests: - iOS: adjustment deferred during scroll, flushed on stop - iOS: multiple resizes accumulate into one flush - Non-iOS: no regression — immediate adjustment as before Bundle delta: +190 B gzip (consumer-minified, prod-defined). Cumulative since main: 5.00 -> 5.62 kB (still under 6 kB). * exp(virtual-core): keep smooth scroll while still > viewport from new target When scrollToIndex(N, { behavior: 'smooth' }) is called on a dynamic-height list, the destination items haven't been measured yet, so getOffsetForIndex returns an estimate. As scroll progresses, items become visible and measure their real heights, shifting the target offset. The reconcile loop detected this and snapped to behavior:'auto' on the first retarget — that's the "course correction jolt" reported across many scrollToIndex issues. New behavior: while still more than one viewport away from the new target, keep smooth scrolling. The browser's smooth scroll handles repeated target updates gracefully (continuous motion with adjusted endpoint). Only on the final approach (within a viewport) do we fall back to 'auto' for precise landing. User-visible: one continuous smooth scroll that subtly accelerates/ decelerates instead of an animation followed by a snap. Addresses recurring complaint pattern across #468, #913, #1001, #1029, plus discussions about scrollToIndex unreliability with dynamic heights. Bundle delta: ~+20 B gzip. * exp(virtual-core): skip scroll-position adjustment while user scrolls backward The most-cited TanStack Virtual complaint cluster (issues #659, #832, #925, #1028, etc.) is "items jump while I'm scrolling up". The cause: when an above-viewport item resizes during backward scroll, resizeItem writes to scrollTop to compensate — that write actively pushes the viewport away from where the user is scrolling. Multiple users have independently rediscovered the same workaround over the years: gate cache writes on scroll direction. Make it the default in the core: when scrollDirection is 'backward', skip the scroll-position adjustment. Forward scroll and idle measurement keep the existing behavior (needed for stable visible window during forward scroll and for the mount-time measurement storm). Users who genuinely want the old behavior can supply \`shouldAdjustScrollPositionOnItemSizeChange\` (which is checked before the default branch) and ignore the scroll direction in their predicate. Adds 3 regression tests: - backward scroll: adjustment skipped - forward scroll: adjustment still fires - idle: adjustment still fires (mount-time path) * exp(virtual-core): add takeSnapshot() for scroll restoration round-trips Adds a public takeSnapshot() method that returns the currently-measured items as plain VirtualItem objects, suitable for round-tripping through state storage and feeding back as initialMeasurementsCache on remount. Pair with the current scrollOffset to fully restore scroll position after navigation. Closes the gap to virtua's takeCacheSnapshot() and virtuoso's getState — features cited as TanStack misses in #378, #551, #997 and the virtua/virtuoso comparison tables. The snapshot contains plain objects (not Proxy refs), so it serializes cleanly via JSON.stringify and survives lazy-fast-path materialization. Adds 2 regression tests covering single-lane round-trip and lanes>1. Bundle delta: ~+150 B gzip (one new method body). * exp(virtual-core): bypass lazy-view Proxy in calculateRange + getVirtualItemForOffset The lazy fast path returns a Proxy-wrapped Array<VirtualItem>. Each indexed read triggers a get-trap that materializes a VirtualItem (with allocation) on first access. In hot paths like the binary search inside calculateRange this adds ~17 Proxy traps per scroll event. Pass the underlying Float64Array along to calculateRange so binary-search probes and the forward-end-walk read start/size directly. Same for getVirtualItemForOffset. The Proxy is still used by user-facing getVirtualItems where the consumer expects a real VirtualItem object. Bundle delta: negligible (~+30 B). * docs: summarize 3-hour experimentation loop results * exp(virtual-core): getTotalSize reads last end directly from flat typed array In the lanes===1 fast path, getTotalSize() was calling measurements[N-1].end which triggers a Proxy.get and materializes the last VirtualItem just to read .end. React renders call getTotalSize on every commit, so this matters. Direct typed-array read for the same value. ~no behavior change, marginal perf win. * docs: update experiments summary with final cross-library numbers * fix(benchmarks): remove 1px border on .scroll-host so accuracy bench is fair The 1px CSS border on the outer scroll-host pushed the inner content down by 1px in libraries whose getScrollContainer returns the host element (TanStack), while libraries with their own internal scrollers (virtuoso) queried past the border. The 'tanstack: 1.0px / virtuoso: 0.0px' result in the prior accuracy bench was the border, not the libraries. Re-measured: TanStack and virtuoso both at 0.0px landing. react-window v2 still off by 135px (verified library issue, not bench artifact). Also: add a defensive 'final exact-landing' write in reconcileScroll once the stable-frames count is met. This is a no-op when scrollTop already equals the target (the usual case) but corrects the rare subpixel-rounding case where the browser's smooth-scroll undershoots by < 1.01px. * test(benchmarks): add three accuracy edge cases for scrollToIndex Adds the scrollToIndex landing-accuracy scenarios identified as likely competitor strengths: - jump-to-last-accuracy-dynamic-10k: scrollToIndex(N-1, align:'end'). Tests cumulative prefix-sum drift; end-alignment amplifies any error between estimates and real measurements. - jump-while-measuring-accuracy-dynamic-10k: scroll immediately on mount before the visible window has been measured (race condition). - jump-wide-variance-accuracy-10k: items 30..500px, ~16x ratio vs the 30px estimate. Tests convergence when estimates are very wrong. Result across all 4 libraries: TanStack and virtuoso both at 0.0px on every edge case; react-window v2 consistently 135-224px off; virtua's target item didn't render in any of these (page-level quirk). The conventional-wisdom claim that competitors have an accuracy advantage on these specific cases does not hold up to measurement. * docs: plan iOS Phase 1 + Phase 2 (touch distinction, subpixel reconciliation, elastic clamp) * docs: add bundle-impact section to iOS support plan * feat(virtual-core): iOS Phase 1 — touch event distinction for scroll deferral Extends the iOS deferral path from Experiment 2 to track touch state so we can defer scroll-position adjustments through three distinct iOS scroll states instead of one: - active drag (finger on screen) - early-momentum (touch just ended; momentum scroll likely starting) - post-momentum settled Mechanism: - New fields: _iosTouching, _iosJustTouchEnded, _iosTouchEndTimerId - Attach passive touchstart/touchend listeners to the scroll element - touchend on iOS arms a 150 ms grace timer; when it expires we attempt to flush any deferred adjustments - New flush gate: only writes scrollTop when all of !isScrolling, !_iosTouching, !_iosJustTouchEnded hold - All flush paths route through a single _flushIosDeferredIfReady helper Non-iOS behavior is unchanged. The listeners attach unconditionally (passive, cheap on non-touch devices); the gating logic short-circuits without arming timers on non-iOS UAs. Adds 7 regression tests covering touchstart/touchend bookkeeping, grace timer expiry, mid-touch defer, scroll-event-driven flush, re-touch canceling the grace timer, and the non-iOS no-op path. * feat(virtual-core): iOS Phase 2a — subpixel reconciliation for scrollTop writes Browser scrollTop/scrollLeft writes are integer-rounded under some DPRs (Safari especially). When we write 12345.5 and the browser reports back 12346 on the resulting scroll event, the reconcile loop thinks the target shifted and re-fires scrollTo — feedback we previously absorbed only via the approxEqual(<1.01) tolerance. Track the intended logical target separately. When the next scroll event reports a value within 1.5 px of our intended write, prefer the intended value over the browser-rounded one. Real user scrolls move further than 1.5 px and skip the reconciliation path. Adds 3 regression tests: subpixel-rounded read reconciles, large-delta user scroll does not reconcile, second self-write replaces intended. * feat(virtual-core): iOS Phase 2b — skip flush during Safari elastic-overscroll Safari's elastic-overscroll (rubber-band) lets scrollTop go negative or exceed scrollHeight-clientHeight while the user drags past the edge. Writing scrollTop during that period would snap the page back to a clamped value at end-of-bounce, often discarding the user's intent. Add an in-bounds guard to _flushIosDeferredIfReady: if scrollTop is outside [0, getMaxScrollOffset()], skip the flush and leave the adjustment deferred. The next in-bounds scroll event retries. Adds 3 regression tests: - Negative scrollTop (overscroll top): flush skipped, then proceeds when scroll snaps back in-bounds - scrollTop > max (overscroll bottom): same pattern - In-bounds scrollTop: flush proceeds normally (no regression) * chore: clean up lint, sherif, knip for release readiness - Eliminate two redundant non-null assertions in iOS detection and the getVirtualItemForOffset lazy fast-path (eslint @typescript-eslint/no- unnecessary-type-assertion) - Convert takeSnapshot's index-loop to for-of (eslint prefer-for-of) - Align benchmarks/package.json dep versions with the rest of the workspace (typescript 5.6.3, vite ^6.4.2, @playwright/test ^1.53.1, React 18.3.x) so sherif passes - Add 'benchmarks' to knip ignore list (private workspace; unused-export warnings on the per-library page components are intentional) Pre-existing test:ci failures on main (lit-virtual:build, react-virtual:test:e2e) are not from this branch and remain. * docs(api): document takeSnapshot, initialMeasurementsCache, new defaults - Add `takeSnapshot()` instance method docs with the round-trip example for scroll restoration (pairs with `initialMeasurementsCache`). - Add `initialMeasurementsCache` option docs (previously undocumented). - Update `shouldAdjustScrollPositionOnItemSizeChange` to describe the new default — adjustments are skipped during backward scroll to avoid scroll-up jank — and to note the iOS-specific deferral behavior so consumers aren't surprised by what they see in Safari. * chore: add changesets for the release Six changesets covering the major themes: - perf(virtual-core): mount/measure-storm rewrite (lazy materialization + audit hotfixes) [minor] - feat(virtual-core): iOS scroll handling (3-phase deferral) [minor] - feat(virtual-core): default skip backward-scroll adjustment [minor] - feat(virtual-core): takeSnapshot() public method [minor] - feat(virtual-core): smooth scrollToIndex keep-alive [patch] - perf(react-virtual): drop useReducer object allocation [patch] * docs: blog post draft for the release * docs: release readiness verdict + summary * docs: voice pass on blog post against tanner-writing-style skill Audit findings against the writing-style SKILL.md plus the two reference posts (Who Owns the Tree, React Server Components Your Way): - title was clever-indirect; now leads with the noun - folded 3 closer-triplet patterns from intro / community-themes / what- I-didn't-chase sections into comma-joined prose - removed staccato 'A reverse infinite scroll. virtua and virtuoso ship one. We don't yet.' three-sentence stack - folded the two parallel cadence closers in 'What's next' and 'The numbers' sections - removed a colon-introduced list in the 'three layers' iOS section, switched to 'Touch event distinction comes first, ...' prose form - added a brief RSC-protocol callback in the virtuoso/auto-measure section to ground the headless-vs-prescriptive frame in recent work - no em-dashes (was already clean) - no 'isn't just X, it's Y' / 'Here's the thing' / 'To be clear' * docs: aggressive trim on blog post Down from 2943 words to 1174 (60% cut). The previous draft read like a release writeup; the reference posts (Who Owns the Tree, RSC Your Way) hit the thesis in one paragraph, drop two or three specifics, and end. This version matches that energy. What got cut: - Detailed audit catalog of 25 findings → one bug example (Map clone) plus a one-sentence list of the rest - Detailed lazy fast-path mechanics → one paragraph naming the trick - iOS Phase 1/2/2b enumeration → one paragraph saying what we defer and when, no implementation breakdown - "What I didn't chase" section → folded into one paragraph at the end - Benchmark methodology dump → one sentence about Playwright - Two-paragraph community-perception inventory → cut entirely (the numbers section does the work) What stayed (the significance): - 1382× measure-storm bug story - 5× cold mount at 100k via lazy fast-path - 0.0 px accuracy match with virtuoso (with the bench-artifact disclosure) - iOS now working, backward-scroll jank gone by default - The "open the benchmark and measure it yourself" closer - The RSC-post callback Reads more like something Tanner would actually write after a long week than a thorough autopsy. * docs: strip comparative framing from blog post @tanstack/react-virtual ships ~15.1M weekly npm downloads. The next- largest virtualization library is at 4.9M, with virtua at 641K (23x smaller than us) and react-cool-virtual at 20K. We're not the challenger here, we're the gorilla. The previous draft read like a defender refuting attacks from smaller players, which is bad form for a market leader and reads as insecure. This version strips every comparative reference: - Title no longer mentions 'the competition' - Opening no longer relays Twitter/Discord trash talk - Dropped 'About those competitor claims' section entirely - Removed every named callout of virtua, virtuoso, react-window, react-virtualized, react-cool-virtual from the body - Removed the 'they have 17 iOS paths, we had none' framing — kept the technical iOS explanation, dropped the vs-them setup - Removed the accuracy section that called out react-window's bug - Numbers section is now about us only, no competitor delta columns - 'What's next' acknowledges reverse-scroll is missing without saying 'competitors have it' - Benchmark suite mentioned in passing as a tool we built, not framed as a competitive scorecard What stayed: the embarrassing-Map-clone bug story (about our code), the lazy fast-path mechanics (about our work), the iOS implementation detail, the backward-scroll fix, takeSnapshot API, the numbers, and the RSC-post callback in the closer. Reads as a confident leader announcing work, not as someone defending their lunch money. * docs: convert numbers section from bullets to a Before/After table Eight before/after deltas read more cleanly in a table than as bullets with arrows. Keeps the two non-numeric rows (iOS momentum, backward- scroll jank) in the same table for rhythm. * ci: apply automated fixes * chore: remove working-doc artifacts from the audit/experiment phase These were useful while the work was in flight but don't earn permanent residence in the public repo. The narrative is captured by: - commit messages (per-change rationale) - changesets (release notes) - docs/api/virtualizer.md (user-facing APIs) - benchmarks/ (reproducible perf claims) - The blog post at tanstack.com#934 Removed: - BLOG_POST.md (lives at tanstack.com now) - COMPETITOR_CLAIMS_VERIFICATION.md (research artifact) - EXPERIMENTS_SUMMARY.md (redundant with commit messages) - IOS_SUPPORT_PLAN.md (plan doc for completed work) - PERFORMANCE_RESEARCH.md (initial audit, captured in commits) - RELEASE_READINESS.md (pre-merge verdict) * fix: address CodeRabbit findings on PR #1168 Real bugs: - iOS deferred flush now rolls its delta into scrollAdjustments so any resize landing before the resulting scroll event sees the correct effective offset (previously the running accumulator stayed at 0 and a follow-up correction would compute from the stale pre-flush offset). - measure() now resets pendingMin so the rebuild starts from index 0. Without this, a prior resizeItem() that left pendingMin > 0 would cause the next getMeasurements() to preserve stale entries before that index, partially defeating the invalidation. Tests: - Add a regression test for the measure() / pendingMin interaction. - Add a regression test that asserts scrollAdjustments tracks the flushed iOS delta. - Replace the wall-clock perf budget on the 1M-item lazy-path test with deterministic functional assertions (length + spot-checks of start/size/end across the range). Benchmarks: - VirtuaPage.getTotalSize() now actually uses the queried sized node before falling back to firstElementChild / host. - Runner reads scenarios from window.bench.scenarios instead of a runtime import('/src/scenarios/types.ts'), which wouldn't resolve under vite preview (only the built dist is served). - Persist the full scenario object on every result row (success and error) and add landingErrorPx to the error-path metrics so the schema is consistent. - Use Array<T> annotations in dataset.ts / scenarios/types.ts to satisfy @typescript-eslint/array-type. - README: language hint on the tree fence (MD040) and React 18 in the fairness notes. * docs(changeset): record measure() pendingMin and iOS flush accumulator fixes * fix(virtual-core): don't call getItemKey with a stale index in RO disconnect cleanup Commit 843690b added an elementsCache cleanup in the ResizeObserver disconnect path that looked up the cache key via getItemKey(index). When items have been removed from the end of the list, that index can be past items.length, so any user-supplied getItemKey that indexes into the data array throws — exactly the bug PR #1148 had fixed for the non-cleanup paths. Fix: find the cache entry by node identity instead. Iterating elementsCache is O(visible-window), which is fine for a path that only fires on disconnect, and it naturally handles the React-replaced-the- node-under-the-same-key case (the === check just won't match). The stale-index e2e test now passes on both react-virtual and angular-virtual, and the two RO-cleanup unit tests still pass since they were written against node identity, not key lookup. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎯 Changes
Performance Comparison:
useVirtualizervsuseExperimentalDOMVirtualizerAll 32 tests passed (34.7s).
useVirtualizeruseExperimentalVirtualizerKey Takeaways
How it works
useExperimentalVirtualizerbypasses React re-renders for item positioning by directly mutating DOM styles (transform,height) during scroll. It only triggers a React re-render when the visible range orisScrollingstate changes, which happens far less frequently than per-frame position updates.Test setup
contain: strict,overflowAnchor: none✅ Checklist
pnpm run test:pr.🚀 Release Impact