Skip to content

Commit 03d0e19

Browse files
committed
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx
2 parents 72d801e + 7202180 commit 03d0e19

57 files changed

Lines changed: 2538 additions & 1674 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs-info.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,36 @@ becomes
204204
npm <command> <package>
205205
```
206206

207+
### Bundler tabs
208+
209+
Bundler tabs render a compact tab row (like package-manager tabs) but accept rich markdown content per bundler (like the framework component). The user's bundler choice is persisted to `localStorage` and synced across every bundler tab block on the page.
210+
211+
Inside `variant="bundler"`, each top-level heading whose text matches a known bundler starts a new section, and the following nodes (prose, code blocks, etc.) become that bundler's panel. The transformer uses the largest heading level present in the block, so `# Vite` / `# Rsbuild` and `## Vite` / `## Rsbuild` both work — just be consistent within a single block.
212+
213+
````md
214+
<!-- ::start:tabs variant="bundler" -->
215+
216+
# Vite
217+
218+
```ts title="vite.config.ts"
219+
import { defineConfig } from 'vite'
220+
221+
export default defineConfig({})
222+
```
223+
224+
# Rsbuild
225+
226+
```ts title="rsbuild.config.ts"
227+
import { defineConfig } from '@rsbuild/cli'
228+
229+
export default defineConfig({})
230+
```
231+
232+
<!-- ::end:tabs -->
233+
````
234+
235+
Supported bundlers: `vite`, `rsbuild`. Heading text is matched case-insensitively. Both sections should be defined; if the user's selected bundler isn't present in a particular block, the first defined panel is shown as a fallback.
236+
207237
## Framework component
208238

209239
Framework blocks let one markdown source contain React, Solid, or other framework-specific content. Internally, the transformer looks for h1 headings inside the framework block and treats each `# Heading` as a framework section boundary. It then stores framework metadata and rewrites the block into separate framework panels.

