Describe the bug
Following the documented MessageScroller pattern exactly — marking every user turn as a scroll anchor with scrollAnchor={message.role === "user"} and mounting an overflowing transcript with defaultScrollPosition="end" — the viewport jumps up to an old, unrelated message the instant a transient row (e.g. a "Typing…" indicator) is replaced by a new row in the same commit, and it does not recover on its own.
Mechanism: anchor rows present at initial mount are never added to the library's handled-anchors bookkeeping — the previousItemCount === 0 mount path that honors defaultScrollPosition never touches that set. Later, when a childList mutation happens to leave the item count unchanged (a same-commit row swap), handleContentChange's "item count unchanged" branch treats every mount-time anchor as newly appeared and re-anchors the viewport to the oldest one it has never seen via scrollToElement(anchor, { align: "start" }) — yanking the reader away from the row they were just looking at. This defeats the anchor system's stated purpose of never moving the viewport against the reader's intent: a purely internal, content-neutral swap (no new anchor actually appeared) does exactly that.
This reproduces with the headless primitives alone (MessageScroller.Provider/Root/Viewport/Content/Item, no shadcn/ui styling) — see the complete inline repro below. It also stays wrong indefinitely in that minimal app, since there's no coincidental later mutation to re-anchor it back.
Source-level analysis (packages/react/src/message-scroller/, current main):
use-message-scroller-controller.ts → applyDefaultScrollPosition() (~L317) scrolls per defaultScrollPosition but never adds the anchors present at mount to handledScrollAnchorsRef — in any mode ("end", "start", or "last-anchor").
use-message-scroller-controller.ts → reconcileScrollPosition(), the items.length === previousItemCount branch (~L456): calls getUnanchoredScrollAnchor(items, handledScrollAnchorsRef.current) and scrolls the result to align: "start" unconditionally — no mode/autoScroll check, no distinction between a row that just became an anchor and one that has been sitting above the viewport since mount.
geometry.ts → getUnanchoredScrollAnchor() (~L130) iterates items in document order from the first row, so it returns the oldest unhandled anchor — which is why the viewport jumps toward the top of the transcript.
Suggested fix direction: seed handledScrollAnchorsRef with all current anchors when the first-content path / applyDefaultScrollPosition() runs (mirroring the handledScrollAnchorsRef.current.add(anchor) the append and same-count branches already do), so the same-count branch only ever sees anchors that genuinely appeared after mount.
Searched first: related to but distinct from #11067 (closed — autoScroll behavior after anchoring) and #11125 (open — manual scroll during an anchored turn destroys the tail spacer); neither covers mount-time anchors never being registered. No open PR touches anchor registration. The bug is byte-identical in 0.2.0 and 0.2.1 (not a 0.2.1 regression).
Affected component/components
message-scroller (@shadcn/react)
How to reproduce
- Create a Vite + React 19 + TypeScript app and install
@shadcn/react@0.2.1.
- Replace
src/App.tsx and src/main.tsx with the code in the collapsed section below: 20 seeded, alternating user/assistant rows tall enough to overflow a fixed-height viewport, MessageScroller.Provider autoScroll defaultScrollPosition="end", and every user row marked scrollAnchor per the docs' own scrollAnchor={message.role === "user"} pattern. A "Send" button appends a new user row (scrollAnchor) + a transient "Typing…" row, then ~800ms later replaces the "Typing…" row with a reply row in one state update (same item count before/after).
npm install && npm run dev, open the app. It loads pre-scrolled to the bottom (following-bottom mode); the on-page readout shows the last seed row (seed-15 in the captured run) at the viewport's top edge.
- Click Send. The viewport correctly follows the new user row + typing row to the bottom (
seed-19 now at the top edge).
- ~800ms later the "Typing…" row is replaced by the reply row.
- Observe: the viewport jumps up and pins an old seed message (
seed-1 in the captured run) to the top edge, pushing the just-sent turn and its reply hundreds of pixels below the fold — and it stays there; nothing in this minimal app ever re-anchors it back.
Full repro code (src/App.tsx + src/main.tsx)
src/App.tsx:
import { MessageScroller } from "@shadcn/react/message-scroller"
import { useEffect, useRef, useState } from "react"
type Role = "user" | "assistant" | "typing"
interface Msg {
id: string
role: Role
text: string
}
const SEED_COUNT = 20
function makeSeed(): Msg[] {
const msgs: Msg[] = []
for (let i = 0; i < SEED_COUNT; i++) {
const role: Role = i % 2 === 0 ? "user" : "assistant"
msgs.push({
id: `seed-${i}`,
role,
text:
role === "user"
? `Seed user message #${i}. Padded out with a bit of extra text so ` +
`each row takes up a realistic amount of vertical space, the ` +
`same way a real chat message would.`
: `Seed assistant reply #${i}. Likewise padded out with a couple ` +
`of sentences of filler content so the 20 seeded rows overflow ` +
`the fixed-height viewport below and the page loads pre-scrolled.`,
})
}
return msgs
}
function Row({ role, text }: { role: Role; text: string }) {
const isUser = role === "user"
const isTyping = role === "typing"
return (
<div
style={{
maxWidth: "70%",
marginInlineStart: isUser ? "auto" : 0,
marginInlineEnd: isUser ? 0 : "auto",
padding: "12px 16px",
borderRadius: 10,
background: isTyping ? "#fef3c7" : isUser ? "#dbeafe" : "#e2e8f0",
border: "1px solid #94a3b8",
fontSize: 14,
lineHeight: 1.5,
}}
>
{text}
</div>
)
}
export default function App() {
const [messages, setMessages] = useState<Msg[]>(() => makeSeed())
const [sendCount, setSendCount] = useState(0)
const [topEdgeId, setTopEdgeId] = useState<string | null>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const timerRef = useRef<number | undefined>(undefined)
// Visible readout: which row sits at the viewport's top edge right now,
// via `document.elementFromPoint`. Runs on a light rAF loop so it also
// catches the library's own *programmatic* scrolls, not just user input.
useEffect(() => {
let raf = 0
const probe = () => {
const vp = viewportRef.current
if (vp) {
const rect = vp.getBoundingClientRect()
// Probe a few y-offsets from the top edge downward: with a flex
// `gap` between rows, a single fixed offset can land exactly in an
// inter-row gap (which resolves to the Content div, not a row) —
// this still reports the row actually pinned at the top edge.
let row: HTMLElement | null = null
for (const dy of [2, 8, 16, 24, 32]) {
const el = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + dy,
)
row = el?.closest<HTMLElement>("[data-message-id]") ?? null
if (row) break
}
const id = row?.getAttribute("data-message-id") ?? null
setTopEdgeId((prev) => (prev === id ? prev : id))
}
raf = requestAnimationFrame(probe)
}
raf = requestAnimationFrame(probe)
return () => cancelAnimationFrame(raf)
}, [])
// Log every top-edge transition to the console for copy-pasteable evidence.
useEffect(() => {
console.log(
`[top-edge] viewport top-edge row is now: ${topEdgeId ?? "(none)"} ` +
`(scrollTop=${viewportRef.current?.scrollTop})`,
)
}, [topEdgeId])
useEffect(
() => () => {
window.clearTimeout(timerRef.current)
},
[],
)
const handleSend = () => {
const n = sendCount + 1
setSendCount(n)
const userId = `sent-user-${n}`
const typingId = `typing-${n}`
console.log(`\n=== MARK: SEND #${n} click ===`)
console.log(
`[before-send] scrollTop=${viewportRef.current?.scrollTop} ` +
`topEdge=${topEdgeId}`,
)
// Step 1: append a user row (scrollAnchor) + a transient "typing…" row.
// This is a genuine +2 append — the library's append branch registers
// the new user row as a handled anchor.
setMessages((prev) => [
...prev,
{ id: userId, role: "user", text: `My follow-up message #${n}` },
{ id: typingId, role: "typing", text: "Typing…" },
])
// Step 2: ~800ms later, replace the typing row with a reply row IN ONE
// state update. Same array length before/after -> net-zero childList
// mutation -> library's "item count unchanged" branch fires and
// re-anchors to the oldest anchor it has never registered.
timerRef.current = window.setTimeout(() => {
console.log(`=== MARK: SEND #${n} reply arrives (net-zero swap) ===`)
setMessages((prev) =>
prev.map((m) =>
m.id === typingId
? {
id: `reply-${n}`,
role: "assistant",
text: `Reply to follow-up #${n}, arriving in one shot.`,
}
: m,
),
)
}, 800)
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100vh",
fontFamily: "system-ui, sans-serif",
}}
>
<div
style={{
padding: "8px 16px",
borderBottom: "1px solid #94a3b8",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<strong>MessageScroller anchor-mount repro (@shadcn/react@0.2.1)</strong>
<button
type="button"
onClick={handleSend}
style={{ padding: "6px 16px", fontSize: 14 }}
>
Send
</button>
</div>
<div
style={{
padding: "6px 16px",
background: "#fef3c7",
borderBottom: "1px solid #94a3b8",
fontSize: 13,
}}
data-testid="top-edge-readout"
>
Viewport top-edge row: <code>{topEdgeId ?? "(none)"}</code>
</div>
<MessageScroller.Provider autoScroll defaultScrollPosition="end">
<MessageScroller.Root
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<MessageScroller.Viewport
ref={viewportRef}
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
background: "#f8fafc",
}}
>
<MessageScroller.Content
style={{
display: "flex",
flexDirection: "column",
gap: 12,
padding: 16,
}}
>
{messages.map((msg) => (
// Docs pattern, verbatim: scrollAnchor={message.role === "user"}
<MessageScroller.Item
key={msg.id}
messageId={msg.id}
scrollAnchor={msg.role === "user"}
>
<Row role={msg.role} text={msg.text} />
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
</MessageScroller.Root>
</MessageScroller.Provider>
</div>
)
}
src/main.tsx (instrumentation that produced the Logs section below — optional for reproducing the bug visually, required for the log evidence):
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App"
// Logs every `Element.scrollTo(...)` call and every `element.scrollTop = x`
// write, with a short call stack, so the console output makes it obvious
// whether a scroll jump came from app code or from inside the library.
const nativeScrollTo = Element.prototype.scrollTo
Element.prototype.scrollTo = function patchedScrollTo(
this: Element,
...args: Parameters<typeof nativeScrollTo>
) {
const stack =
new Error().stack
?.split("\n")
.slice(2, 7)
.map((line) => line.trim())
.join(" <- ") ?? "(no stack)"
const arg = args[0]
const desc =
arg && typeof arg === "object" ? JSON.stringify(arg) : String(arg)
console.log(`[scrollTo] ${desc}\n stack: ${stack}`)
return nativeScrollTo.apply(this, args)
}
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
"scrollTop",
)
if (scrollTopDescriptor?.get && scrollTopDescriptor.set) {
const nativeGet = scrollTopDescriptor.get
const nativeSet = scrollTopDescriptor.set
Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true,
enumerable: scrollTopDescriptor.enumerable,
get(this: Element) {
return nativeGet.call(this)
},
set(this: Element, value: number) {
const stack =
new Error().stack
?.split("\n")
.slice(2, 7)
.map((line) => line.trim())
.join(" <- ") ?? "(no stack)"
console.log(`[scrollTop=] ${value}\n stack: ${stack}`)
nativeSet.call(this, value)
},
})
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
package.json dependencies: @shadcn/react@0.2.1, react@19.2.3, react-dom@19.2.3, devDependencies @vitejs/plugin-react@^4.3.4, vite@^6.0.5, typescript@^5.7.2.
Codesandbox/StackBlitz link
The complete, runnable reproduction is inline above — a single App.tsx dropped into the stock Vite React-TS template with one pinned dependency (@shadcn/react@0.2.1); main.tsx only adds optional logging. Happy to publish it as a StackBlitz or a public repo on request if that's preferred.
Logs
Captured from the verified repro run (vite dev server, Chromium, Element.prototype.scrollTo / scrollTop instrumented per main.tsx above). Copy-paste text, unedited except for trimming the Vite dev-bundle query-string suffix (?v=dd20ab18) for readability:
[top-edge] viewport top-edge row is now: seed-15 (scrollTop=1518)
=== MARK: SEND #1 click ===
[before-send] scrollTop=1518 topEdge=seed-15
[scrollTo] {"top":1956,"behavior":"auto"}
stack: at node_modules/.vite/deps/@shadcn_react_message-scroller.js:199:21 <- at .../message-scroller.js:207:153 <- at .../message-scroller.js:329:11 <- at .../message-scroller.js:341:7 <- at MutationObserver.<anonymous> (.../message-scroller.js:478:7)
[top-edge] viewport top-edge row is now: seed-19 (scrollTop=1956)
=== MARK: SEND #1 reply arrives (net-zero swap) ===
[scrollTo] {"top":138,"behavior":"auto"}
stack: at node_modules/.vite/deps/@shadcn_react_message-scroller.js:199:21 <- at .../message-scroller.js:207:153 <- at .../message-scroller.js:336:11 <- at .../message-scroller.js:341:7 <- at MutationObserver.<anonymous> (.../message-scroller.js:478:7)
[top-edge] viewport top-edge row is now: seed-1 (scrollTop=138)
# 3 seconds later, no further interaction — confirmed via
# document.querySelector('[role="region"]').scrollTop:
scrollTop === 138 # still stuck on seed-1; never self-corrects
Every scrollTo call's stack is 100% inside node_modules/.vite/deps/@shadcn_react_message-scroller.js (MutationObserver → handleContentChange → scrollToElement) — zero scroll calls originated in the app's own code.
System Info
OS: macOS 26.5.1 (BuildVersion 25F80)
Browser: Chromium 149.0.0.0 (bug is plain DOM/MutationObserver logic, browser-independent)
Node: v26.3.0
React: 19.2.3
@shadcn/react: 0.2.1 (bug byte-identical in 0.2.0 per source diff — not a 0.2.1 regression)
Vite: 6.4.3
Before submitting
Describe the bug
Following the documented
MessageScrollerpattern exactly — marking every user turn as a scroll anchor withscrollAnchor={message.role === "user"}and mounting an overflowing transcript withdefaultScrollPosition="end"— the viewport jumps up to an old, unrelated message the instant a transient row (e.g. a "Typing…" indicator) is replaced by a new row in the same commit, and it does not recover on its own.Mechanism: anchor rows present at initial mount are never added to the library's handled-anchors bookkeeping — the
previousItemCount === 0mount path that honorsdefaultScrollPositionnever touches that set. Later, when a childList mutation happens to leave the item count unchanged (a same-commit row swap),handleContentChange's "item count unchanged" branch treats every mount-time anchor as newly appeared and re-anchors the viewport to the oldest one it has never seen viascrollToElement(anchor, { align: "start" })— yanking the reader away from the row they were just looking at. This defeats the anchor system's stated purpose of never moving the viewport against the reader's intent: a purely internal, content-neutral swap (no new anchor actually appeared) does exactly that.This reproduces with the headless primitives alone (
MessageScroller.Provider/Root/Viewport/Content/Item, no shadcn/ui styling) — see the complete inline repro below. It also stays wrong indefinitely in that minimal app, since there's no coincidental later mutation to re-anchor it back.Source-level analysis (
packages/react/src/message-scroller/, currentmain):use-message-scroller-controller.ts→applyDefaultScrollPosition()(~L317) scrolls perdefaultScrollPositionbut never adds the anchors present at mount tohandledScrollAnchorsRef— in any mode ("end","start", or"last-anchor").use-message-scroller-controller.ts→reconcileScrollPosition(), theitems.length === previousItemCountbranch (~L456): callsgetUnanchoredScrollAnchor(items, handledScrollAnchorsRef.current)and scrolls the result toalign: "start"unconditionally — no mode/autoScroll check, no distinction between a row that just became an anchor and one that has been sitting above the viewport since mount.geometry.ts→getUnanchoredScrollAnchor()(~L130) iteratesitemsin document order from the first row, so it returns the oldest unhandled anchor — which is why the viewport jumps toward the top of the transcript.Suggested fix direction: seed
handledScrollAnchorsRefwith all current anchors when the first-content path /applyDefaultScrollPosition()runs (mirroring thehandledScrollAnchorsRef.current.add(anchor)the append and same-count branches already do), so the same-count branch only ever sees anchors that genuinely appeared after mount.Searched first: related to but distinct from #11067 (closed —
autoScrollbehavior after anchoring) and #11125 (open — manual scroll during an anchored turn destroys the tail spacer); neither covers mount-time anchors never being registered. No open PR touches anchor registration. The bug is byte-identical in 0.2.0 and 0.2.1 (not a 0.2.1 regression).Affected component/components
message-scroller (
@shadcn/react)How to reproduce
@shadcn/react@0.2.1.src/App.tsxandsrc/main.tsxwith the code in the collapsed section below: 20 seeded, alternating user/assistant rows tall enough to overflow a fixed-height viewport,MessageScroller.Provider autoScroll defaultScrollPosition="end", and every user row markedscrollAnchorper the docs' ownscrollAnchor={message.role === "user"}pattern. A "Send" button appends a new user row (scrollAnchor) + a transient "Typing…" row, then ~800ms later replaces the "Typing…" row with a reply row in one state update (same item count before/after).npm install && npm run dev, open the app. It loads pre-scrolled to the bottom (following-bottom mode); the on-page readout shows the last seed row (seed-15in the captured run) at the viewport's top edge.seed-19now at the top edge).seed-1in the captured run) to the top edge, pushing the just-sent turn and its reply hundreds of pixels below the fold — and it stays there; nothing in this minimal app ever re-anchors it back.Full repro code (src/App.tsx + src/main.tsx)
src/App.tsx:src/main.tsx(instrumentation that produced the Logs section below — optional for reproducing the bug visually, required for the log evidence):package.jsondependencies:@shadcn/react@0.2.1,react@19.2.3,react-dom@19.2.3, devDependencies@vitejs/plugin-react@^4.3.4,vite@^6.0.5,typescript@^5.7.2.Codesandbox/StackBlitz link
The complete, runnable reproduction is inline above — a single
App.tsxdropped into the stock Vite React-TS template with one pinned dependency (@shadcn/react@0.2.1);main.tsxonly adds optional logging. Happy to publish it as a StackBlitz or a public repo on request if that's preferred.Logs
Captured from the verified repro run (
vitedev server, Chromium,Element.prototype.scrollTo/scrollTopinstrumented permain.tsxabove). Copy-paste text, unedited except for trimming the Vite dev-bundle query-string suffix (?v=dd20ab18) for readability:Every
scrollTocall's stack is 100% insidenode_modules/.vite/deps/@shadcn_react_message-scroller.js(MutationObserver → handleContentChange → scrollToElement) — zero scroll calls originated in the app's own code.System Info
Before submitting