feat(angular-virtual): add chat example and require Angular 20 (#1228) · TanStack/virtual@ba5c47a · GitHub
Skip to content

Commit ba5c47a

Browse files
authored
feat(angular-virtual): add chat example and require Angular 20 (#1228)
1 parent 7dcfc07 commit ba5c47a

61 files changed

Lines changed: 3714 additions & 2617 deletions

File tree

Some content is hidden

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

‎.changeset/hot-points-crash.md‎

Lines changed: 5 additions & 0 deletions

‎benchmarks/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@types/react": "^19.2.16",
2525
"@types/react-dom": "^19.2.3",
2626
"@vitejs/plugin-react": "^4.5.2",
27-
"typescript": "5.6.3",
27+
"typescript": "5.9.3",
2828
"vite": "^6.4.2"
2929
}
3030
}

‎docs/config.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@
109109
"to": "framework/angular/examples/infinite-scroll",
110110
"label": "Infinite Scroll"
111111
},
112+
{
113+
"to": "framework/angular/examples/chat",
114+
"label": "Chat"
115+
},
112116
{
113117
"to": "framework/angular/examples/smooth-scroll",
114118
"label": "Smooth Scroll"

‎examples/angular/chat/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# TanStack Virtual Angular Chat Example
2+
3+
Demonstrates end-anchored virtualization for chat-style UIs:
4+
5+
- starts at the newest message
6+
- keeps the visible message stable when older history is prepended
7+
- follows appended messages only while the reader is already at the end
8+
- remains pinned while a dynamically measured reply streams in
9+
10+
Run it from the repository root with:
11+
12+
```sh
13+
pnpm --filter @tanstack/virtual-example-angular-chat start
14+
```

‎examples/angular/chat/angular.json‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3+
"version": 1,
4+
"cli": {
5+
"packageManager": "pnpm",
6+
"analytics": false,
7+
"cache": { "enabled": false }
8+
},
9+
"projects": {
10+
"@tanstack/virtual-example-angular-chat": {
11+
"projectType": "application",
12+
"root": "",
13+
"sourceRoot": "src",
14+
"prefix": "app",
15+
"architect": {
16+
"build": {
17+
"builder": "@angular-devkit/build-angular:application",
18+
"options": {
19+
"outputPath": "dist/tanstack/virtual-example-angular-chat",
20+
"index": "src/index.html",
21+
"browser": "src/main.ts",
22+
"polyfills": ["zone.js"],
23+
"tsConfig": "tsconfig.app.json",
24+
"assets": [],
25+
"styles": ["src/styles.css"],
26+
"scripts": []
27+
},
28+
"configurations": {
29+
"production": { "outputHashing": "all" },
30+
"development": {
31+
"optimization": false,
32+
"extractLicenses": false,
33+
"sourceMap": true
34+
}
35+
},
36+
"defaultConfiguration": "production"
37+
},
38+
"serve": {
39+
"builder": "@angular-devkit/build-angular:dev-server",
40+
"configurations": {
41+
"production": {
42+
"buildTarget": "@tanstack/virtual-example-angular-chat:build:production"
43+
},
44+
"development": {
45+
"buildTarget": "@tanstack/virtual-example-angular-chat:build:development"
46+
}
47+
},
48+
"defaultConfiguration": "development"
49+
}
50+
}
51+
}
52+
}
53+
}

