Skip to content

feat: support end-anchored virtualizers#1173

Open
tannerlinsley wants to merge 1 commit into
mainfrom
codex/reverse-chat-virtualization
Open

feat: support end-anchored virtualizers#1173
tannerlinsley wants to merge 1 commit into
mainfrom
codex/reverse-chat-virtualization

Conversation

@tannerlinsley
Copy link
Copy Markdown
Member

@tannerlinsley tannerlinsley commented May 24, 2026

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
Copy link
Copy Markdown

coderabbitai Bot commented May 24, 2026

📝 Walkthrough

Walkthrough

This PR adds end-anchored virtualization and chat/log controls: new Virtualizer options (anchorTo, followOnAppend, scrollEndThreshold), helpers (scrollToEnd, getDistanceFromEnd, isAtEnd), updated core logic to preserve end-pinning during prepends/streaming, docs, examples, e2e tests, and a changeset bump.

Changes

End-Anchored Virtualization

Layer / File(s) Summary
End-anchoring types and option contracts
packages/virtual-core/src/index.ts
Type and default option additions: ScrollAnchor/FollowOnAppend/ScrollToEndOptions, PendingScrollAnchor/pendingScrollAnchor storage, and new VirtualizerOptions fields anchorTo, followOnAppend, scrollEndThreshold.
Pending anchor capture & lifecycle
packages/virtual-core/src/index.ts
Default options set; setOptions captures pending anchors when entering 'end' mode, records follow-on append behavior, and applies pending anchors during the update cycle.
Resize and scroll adjustment
packages/virtual-core/src/index.ts
Detects wasAtEnd during resize and applies total-size delta corrections for end-pinned viewports; preserves prior per-item heuristic corrections otherwise.
Public helpers and scroll-to-end
packages/virtual-core/src/index.ts
Adds getDistanceFromEnd(), isAtEnd(threshold?), and scrollToEnd({ behavior? }) for querying and moving to the tail.
Virtual core tests
packages/virtual-core/tests/index.test.ts
Unit tests and helper for end-anchoring: prepend stability, append/no-yank semantics, followOnAppend variants (auto/smooth), streaming growth handling, and end-distance assertions.
API docs, chat guide & changeset
docs/api/virtualizer.md, docs/chat.md, .changeset/chat-reverse-virtualization.md, docs/config.json, docs/introduction.md
Documents anchorTo, followOnAppend, scrollEndThreshold, instance APIs scrollToEnd, getDistanceFromEnd, isAtEnd, adds chat guide and nav entry, and records minor version bump.
React chat example scaffold & styling
examples/react/chat/package.json, tsconfig.json, vite.config.js, .gitignore, README.md, index.html, src/index.css
Adds example project configuration, entry files, and CSS for chat layout and message bubbles.
React chat example implementation
examples/react/chat/src/main.tsx
Implements chat UI using @tanstack/react-virtual anchored to end with prepend/append/stream interactions, auto-history trigger, initial scroll-to-end, and cleanup.
E2E build and Playwright wiring
packages/react-virtual/e2e/app/vite.config.ts, packages/react-virtual/playwright.config.ts, packages/react-virtual/e2e/app/chat/index.html
Adds chat build entry, parameterizes Playwright PORT via env, and provides HTML mount for e2e app.
E2E chat app & tests
packages/react-virtual/e2e/app/chat/main.tsx, packages/react-virtual/e2e/app/test/chat.spec.ts
E2E chat demo with controls (prepend/append/grow/scroll) and Playwright tests verifying prepend stability, append/no-yank for scrolled-up users, follow-on append when at end, and growth pinning.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 I nibble bytes at the tail's bright end,

I hop when messages stream and bend.
Prepends keep steady, appends gently send,
A soft smooth scroll, then I descend.
Hopping, I guard the chat's calm blend.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main feature being introduced: end-anchored virtualization support.
Description check ✅ Passed The pull request description provides a clear summary of changes, bundle size impact, and comprehensive testing details, though it lacks the formal template structure with checklist items.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/reverse-chat-virtualization

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

examples/react/chat/vite.config.js

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): examples/react/chat/vite.config.js

packages/virtual-core/tests/index.test.ts

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): packages/virtual-core/tests/index.test.ts


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 24, 2026

