feat: support end-anchored virtualizers by tannerlinsley · Pull Request #1173 · TanStack/virtual · GitHub
Skip to content

feat: support end-anchored virtualizers - #1173

Merged
tannerlinsley merged 2 commits into
mainfrom
codex/reverse-chat-virtualization
May 25, 2026
Merged

tannerlinsley merged 2 commits into
mainfrom
codex/reverse-chat-virtualization

Conversation

@tannerlinsley

@tannerlinsley tannerlinsley commented May 24, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Add end anchoring via anchorTo: 'end' for chat-style prepend stability and bottom-pinned resize behavior
  • Add append following controls, end-distance helpers, docs, changeset, and focused virtual-core coverage
  • Add a first-class Chat guide, React example navigation entry, and standalone examples/react/chat demo for streaming, appending, and loading older messages

Bundle Size

  • @tanstack/virtual-core ESM dist: +747 B gzip versus origin/main
  • App-minified/Terser ESM estimate: +533 B gzip
  • @tanstack/react-virtual: 0 B delta

Testing

  • pnpm --filter @tanstack/virtual-core test:lib -- --run
  • pnpm --filter @tanstack/virtual-core test:types
  • pnpm --filter @tanstack/virtual-core test:eslint
  • pnpm --filter @tanstack/virtual-core build
  • pnpm --filter @tanstack/virtual-core test:build
  • pnpm --filter @tanstack/react-virtual test:types
  • pnpm --filter @tanstack/react-virtual test:eslint
  • pnpm --filter @tanstack/react-virtual build
  • pnpm --filter @tanstack/react-virtual test:build
  • pnpm --filter tanstack-react-virtual-example-chat build
  • pnpm run test:docs
  • pnpm run test:sherif
  • pnpm run test:knip
  • pnpm exec tsc -p packages/react-virtual/e2e/app/tsconfig.json --noEmit
  • VITE_SERVER_PORT=5175 pnpm --dir packages/react-virtual exec playwright test --workers=1
  • VITE_SERVER_PORT=5177 pnpm --dir packages/react-virtual exec playwright test app/test/chat.spec.ts --workers=1

Note: default parallel React e2e showed intermittent timeouts in pre-existing smooth-scroll specs under load; the affected specs passed in isolation and the full suite passed serially.

Summary by CodeRabbit

  • New Features

    • End-anchored virtualization for chat/log reverse-feed scenarios
    • followOnAppend and scrollEndThreshold controls
    • Helper methods: scrollToEnd(), getDistanceFromEnd(), isAtEnd()
  • Documentation

    • API docs and a new Chat guide describing end-anchor and follow-on behaviors; intro and docs index updated
  • Examples

    • New React chat example with demo, styles, build/dev config, and README
  • Tests

    • E2E and unit tests validating end-anchoring, follow behavior, and streaming growth

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 24, 2026 •

Copy link
Copy Markdown

@github-actions

github-actions Bot commented May 24, 2026 •

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

1 package(s) bumped directly, 7 bumped as dependents.

🟨 Minor bumps

Package Version Reason
@tanstack/virtual-core 3.15.0 → 3.16.0 Changeset

🟩 Patch bumps

Package Version Reason
@tanstack/angular-virtual 5.0.1 → 5.0.2 Dependent
@tanstack/lit-virtual 3.13.26 → 3.13.27 Dependent
@tanstack/react-virtual 3.13.25 → 3.13.26 Dependent
@tanstack/solid-virtual 3.13.25 → 3.13.26 Dependent
@tanstack/svelte-virtual 3.13.25 → 3.13.26 Dependent
@tanstack/virtual-benchmarks 0.0.1 → 0.0.2 Dependent
@tanstack/vue-virtual 3.13.25 → 3.13.26 Dependent

@nx-cloud

nx-cloud Bot commented May 24, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 2a80e00

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 2m 22s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 13s View ↗

☁️ Nx Cloud last updated this comment at 2026-05-25 16:50:32 UTC

@pkg-pr-new

pkg-pr-new Bot commented May 24, 2026 •

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
examples/react/chat/README.md (1)

