Skip to content

[bug]: MessageScroller: mount-time anchors are never registered, so a same-count content swap re-anchors to a stale message #11128

Description

@medihack

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

  1. Create a Vite + React 19 + TypeScript app and install @shadcn/react@0.2.1.
  2. 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).
  3. 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.
  4. Click Send. The viewport correctly follows the new user row + typing row to the bottom (seed-19 now at the top edge).
  5. ~800ms later the "Typing…" row is replaced by the reply row.
  6. 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

  • I've made research efforts and searched the documentation
  • I've searched for existing issues

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions