Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hot-points-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/angular-virtual': minor
---

require angular 20 and up following their lts
2 changes: 1 addition & 1 deletion benchmarks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "5.6.3",
"typescript": "5.9.3",
"vite": "^6.4.2"
}
}
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
"to": "framework/angular/examples/infinite-scroll",
"label": "Infinite Scroll"
},
{
"to": "framework/angular/examples/chat",
"label": "Chat"
},
{
"to": "framework/angular/examples/smooth-scroll",
"label": "Smooth Scroll"
Expand Down
14 changes: 14 additions & 0 deletions examples/angular/chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# TanStack Virtual Angular Chat Example

Demonstrates end-anchored virtualization for chat-style UIs:

- starts at the newest message
- keeps the visible message stable when older history is prepended
- follows appended messages only while the reader is already at the end
- remains pinned while a dynamically measured reply streams in

Run it from the repository root with:

```sh
pnpm --filter @tanstack/virtual-example-angular-chat start
```
53 changes: 53 additions & 0 deletions examples/angular/chat/angular.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "pnpm",
"analytics": false,
"cache": { "enabled": false }
},
"projects": {
"@tanstack/virtual-example-angular-chat": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/tanstack/virtual-example-angular-chat",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": { "outputHashing": "all" },
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "@tanstack/virtual-example-angular-chat:build:production"
},
"development": {
"buildTarget": "@tanstack/virtual-example-angular-chat:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
31 changes: 31 additions & 0 deletions examples/angular/chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@tanstack/virtual-example-angular-chat",
"private": true,
"type": "module",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development"
},
"dependencies": {
"@angular/animations": "^20.0.0",
"@angular/common": "^20.0.0",
"@angular/compiler": "^20.0.0",
"@angular/core": "^20.0.0",
"@angular/forms": "^20.0.0",
"@angular/platform-browser": "^20.0.0",
"@angular/platform-browser-dynamic": "^20.0.0",
"@angular/router": "^20.0.0",
"@tanstack/angular-virtual": "^5.0.8",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"zone.js": "0.15.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^20.0.0",
"@angular/cli": "^20.0.0",
"@angular/compiler-cli": "^20.0.0",
"typescript": "5.9.3"
}
}
226 changes: 226 additions & 0 deletions examples/angular/chat/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
afterNextRender,
effect,
inject,
signal,
viewChild,
viewChildren,
} from '@angular/core'
import { injectVirtualizer } from '@tanstack/angular-virtual'
import type { ElementRef } from '@angular/core'

type Message = {
id: string
author: 'user' | 'assistant'
text: string
}

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.',
]

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),
)

@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="App">
<div class="Toolbar">
<div class="ToolbarGroup">
<button type="button" (click)="prependHistory()">Load older</button>
<button type="button" (click)="appendMessage()">Add message</button>
<button type="button" (click)="streamReply()">Stream reply</button>
<button type="button" (click)="virtualizer.scrollToEnd()">
Latest
</button>
</div>
<div class="Status">
@if (loadingHistory()) {
Loading history
} @else if (virtualizer.isAtEnd(80)) {
At latest
} @else {
Reading history
}
</div>
</div>

<div class="Shell">
<div #scrollElement class="Messages" (scroll)="onScroll()">
<div
class="MessagesSizer"
[style.height.px]="virtualizer.getTotalSize()"
>
@for (row of virtualizer.getVirtualItems(); track row.key) {
<div
#virtualItem
class="MessageRow"
[attr.data-index]="row.index"
[style.transform]="'translateY(' + row.start + 'px)'"
>
<div
class="Bubble"
[class.Bubble-user]="messages()[row.index].author === 'user'"
[class.Bubble-assistant]="
messages()[row.index].author === 'assistant'
"
>
<div class="Meta">{{ messages()[row.index].author }}</div>
{{ messages()[row.index].text || '...' }}
</div>
</div>
}
</div>
</div>
</div>
</div>
`,
})
export class AppComponent {
readonly messages = signal(initialMessages)
readonly loadingHistory = signal(false)

readonly scrollElement =
viewChild<ElementRef<HTMLDivElement>>('scrollElement')
readonly virtualItems =
viewChildren<ElementRef<HTMLDivElement>>('virtualItem')

private firstMessageIndex = 0
private nextMessageIndex = initialMessages.length
private autoHistoryTimer: number | undefined
private streamTimer: number | undefined
private autoHistoryEnabled = false

readonly virtualizer = injectVirtualizer<HTMLDivElement, HTMLDivElement>(
() => ({
scrollElement: this.scrollElement(),
count: this.messages().length,
estimateSize: () => 74,
getItemKey: (index) => this.messages()[index].id,
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: 80,
overscan: 6,
useApplicationRefTick: false,
}),
)

private readonly measureItems = effect(
() =>
this.virtualItems().forEach((element) => {
this.virtualizer.measureElement(element.nativeElement)
}),
{ allowSignalWrites: true },
)

constructor() {
const destroyRef = inject(DestroyRef)

afterNextRender(() => {
this.virtualizer.scrollToEnd()
this.autoHistoryTimer = window.setTimeout(() => {
this.autoHistoryEnabled = true
}, 250)
})

destroyRef.onDestroy(() => {
if (this.autoHistoryTimer !== undefined) {
window.clearTimeout(this.autoHistoryTimer)
}
if (this.streamTimer !== undefined) {
window.clearInterval(this.streamTimer)
}
})
}

prependHistory(): void {
if (this.loadingHistory() || this.firstMessageIndex <= -90) return

this.loadingHistory.set(true)
window.setTimeout(() => {
const start = this.firstMessageIndex - 12
this.firstMessageIndex = start
this.messages.update((current) => [
...Array.from({ length: 12 }, (_, offset) =>
makeMessage(start + offset),
),
...current,
])
this.loadingHistory.set(false)
}, 180)
}

appendMessage(): void {
const next = this.nextMessageIndex
this.nextMessageIndex += 1
this.messages.update((current) => [...current, makeMessage(next)])
}

streamReply(): void {
if (this.streamTimer !== undefined) 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

this.messages.update((current) => [
...current,
{ id, author: 'assistant', text: '' },
])

this.streamTimer = window.setInterval(() => {
this.messages.update((current) =>
current.map((message) =>
message.id === id
? {
...message,
text: chunks.slice(0, chunkIndex + 1).join(''),
}
: message,
),
)

chunkIndex += 1
if (chunkIndex === chunks.length) {
window.clearInterval(this.streamTimer)
this.streamTimer = undefined
}
}, 280)
}

onScroll(): void {
const scrollElement = this.scrollElement()?.nativeElement
if (
!scrollElement ||
!this.autoHistoryEnabled ||
this.virtualizer.isAtEnd(80)
) {
return
}

if (scrollElement.scrollTop < 120) this.prependHistory()
}
}
12 changes: 12 additions & 0 deletions examples/angular/chat/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>TanStack Virtual Angular Chat Example</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<app-root></app-root>
</body>
</html>
4 changes: 4 additions & 0 deletions examples/angular/chat/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser'
import { AppComponent } from './app/app.component'

bootstrapApplication(AppComponent).catch((error) => console.error(error))
Loading