🚀 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
Copy link
Copy Markdown

nx-cloud Bot commented May 24, 2026

View your CI Pipeline Execution ↗ for commit bc6a315

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

☁️ Nx Cloud last updated this comment at 2026-05-24 22:12:55 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 24, 2026

More templates

@tanstack/angular-virtual

npm i https://pkg.pr.new/@tanstack/angular-virtual@1173

@tanstack/lit-virtual

npm i https://pkg.pr.new/@tanstack/lit-virtual@1173

@tanstack/react-virtual

npm i https://pkg.pr.new/@tanstack/react-virtual@1173

@tanstack/solid-virtual

npm i https://pkg.pr.new/@tanstack/solid-virtual@1173

@tanstack/svelte-virtual

npm i https://pkg.pr.new/@tanstack/svelte-virtual@1173

@tanstack/virtual-core

npm i https://pkg.pr.new/@tanstack/virtual-core@1173

@tanstack/vue-virtual

npm i https://pkg.pr.new/@tanstack/vue-virtual@1173

commit: bc6a315

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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

@@ -0,0 +1,218 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In React 19, does react-dom/clientprovide a default export, or shouldcreateRoot be imported as a named export?

💡 Result:

React 19: import createRoot from react-dom/client as a named export (not a default export). Use: import { createRoot } from 'react-dom/client'; The official React reference for createRoot shows this named-import form [1]. The public TypeScript type definitions for react-dom/client declare createRoot as an exported function (named export) rather than a default export [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="examples/react/chat/src/main.tsx"
echo "== File =="
ls -la "$file"

echo
echo "== Line 1-40 =="
sed -n '1,40p' "$file" | nl -ba

echo
echo "== Around import line (1-5) =="
sed -n '1,8p' "$file" | nl -ba

echo
echo "== Around line 110-160 (lifecycle concern) =="
sed -n '105,160p' "$file" | nl -ba

echo
echo "== Around line 115-145 (extra context) =="
sed -n '115,145p' "$file" | nl -ba

echo
echo "== Search for didInitialScroll, autoHistoryEnabled, autoHistory, useLayoutEffect, timeout =="
rg -n "didInitialScroll|autoHistoryEnabled|autoHistory|useLayoutEffect|setTimeout|clearTimeout" "$file" || true

Repository: TanStack/virtual

Length of output: 232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="examples/react/chat/src/main.tsx"
echo "== File =="
ls -la "$file"

echo
echo "== Lines 1-60 =="
sed -n '1,60p' "$file" | cat -n

echo
echo "== Lines 100-170 =="
sed -n '100,170p' "$file" | cat -n

echo
echo "== Around import (lines 1-10) =="
sed -n '1,10p' "$file" | cat -n

echo
echo "== Search for key state/effect/timeout symbols =="
rg -n "didInitialScroll|autoHistoryEnabled|autoHistory|useLayoutEffect|setTimeout|clearTimeout|scrollTo" "$file" || true

echo
echo "== Verify render/createRoot usage =="
rg -n "createRoot|ReactDOM" "$file" || true

Repository: TanStack/virtual

Length of output: 6146


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="examples/react/chat/src/main.tsx"
rg -n "setAutoHistoryEnabled|autoHistoryEnabled" "$file"

Repository: TanStack/virtual

Length of output: 252


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="examples/react/chat/src/main.tsx"
rg -n "ReactDOM" "$file" || true

Repository: TanStack/virtual

Length of output: 176


Fix react-dom/client usage and ensure auto-history enablement in the React chat example

  • react-dom/client is default-imported as ReactDOM, but createRoot is a named export; switch to import { createRoot } ... and update the render call.
  • The useLayoutEffect sets didInitialScroll immediately, which causes the effect to rerun and clears the pending setTimeout before setAutoHistoryEnabled(true) can execute, leaving autoHistoryEnabled false.
Suggested fix
-import ReactDOM from 'react-dom/client'
+import { createRoot } from 'react-dom/client'
@@
-ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
+createRoot(document.getElementById('root')!).render(<App />)
🤖 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/src/main.tsx` at 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.

Comment on lines +123 to +133
React.useLayoutEffect(() => {
if (didInitialScroll) return
virtualizer.scrollToEnd()
setDidInitialScroll(true)

const id = window.setTimeout(() => {
setAutoHistoryEnabled(true)
}, 250)

return () => window.clearTimeout(id)
}, [didInitialScroll, virtualizer])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In React, when a useLayoutEffect dependency changes, does the previous cleanup run before the next effect callback?

💡 Result:

Yes. When the dependency array of a useLayoutEffect changes, React runs the previous effect’s cleanup function first (with the old dependency values), and then runs the next useLayoutEffect setup callback (with the new dependency values) after the commit with the updated DOM.[1] React describes the same ordering for effects generally: “After every commit with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values.”[2]

Citations:


autoHistoryEnabled timer is cleared before it can fire. (examples/react/chat/src/main.tsx:123-133)

React runs the previous effect cleanup before the next effect callback when dependencies change. Since this effect sets didInitialScroll inside the same run, the effect re-runs immediately and the cleanup clears the timeout before the 250ms elapses, so setAutoHistoryEnabled(true) likely never runs.

The suggested split into a separate useEffect still depends on didInitialScroll, so when didInitialScroll flips to true it will cancel the newly-created timeout as well.

Fix by scheduling the timeout in an effect that doesn’t re-run/cleanup on the didInitialScroll change (e.g., run once and clear on unmount), or delay the didInitialScroll update until after the timeout completes.

🤖 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/src/main.tsx` around lines 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.

if (!container) throw new Error('Container not found')
container.scrollTop = 350
})
await page.waitForTimeout(100)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify fixed sleeps are removed from Playwright chat e2e specs
rg -nP --type=ts '\bwaitForTimeout\s*\(' packages/react-virtual/e2e/app/test

