diff --git a/.changeset/shared-scroller-offscreen-range.md b/.changeset/shared-scroller-offscreen-range.md new file mode 100644 index 000000000..a829ca4aa --- /dev/null +++ b/.changeset/shared-scroller-offscreen-range.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Render only the last rows of a list that is above the viewport in a shared scroll element. The scroll offset is capped at the end of the scroll element, the list's end plus the content after it, rather than at the list's end, so a list that has the element to itself is capped as before. diff --git a/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index 2073055fd..f6f634c61 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,89 @@ # @tanstack/virtual-benchmarks +## 0.0.29 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.26 + +## 0.0.28 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.25 + +## 0.0.27 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.24 + +## 0.0.26 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.23 + +## 0.0.25 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.22 + +## 0.0.24 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.21 + +## 0.0.23 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.20 + +## 0.0.22 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.19 + +## 0.0.21 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.18 + +## 0.0.20 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.17 + +## 0.0.19 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.16 + +## 0.0.18 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.15 + ## 0.0.17 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index cca47b080..dab1a4e49 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/virtual-benchmarks", "private": true, - "version": "0.0.17", + "version": "0.0.29", "type": "module", "scripts": { "dev": "vite", diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 0a910e25c..a5715fbc1 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -80,7 +80,7 @@ The sync parameter indicates whether scrolling is currently in progress. It is ` overscan?: number ``` -The number of items to render above and below the visible area. Increasing this number will increase the amount of time it takes to render the virtualizer, but might decrease the likelihood of seeing slow-rendering blank items at the top and bottom of the virtualizer when scrolling. The default value is `1`. +The number of items to render above and below the visible area. Increasing this number will increase the amount of time it takes to render the virtualizer, but might decrease the likelihood of seeing slow-rendering blank items at the top and bottom of the virtualizer when scrolling. The default value is `1`. When `lanes` is greater than `1`, overscan counts whole rows of `lanes` items, so every lane renders the same number of extra items. ### `horizontal` @@ -146,7 +146,7 @@ This function is passed the index of each item and should return a unique key fo rangeExtractor?: (range: Range) => number[] ``` -This function receives visible range indexes and should return array of indexes to render. This is useful if you need to add or remove items from the virtualizer manually regardless of the visible range, eg. rendering sticky items, headers, footers, etc. The default range extractor implementation will return the visible range indexes and is exported as `defaultRangeExtractor`. +This function receives visible range indexes and should return array of indexes to render. This is useful if you need to add or remove items from the virtualizer manually regardless of the visible range, eg. rendering sticky items, headers, footers, etc. The default range extractor implementation will return the visible range indexes and is exported as `defaultRangeExtractor`. When `lanes` is greater than `1`, `range.lanes` holds the lane count and `defaultRangeExtractor` applies `overscan` as whole rows; a custom extractor that computes its own indexes should do the same to keep lanes aligned. ### `scrollToFn` @@ -177,6 +177,8 @@ observeElementRect: ( An optional function that if provided is called when the scrollElement changes and should implement the initial measurement and continuous monitoring of the scrollElement's `Rect` (an object with `width` and `height`). It's called with the instance (which also gives you access to the scrollElement via `instance.scrollElement`. Built-in implementations are exported as `observeElementRect` and `observeWindowRect` which are automatically configured for you by your framework adapter's exported functions like `useVirtualizer` or `useWindowVirtualizer`. +The built-in `observeElementRect` measures the visible client area (`clientWidth` and `clientHeight`), excluding borders and scrollbars. Custom implementations should report the same viewport dimensions. + ### `observeElementOffset` ```tsx @@ -592,7 +594,7 @@ virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( delta, instance, ) => { - return item.start < instance.getScrollOffset() + instance.scrollAdjustments + return item.start < (instance.scrollOffset ?? 0) } ``` @@ -600,6 +602,8 @@ The callback receives the resized `item`, the size `delta`, and the `instance`; On iOS WebKit, scroll-position writes are deferred regardless of this callback while a finger is on screen, during momentum-scroll, and during elastic-overscroll bounce. The cumulative delta is flushed in a single write once the scroll settles, preserving iOS's native momentum physics. +On iOS 27 and later a write no longer stops a fling but is overridden by it within a frame, so a `scrollToIndex`, `scrollToOffset` or `scrollToEnd` issued during a fling is written once the fling ends (about 150ms after its last frame) rather than being lost; a drag before then cancels it, a tap does not. + ### `isScrolling` ```tsx @@ -623,3 +627,7 @@ scrollOffset: number ``` This option represents the current scroll position along the scrolling axis. It is measured in pixels from the starting point of the scrollable area. + +It is a position in the list's layout, and it stays consistent with `getOffsetForIndex`, the argument to `scrollToOffset`, and `initialOffset`: restoring a saved value returns to the same layout position. That is the same item only while the items and sizes before it are unchanged; to restore across changes, pair it with `takeSnapshot()` and `initialMeasurementsCache` as described above, or scroll to the item with `scrollToIndex`. + +While a scroll correction is held, because a write to the element is impossible at that moment (a touch gesture or its momentum on iOS, or the element rubber-banding past an edge), `getVirtualItems()` and `getTotalSize()` describe what is rendered, shifted by the held amount so the rows on screen do not move, while `scrollOffset` keeps the layout position. The hold ends with a single write once the element can take it. Prefer `getVirtualItems()` positions over `item.start - scrollOffset` for where a row sits in the viewport, since that difference is off by the held amount for the length of the gesture or bounce. diff --git a/docs/framework/lit/lit-virtual.md b/docs/framework/lit/lit-virtual.md index 8700490a4..98df3c8c3 100644 --- a/docs/framework/lit/lit-virtual.md +++ b/docs/framework/lit/lit-virtual.md @@ -34,3 +34,28 @@ private windowVirtualizerController = new WindowVirtualizerController this.scrollElementRef.value, + count: this.count, + estimateSize: () => 35, +}) + +protected willUpdate(changed: PropertyValues) { + if (changed.has('count')) { + this.virtualizerController.setOptions({ count: this.count }) + } +} +``` + +Call `setOptions` on the controller. `getVirtualizer().setOptions(...)` replaces options wholesale, drops the host `onChange` wrapper, and leaves the controller's stored options stale. diff --git a/examples/angular/chat/package.json b/examples/angular/chat/package.json index eb468a248..3eb4a99b0 100644 --- a/examples/angular/chat/package.json +++ b/examples/angular/chat/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/dynamic/package.json b/examples/angular/dynamic/package.json index 026b95cdf..b838a5d0e 100644 --- a/examples/angular/dynamic/package.json +++ b/examples/angular/dynamic/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/fixed/package.json b/examples/angular/fixed/package.json index 1d39df0ea..55520da2f 100644 --- a/examples/angular/fixed/package.json +++ b/examples/angular/fixed/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/infinite-scroll/package.json b/examples/angular/infinite-scroll/package.json index cc7f623c3..f17fe0336 100644 --- a/examples/angular/infinite-scroll/package.json +++ b/examples/angular/infinite-scroll/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@tanstack/angular-query-experimental": "5.80.7", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/padding/package.json b/examples/angular/padding/package.json index 875fd1489..6857e75cd 100644 --- a/examples/angular/padding/package.json +++ b/examples/angular/padding/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/smooth-scroll/package.json b/examples/angular/smooth-scroll/package.json index f543416b1..db255cf94 100644 --- a/examples/angular/smooth-scroll/package.json +++ b/examples/angular/smooth-scroll/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/sticky/package.json b/examples/angular/sticky/package.json index 8b542c6be..966d7700c 100644 --- a/examples/angular/sticky/package.json +++ b/examples/angular/sticky/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/package.json b/examples/angular/table/package.json index 272967781..4454a68f7 100644 --- a/examples/angular/table/package.json +++ b/examples/angular/table/package.json @@ -19,7 +19,7 @@ "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", "@tanstack/angular-table": "8.21.3", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/src/app/app.component.ts b/examples/angular/table/src/app/app.component.ts index a90d2752e..24cb5ab7c 100644 --- a/examples/angular/table/src/app/app.component.ts +++ b/examples/angular/table/src/app/app.component.ts @@ -25,91 +25,107 @@ import { makeData, type Person } from './make-data' imports: [FlexRenderDirective], template: `