1-6: ⚡ Quick win

Consider adding a brief description of what this example demonstrates.

The README is functional but could benefit from a sentence or two explaining that this example showcases end-anchored virtualization for chat-style UIs, including prepending history, streaming messages, and auto-follow behavior.

🤖 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 `@examples/react/chat/README.md` around lines 1 - 6, Update the README.md for
the chat example to include a short descriptive paragraph explaining what the
example demonstrates: that it showcases end-anchored virtualization for
chat-style UIs, supports prepending history, streaming messages, and automatic
follow/autoscroll behavior; place this sentence or two near the top under the
title in examples/react/chat/README.md so developers immediately understand the
key features shown by the example.
packages/virtual-core/src/index.ts (1)

646-654: 💤 Low value

Consider caching key-to-index mapping for large lists.

findIndexByKey performs O(n) linear search. While this is only called during anchor restoration (not per frame), it could become noticeable with very large lists (10k+ items) during rapid prepend operations.

For now this is acceptable since anchor restoration is infrequent, but if performance becomes an issue, consider maintaining a Map<Key, number> index during measurement builds.

🤖 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 `@packages/virtual-core/src/index.ts` around lines 646 - 654, findIndexByKey
currently does an O(n) scan over this.options.count using
this.options.getItemKey which can be slow for very large lists; to fix,
introduce a Map<Key, number> (e.g., this.keyToIndex) that is populated during
measurement/build phases (the same place where item sizes/indices are computed)
and used by findIndexByKey to return index in O(1); ensure the map is
maintained/updated on any mutations that change indices
(prepend/insert/remove/clear) or invalidated and rebuilt when measurements are
recomputed so the mapping stays in sync with count and getItemKey.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@examples/react/chat/README.md`:
- Around line 1-6: Update the README.md for the chat example to include a short
descriptive paragraph explaining what the example demonstrates: that it
showcases end-anchored virtualization for chat-style UIs, supports prepending
history, streaming messages, and automatic follow/autoscroll behavior; place
this sentence or two near the top under the title in
examples/react/chat/README.md so developers immediately understand the key
features shown by the example.

In `@packages/virtual-core/src/index.ts`:
- Around line 646-654: findIndexByKey currently does an O(n) scan over
this.options.count using this.options.getItemKey which can be slow for very
large lists; to fix, introduce a Map<Key, number> (e.g., this.keyToIndex) that
is populated during measurement/build phases (the same place where item
sizes/indices are computed) and used by findIndexByKey to return index in O(1);
ensure the map is maintained/updated on any mutations that change indices
(prepend/insert/remove/clear) or invalidated and rebuilt when measurements are
recomputed so the mapping stays in sync with count and getItemKey.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0382c210-0433-4cc5-9310-0a7845749778

📥 Commits

Reviewing files that changed from the base of the PR and between 949180b and 76f1131.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • .changeset/chat-reverse-virtualization.md
  • docs/api/virtualizer.md
  • examples/react/chat/.gitignore
  • examples/react/chat/README.md
  • examples/react/chat/index.html
  • examples/react/chat/package.json
  • examples/react/chat/src/index.css
  • examples/react/chat/src/main.tsx
  • examples/react/chat/tsconfig.json
  • examples/react/chat/vite.config.js
  • packages/react-virtual/e2e/app/chat/index.html
  • packages/react-virtual/e2e/app/chat/main.tsx
  • packages/react-virtual/e2e/app/test/chat.spec.ts
  • packages/react-virtual/e2e/app/vite.config.ts
  • packages/react-virtual/playwright.config.ts
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts

@tannerlinsley
tannerlinsley force-pushed the codex/reverse-chat-virtualization branch from 76f1131 to 34bc57b Compare May 24, 2026 20:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/react-virtual/e2e/app/test/chat.spec.ts (1)

53-54: ⚡ Quick win

Replace fixed sleeps with condition-based waits.

The hard-coded waits can make this spec flaky under variable CI load. Prefer expect.poll(...)/locator-based waits tied to scroll/message state transitions instead of waitForTimeout(100).

♻️ Suggested direction
-  await page.waitForTimeout(100)
+  await expect
+    .poll(async () => {
+      const current = await firstVisibleMessage(page)
+      return current.id
+    })
+    .toBeDefined()
...
-  await page.waitForTimeout(100)
+  await expect
+    .poll(async () =>
+      page.evaluate(() => {
+        const container = document.querySelector('`#scroll-container`')
+        if (!container) throw new Error('Container not found')
+        return container.scrollTop
+      }),
+    )
+    .toBeCloseTo(before, 0)