Repository: TanStack/virtual

Length of output: 2348


Replace fixed page.waitForTimeout(100) sleeps in chat.spec.ts
packages/react-virtual/e2e/app/test/chat.spec.ts still contains multiple fixed sleeps (await page.waitForTimeout(100)) at lines 53, 58, 78, and 87. These can race and flake under load; replace them with state-based waits (e.g., expect.poll on scroll container scrollTop/scrollHeight or relevant visibility) after each scroll/click action.

🤖 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` at 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.

Comment on lines +1527 to +1536
getDistanceFromEnd = () => {
return Math.max(
this.getTotalSize() - this.getSize() - this.getScrollOffset(),
0,
)
}

isAtEnd = (threshold = this.options.scrollEndThreshold) => {
return this.getDistanceFromEnd() <= threshold
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the real max scroll offset for end detection.

scrollToEnd() and last-item 'end' alignment already route through getMaxScrollOffset(), but isAtEnd() still compares against getTotalSize() - getSize(). If the DOM scroll extent is larger than the virtual total (for example container padding/borders or window scrolling), a viewport can be physically pinned at the bottom and still fail isAtEnd(), which disables followOnAppend and the end-pinned resize correction path.

Suggested fix
  getDistanceFromEnd = () => {
-    return Math.max(
-      this.getTotalSize() - this.getSize() - this.getScrollOffset(),
-      0,
-    )
+    const maxScrollOffset = this.scrollElement
+      ? this.getMaxScrollOffset()
+      : Math.max(this.getTotalSize() - this.getSize(), 0)
+
+    return Math.max(maxScrollOffset - this.getScrollOffset(), 0)
  }

A regression where scrollHeight - clientHeight exceeds getTotalSize() - getSize() would lock this down.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getDistanceFromEnd = () => {
return Math.max(
this.getTotalSize() - this.getSize() - this.getScrollOffset(),
0,
)
}
isAtEnd = (threshold = this.options.scrollEndThreshold) => {
return this.getDistanceFromEnd() <= threshold
}
getDistanceFromEnd = () => {
const maxScrollOffset = this.scrollElement
? this.getMaxScrollOffset()
: Math.max(this.getTotalSize() - this.getSize(), 0)
return Math.max(maxScrollOffset - this.getScrollOffset(), 0)
}
isAtEnd = (threshold = this.options.scrollEndThreshold) => {
return this.getDistanceFromEnd() <= threshold
}
🤖 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 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.

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