- For tables, the basis for the offset of the translate css function is from - the row's initial position itself. Because of this, we need to calculate - the translateY pixel count different and base it off the the index. + Table rows can't be absolutely positioned, so only the visible rows are + rendered, with a spacer row above and below them. The spacers keep the + table as tall as all of its rows, so the sticky header stays in place + while scrolling.


-
- - - @for ( - headerGroup of table.getHeaderGroups(); - track headerGroup.id - ) { - - @for (header of headerGroup.headers; track header.id) { - - } - - } - - - @for ( - virtualRow of virtualizer.getVirtualItems(); - track data[virtualRow.index].id - ) { - - @for ( - cell of rows()[virtualRow.index].getVisibleCells(); - track cell.id - ) { - - } - - } - -
- @if (!header.isPlaceholder) { -
- - {{ headerText }} - {{ getSortIcon(header.column.getIsSorted()) }} - -
- } -
- + @if (!header.isPlaceholder) { +
- {{ cellText }} - -
-
+ + {{ headerText }} + {{ getSortIcon(header.column.getIsSorted()) }} + +
+ } + + } + + } + + + @if (paddingTop() > 0) { + + + + } + @for ( + virtualRow of virtualizer.getVirtualItems(); + track data[virtualRow.index].id + ) { + + @for ( + cell of rows()[virtualRow.index].getVisibleCells(); + track cell.id + ) { + + + {{ cellText }} + + + } + + } + @if (paddingBottom() > 0) { + + + + } + + `, styles: ` .scroll-container { height: 600px; overflow: auto; + overflow-anchor: none; + } + + table { + border-collapse: collapse; + } + + thead { + position: sticky; + top: 0; + background: white; } `, }) @@ -191,4 +207,18 @@ export class AppComponent { estimateSize: () => 34, overscan: 20, })) + + // Height of the spacer rows rendered above and below the visible rows + paddingTop = computed(() => { + const virtualRows = this.virtualizer.getVirtualItems() + return virtualRows.length > 0 ? virtualRows[0].start : 0 + }) + + paddingBottom = computed(() => { + const virtualRows = this.virtualizer.getVirtualItems() + return virtualRows.length > 0 + ? this.virtualizer.getTotalSize() - + virtualRows[virtualRows.length - 1].end + : 0 + }) } diff --git a/examples/angular/variable/package.json b/examples/angular/variable/package.json index 93a3b0bb2..45fa43b20 100644 --- a/examples/angular/variable/package.json +++ b/examples/angular/variable/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/window/package.json b/examples/angular/window/package.json index ed78a33c9..f267c1e76 100644 --- a/examples/angular/window/package.json +++ b/examples/angular/window/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.12", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/lit/dynamic/package.json b/examples/lit/dynamic/package.json index 39ec6af1c..d4fdf5fe3 100644 --- a/examples/lit/dynamic/package.json +++ b/examples/lit/dynamic/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.3", - "@tanstack/virtual-core": "^3.18.0", + "@tanstack/lit-virtual": "^3.15.3", + "@tanstack/virtual-core": "^3.19.11", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/lit/fixed/package.json b/examples/lit/fixed/package.json index ae7e8df12..b0aeaf381 100644 --- a/examples/lit/fixed/package.json +++ b/examples/lit/fixed/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.3", - "@tanstack/virtual-core": "^3.18.0", + "@tanstack/lit-virtual": "^3.15.3", + "@tanstack/virtual-core": "^3.19.11", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/marko/chat-pretext/package.json b/examples/marko/chat-pretext/package.json index 27135709c..17475448f 100644 --- a/examples/marko/chat-pretext/package.json +++ b/examples/marko/chat-pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/chat/package.json b/examples/marko/chat/package.json index 227de73b2..93b677456 100644 --- a/examples/marko/chat/package.json +++ b/examples/marko/chat/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/dynamic/package.json b/examples/marko/dynamic/package.json index e204a4a6d..855dc41e6 100644 --- a/examples/marko/dynamic/package.json +++ b/examples/marko/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/fixed/package.json b/examples/marko/fixed/package.json index 33b8b8bff..547fecf2b 100644 --- a/examples/marko/fixed/package.json +++ b/examples/marko/fixed/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/grid/package.json b/examples/marko/grid/package.json index 9e3fdbd08..5b10c3e5f 100644 --- a/examples/marko/grid/package.json +++ b/examples/marko/grid/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/infinite-scroll/package.json b/examples/marko/infinite-scroll/package.json index 60d103e07..c5aaaa17e 100644 --- a/examples/marko/infinite-scroll/package.json +++ b/examples/marko/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/padding/package.json b/examples/marko/padding/package.json index 63bf00f81..436ca7929 100644 --- a/examples/marko/padding/package.json +++ b/examples/marko/padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/pretext/package.json b/examples/marko/pretext/package.json index 514bcd3f0..2df4a30d0 100644 --- a/examples/marko/pretext/package.json +++ b/examples/marko/pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/scroll-padding/package.json b/examples/marko/scroll-padding/package.json index bd4ebbe2f..f9bb1da0e 100644 --- a/examples/marko/scroll-padding/package.json +++ b/examples/marko/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/smooth-scroll/package.json b/examples/marko/smooth-scroll/package.json index 5865c7f24..c165d4582 100644 --- a/examples/marko/smooth-scroll/package.json +++ b/examples/marko/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-fetch/package.json b/examples/marko/ssr-fetch/package.json index 4b41fffe6..ed60140fc 100644 --- a/examples/marko/ssr-fetch/package.json +++ b/examples/marko/ssr-fetch/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-restore/package.json b/examples/marko/ssr-restore/package.json index d1a1a1283..ef1a652b0 100644 --- a/examples/marko/ssr-restore/package.json +++ b/examples/marko/ssr-restore/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-slice/package.json b/examples/marko/ssr-slice/package.json index 7df0e402b..228e54414 100644 --- a/examples/marko/ssr-slice/package.json +++ b/examples/marko/ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr/package.json b/examples/marko/ssr/package.json index 19e4399c7..1caafc78b 100644 --- a/examples/marko/ssr/package.json +++ b/examples/marko/ssr/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/sticky/package.json b/examples/marko/sticky/package.json index 8d729f46c..9525a30ad 100644 --- a/examples/marko/sticky/package.json +++ b/examples/marko/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/table/package.json b/examples/marko/table/package.json index 99658e48e..bc0c9ee78 100644 --- a/examples/marko/table/package.json +++ b/examples/marko/table/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/variable/package.json b/examples/marko/variable/package.json index d7b9c1e4b..17c1c9810 100644 --- a/examples/marko/variable/package.json +++ b/examples/marko/variable/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window-ssr-slice/package.json b/examples/marko/window-ssr-slice/package.json index 3ef2690dd..4a5f64fa9 100644 --- a/examples/marko/window-ssr-slice/package.json +++ b/examples/marko/window-ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window/package.json b/examples/marko/window/package.json index 886bb11cc..3e71ae06a 100644 --- a/examples/marko/window/package.json +++ b/examples/marko/window/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.12", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/react/chat/README.md b/examples/react/chat/README.md index fc23edc9a..94cc86a96 100644 --- a/examples/react/chat/README.md +++ b/examples/react/chat/README.md @@ -7,6 +7,11 @@ Demonstrates end-anchored virtualization for chat-style UIs: - follows appended output only when the viewport is already at the latest message - keeps streaming bottom output pinned as the last row grows +The UI is a simple ChatGPT-style layout: a sidebar with chat history and a +message pane with a composer. Data comes from an in-memory mock API +(`src/api.ts`) through TanStack Query, and assistant replies are generated and +streamed word by word, so the example needs no server or API key. + ```bash npm install npm run dev diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 063ea0b5d..f94ce7000 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,7 +8,8 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-query": "^5.80.7", + "@tanstack/react-virtual": "^3.14.26", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/chat/src/api.ts b/examples/react/chat/src/api.ts new file mode 100644 index 000000000..9d592d442 --- /dev/null +++ b/examples/react/chat/src/api.ts @@ -0,0 +1,224 @@ +// An in-memory stand-in for a chat backend. Seeded chats are generated on +// first read from a fixed seed, so the history is the same after a reload. + +export type Author = 'user' | 'assistant' + +export type Message = { + id: string + author: Author + text: string +} + +export type Chat = { + id: string + title: string + updatedAt: number +} + +export type MessagePage = { + messages: Message[] + // Index of the first message of this page in the full chat history. + start: number +} + +const PAGE_SIZE = 30 +const READ_LATENCY = 300 +const WRITE_LATENCY = 50 + +type Random = () => number + +function seededRandom(seed: number): Random { + return () => { + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const pick = (random: Random, items: ReadonlyArray): T => + items[Math.floor(random() * items.length)]! + +const between = (random: Random, min: number, max: number) => + min + Math.floor(random() * (max - min + 1)) + +const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +const titles = [ + 'Debounce a search input', + 'Virtualize a 10k row table', + 'Explain React Suspense', + 'Plan a weekend in Rzeszów', + 'Beginner running plan', +] + +const followUps = [ + 'Can you explain that in more detail?', + 'Show me an example.', + 'What are the trade-offs?', + 'How would I test this?', + 'Can you make it shorter?', + 'Why does that work?', + 'What if the list has 10,000 items?', + 'Is there a simpler way?', + 'What would you do differently in production?', + 'Ok, and what is the next step?', + 'Can you put that in a table or a list?', + 'Thanks! One more question about the last point.', +] + +const sentences = [ + 'Start with the smallest version that works and measure it before you optimize.', + 'The main cost is usually not the code itself but the coordination around it.', + 'If the input changes often, keep the derived values out of state and compute them on render.', + 'A good default is to keep the data close to where it is used.', + 'You can always add a cache later, but removing one is much harder.', + 'Most of the time the simple solution is fast enough.', + 'Try to make the common case fast and the rare case correct.', + 'Write down the assumptions first, because they tend to change.', + 'This also makes the behavior easier to test in isolation.', + 'Watch out for edge cases at the boundaries, such as empty lists and very long values.', + 'Keep each step small enough that you can undo it without losing much.', + 'In practice, people tend to overestimate how much flexibility they need.', + 'The trade-off is a bit more setup in exchange for fewer surprises later.', + 'When in doubt, pick the option that is easier to change.', + 'It helps to look at a real example before you generalize.', + 'Consistency matters more than the exact choice.', +] + +// One to three paragraphs of random sentences, so rows have different heights. +function generateReply(random: Random, prompt: string): string { + const paragraphs = Array.from({ length: between(random, 1, 3) }, () => + Array.from({ length: between(random, 1, 4) }, () => + pick(random, sentences), + ).join(' '), + ) + return [`You asked: “${prompt}”`, ...paragraphs].join('\n\n') +} + +// Fixed replies for prompts that deserve a real answer. +const cannedReplies: Record = { + 'How do I virtualize a chat list?': [ + 'You asked: “How do I virtualize a chat list?”', + 'A chat is a virtual list anchored to the end: new messages arrive at the bottom, older history loads at the top, and replies grow while they stream. This example uses anchorTo: "end" with stable keys from getItemKey, followOnAppend with scrollEndThreshold, and scrollToEnd() with isAtEnd() for the jump button.', + "Tanner Linsley explains it in “Chat UIs Are Lists Until They Aren't”: https://tanstack.com/blog/tanstack-virtual-chat", + ].join('\n\n'), +} + +const now = Date.now() +const chats: Chat[] = titles.map((title, index) => ({ + id: `chat-${index}`, + title, + updatedAt: now - index * 5 * 60 * 60 * 1000, +})) +const messagesByChat = new Map() +let nextId = 0 + +function seedMessages(chatId: string): Message[] { + const chatIndex = chats.findIndex((chat) => chat.id === chatId) + const random = seededRandom(chatIndex + 1) + const title = chats[chatIndex]!.title + const turns = between(random, 20, 150) + const messages: Message[] = [] + for (let turn = 0; turn < turns; turn++) { + const prompt = + turn === 0 ? `${title}. Where should I start?` : pick(random, followUps) + messages.push({ id: `${chatId}-${turn}-q`, author: 'user', text: prompt }) + messages.push({ + id: `${chatId}-${turn}-a`, + author: 'assistant', + text: generateReply(random, prompt), + }) + } + return messages +} + +function getMessages(chatId: string): Message[] { + let messages = messagesByChat.get(chatId) + if (!messages) { + messages = seedMessages(chatId) + messagesByChat.set(chatId, messages) + } + return messages +} + +function touchChat(chatId: string) { + const chat = chats.find((item) => item.id === chatId)! + chat.updatedAt = Date.now() +} + +export async function fetchChats(): Promise { + await delay(READ_LATENCY) + return [...chats].sort((a, b) => b.updatedAt - a.updatedAt) +} + +// Returns the page of messages that ends right before `before`, or the +// latest page when `before` is null. +export async function fetchMessages( + chatId: string, + before: number | null, +): Promise { + await delay(READ_LATENCY) + const messages = getMessages(chatId) + const end = before ?? messages.length + const start = Math.max(0, end - PAGE_SIZE) + return { messages: messages.slice(start, end), start } +} + +export async function createChat(firstMessage: string): Promise { + await delay(WRITE_LATENCY) + const title = + firstMessage.length > 40 + ? `${firstMessage.slice(0, 40).trim()}…` + : firstMessage + const chat: Chat = { id: `chat-new-${nextId++}`, title, updatedAt: 0 } + chats.push(chat) + messagesByChat.set(chat.id, []) + return chat +} + +export async function postMessage( + chatId: string, + text: string, +): Promise { + await delay(WRITE_LATENCY) + const message: Message = { id: `m-${nextId++}`, author: 'user', text } + getMessages(chatId).push(message) + touchChat(chatId) + return message +} + +// Streams a generated reply word by word. `onUpdate` gets the whole message +// so far on every chunk. Returns a stop function that keeps the partial reply. +export function streamReply( + chatId: string, + prompt: string, + onUpdate: (message: Message) => void, + onDone: () => void, +): () => void { + const messages = getMessages(chatId) + const index = messages.length + const reply = cannedReplies[prompt] ?? generateReply(Math.random, prompt) + const words = reply.match(/\s*\S+/g) ?? [] + let message: Message = { id: `m-${nextId++}`, author: 'assistant', text: '' } + let position = 0 + + const intervalId = setInterval(() => { + const count = 1 + Math.floor(Math.random() * 3) + message = { + ...message, + text: message.text + words.slice(position, position + count).join(''), + } + position += count + messages[index] = message + onUpdate(message) + if (position >= words.length) { + clearInterval(intervalId) + touchChat(chatId) + onDone() + } + }, 40) + + return () => clearInterval(intervalId) +} diff --git a/examples/react/chat/src/index.css b/examples/react/chat/src/index.css index e8e379d47..6b4b61ca7 100644 --- a/examples/react/chat/src/index.css +++ b/examples/react/chat/src/index.css @@ -2,6 +2,33 @@ box-sizing: border-box; } +:root { + --bg: #ffffff; + --sidebar-bg: #f9f9f9; + --hover: #ececec; + --text: #0d0d0d; + --muted: #6b6b6b; + --border: #e5e5e5; + --bubble: #f1f1f1; + --accent: #0d0d0d; + --accent-text: #ffffff; + --sidebar-width: 260px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #212121; + --sidebar-bg: #171717; + --hover: #2f2f2f; + --text: #ececec; + --muted: #9b9b9b; + --border: #3a3a3a; + --bubble: #303030; + --accent: #ececec; + --accent-text: #0d0d0d; + } +} + html { font-family: Inter, @@ -11,8 +38,8 @@ html { BlinkMacSystemFont, 'Segoe UI', sans-serif; - color: #171717; - background: #f6f8fa; + color: var(--text); + background: var(--bg); } body { @@ -20,85 +47,335 @@ body { } button { - border: 1px solid #c6d0da; - border-radius: 6px; - background: #ffffff; - color: #171717; - cursor: pointer; font: inherit; - font-size: 13px; - padding: 7px 10px; -} - -button:hover { - background: #eef3f7; + color: inherit; + cursor: pointer; } .App { - height: 100vh; + height: 100dvh; display: grid; - grid-template-rows: auto 1fr; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); +} + +.App[data-sidebar-open='false'] { + grid-template-columns: 0 minmax(0, 1fr); } -.Toolbar { +/* Sidebar */ + +.Sidebar { + background: var(--sidebar-bg); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding: 8px; +} + +.App[data-sidebar-open='false'] .Sidebar { + visibility: hidden; +} + +.SidebarHeader { align-items: center; - background: #ffffff; - border-bottom: 1px solid #d9e0e6; display: flex; - gap: 8px; justify-content: space-between; - padding: 10px 12px; + padding: 4px 4px 8px 8px; +} + +.Brand { + font-weight: 600; } -.ToolbarGroup { +.IconButton { + background: none; + border: 0; + border-radius: 8px; + display: grid; + height: 40px; + place-items: center; + width: 40px; +} + +.IconButton:hover { + background: var(--hover); +} + +.NewChat { + align-items: center; display: flex; - gap: 8px; + gap: 10px; +} + +.NewChat, +.ChatLink { + background: none; + border: 0; + border-radius: 8px; + flex: none; + overflow: hidden; + padding: 8px 10px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.NewChat:hover, +.ChatLink:hover, +.NewChat[aria-current='page'], +.ChatLink[aria-current='page'] { + background: var(--hover); } -.Status { - color: #5c6670; +.SidebarLabel { + color: var(--muted); font-size: 13px; + padding: 20px 10px 6px; } -.Shell { - display: grid; +.ChatList { + display: flex; + flex-direction: column; + font-size: 14px; + min-height: 0; + overflow-y: auto; +} + +.SidebarHint { + color: var(--muted); + font-size: 14px; + padding: 8px 10px; +} + +.Backdrop { + display: none; +} + +/* Main */ + +.Main { + display: flex; + flex-direction: column; min-height: 0; + min-width: 0; +} + +.MainHeader { + align-items: center; + display: flex; + gap: 8px; + height: 52px; + padding: 0 12px; +} + +.MainTitle { + font-weight: 500; overflow: hidden; - place-items: stretch; + text-overflow: ellipsis; + white-space: nowrap; } -.Messages { +.Pane { + flex: 1; min-height: 0; + position: relative; +} + +.Messages { + height: 100%; overflow: auto; overflow-anchor: none; - width: 100%; } +.PaneHint { + color: var(--muted); + flex: 1; + padding: 24px; + text-align: center; +} + +.HistoryLoader { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 13px; + left: 50%; + padding: 4px 12px; + position: absolute; + top: 8px; + transform: translateX(-50%); +} + +.JumpToLatest { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + bottom: 12px; + box-shadow: 0 2px 8px rgb(0 0 0 / 0.12); + display: grid; + height: 40px; + left: 50%; + place-items: center; + position: absolute; + transform: translateX(-50%); + width: 40px; +} + +/* Messages */ + .MessageRow { - padding: 6px 12px; + padding: 0 16px; } -.Bubble { - border: 1px solid #d7dee5; - border-radius: 8px; - line-height: 1.45; - max-width: min(720px, 88vw); - padding: 10px 12px; +.Message { + line-height: 1.6; + margin: 0 auto; + max-width: 768px; + padding: 12px 0; +} + +.Message-user { + display: flex; + justify-content: flex-end; +} + +.UserBubble { + background: var(--bubble); + border-radius: 18px; + max-width: 70%; + padding: 8px 16px; white-space: pre-wrap; } -.Bubble-user { - background: #e6f3ff; - margin-left: auto; +.Message-assistant { + white-space: pre-wrap; +} + +.Message-assistant a { + color: inherit; + overflow-wrap: anywhere; +} + +/* Composer */ + +.ComposerDock { + padding: 0 16px 16px; } -.Bubble-assistant { - background: #ffffff; - margin-right: auto; +.Empty { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + padding: 0 16px 15vh; } -.Meta { - color: #637081; - font-size: 12px; - margin-bottom: 4px; +.Empty h1 { + font-size: 28px; + font-weight: 500; + margin: 0 0 24px; +} + +.Suggestions { + display: flex; + flex-direction: column; + margin-top: 16px; + max-width: 768px; + width: 100%; +} + +.Suggestion { + align-items: center; + background: none; + border: 0; + border-radius: 12px; + color: var(--muted); + display: flex; + gap: 12px; + padding: 10px 20px; + text-align: left; +} + +.Suggestion span { + display: flex; +} + +.Suggestion:hover:not(:disabled) { + background: var(--hover); + color: var(--text); +} + +.Composer { + align-items: flex-end; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 28px; + box-shadow: 0 2px 12px rgb(0 0 0 / 0.06); + display: flex; + gap: 8px; + margin: 0 auto; + max-width: 768px; + padding: 10px 10px 10px 20px; + width: 100%; +} + +.Composer textarea { + background: none; + border: 0; + color: inherit; + field-sizing: content; + flex: 1; + font: inherit; + line-height: 1.5; + max-height: 200px; + outline: none; + padding: 6px 0; + resize: none; +} + +.SendButton { + background: var(--accent); + border: 0; + border-radius: 50%; + color: var(--accent-text); + display: grid; + flex: none; + height: 40px; + place-items: center; + width: 40px; +} + +.SendButton:disabled { + cursor: default; + opacity: 0.3; +} + +/* Narrow screens: the sidebar slides over the chat. */ + +@media (max-width: 768px) { + .App, + .App[data-sidebar-open='false'] { + grid-template-columns: minmax(0, 1fr); + } + + .Sidebar { + bottom: 0; + left: 0; + position: fixed; + top: 0; + width: var(--sidebar-width); + z-index: 2; + } + + .App[data-sidebar-open='true'] .Backdrop { + background: rgb(0 0 0 / 0.4); + display: block; + inset: 0; + position: fixed; + z-index: 1; + } } diff --git a/examples/react/chat/src/main.tsx b/examples/react/chat/src/main.tsx index 7329f8f28..0881665bf 100644 --- a/examples/react/chat/src/main.tsx +++ b/examples/react/chat/src/main.tsx @@ -1,48 +1,230 @@ -import React from 'react' +import * as React from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' +import { queryClient, useChats, useMessages, useSendMessage } from './queries' +import type { Message } from './api' import './index.css' -type Message = { - id: string - author: 'user' | 'assistant' - text: string +// Feather icon paths (MIT, feathericons.com), 24×24 strokes. +const icons = { + menu: 'M3 6h18M3 12h18M3 18h18', + close: 'M15 18l-6-6 6-6', + newChat: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z', + up: 'M12 19V5M5 12l7-7 7 7', + down: 'M12 5v14M19 12l-7 7-7-7', + stop: 'M7 7h10v10H7Z', } -const replies = [ - 'I can break that into the smallest next step and keep the current viewport pinned while this answer grows.', - 'Older messages are loaded above the viewport. The visible row keeps the same screen position after the prepend.', - 'When the thread is not at the bottom, new output waits below without pulling the reader away from history.', +function Icon({ path }: { path: string }) { + return ( + + ) +} + +const suggestions = [ + { + text: 'How do I virtualize a chat list?', + icon: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z', + color: '#3b82f6', + }, + { + text: 'Explain scroll anchoring in one paragraph.', + icon: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12 22V8M5 12H2a10 10 0 0 0 20 0h-3', + color: '#f59e0b', + }, + { + text: 'What should I test in an infinite list?', + icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3', + color: '#10b981', + }, ] -const makeMessage = (index: number): Message => ({ - id: `message-${index}`, - author: index % 4 === 0 ? 'user' : 'assistant', - text: - index % 4 === 0 - ? `Can you check item ${index}?` - : `Message ${index}: ${replies[Math.abs(index) % replies.length]}`, -}) - -const initialMessages = Array.from({ length: 45 }, (_, index) => - makeMessage(index), -) +const isNarrow = () => window.matchMedia('(max-width: 768px)').matches function App() { + const [activeChatId, setActiveChatId] = React.useState(null) + const [sidebarOpen, setSidebarOpen] = React.useState(() => !isNarrow()) + const chats = useChats() + const { send, sending, streaming, stop } = useSendMessage() + const activeChat = chats.data?.find((chat) => chat.id === activeChatId) + + const openChat = (chatId: string | null) => { + setActiveChatId(chatId) + if (isNarrow()) setSidebarOpen(false) + } + + const onSend = (text: string) => { + send( + { chatId: activeChatId, text }, + { + // Keep a chat the user opened while the send was in flight. + onSuccess: ({ chatId }) => + setActiveChatId((current) => + current === activeChatId ? chatId : current, + ), + }, + ) + } + + const composer = ( + + ) + + return ( +
+ + + ) +} + +function ChatPane({ chatId }: { chatId: string }) { + const query = useMessages(chatId) + + if (query.isPending) { + return
Loading messages…
+ } + if (query.isError) { + return
Could not load messages.
+ } + + return ( + page.messages)} + loadingOlder={query.isFetchingPreviousPage} + onLoadOlder={() => { + if (query.hasPreviousPage) { + query.fetchPreviousPage({ cancelRefetch: false }) + } + }} + /> + ) +} + +function MessageList({ + messages, + loadingOlder, + onLoadOlder, +}: { + messages: Message[] + loadingOlder: boolean + onLoadOlder: () => void +}) { const parentRef = React.useRef(null) - const firstMessageIndexRef = React.useRef(0) - const nextMessageIndexRef = React.useRef(initialMessages.length) - const streamTimerRef = React.useRef(null) - const loadingHistoryRef = React.useRef(false) - const [messages, setMessages] = React.useState(initialMessages) - const [loadingHistory, setLoadingHistory] = React.useState(false) - const [didInitialScroll, setDidInitialScroll] = React.useState(false) - const [autoHistoryEnabled, setAutoHistoryEnabled] = React.useState(false) + const lastScrollTopRef = React.useRef(0) + const [atEnd, setAtEnd] = React.useState(true) const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => parentRef.current, - estimateSize: () => 74, + estimateSize: () => 120, getItemKey: React.useCallback( (index: number) => messages[index]!.id, [messages], @@ -52,172 +234,177 @@ function App() { scrollEndThreshold: 80, overscan: 6, directDomUpdates: true, + // Runs after the virtualizer reads each scroll event. React's onScroll + // runs before it, so isAtEnd() there still sees the previous position. + onChange: (instance) => setAtEnd(instance.isAtEnd()), }) - const virtualItems = virtualizer.getVirtualItems() - - const prependHistory = React.useCallback(() => { - if (loadingHistoryRef.current || firstMessageIndexRef.current <= -90) { - return - } - - loadingHistoryRef.current = true - setLoadingHistory(true) - window.setTimeout(() => { - const start = firstMessageIndexRef.current - 12 - firstMessageIndexRef.current = start - setMessages((current) => [ - ...Array.from({ length: 12 }, (_, offset) => - makeMessage(start + offset), - ), - ...current, - ]) - loadingHistoryRef.current = false - setLoadingHistory(false) - }, 180) - }, []) - - const appendMessage = React.useCallback(() => { - const next = nextMessageIndexRef.current - nextMessageIndexRef.current += 1 - setMessages((current) => [...current, makeMessage(next)]) - }, []) - - const streamReply = React.useCallback(() => { - if (streamTimerRef.current !== null) return - - const id = `stream-${Date.now()}` - const chunks = [ - 'Thinking through the failure mode.', - ' The list should follow only when it was already pinned.', - ' Prepends should keep the reader anchored to the same message.', - ' Streaming output should grow without drifting off the bottom.', - ] - let chunkIndex = 0 - - setMessages((current) => [ - ...current, - { - id, - author: 'assistant', - text: '', - }, - ]) - - streamTimerRef.current = window.setInterval(() => { - setMessages((current) => - current.map((message) => - message.id === id - ? { - ...message, - text: chunks.slice(0, chunkIndex + 1).join(''), - } - : message, - ), - ) - - chunkIndex += 1 - if (chunkIndex === chunks.length && streamTimerRef.current !== null) { - window.clearInterval(streamTimerRef.current) - streamTimerRef.current = null - } - }, 280) - }, []) - + // Start at the latest message, and jump to it again after each send, like + // most chat apps. Assistant output follows only when the reader is already + // at the latest message (followOnAppend). + const lastUserMessageId = [...messages] + .reverse() + .find((message) => message.author === 'user')?.id React.useLayoutEffect(() => { - if (didInitialScroll) return virtualizer.scrollToEnd() - setDidInitialScroll(true) - }, [didInitialScroll, virtualizer]) - - React.useEffect(() => { - const id = window.setTimeout(() => { - setAutoHistoryEnabled(true) - }, 250) - - return () => window.clearTimeout(id) - }, []) - - React.useEffect(() => { - return () => { - if (streamTimerRef.current !== null) { - window.clearInterval(streamTimerRef.current) - } - } - }, []) + }, [lastUserMessageId, virtualizer]) return ( -
-
-
- - - - -
-
- {loadingHistory - ? 'Loading history' - : virtualizer.isAtEnd(80) - ? 'At latest' - : 'Reading history'} -
-
- -
+
+
{ + const { scrollTop } = event.currentTarget + const movingUp = scrollTop < lastScrollTopRef.current + lastScrollTopRef.current = scrollTop + // Load only while scrolling up. Safari's rubber band reports + // negative positions past the top; ignore them. + if (movingUp && scrollTop >= 0 && scrollTop < 400) { + onLoadOlder() + } + }} + >
{ - if (!autoHistoryEnabled || virtualizer.isAtEnd(80)) return - if (event.currentTarget.scrollTop < 120) { - prependHistory() - } + ref={virtualizer.containerRef} + style={{ + position: 'relative', + width: '100%', }} > -
- {virtualItems.map((virtualItem) => { - const message = messages[virtualItem.index]! - - return ( -
-
-
{message.author}
- {message.text || '...'} -
-
- ) - })} -
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const message = messages[virtualItem.index]! + + return ( +
+ +
+ ) + })}
+ + {loadingOlder &&
Loading older…
} + {!atEnd && ( + + )}
) } -createRoot(document.getElementById('root')!).render() +function MessageView({ message }: { message: Message }) { + if (message.author === 'user') { + return ( +
+
{message.text}
+
+ ) + } + + return ( +
+ {message.text.split(/(https?:\/\/\S+)/).map((part, index) => + index % 2 ? ( + + {part} + + ) : ( + part + ), + )} +
+ ) +} + +function Composer({ + disabled, + streaming, + onSend, + onStop, +}: { + disabled: boolean + streaming: boolean + onSend: (text: string) => void + onStop: () => void +}) { + const [text, setText] = React.useState('') + + const submit = () => { + const value = text.trim() + if (!value || disabled || streaming) return + onSend(value) + setText('') + } + + return ( +
{ + event.preventDefault() + submit() + }} + > +