‎examples/angular/chat/package.json‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@tanstack/virtual-example-angular-chat",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"ng": "ng",
7+
"start": "ng serve",
8+
"build": "ng build",
9+
"watch": "ng build --watch --configuration development"
10+
},
11+
"dependencies": {
12+
"@angular/animations": "^20.2.0",
13+
"@angular/common": "^20.2.0",
14+
"@angular/compiler": "^20.2.0",
15+
"@angular/core": "^20.2.0",
16+
"@angular/forms": "^20.2.0",
17+
"@angular/platform-browser": "^20.2.0",
18+
"@angular/platform-browser-dynamic": "^20.2.0",
19+
"@angular/router": "^20.2.0",
20+
"@tanstack/angular-virtual": "^5.0.8",
21+
"rxjs": "^7.8.2",
22+
"tslib": "^2.8.1",
23+
"zone.js": "0.15.1"
24+
},
25+
"devDependencies": {
26+
"@angular-devkit/build-angular": "^20.2.0",
27+
"@angular/cli": "^20.2.0",
28+
"@angular/compiler-cli": "^20.2.0",
29+
"typescript": "5.9.3"
30+
}
31+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
DestroyRef,
5+
afterNextRender,
6+
afterRenderEffect,
7+
inject,
8+
signal,
9+
viewChild,
10+
viewChildren,
11+
} from '@angular/core'
12+
import { injectVirtualizer } from '@tanstack/angular-virtual'
13+
import type { ElementRef } from '@angular/core'
14+
15+
type Message = {
16+
id: string
17+
author: 'user' | 'assistant'
18+
text: string
19+
}
20+
21+
const replies = [
22+
'I can break that into the smallest next step and keep the current viewport pinned while this answer grows.',
23+
'Older messages are loaded above the viewport. The visible row keeps the same screen position after the prepend.',
24+
'When the thread is not at the bottom, new output waits below without pulling the reader away from history.',
25+
]
26+
27+
const makeMessage = (index: number): Message => ({
28+
id: `message-${index}`,
29+
author: index % 4 === 0 ? 'user' : 'assistant',
30+
text:
31+
index % 4 === 0
32+
? `Can you check item ${index}?`
33+
: `Message ${index}: ${replies[Math.abs(index) % replies.length]}`,
34+
})
35+
36+
const initialMessages = Array.from({ length: 45 }, (_, index) =>
37+
makeMessage(index),
38+
)
39+
40+
@Component({
41+
selector: 'app-root',
42+
standalone: true,
43+
changeDetection: ChangeDetectionStrategy.OnPush,
44+
template: `
45+
<div class="App">
46+
<div class="Toolbar">
47+
<div class="ToolbarGroup">
48+
<button type="button" (click)="prependHistory()">Load older</button>
49+
<button type="button" (click)="appendMessage()">Add message</button>
50+
<button type="button" (click)="streamReply()">Stream reply</button>
51+
<button type="button" (click)="virtualizer.scrollToEnd()">
52+
Latest
53+
</button>
54+
</div>
55+
<div class="Status">
56+
@if (loadingHistory()) {
57+
Loading history
58+
} @else if (virtualizer.isAtEnd(80)) {
59+
At latest
60+
} @else {
61+
Reading history
62+
}
63+
</div>
64+
</div>
65+
66+
<div class="Shell">
67+
<div #scrollElement class="Messages" (scroll)="onScroll()">
68+
<div
69+
class="MessagesSizer"
70+
[style.height.px]="virtualizer.getTotalSize()"
71+
>
72+
@for (row of virtualizer.getVirtualItems(); track row.key) {
73+
<div
74+
#virtualItem
75+
class="MessageRow"
76+
[attr.data-index]="row.index"
77+
[style.transform]="'translateY(' + row.start + 'px)'"
78+
>
79+
<div
80+
class="Bubble"
81+
[class.Bubble-user]="messages()[row.index].author === 'user'"
82+
[class.Bubble-assistant]="
83+
messages()[row.index].author === 'assistant'
84+
"
85+
>
86+
<div class="Meta">{{ messages()[row.index].author }}</div>
87+
{{ messages()[row.index].text || '...' }}
88+
</div>
89+
</div>
90+
}
91+
</div>
92+
</div>
93+
</div>
94+
</div>
95+
`,
96+
})
97+
export class AppComponent {
98+
readonly messages = signal(initialMessages)
99+
readonly loadingHistory = signal(false)
100+
101+
readonly scrollElement =
102+
viewChild<ElementRef<HTMLDivElement>>('scrollElement')
103+
readonly virtualItems =
104+
viewChildren<ElementRef<HTMLDivElement>>('virtualItem')
105+
106+
private firstMessageIndex = 0
107+
private nextMessageIndex = initialMessages.length
108+
private autoHistoryTimer: number | undefined
109+
private streamTimer: number | undefined
110+
private autoHistoryEnabled = false
111+
112+
// Register before the virtualizer so item measurement runs before its
113+
// post-render scroll reconciliation, matching React refs before layout effects.
114+
private readonly measureItems = afterRenderEffect({
115+
mixedReadWrite: () => this.measureVirtualItems(),
116+
})
117+
118+
readonly virtualizer = injectVirtualizer<HTMLDivElement, HTMLDivElement>(
119+
() => ({
120+
scrollElement: this.scrollElement(),
121+
count: this.messages().length,
122+
estimateSize: () => 74,
123+
getItemKey: (index) => this.messages()[index].id,
124+
anchorTo: 'end',
125+
followOnAppend: true,
126+
scrollEndThreshold: 80,
127+
overscan: 6,
128+
useApplicationRefTick: false,
129+
}),
130+
)
131+
132+
private measureVirtualItems(): void {
133+
this.virtualItems().forEach((element) => {
134+
this.virtualizer.measureElement(element.nativeElement)
135+
})
136+
}
137+
138+
constructor() {
139+
const destroyRef = inject(DestroyRef)
140+
141+
afterNextRender(() => {
142+
this.virtualizer.scrollToEnd()
143+
this.autoHistoryTimer = window.setTimeout(() => {
144+
this.autoHistoryEnabled = true
145+
}, 250)
146+
})
147+
148+
destroyRef.onDestroy(() => {
149+
if (this.autoHistoryTimer !== undefined) {
150+
window.clearTimeout(this.autoHistoryTimer)
151+
}
152+
if (this.streamTimer !== undefined) {
153+
window.clearInterval(this.streamTimer)
154+
}
155+
})
156+
}
157+
158+
prependHistory(): void {
159+
if (this.loadingHistory() || this.firstMessageIndex <= -90) return
160+
161+
this.loadingHistory.set(true)
162+
window.setTimeout(() => {
163+
const start = this.firstMessageIndex - 12
164+
this.firstMessageIndex = start
165+
this.messages.update((current) => [
166+
...Array.from({ length: 12 }, (_, offset) =>
167+
makeMessage(start + offset),
168+
),
169+
...current,
170+
])
171+
this.loadingHistory.set(false)
172+
}, 180)
173+
}
174+
175+
appendMessage(): void {
176+
const next = this.nextMessageIndex
177+
this.nextMessageIndex += 1
178+
this.messages.update((current) => [...current, makeMessage(next)])
179+
}
180+
181+
streamReply(): void {
182+
if (this.streamTimer !== undefined) return
183+
184+
const id = `stream-${Date.now()}`
185+
const chunks = [
186+
'Thinking through the failure mode.',
187+
' The list should follow only when it was already pinned.',
188+
' Prepends should keep the reader anchored to the same message.',
189+
' Streaming output should grow without drifting off the bottom.',
190+
]
191+
let chunkIndex = 0
192+
193+
this.messages.update((current) => [
194+
...current,
195+
{ id, author: 'assistant', text: '' },
196+
])
197+
198+
this.streamTimer = window.setInterval(() => {
199+
this.messages.update((current) =>
200+
current.map((message) =>
201+
message.id === id
202+
? {
203+
...message,
204+
text: chunks.slice(0, chunkIndex + 1).join(''),
205+
}
206+
: message,
207+
),
208+
)
209+
210+
chunkIndex += 1
211+
if (chunkIndex === chunks.length) {
212+
window.clearInterval(this.streamTimer)
213+
this.streamTimer = undefined
214+
}
215+
}, 280)
216+
}
217+
218+
onScroll(): void {
219+
const scrollElement = this.scrollElement()?.nativeElement
220+
if (
221+
!scrollElement ||
222+
!this.autoHistoryEnabled ||
223+
this.virtualizer.isAtEnd(80)
224+
) {
225+
return
226+
}
227+
228+
if (scrollElement.scrollTop < 120) this.prependHistory()
229+
}
230+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>TanStack Virtual Angular Chat Example</title>
6+
<base href="/" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1" />
8+
</head>
9+
<body>
10+
<app-root></app-root>
11+
</body>
12+
</html>

‎examples/angular/chat/src/main.ts‎

Lines changed: 4 additions & 0 deletions

0 commit comments

Comments
 (0)