Skip to content
Merged
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': major
---

require angular 20 and up following their lts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Polish the changeset description.

Use clearer release-note wording, such as “Require Angular 20 or later, following Angular’s LTS policy.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/hot-points-crash.md at line 5, Update the changeset description
text to clearly state that Angular 20 or later is required and that this follows
Angular’s LTS policy.

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.2.0",
"@angular/common": "^20.2.0",
"@angular/compiler": "^20.2.0",
"@angular/core": "^20.2.0",
"@angular/forms": "^20.2.0",
"@angular/platform-browser": "^20.2.0",
"@angular/platform-browser-dynamic": "^20.2.0",
"@angular/router": "^20.2.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.2.0",
"@angular/cli": "^20.2.0",
"@angular/compiler-cli": "^20.2.0",
"typescript": "5.9.3"
}
}
230 changes: 230 additions & 0 deletions examples/angular/chat/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
afterNextRender,
afterRenderEffect,
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

// Register before the virtualizer so item measurement runs before its
// post-render scroll reconciliation, matching React refs before layout effects.
private readonly measureItems = afterRenderEffect({
mixedReadWrite: () => this.measureVirtualItems(),
})
Comment on lines +112 to +116

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking if this could be improved. I don't like that if this effect is placed after injectVirtualizer, the followOnAppend fails. There should be a simple way to solve this but I haven't found one yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the plain effect works, but ideally afterRenderEffect should be used.


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 measureVirtualItems(): void {
this.virtualItems().forEach((element) => {
this.virtualizer.measureElement(element.nativeElement)
})
}

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
Loading