package.json

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,19 @@
5252
"@takumi-rs/core": "^1.1.2",
5353
"@takumi-rs/helpers": "^1.1.2",
5454
"@takumi-rs/image-response": "^1.1.2",
55-
"@tanstack/ai": "^0.10.2",
56-
"@tanstack/ai-anthropic": "^0.7.3",
57-
"@tanstack/ai-client": "^0.7.9",
58-
"@tanstack/ai-openai": "^0.7.4",
59-
"@tanstack/create": "^0.66.0",
60-
"@tanstack/pacer": "^0.20.1",
61-
"@tanstack/react-hotkeys": "^0.9.1",
62-
"@tanstack/react-pacer": "^0.21.1",
63-
"@tanstack/react-query": "^5.96.2",
64-
"@tanstack/react-router": "1.168.24",
65-
"@tanstack/react-router-devtools": "1.166.13",
66-
"@tanstack/react-router-ssr-query": "1.166.12",
67-
"@tanstack/react-start": "1.167.49",
68-
"@tanstack/react-start-server": "1.166.43",
55+
"@tanstack/ai": "^0.20.1",
56+
"@tanstack/ai-anthropic": "^0.10.1",
57+
"@tanstack/ai-client": "^0.11.3",
58+
"@tanstack/ai-openai": "^0.9.5",
59+
"@tanstack/create": "^0.68.0",
60+
"@tanstack/pacer": "^0.21.1",
61+
"@tanstack/react-hotkeys": "^0.10.0",
62+
"@tanstack/react-pacer": "^0.22.1",
63+
"@tanstack/react-query": "^5.100.11",
64+
"@tanstack/react-router": "1.170.7",
65+
"@tanstack/react-router-devtools": "1.167.0",
66+
"@tanstack/react-router-ssr-query": "1.167.0",
67+
"@tanstack/react-start": "1.168.10",
6968
"@tanstack/react-table": "^8.21.3",
7069
"@types/d3": "^7.4.3",
7170
"@uploadthing/react": "^7.3.3",
@@ -126,10 +125,10 @@
126125
"@content-collections/vite": "^0.2.9",
127126
"@playwright/test": "^1.59.0",
128127
"@shikijs/transformers": "^4.0.2",
129-
"@tanstack/devtools-vite": "^0.6.0",
130-
"@tanstack/react-devtools": "^0.10.2",
131-
"@tanstack/react-query-devtools": "^5.99.0",
132-
"@tanstack/redact": "^0.0.6",
128+
"@tanstack/devtools-vite": "^0.7.0",
129+
"@tanstack/react-devtools": "^0.10.5",
130+
"@tanstack/react-query-devtools": "^5.100.11",
131+
"@tanstack/redact": "^0.0.12",
133132
"@types/hast": "^3.0.4",
134133
"@types/node": "^25.5.0",
135134
"@types/pg": "^8.20.0",
@@ -150,7 +149,7 @@
150149
"tailwindcss": "^4.2.2",
151150
"tsx": "^4.21.0",
152151
"typescript": "^6.0.2",
153-
"vite": "^8.0.3"
152+
"vite": "^8.0.13"
154153
},
155154
"engines": {
156155
"node": ">=22.0.0"

pnpm-lock.yaml

Lines changed: 877 additions & 747 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/blog/tanstack-virtual-chat.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
---
2+
title: Chat UIs Are Lists Until They Aren't
3+
published: 2026-05-25
4+
excerpt: Chat, AI streams, and logs don't behave like ordinary lists. TanStack Virtual now supports end-anchored virtualization for prepend-stable history, append-follow, and streaming output that stays pinned.
5+
library: virtual
6+
authors:
7+
- Tanner Linsley
8+
---
9+
10+
In the last TanStack Virtual release, I left one thing on the table: reverse infinite scroll for chat, and it deserved its own pass.
11+
12+
Chat used to be a niche UI, now it's everywhere, in support inboxes, activity logs, multiplayer feeds, copilots, AI agents, and streaming assistants. They all look like lists, but they don't behave like the lists virtualization libraries were originally built around.
13+
14+
A normal virtual list is start-anchored, so the top of the content is the stable point. You scroll down, append more rows, measure dynamic heights, and everything mostly works.
15+
16+
Chat flips that contract.
17+
18+
- New output appears at the end.
19+
- Older history loads by prepending items at the start.
20+
- The last message can grow token by token while the model is streaming.
21+
- The user should only follow new output if they were already at the latest message.
22+
23+
That last part matters. If someone scrolls up to read history, incoming messages shouldn't yank them back to the bottom, and if they're already there, the UI should stay pinned without every app rewriting the same scroll math.
24+
25+
TanStack Virtual now has a first-class way to model that.
26+
27+
```tsx
28+
const virtualizer = useVirtualizer({
29+
count: messages.length,
30+
getScrollElement: () => parentRef.current,
31+
estimateSize: () => 72,
32+
getItemKey: (index) => messages[index]!.id,
33+
anchorTo: 'end',
34+
followOnAppend: true,
35+
scrollEndThreshold: 80,
36+
})
37+
```
38+
39+
## End anchoring
40+
41+
`anchorTo: 'end'` tells the virtualizer that the end of the list is the edge you want to preserve.
42+
43+
When you prepend older messages, TanStack Virtual captures the currently visible item, finds the same keyed item after the data changes, and adjusts the scroll offset so it stays in the same visual position.
44+
45+
That means no `column-reverse`, no inverted transforms, and no manual `scrollTop += delta` bookkeeping in every app. Just normal data:
46+
47+
```tsx
48+
setMessages((current) => [...olderMessages, ...current])
49+
```
50+
51+
The only real requirement is a stable key:
52+
53+
```tsx
54+
getItemKey: (index) => messages[index]!.id
55+
```
56+
57+
Index keys can't make prepend stability work, because after a prepend every old item moves to a new index, and the virtualizer needs to know which message is still the same message.
58+
59+
## Follow only when pinned
60+
61+
`followOnAppend` handles the "stay at latest, unless I am reading history" rule.
62+
63+
If the user is already near the end, appended messages keep the viewport pinned, and if they've scrolled up, new output lands below without stealing their place.
64+
65+
```tsx
66+
followOnAppend: true
67+
```
68+
69+
You can also pass a scroll behavior:
70+
71+
```tsx
72+
followOnAppend: 'smooth'
73+
```
74+
75+
The threshold is configurable too:
76+
77+
```tsx
78+
scrollEndThreshold: 80
79+
```
80+
81+
That same end-state logic is exposed for UI:
82+
83+
```tsx
84+
virtualizer.isAtEnd()
85+
virtualizer.getDistanceFromEnd()
86+
virtualizer.scrollToEnd()
87+
```
88+
89+
So your "Jump to latest" button can use the same rules as the virtualizer itself.
90+
91+
## Streaming output
92+
93+
The modern version of chat isn't append-a-message, it's append a message and then resize it dozens or hundreds of times while tokens stream in.
94+
95+
Without end anchoring, the scroll height grows but the scroll offset doesn't, so the user slowly drifts away from the bottom.
96+
97+
With `anchorTo: 'end'`, if the viewport was pinned before the last item grew, TanStack Virtual applies the size delta and keeps the end pinned.
98+
99+
That's the point of this feature: the common chat behaviors aren't userland chores anymore.
100+
101+
## Still headless
102+
103+
This still isn't a chat component.
104+
105+
TanStack Virtual still doesn't render bubbles, loaders, timestamps, avatars, unread dividers, or composer UI. That part belongs to your app.
106+
107+
What it does now is handle the scroll physics that almost every chat UI needs:
108+
109+
- stable prepends
110+
- conditional append-follow
111+
- pinned streaming growth
112+
- end-distance helpers
113+
114+
It's a small API with a pretty big ergonomic win.
115+
116+
There is also a new [Chat guide](/virtual/latest/docs/chat) and a [React chat example](/virtual/latest/docs/framework/react/examples/chat) showing history prepends, appended messages, streaming replies, and a "Latest" control built with `scrollToEnd()`.
117+
118+
Chat is one of the dominant UI patterns of modern apps now, and TanStack Virtual should make it feel boring to build.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
title: TanStack Virtual just got a lot faster, and finally handles iOS
3+
published: 2026-05-19
4+
authors:
5+
- Tanner Linsley
6+
library: virtual
7+
excerpt: A perf-focused release for TanStack Virtual. Cold mount at 100k items is 5x faster, a hilarious worst-case bug now runs 1382x faster, iOS Safari momentum scroll works for the first time, and scroll-up jank with dynamic items is gone by default.
8+
---
9+
10+
I spent three days last week auditing TanStack Virtual end-to-end, and what came out of it is the biggest single perf release the library has shipped in years. Cold mount on a 100k-item list dropped from 6.1 ms to 4.5 ms in real React. A worst-case `resizeItem` storm on 10k items went from nearly two seconds to 1.3 milliseconds. iOS Safari momentum scroll, which had been broken for years on dynamic-height lists, now actually works. Scroll-up jank with dynamic items, the single largest complaint cluster in our tracker, is gone by default.
11+
12+
The work was a mix of bug fixes, a substantial internal rewrite for the hot path, and a new iOS-specific code path. Most of it landed in `virtual-core` so every framework adapter benefits. Here's what changed and why.
13+
14+
## One bug was genuinely embarrassing
15+
16+
Before measuring anything I read the entire `virtual-core` source looking for things that were quantifiably bad, and the worst one was a Map clone hiding in plain sight. Every time `resizeItem` ran, we'd do `this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size))`, which copies the whole size cache into a fresh Map just to invalidate a memo dep. For a 10k-item list where every item resizes once on mount, that's about 50 million wasted operations and a 1.9-second cold mount that nobody had pinned down. The fix was four lines (use a version counter, same dep pattern, integer comparison) and dropped that to 1.3 milliseconds. **1382× faster.**
17+
18+
Below it were the usual smaller suspects: an `Object.entries+delete` pattern in `setOptions` that was triggering V8's dictionary-mode deopt on every render, a `Math.min(...arr)` spread that could blow the argument-list limit at 125k items, an `elementsCache` leak when React replaced a measured node, a `useReducer(() => ({}), {})` rerender pattern allocating per scroll event. None catastrophic alone, but together they explain why our issue tracker had recurring complaints about scroll stutter and slow initial renders on large lists.
19+
20+
## The real ceiling was object allocation at scale
21+
22+
After the audit fixes we still mounted a 100k-item list slower than we should have, and the cause was that we were allocating a `VirtualItem` object per index even though only ~50 are ever visible. The fix is the biggest single change in the release.
23+
24+
For single-lane lists (the default and the common case) we now store start and size as a flat `Float64Array` and only construct `VirtualItem` objects when something actually reads `measurements[i]`. The public API still hands out an `Array<VirtualItem>` shape, but it's a `Proxy` that materializes lazily and caches. Internal hot paths read straight from the typed array, skipping the Proxy.
25+
26+
Cold mount at 100k went from 6.1 ms to 4.5 ms in real React, and 2.5 ms to 0.54 ms in the synthetic bench. At 500k items it's now 2.7 ms instead of 14. The work is fully backward compatible: `measurementsCache` still satisfies its `Array<VirtualItem>` contract, internal consumers continue to read `[i].start` and `[i].end` the same way they used to, and only the lanes>1 path keeps the old eager allocation because lane assignment is order-dependent and harder to defer cleanly.
27+
28+
## iOS Safari is rude
29+
30+
If you've ever called `el.scrollTop = x` during a momentum scroll on iOS Safari, you know what happens: momentum dies, page snaps, user sees a jolt. iOS WebKit treats any programmatic scrollTop write during a touch-driven scroll as a cancel instruction, which is the opposite of what virtualization libraries want to do, because virtualization libraries write scrollTop in response to size measurements arriving.
31+
32+
We had no iOS-specific handling at all. The "scroll stops abruptly when content above me resizes" complaints in our tracker have been some flavor of this for years.
33+
34+
The fix defers the scrollTop write while a finger's on the screen, during the 150 ms post-touchend momentum window, and during the elastic-overscroll bounce. The accumulated adjustment flushes in a single write once everything actually settles, and the user keeps their momentum. About 370 bytes of iOS-specific code that doesn't tree-shake away on non-iOS bundles since the detection is runtime, but the per-event cost on non-iOS is one cached boolean check. That's an acceptable trade given how much of mobile traffic is iOS.
35+
36+
## The backward-scroll jank had been festering for five years
37+
38+
The biggest single complaint cluster in our issue tracker is "items jump while I scroll up" with dynamic heights, and the cause is that we were writing scrollTop on every above-viewport resize to keep the visible window stable. That makes sense during forward scroll, but during backward scroll the same write actively pushes the user past where they're trying to go. The community had independently rediscovered the same workaround five separate times across the years.
39+
40+
We just gate it on direction now. Forward scroll and mount-time adjustments still fire, backward scroll skips them. Anyone who wants the old behavior can supply `shouldAdjustScrollPositionOnItemSizeChange` (it was already there) and ignore the direction.
41+
42+
## A new method for scroll restoration
43+
44+
`virtualizer.takeSnapshot()` returns the currently-measured items as plain `VirtualItem` objects, suitable for persisting through state storage and feeding back as `initialMeasurementsCache` on remount. Pair with the current `scrollOffset` and you get exact scroll restoration after route navigation:
45+
46+
```tsx
47+
// On unmount
48+
const snapshot = virtualizer.takeSnapshot()
49+
const offset = virtualizer.scrollOffset
50+
sessionStorage.setItem('myList', JSON.stringify({ snapshot, offset }))
51+
52+
// On remount
53+
const saved = JSON.parse(sessionStorage.getItem('myList') ?? 'null')
54+
useVirtualizer({
55+
count: items.length,
56+
estimateSize: () => 50,
57+
getScrollElement: () => parentRef.current,
58+
initialMeasurementsCache: saved?.snapshot,
59+
initialOffset: saved?.offset,
60+
})
61+
```
62+
63+
Only items the consumer actually rendered show up in the snapshot, since unmeasured items can fall back to `estimateSize` on restore.
64+
65+
## The numbers
66+
67+
Compared to the current published version:
68+
69+
| Metric | Before | After |
70+
| ----------------------------------------------------- | ----------- | --------------- |
71+
| Cold mount @ 100k items (real React) | 6.1 ms | 4.5 ms |
72+
| Cold mount @ 100k items (synthetic) | 2.5 ms | 0.54 ms |
73+
| Cold mount @ 500k items (synthetic) | 14 ms | 2.7 ms |
74+
| `resizeItem` storm on 10k items | 1.9 s | 1.3 ms |
75+
| `setOptions` × 10,000 (per render) | 14.4 ms | 1.3 ms |
76+
| `scrollToIndex` landing accuracy on dynamic 10k lists | within 1 px | 0.0 px |
77+
| iOS Safari momentum scroll | broken | works |
78+
| Backward-scroll jank with dynamic items | recurring | gone by default |
79+
80+
Bundle delta is about +900 bytes gzip, mostly the lazy fast-path machinery and the iOS code. Production minified comes out around 6.1 kB total. 91 unit tests, all green.
81+
82+
## What's still on the list
83+
84+
Reverse infinite scroll for chat use cases is the one big thing missing, and given how much of the modern web is now a streaming UI on top of a list, it deserves its own release with its own design pass rather than getting wedged into this one. A Fenwick-tree memory rewrite for 1M+ item lists is the other piece; it'll come if a real-world case actually asks for it.
85+
86+
I also built a cross-library benchmark suite at `benchmarks/` while I was at it, since I wanted to verify my own changes didn't regress anything and the existing comparison content online is either stale or contradictory. It runs the same scenarios across every major virtualization library via Playwright, reports medians across runs, and is fully reproducible: `cd benchmarks && pnpm bench`. The bench is in the repo if you want to see it.

0 commit comments

Comments
 (0)