Also applies to: 58-59, 78-79, 87-88

🤖 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 `@packages/react-virtual/e2e/app/test/chat.spec.ts` around lines 53 - 54,
Replace the brittle page.waitForTimeout(100) calls with condition-based waits:
locate the chat scroll container or message list and wait for the expected state
using Playwright's expect/locator APIs (e.g., await
expect(locator('selector-for-messages')).toHaveCount(expected) or await
expect.poll(() => locator('selector-for-scroll').evaluate(...)).toMatch(...));
in other words, swap each page.waitForTimeout(100) (and the similar occurrences
at 58-59, 78-79, 87-88) with a targeted wait that asserts the message/scroll
state change (use expect.poll for custom predicates or
locator.waitFor/expect.toHaveText/toHaveCount for standard conditions) so the
spec waits for actual UI transitions instead of a fixed sleep.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@packages/react-virtual/e2e/app/test/chat.spec.ts`:
- Around line 53-54: Replace the brittle page.waitForTimeout(100) calls with
condition-based waits: locate the chat scroll container or message list and wait
for the expected state using Playwright's expect/locator APIs (e.g., await
expect(locator('selector-for-messages')).toHaveCount(expected) or await
expect.poll(() => locator('selector-for-scroll').evaluate(...)).toMatch(...));
in other words, swap each page.waitForTimeout(100) (and the similar occurrences
at 58-59, 78-79, 87-88) with a targeted wait that asserts the message/scroll
state change (use expect.poll for custom predicates or
locator.waitFor/expect.toHaveText/toHaveCount for standard conditions) so the
spec waits for actual UI transitions instead of a fixed sleep.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92aeb2de-aff1-4b1e-9e84-edc04c0b0d9d

📥 Commits

Reviewing files that changed from the base of the PR and between 76f1131 and 34bc57b.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • .changeset/chat-reverse-virtualization.md
  • docs/api/virtualizer.md
  • examples/react/chat/.gitignore
  • examples/react/chat/README.md
  • examples/react/chat/index.html
  • examples/react/chat/package.json
  • examples/react/chat/src/index.css
  • examples/react/chat/src/main.tsx
  • examples/react/chat/tsconfig.json
  • examples/react/chat/vite.config.js
  • packages/react-virtual/e2e/app/chat/index.html
  • packages/react-virtual/e2e/app/chat/main.tsx
  • packages/react-virtual/e2e/app/test/chat.spec.ts
  • packages/react-virtual/e2e/app/vite.config.ts
  • packages/react-virtual/playwright.config.ts
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts
✅ Files skipped from review due to trivial changes (3)
  • examples/react/chat/package.json
  • examples/react/chat/README.md
  • .changeset/chat-reverse-virtualization.md

@tannerlinsley
tannerlinsley force-pushed the codex/reverse-chat-virtualization branch from 34bc57b to bc6a315 Compare May 24, 2026 22:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.

Inline comments:
In `@examples/react/chat/src/main.tsx`:
- Around line 123-133: The current useLayoutEffect sets didInitialScroll and
also starts a timeout for setAutoHistoryEnabled, but React will run the cleanup
immediately when didInitialScroll changes so the timeout is cleared before
firing; fix by separating responsibilities: keep the scroll logic in the
existing React.useLayoutEffect that calls virtualizer.scrollToEnd() and
setDidInitialScroll(true) (leave that effect dependent on didInitialScroll and
virtualizer), then add a separate React.useEffect with an empty dependency array
that schedules the window.setTimeout(() => setAutoHistoryEnabled(true), 250) and
returns a cleanup that clears that timeout on unmount; alternatively, delay
calling setDidInitialScroll until after the timeout completes—use setTimeout to
call setAutoHistoryEnabled(true) then setDidInitialScroll(true) if you prefer
the single-effect approach.
- Line 2: Change the default import to the named createRoot from
'react-dom/client' and update the app bootstrap to use
createRoot(...).render(...). In the component using useLayoutEffect, prevent
didInitialScroll from being set synchronously (which cancels the pending
timeout) by moving the setDidInitialScroll call into the timeout callback after
setAutoHistoryEnabled(true) (or call setAutoHistoryEnabled(true) first, then
setDidInitialScroll(true) inside the same timeout), and ensure the cleanup still
clears the timeout; refer to createRoot, useLayoutEffect, didInitialScroll,
setAutoHistoryEnabled and setDidInitialScroll to locate the changes.

In `@packages/react-virtual/e2e/app/test/chat.spec.ts`:
- Line 53: Replace the fragile fixed sleeps (calls to page.waitForTimeout(100))
in chat.spec.ts with state-based waits: after each scroll or click, use
Playwright's expect.poll or expect.toHaveJSProperty against the chat scroll
container (e.g., check its scrollTop/scrollHeight or the relevant element's
visibility) until the expected value/visibility appears; specifically locate the
occurrences of page.waitForTimeout in chat.spec.ts and swap them for
expect.poll(() =>
document.querySelector('<scroll-selector>').scrollTop).toBeGreaterThan(...) or
expect(locator('<item-selector>')).toBeVisible() so the test waits for the
actual UI state instead of a fixed timeout.

In `@packages/virtual-core/src/index.ts`:
- Around line 1527-1536: isAtEnd()/getDistanceFromEnd() use getTotalSize() -
getSize() instead of the actual DOM max scroll offset, so a DOM scroll extent
larger than virtual size can prevent end detection; update getDistanceFromEnd to
compute distance using getMaxScrollOffset() - this.getScrollOffset() (clamped to
>=0) and keep isAtEnd(threshold = this.options.scrollEndThreshold) comparing
that result to threshold; modify references in getDistanceFromEnd, isAtEnd and
any callers relying on the old computation (functions: getDistanceFromEnd,
isAtEnd, getMaxScrollOffset, getScrollOffset, getTotalSize, getSize) to ensure
followOnAppend/end-pinned behavior works with the real max scroll offset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 671aaa81-a34f-42cb-a74f-dfe76b4598c8

📥 Commits

Reviewing files that changed from the base of the PR and between 34bc57b and bc6a315.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • .changeset/chat-reverse-virtualization.md
  • docs/api/virtualizer.md
  • docs/chat.md
  • docs/config.json
  • docs/introduction.md
  • examples/react/chat/.gitignore
  • examples/react/chat/README.md
  • examples/react/chat/index.html
  • examples/react/chat/package.json
  • examples/react/chat/src/index.css
  • examples/react/chat/src/main.tsx
  • examples/react/chat/tsconfig.json
  • examples/react/chat/vite.config.js
  • packages/react-virtual/e2e/app/chat/index.html
  • packages/react-virtual/e2e/app/chat/main.tsx
  • packages/react-virtual/e2e/app/test/chat.spec.ts
  • packages/react-virtual/e2e/app/vite.config.ts
  • packages/react-virtual/playwright.config.ts
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts
✅ Files skipped from review due to trivial changes (7)
  • packages/react-virtual/e2e/app/chat/index.html
  • examples/react/chat/README.md
  • docs/introduction.md
  • examples/react/chat/package.json
  • examples/react/chat/.gitignore
  • packages/react-virtual/playwright.config.ts
  • .changeset/chat-reverse-virtualization.md

Comment thread examples/react/chat/src/main.tsx Outdated
Comment thread examples/react/chat/src/main.tsx Outdated
Comment thread packages/react-virtual/e2e/app/test/chat.spec.ts Outdated
Comment thread packages/virtual-core/src/index.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant