close
Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: TanStack/virtual
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: @tanstack/react-virtual@3.13.24
Choose a base ref
...
head repository: TanStack/virtual
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: @tanstack/react-virtual@3.13.25
Choose a head ref
  • 10 commits
  • 115 files changed
  • 10 contributors

Commits on Apr 20, 2026

  1. fix(angular-virtual): capture _didMount cleanup to remove scroll/resi…

    …ze listeners on destroy (#1159)
    jsaguet authored Apr 20, 2026
    Configuration menu
    Copy the full SHA
    720d6fc View commit details
    Browse the repository at this point in the history
  2. Configuration menu
    Copy the full SHA
    3113d3d View commit details
    Browse the repository at this point in the history

Commits on Apr 26, 2026

  1. Configuration menu
    Copy the full SHA
    3374977 View commit details
    Browse the repository at this point in the history
  2. Configuration menu
    Copy the full SHA
    ca8b4ba View commit details
    Browse the repository at this point in the history
  3. Configuration menu
    Copy the full SHA
    5ae5db1 View commit details
    Browse the repository at this point in the history

Commits on May 13, 2026

  1. ci: add zizmor workflow (#1163)

    * ci: add zizmor workflow
    
    * Update .github/workflows/zizmor.yml
    Sheraff authored May 13, 2026
    Configuration menu
    Copy the full SHA
    792bece View commit details
    Browse the repository at this point in the history
  2. chore(pnpm): upgrade pnpm to v11 (#1164)

    Co-authored-by: Kevin Van Cott <kevinvandy656@gmail.com>
    harry-whorlow and KevinVandy authored May 13, 2026
    Configuration menu
    Copy the full SHA
    c62ad62 View commit details
    Browse the repository at this point in the history
  3. Configuration menu
    Copy the full SHA
    b9feeb6 View commit details
    Browse the repository at this point in the history

Commits on May 20, 2026

  1. perf: virtual-core rewrite for mount/measure-storm, plus iOS Safari h…

    …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>
    tannerlinsley and autofix-ci[bot] authored May 20, 2026
    Configuration menu
    Copy the full SHA
    99355ad View commit details
    Browse the repository at this point in the history
  2. ci: Version Packages (#1169)

    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    github-actions[bot] authored May 20, 2026
    Configuration menu
    Copy the full SHA
    949180b View commit details
    Browse the repository at this point in the history
Loading