snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
# MessageScroller — performance
MessageScroller is **not virtualized** by default. It renders every message as a real DOM
node and tracks scroll position with geometry reads on the scroll, resize, and
content-change paths. The question this doc answers — with a runnable benchmark,
not assertions of faith — is whether that design holds up for long, markdown-rich
transcripts.
**Verdict: yes, with wide headroom.** A scroll commit on 1000 messages blocks the
main thread for under 1ms; the same on 600 heavy-markdown items (64k DOM nodes)
is well under 1ms with zero dropped frames; and while a heavy reply streams in,
the scroller's own per-token work is ~1–2ms — a fraction of a frame on top of the
markdown render. The limiting factor at extreme scale is the un-virtualized row
count itself, not the scroller's work.
## Running it
```bash
# from packages/react
pnpm test:browser src/message-scroller/message-scroller.perf.browser.test.tsx
```
It runs in real Chromium via Playwright (`vitest.browser.config.ts`) and prints a
numbers table per layer. **It must run in a real browser** — the entire cost here
is forced layout, and jsdom stubs `getBoundingClientRect` to a free property
read, so a jsdom "benchmark" would measure nothing real. The file matches the
`*.browser.test.tsx` glob, so it also runs in CI (`browser-tests.yml`) and
doubles as a regression guard.
**The suite runs outside React's act environment.** Both browser test files set
`IS_REACT_ACT_ENVIRONMENT = false`. This component is driven by the event loop —
`requestAnimationFrame`, `ResizeObserver`, `IntersectionObserver` — which `act()`
cannot wrap, so the tests flush the renders they time with `flushSync` and settle
async scroll work on real frames. Do not reintroduce `act()` here: it re-adds the
spurious "update not wrapped in act(...)" warnings from the rAF-driven visibility
subscriber without changing what is measured.
## Why this is the right thing to measure
The scroller's hot-path cost is not the `getBoundingClientRect` _call_ — it is the
**forced synchronous reflow** that call triggers when layout is dirty. The
benchmark is built around two facts about when layout is and isn't dirty:
- **Scrolling does not dirty layout.** Changing `scrollTop` is a scroll-offset
change, not a layout invalidation, so during steady scroll `getBoundingClientRect`
reads already-computed boxes. Cost tracks the _number of items iterated_ (one
rect read per top-level row in `getContentBottom`), and is nearly blind to how
deep each row's subtree is. This is why heavy markdown barely changes the scroll
numbers.
- **Growth dirties layout.** When a streaming reply gets taller, the next geometry
read pays for a real reflow. That is the only path where DOM weight can bite, so
it gets its own layer and an A/B isolation against a plain scroll div.
## The three layers
### Layer 1 — algorithmic scaling (isolated)
Calls `getMessageScrollerScrollable` (which drives the O(n) `getContentBottom` scan) in
a tight loop against a real laid-out DOM, toggling `scrollTop` each iteration to
force the reflow that real scrolling forces. Answers: how does one scroll commit
scale with row count?
### Layer 2 — real scroll under load (the integration proof)
Mounts a large transcript with scroll-state and visibility both subscribed (a real
app), drives a top-to-bottom scroll, and times the **synchronous scroll handler**
(`handleScroll → syncAfterScroll → commitScrollState → getContentBottom` runs
inside `dispatchEvent`, so timing the dispatch is timing the exact main-thread
block a user scroll causes). Counts long tasks. Run both with trivial rows and
with heavy markdown rows.
### Layer 3 — streaming (the real-chat worst case)
Streams an assistant reply token-by-token at the bottom of a long transcript of
**memoized** heavy turns (so only the growing reply re-renders, like a real app).
Runs the identical growth inside MessageScroller (auto-follow) and inside a plain
scroll `<div>` (manual follow); the **delta** isolates the scroller's per-token
cost from the markdown's own render cost.
## Indicative numbers
> Measured on Apple Silicon, headless Chromium (Playwright), 2026-06. These are
> **indicative, not guarantees** — absolute timings drift with hardware and CI.
> The durable contract is the budget assertions below; the test is the source of
> truth. Re-run to get numbers for your machine.
**Layer 1 — per-commit cost vs. row count (trivial rows):**
| messages | median | p95 | max |
| -------- | ------ | ----- | ----- |
| 100 | 0.1ms | 0.1ms | 0.7ms |
| 500 | 0.3ms | 0.3ms | 1.2ms |
| 1000 | 0.5ms | 0.6ms | 1.9ms |
| 2000 | 1.1ms | 1.2ms | 4.6ms |
Linear in row count with a tiny constant. Extrapolated, a single commit alone
would not reach one 16ms frame until ~30,000 rendered rows.
**Layer 1 — per-commit cost vs. DOM weight (heavy markdown):**
| turns | items | DOM nodes | median | p95 |
| ----- | ----- | --------- | ------ | ----- |
| 50 | 100 | 10,509 | 0.1ms | 0.2ms |
| 150 | 300 | 31,996 | 0.2ms | 0.3ms |
| 300 | 600 | 64,135 | 0.4ms | 0.5ms |
64k DOM nodes barely moves the cost — it tracks item count, not subtree depth,
because scrolling doesn't dirty layout (see above).
**Layer 2 — real top-to-bottom scroll:**
| scenario | handler median | handler p95 | handler max | long tasks |
| --------------------------- | -------------- | ----------- | ----------- | ---------- |
| 1000 trivial messages | 0.9ms | 1.2ms | 2.1ms | 0 |
| 300 heavy turns (64k nodes) | 0.6ms | 0.8ms | 0.9ms | 0 |
**Layer 3 — streaming a heavy reply (per-token commit, `flushSync`-timed):**
| tree | median | p95 | max | long tasks |
| ---------------------- | ---------- | ----- | ----- | ---------- |
| plain `<div>` baseline | 1.2ms | 2.2ms | 2.3ms | 0 |
| MessageScroller | 3.2ms | 5.7ms | 8.3ms | 0 |
| **scroller overhead** | **~1–2ms** | | | |
Each token re-renders the growing reply; `flushSync` times that commit plus the
scroller's `handleContentChange` layout effect (re-scan items, follow-bottom).
The overhead over a plain `<div>` is the scroller's synchronous content-change
work — a consistent **~1–2ms per token**, a fraction of a frame, with zero long
tasks. (An earlier `act()`-wrapped version of this layer timed the whole async
flush and spuriously reported ~0/negative overhead; the `flushSync` timing is the
honest one.) The per-token total is still dominated by the markdown render, which
is the message component's cost, not the scroller's.
## Preventing regressions
The suite runs in CI on every PR that touches `packages/react/**`
(`.github/workflows/browser-tests.yml`). It guards against regressions two
ways, in order of strength:
### 1. Deterministic guard (the real one)
`getBoundingClientRect` is spied during a single scroll commit and the call count
is asserted to be **O(items), not O(items²)** — currently ~1.0 reads per item
(2030 reads for 2000 items). This count is **machine-independent and has zero
variance**, so it can be a tight bound: the classic perf regression (a nested rect
loop, an accidental per-item `getComputedStyle`) makes the per-item ratio scale
with item count and blows past the bound deterministically, where a timing test
would just shrug. This is the assertion to trust.
### 2. Timing budgets (coarse backstop)
Wall-clock assertions catch a catastrophic slowdown, but they are
hardware-dependent, so they carry generous slack and only flag multi-× blowups:
- Layer 1: per-commit median at the largest row count `< 16ms` (one frame).
- Layer 2: scroll-handler median `< 16ms`, p95 `< 32ms`.
- Layer 3: scroller per-token median `< 16ms`, and overhead over baseline `< 16ms`.
Do not tighten the timing budgets toward the indicative numbers — CI runner noise
will make them flake. If you want an earlier warning on gradual creep, prefer
adding more invariants to the deterministic guard (more counts, scaling-ratio
checks between two row counts measured in the same run) over shrinking the ms
thresholds.
## Boundaries and non-goals
- **No virtualization.** Cost is O(rendered rows). Realistic transcripts (hundreds
to low thousands of turns) have enormous headroom; tens of thousands of _live_
rows would eventually make `getContentBottom`'s all-items scan matter. The fix is
already scoped if ever needed: the last non-spacer child carries the max bottom,
so the scan can collapse to O(1).
- **Markdown render cost is out of scope.** Layer 3 shows per-token cost is the
content renderer's, not the scroller's. Mitigations (memoizing history, batching
tokens, virtualizing code blocks) belong to the message components.
- **Timings are machine-relative.** Don't copy these numbers into user-facing docs
as promises; cite the methodology and re-run.

View File

@@ -0,0 +1,73 @@
# MessageScroller
Headless scroll container for chat transcripts. Owns scroll position, anchoring,
auto-follow, and visibility tracking so message components can stay presentational.
## Usage
```tsx
import {
MessageScroller,
useMessageScroller,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
;<MessageScroller.Provider autoScroll>
<MessageScroller.Root>
<MessageScroller.Viewport>
<MessageScroller.Content>
<MessageScroller.Item messageId="m1" scrollAnchor>
…
</MessageScroller.Item>
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller.Root>
</MessageScroller.Provider>
```
## Exports
All from `@shadcn/react/message-scroller`.
### Parts
| Part | Role | Notable props |
| -------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `MessageScroller.Provider` | Headless root; owns scroll state, anchoring, auto-follow, and visibility | `autoScroll`, `defaultScrollPosition`, `scrollPreviousItemPeek`, `scrollMargin`, `scrollEdgeThreshold` |
| `MessageScroller.Root` | Styled frame; lays out the viewport, content, and controls in the provider | `data-pending-scroll` until the opening position applies |
| `MessageScroller.Viewport` | Scrollable frame | `preserveScrollOnPrepend`; `data-pending-scroll` until the opening position applies |
| `MessageScroller.Content` | Message list; defaults `role="log"` + `aria-relevant="additions"` | — |
| `MessageScroller.Item` | One message wrapper | `messageId`, `scrollAnchor` |
| `MessageScroller.Button` | Scroll-to-end/start affordance; auto-hides when caught up | `direction` |
### Hooks (flat siblings)
| Hook | Returns |
| -------------------------------- | --------------------------------------------------------------------------- |
| `useMessageScroller()` | `{ scrollToMessage, scrollToStart, scrollToEnd }` |
| `useMessageScrollerScrollable()` | `MessageScrollerScrollable` — `{ start, end }`, the edges the viewport can scroll toward |
| `useMessageScrollerVisibility()` | `MessageScrollerVisibilityState` — `currentAnchorId`, `visibleMessageIds` |
### Types
| Type | Meaning |
| -------------------------------------- | --------------------------------------------------------------------- |
| `MessageScrollerScrollable` | result of `useMessageScrollerScrollable()` |
| `MessageScrollerVisibilityState` | result of `useMessageScrollerVisibility()` |
| `MessageScrollerScrollOptions` | options for the scroll commands (`align`, `behavior`, `scrollMargin`) |
| `MessageScrollerScrollAlign` | `"start" \| "center" \| "end" \| "nearest"` |
| `MessageScrollerDefaultScrollPosition` | `"start" \| "end" \| "last-anchor"` |
## Tests
| File | Environment | Covers |
| ---------------------------------------- | ----------- | -------------------------------------------------------------------------- |
| `geometry.test.ts` | jsdom | Geometry math with stubbed rects. |
| `message-scroller.browser.test.tsx` | chromium | Behavior jsdom can't model (native scroll anchoring, prepend, visibility). |
| `message-scroller.perf.browser.test.tsx` | chromium | Performance benchmark + regression guard. |
```bash
pnpm test # unit (jsdom)
pnpm test:browser # behavior + performance (chromium)
```

View File

@@ -0,0 +1,446 @@
import * as React from "react"
import { composeRefs, mergeProps, useRender } from "../use-render"
import { USER_SCROLL_KEYS } from "./types"
import type {
MessageScrollerButtonProps,
MessageScrollerContentProps,
MessageScrollerContextValue,
MessageScrollerItemProps,
MessageScrollerProps,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerViewportProps,
} from "./types"
import { useMessageScrollerController } from "./use-message-scroller-controller"
import { useLatest } from "./utils"
const MessageScrollerContext =
React.createContext<MessageScrollerContextValue | null>(null)
const MessageScrollerItemContext =
React.createContext<MessageScrollerRegisterMessage | null>(null)
function useMessageScrollerContext() {
const context = React.useContext(MessageScrollerContext)
if (!context) {
throw new Error("useMessageScroller must be used within a MessageScroller.")
}
return context
}
function useMessageScrollerItemContext() {
const context = React.useContext(MessageScrollerItemContext)
if (!context) {
throw new Error(
"MessageScrollerItem must be used within a MessageScroller."
)
}
return context
}
function useMessageScroller() {
const { scrollToEnd, scrollToMessage, scrollToStart } =
useMessageScrollerContext()
return React.useMemo(
() => ({
scrollToEnd,
scrollToMessage,
scrollToStart,
}),
[scrollToEnd, scrollToMessage, scrollToStart]
)
}
function useMessageScrollerScrollable() {
const { stateStore } = useMessageScrollerContext()
return React.useSyncExternalStore(
stateStore.subscribe,
stateStore.getSnapshot,
stateStore.getSnapshot
)
}
function useMessageScrollerVisibility() {
const { observeVisibility, unobserveVisibility, visibilityStore } =
useMessageScrollerContext()
const subscribe = React.useCallback(
(listener: () => void) =>
visibilityStore.subscribe(
listener,
observeVisibility,
unobserveVisibility
),
[observeVisibility, unobserveVisibility, visibilityStore]
)
return React.useSyncExternalStore(
subscribe,
visibilityStore.getSnapshot,
visibilityStore.getSnapshot
)
}
function MessageScrollerProvider({
autoScroll = false,
children,
defaultScrollPosition = "end",
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
}: MessageScrollerProviderProps) {
const { context, registerMessage } = useMessageScrollerController({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
})
return (
<MessageScrollerContext.Provider value={context}>
<MessageScrollerItemContext.Provider value={registerMessage}>
{children}
</MessageScrollerItemContext.Provider>
</MessageScrollerContext.Provider>
)
}
function usePendingDefaultScroll() {
const { pendingDefaultScrollStore } = useMessageScrollerContext()
return React.useSyncExternalStore(
pendingDefaultScrollStore.subscribe,
pendingDefaultScrollStore.getSnapshot,
pendingDefaultScrollStore.getSnapshot
)
}
function MessageScroller({ children, ...props }: MessageScrollerProps) {
const { setRootElement } = useMessageScrollerContext()
const pendingDefaultScroll = usePendingDefaultScroll()
return (
<div
ref={setRootElement}
{...props}
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
>
{children}
</div>
)
}
function MessageScrollerViewport({
"aria-label": ariaLabel,
children,
onKeyDown,
onScroll,
onTouchMove,
onWheel,
preserveScrollOnPrepend = true,
ref,
role,
tabIndex,
...props
}: MessageScrollerViewportProps) {
const {
handleResize,
preserveScrollOnPrependRef,
setViewportElement,
syncAfterScroll,
userScrollIntent,
viewportRef,
} = useMessageScrollerContext()
const pendingDefaultScroll = usePendingDefaultScroll()
preserveScrollOnPrependRef.current = preserveScrollOnPrepend
const setViewportRef = React.useCallback(
(element: HTMLDivElement | null) => {
setViewportElement(element)
composeRefs(ref)?.(element)
},
[ref, setViewportElement]
)
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
syncAfterScroll()
onScroll?.(event)
}
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
userScrollIntent()
onWheel?.(event)
}
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
userScrollIntent()
onTouchMove?.(event)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (USER_SCROLL_KEYS.has(event.key)) {
userScrollIntent()
}
onKeyDown?.(event)
}
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || typeof ResizeObserver === "undefined") {
return
}
// Coalesce into rAF: handleResize mutates the spacer inside the observed
// content, and resizing an observed element during delivery fires
// "ResizeObserver loop completed with undelivered notifications".
let frame = 0
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(handleResize)
})
observer.observe(viewport)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
}
}, [handleResize, viewportRef])
return (
<div
ref={setViewportRef}
role={role ?? "region"}
aria-label={ariaLabel ?? "Messages"}
tabIndex={tabIndex ?? 0}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
onTouchMove={handleTouchMove}
onWheel={handleWheel}
{...props}
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
>
{children}
</div>
)
}
function MessageScrollerContent({
"aria-relevant": ariaRelevant,
children,
ref,
role,
spacerClassName,
...props
}: MessageScrollerContentProps) {
const {
handleContentChange,
handleResize,
setContentElement,
setSpacerElement,
} = useMessageScrollerContext()
const contentRef = React.useRef<HTMLDivElement | null>(null)
const setContentRef = React.useCallback(
(element: HTMLDivElement | null) => {
contentRef.current = element
setContentElement(element)
composeRefs(ref)?.(element)
},
[ref, setContentElement]
)
React.useLayoutEffect(() => {
const content = contentRef.current
if (!content) {
return
}
handleContentChange()
if (typeof MutationObserver === "undefined") {
return
}
const observer = new MutationObserver(() => {
handleContentChange()
})
observer.observe(content, { childList: true })
return () => observer.disconnect()
}, [handleContentChange])
React.useEffect(() => {
const content = contentRef.current
if (!content || typeof ResizeObserver === "undefined") {
return
}
// Coalesce into rAF: handleResize mutates the spacer inside this observed
// element, and resizing an observed element during delivery fires
// "ResizeObserver loop completed with undelivered notifications".
let frame = 0
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(handleResize)
})
observer.observe(content)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
}
}, [handleResize])
return (
<div
ref={setContentRef}
role={role ?? "log"}
aria-relevant={ariaRelevant ?? "additions"}
{...props}
>
{children}
<div
ref={setSpacerElement}
aria-hidden="true"
data-message-scroller-spacer=""
hidden
className={spacerClassName}
/>
</div>
)
}
function MessageScrollerItem({
messageId,
ref,
scrollAnchor = false,
...props
}: MessageScrollerItemProps) {
const registerMessage = useMessageScrollerItemContext()
const elementRef = React.useRef<HTMLDivElement | null>(null)
const setItemRef = React.useCallback(
(element: HTMLDivElement | null) => {
const previousElement = elementRef.current
elementRef.current = element
if (messageId) {
registerMessage(messageId, element, previousElement)
}
composeRefs(ref)?.(element)
},
[messageId, ref, registerMessage]
)
return (
<div
ref={setItemRef}
data-message-id={messageId}
data-scroll-anchor={scrollAnchor ? "true" : "false"}
{...props}
/>
)
}
function MessageScrollerButton({
behavior = "smooth",
children,
direction = "end",
onClick,
render,
tabIndex,
type = "button",
...props
}: MessageScrollerButtonProps) {
const { scrollToEnd, scrollToStart, stateStore } = useMessageScrollerContext()
const onClickRef = useLatest(onClick)
const subscribe = React.useCallback(
(listener: () => void) => stateStore.subscribe(listener),
[stateStore]
)
const getSnapshot = React.useCallback(() => {
const state = stateStore.getSnapshot()
return direction === "start" ? state.start : state.end
}, [direction, stateStore])
const isActive = React.useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot
)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (!isActive) {
return
}
onClickRef.current?.(event)
if (!event.defaultPrevented) {
event.currentTarget.blur()
if (direction === "start") {
scrollToStart({ behavior })
} else {
scrollToEnd({ behavior })
}
}
},
[behavior, direction, isActive, onClickRef, scrollToEnd, scrollToStart]
)
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type,
inert: !isActive,
tabIndex: isActive ? tabIndex : -1,
children: children ?? <span>Scroll to {direction}</span>,
onClick: handleClick,
},
props
),
render,
state: {
active: isActive,
direction,
},
stateAttributesMapping: {
active: (value) => ({
"data-active": value ? "true" : "false",
}),
},
})
}
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}

View File

@@ -0,0 +1,451 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest"
import {
getContentBottom,
getElementScrollTop,
getLastScrollAnchor,
getMessageScrollerScrollable,
getNewScrollAnchor,
getUnanchoredScrollAnchor,
} from "./geometry"
// jsdom does not compute layout, so each element's rect, scroll position, and
// dimensions are stubbed directly. Rects are expressed in viewport (client)
// coordinates and shift with the owning viewport's scrollTop.
type RectInput = {
top: number
height: number
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.()
}
})
// Override window.getComputedStyle once per test so padding stubs read from
// data attributes set on the element.
function stubComputedStyle() {
const original = window.getComputedStyle
window.getComputedStyle = ((element: Element) => {
const style = original(element)
if (element instanceof HTMLElement && element.dataset.paddingStart) {
return {
...style,
gap: "0px",
rowGap: "0px",
paddingBlockStart: `${element.dataset.paddingStart}px`,
paddingTop: `${element.dataset.paddingStart}px`,
paddingBlockEnd: `${element.dataset.paddingEnd ?? "0"}px`,
paddingBottom: `${element.dataset.paddingEnd ?? "0"}px`,
} as CSSStyleDeclaration
}
return style
}) as typeof window.getComputedStyle
cleanups.push(() => {
window.getComputedStyle = original
})
}
function setRect(element: HTMLElement, rect: RectInput) {
element.getBoundingClientRect = () =>
({
top: rect.top,
bottom: rect.top + rect.height,
height: rect.height,
left: 0,
right: 0,
width: 0,
x: 0,
y: rect.top,
toJSON: () => ({}),
}) as DOMRect
}
function setClientHeight(element: HTMLElement, height: number) {
Object.defineProperty(element, "clientHeight", {
configurable: true,
get: () => height,
})
}
// Build a viewport/content/items fixture. The viewport rect is anchored at
// `viewportTop` in client space; item rects are given in viewport-relative
// terms and offset by viewportTop so getElementTop math lines up with scrollTop.
function createFixture(options: {
viewportTop?: number
viewportHeight: number
scrollTop?: number
scrollHeight?: number
contentPaddingStart?: number
contentPaddingEnd?: number
items: Array<{
messageId?: string
scrollAnchor?: boolean
top: number
height: number
}>
}) {
const viewportTop = options.viewportTop ?? 0
const scrollTop = options.scrollTop ?? 0
const viewport = document.createElement("div")
const content = document.createElement("div")
const spacer = document.createElement("div")
viewport.appendChild(content)
content.appendChild(spacer)
setRect(viewport, { top: viewportTop, height: options.viewportHeight })
setClientHeight(viewport, options.viewportHeight)
viewport.scrollTop = scrollTop
if (typeof options.scrollHeight === "number") {
Object.defineProperty(viewport, "scrollHeight", {
configurable: true,
get: () => options.scrollHeight as number,
})
}
if (typeof options.contentPaddingStart === "number") {
content.dataset.paddingStart = String(options.contentPaddingStart)
content.dataset.paddingEnd = String(options.contentPaddingEnd ?? 0)
}
const items = options.items.map((item) => {
const element = document.createElement("div")
if (item.messageId) {
element.dataset.messageId = item.messageId
}
element.dataset.scrollAnchor = item.scrollAnchor ? "true" : "false"
// Item rects are in client coordinates: viewport-relative top shifted by the
// viewport's own top so getElementTop adds back scrollTop correctly.
setRect(element, { top: viewportTop + item.top, height: item.height })
content.insertBefore(element, spacer)
return element
})
return { viewport, content, spacer, items }
}
function createItems(
specs: Array<{ id: string; anchor?: boolean }>
): HTMLElement[] {
return specs.map((spec) => {
const element = document.createElement("div")
element.dataset.messageId = spec.id
element.dataset.scrollAnchor = spec.anchor ? "true" : "false"
return element
})
}
describe("getElementScrollTop", () => {
it("aligns to start by subtracting content padding and margin", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 10,
contentPaddingEnd: 10,
items: [{ messageId: "a", top: 100, height: 50 }],
})
// elementTop = 100; minus padding.start (10) minus scrollMargin (5).
expect(
getElementScrollTop({
align: "start",
element: items[0],
scrollMargin: 5,
spacer,
viewport,
})
).toBe(85)
})
it("centers the element within the padded inset", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 20,
contentPaddingEnd: 20,
items: [{ messageId: "a", top: 300, height: 40 }],
})
// insetHeight = 200 - 20 - 20 = 160; offset = (160 - 40) / 2 = 60.
// elementTop (300) - padding.start (20) - 60 - scrollMargin (0) = 220.
expect(
getElementScrollTop({
align: "center",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(220)
})
it("aligns to end against the viewport bottom", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 0,
contentPaddingEnd: 8,
items: [{ messageId: "a", top: 300, height: 50 }],
})
// elementTop (300) - clientHeight (200) + height (50) + padding.end (8)
// + scrollMargin (0) = 158.
expect(
getElementScrollTop({
align: "end",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(158)
})
it("returns the current scrollTop when nearest and already visible", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 50,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// Element top relative to viewport is 60, so in content space it sits at
// scrollTop (50) + 60 = 110, fully inside [50, 250].
items: [{ messageId: "a", top: 60, height: 40 }],
})
expect(
getElementScrollTop({
align: "nearest",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(50)
})
it("scrolls up to reveal an element above the viewport when nearest", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 300,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// Relative top -100 means content-space top is 300 + (-100) = 200, which
// is above viewportTop (scrollTop 300), so it must scroll up to 200.
items: [{ messageId: "a", top: -100, height: 40 }],
})
expect(
getElementScrollTop({
align: "nearest",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(200)
})
})
describe("getContentBottom", () => {
it("returns padding sum when there are no items", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 12,
contentPaddingEnd: 8,
items: [],
})
expect(getContentBottom({ content, spacer, viewport })).toBe(20)
})
it("measures from the lowest item bottom plus end padding", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 30,
contentPaddingStart: 0,
contentPaddingEnd: 16,
items: [
{ messageId: "a", top: 0, height: 100 },
{ messageId: "b", top: 100, height: 150 },
],
})
// Lowest item bottom in client space is 250; minus viewportRect.top (0)
// plus scrollTop (30) plus padding.end (16) = 296.
expect(getContentBottom({ content, spacer, viewport })).toBe(296)
})
it("excludes the spacer from the measurement", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 0,
contentPaddingEnd: 0,
items: [{ messageId: "a", top: 0, height: 80 }],
})
// Give the spacer a tall rect; it must not raise contentBottom past 80.
setRect(spacer, { top: 0, height: 9999 })
expect(getContentBottom({ content, spacer, viewport })).toBe(80)
})
})
describe("getNewScrollAnchor / getLastScrollAnchor", () => {
it("finds the first anchor at or after the previous item count", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b" },
{ id: "c", anchor: true },
{ id: "d", anchor: true },
])
// Starting from index 1 skips the anchor at index 0.
expect(getNewScrollAnchor(items, 1)).toBe(items[2])
})
it("returns null when no anchor exists after the boundary", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b" },
{ id: "c" },
])
expect(getNewScrollAnchor(items, 1)).toBeNull()
})
it("finds the last anchor scanning from the end", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b", anchor: true },
{ id: "c" },
])
expect(getLastScrollAnchor(items)).toBe(items[1])
})
it("returns null when there are no anchors", () => {
const items = createItems([{ id: "a" }, { id: "b" }])
expect(getLastScrollAnchor(items)).toBeNull()
})
})
describe("getUnanchoredScrollAnchor", () => {
it("returns the first anchor that has not been handled yet", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b", anchor: true },
])
const handled = new WeakSet<HTMLElement>()
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[0])
handled.add(items[0]!)
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[1])
})
it("returns null when every anchor has already been handled", () => {
const items = createItems([{ id: "a", anchor: true }])
const handled = new WeakSet<HTMLElement>([items[0]!])
expect(getUnanchoredScrollAnchor(items, handled)).toBeNull()
})
})
describe("getMessageScrollerScrollable", () => {
it("cannot scroll toward either edge when viewport or content is missing", () => {
const scrollable = getMessageScrollerScrollable({
content: null,
scrollEdgeThreshold: 8,
spacer: null,
viewport: null,
})
expect(scrollable).toMatchObject({
start: false,
end: false,
})
})
it("treats sub-threshold gaps at both edges as not scrollable", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
// scrollTop 5 <= threshold 8, so it cannot scroll toward the start.
scrollTop: 5,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// contentBottom 204; 204 - 5 - 200 = -1 <= 8, so it cannot scroll toward the end.
items: [{ messageId: "a", top: 199, height: 0 }],
})
const scrollable = getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
expect(scrollable).toMatchObject({
start: false,
end: false,
})
})
it("can scroll toward both edges past the threshold", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
// scrollTop 100 > threshold 8, so it can scroll toward the start.
scrollTop: 100,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// contentBottom 1000; 1000 - 100 - 200 = 700 > 8, so it can scroll toward the end.
items: [{ messageId: "a", top: 900, height: 100 }],
})
const scrollable = getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
expect(scrollable).toMatchObject({
start: true,
end: true,
})
})
})

View File

@@ -0,0 +1,387 @@
import {
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
SCROLL_POSITION_EPSILON,
} from "./types"
import type {
MessageScrollerScrollable,
MessageScrollerScrollAlign,
MessageScrollerVisibilityState,
} from "./types"
function getMessageScrollerScrollable({
content,
scrollEdgeThreshold,
spacer,
viewport,
}: {
content: HTMLElement | null
scrollEdgeThreshold: number
spacer: HTMLElement | null
viewport: HTMLElement | null
}): MessageScrollerScrollable {
if (!viewport || !content) {
return EMPTY_MESSAGE_SCROLLER_SCROLLABLE
}
const contentBottom = getContentBottom({ content, spacer, viewport })
return {
start: viewport.scrollTop > scrollEdgeThreshold,
end:
contentBottom - viewport.scrollTop - viewport.clientHeight >
scrollEdgeThreshold,
}
}
function getMessageScrollerVisibilityState({
content,
scrollMargin,
scrollPreviousItemPeek,
spacer,
viewport,
visibleMessageIds,
}: {
content: HTMLElement | null
scrollMargin: number
scrollPreviousItemPeek: number
spacer: HTMLElement | null
viewport: HTMLElement | null
visibleMessageIds: Set<string>
}): MessageScrollerVisibilityState {
if (!content || !viewport) {
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
}
const viewportRect = viewport.getBoundingClientRect()
// The reading line sits scrollPreviousItemPeek below scrollMargin: anchored
// turns land there with the previous turn peeking above. A row only peeking in
// that band has not been read down to yet, so it counts as neither visible nor
// current.
const lineTop = viewportRect.top + scrollMargin + scrollPreviousItemPeek
const trackByLayout = typeof IntersectionObserver === "undefined"
const visible: string[] = []
let currentAnchorId: string | null = null
// Walk rows in document order so visible ids come out top-to-bottom.
for (const item of getMessageScrollerItems(content, spacer)) {
const messageId = item.dataset.messageId
if (!messageId) {
continue
}
const isAnchor = item.dataset.scrollAnchor === "true"
// Anchors need a rect to place the current line; non-anchors lean on the
// observer set (or a rect in the no-observer fallback).
const rect = isAnchor || trackByLayout ? item.getBoundingClientRect() : null
const isVisible =
trackByLayout && rect
? rect.bottom > lineTop && rect.top < viewportRect.bottom
: visibleMessageIds.has(messageId)
if (isVisible) {
visible.push(messageId)
}
// Current is the last anchor to have reached the reading line: the turn you
// scrolled to (placed at the line) wins over newer turns lower down, the
// previous turn peeking above the line has been passed, and it stays current
// even after its header scrolls above the viewport.
if (isAnchor && rect && rect.top <= lineTop + SCROLL_POSITION_EPSILON) {
currentAnchorId = messageId
}
}
if (visible.length === 0 && currentAnchorId === null) {
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
}
return {
currentAnchorId,
visibleMessageIds: visible,
}
}
function getMessageScrollerItems(
content: HTMLElement,
spacer: HTMLElement | null
) {
return Array.from(content.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement && child !== spacer
)
}
function getNewScrollAnchor(items: HTMLElement[], previousItemCount: number) {
for (let index = previousItemCount; index < items.length; index++) {
const item = items[index]
if (item?.dataset.scrollAnchor === "true") {
return item
}
}
return null
}
function getUnanchoredScrollAnchor(
items: HTMLElement[],
handledAnchors: { has(element: HTMLElement): boolean }
) {
for (const item of items) {
if (item.dataset.scrollAnchor === "true" && !handledAnchors.has(item)) {
return item
}
}
return null
}
function hasMultipleNewScrollAnchors(
items: HTMLElement[],
previousItemCount: number
) {
let count = 0
for (let index = previousItemCount; index < items.length; index++) {
const item = items[index]
if (item?.dataset.scrollAnchor !== "true") {
continue
}
count += 1
if (count > 1) {
return true
}
}
return false
}
function getLastScrollAnchor(items: HTMLElement[]) {
for (let index = items.length - 1; index >= 0; index--) {
const item = items[index]
if (item?.dataset.scrollAnchor === "true") {
return item
}
}
return null
}
function getFirstVisibleMessageItem({
content,
spacer,
viewport,
}: {
content: HTMLElement
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const viewportRect = viewport.getBoundingClientRect()
for (const item of getMessageScrollerItems(content, spacer)) {
if (!item.dataset.messageId) {
continue
}
const rect = item.getBoundingClientRect()
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
return item
}
}
return null
}
function getElementScrollTop({
align,
element,
scrollMargin,
spacer,
viewport,
}: {
align: MessageScrollerScrollAlign
element: HTMLElement
scrollMargin: number
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const elementTop = getElementTop(element, viewport)
const elementHeight = element.getBoundingClientRect().height
const contentPadding = getContentBlockPadding(spacer)
if (align === "center") {
const insetHeight = Math.max(
0,
viewport.clientHeight - contentPadding.start - contentPadding.end
)
return (
elementTop -
contentPadding.start -
(insetHeight - elementHeight) / 2 -
scrollMargin
)
}
if (align === "end") {
return (
elementTop -
viewport.clientHeight +
elementHeight +
contentPadding.end +
scrollMargin
)
}
if (align === "nearest") {
const elementBottom = elementTop + elementHeight
const viewportTop = viewport.scrollTop + contentPadding.start
const viewportBottom =
viewport.scrollTop + viewport.clientHeight - contentPadding.end
if (elementTop >= viewportTop && elementBottom <= viewportBottom) {
return viewport.scrollTop
}
if (elementTop < viewportTop) {
return elementTop - contentPadding.start - scrollMargin
}
return (
elementBottom - viewport.clientHeight + contentPadding.end + scrollMargin
)
}
return elementTop - contentPadding.start - scrollMargin
}
function getElementTop(element: HTMLElement, viewport: HTMLElement) {
const elementRect = element.getBoundingClientRect()
const viewportRect = viewport.getBoundingClientRect()
return elementRect.top - viewportRect.top + viewport.scrollTop
}
function getElementViewportTop(element: HTMLElement, viewport: HTMLElement) {
return (
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
)
}
function getTailSpacerHeight({
content,
scrollTop,
spacer,
viewport,
}: {
content: HTMLElement
scrollTop: number
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const contentBottom = getContentBottom({ content, spacer, viewport })
return scrollTop + viewport.clientHeight - contentBottom
}
function getContentBottom({
content,
spacer,
viewport,
}: {
content: HTMLElement
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const items = getMessageScrollerItems(content, spacer)
const padding = getBlockPadding(content)
const viewportRect = viewport.getBoundingClientRect()
const scrollTop = viewport.scrollTop
let contentBottom = padding.start + padding.end
for (const item of items) {
const rect = item.getBoundingClientRect()
contentBottom = Math.max(
contentBottom,
rect.bottom - viewportRect.top + scrollTop + padding.end
)
}
return contentBottom
}
function getMaxScrollTop(viewport: HTMLElement) {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight)
}
function getBlockPadding(element: HTMLElement) {
const style = window.getComputedStyle(element)
return {
end: readCssPixel(style.paddingBlockEnd || style.paddingBottom),
start: readCssPixel(style.paddingBlockStart || style.paddingTop),
}
}
function getContentBlockPadding(spacer: HTMLElement | null) {
const content = spacer?.parentElement
if (!content) {
return {
end: 0,
start: 0,
}
}
return getBlockPadding(content)
}
function getFlexGap(element: HTMLElement | null) {
if (!element) {
return 0
}
const style = window.getComputedStyle(element)
const gap = style.rowGap === "normal" ? style.gap : style.rowGap
return readCssPixel(gap)
}
function readCssPixel(value: string | undefined) {
if (!value) {
return 0
}
const number = Number.parseFloat(value)
return Number.isFinite(number) ? number : 0
}
export {
getContentBlockPadding,
getContentBottom,
getElementScrollTop,
getElementTop,
getElementViewportTop,
getFirstVisibleMessageItem,
getFlexGap,
getLastScrollAnchor,
getMaxScrollTop,
getMessageScrollerItems,
getMessageScrollerScrollable,
getMessageScrollerVisibilityState,
getNewScrollAnchor,
getTailSpacerHeight,
getUnanchoredScrollAnchor,
hasMultipleNewScrollAnchors,
}

View File

@@ -0,0 +1,31 @@
import {
MessageScrollerButton as Button,
MessageScrollerContent as Content,
MessageScrollerItem as Item,
MessageScrollerProvider as Provider,
MessageScroller as Root,
MessageScrollerViewport as Viewport,
} from "./components"
export const MessageScroller = {
Provider,
Root,
Viewport,
Content,
Item,
Button,
}
export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "./components"
export type {
MessageScrollerDefaultScrollPosition,
MessageScrollerScrollAlign,
MessageScrollerScrollOptions,
MessageScrollerScrollable,
MessageScrollerVisibilityState,
} from "./types"

View File

@@ -0,0 +1,698 @@
import * as React from "react"
import { flushSync } from "react-dom"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, expect, test } from "vitest"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerVisibility,
} from "."
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
const MessageScroller = MessageScrollerPrimitive.Root
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
const MessageScrollerContent = MessageScrollerPrimitive.Content
const MessageScrollerItem = MessageScrollerPrimitive.Item
const MessageScrollerButton = MessageScrollerPrimitive.Button
// Real-browser regression for the prepend double-compensation bug. In Chromium
// native scroll anchoring shifts scrollTop on prepend; the component's restore
// must NOT compensate again. This is invisible to jsdom: with no native
// anchoring there, the viewport-relative and content-relative measurements
// produce the same number, so the bug and the fix look identical. Only a real
// engine with scroll anchoring can tell them apart.
// This component is driven by the event loop — requestAnimationFrame,
// ResizeObserver, IntersectionObserver — none of which act() can wrap. The suite
// flushes renders synchronously with flushSync and settles the async scroll work
// on real frames via settle(), so it runs OUTSIDE the act environment that
// vitest.browser.setup.ts enables globally (vitest isolates files, so other
// suites keep it on). Without this, the rAF-driven visibility subscriber logs
// spurious "not wrapped in act" warnings even though the assertions, which read
// only after settle(), are correct.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = false
const ITEM_HEIGHT = 80
const VIEWPORT_HEIGHT = 200
type TestItem = {
height?: number
id: string
scrollAnchor?: boolean
}
let root: Root | null = null
let container: HTMLDivElement | null = null
afterEach(() => {
root?.unmount()
container?.remove()
root = null
container = null
})
function Thread({
autoScroll,
defaultScrollPosition,
items,
scrollPreviousItemPeek,
showButton = false,
showJumpButton = false,
showVisibility = false,
}: {
autoScroll?: boolean
defaultScrollPosition?: React.ComponentProps<
typeof MessageScrollerProvider
>["defaultScrollPosition"]
items: TestItem[]
scrollPreviousItemPeek?: number
showButton?: boolean
showJumpButton?: boolean
showVisibility?: boolean
}) {
return (
<MessageScrollerProvider
autoScroll={autoScroll}
defaultScrollPosition={defaultScrollPosition}
scrollPreviousItemPeek={scrollPreviousItemPeek}
>
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{items.map((item) => (
<MessageScrollerItem
key={item.id}
messageId={item.id}
scrollAnchor={item.scrollAnchor}
style={{
height: item.height ?? ITEM_HEIGHT,
flex: "none",
}}
>
{item.id}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
{showButton ? (
<MessageScrollerButton behavior="auto">
Scroll to end
</MessageScrollerButton>
) : null}
{showJumpButton ? <JumpButton messageId="m5" /> : null}
{showVisibility ? <VisibilityProbe /> : null}
</MessageScroller>
</MessageScrollerProvider>
)
}
function VisibilityProbe() {
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
return (
<div
data-testid="visibility"
data-current-anchor={currentAnchorId ?? ""}
data-visible={visibleMessageIds.join(",")}
/>
)
}
const MemoMessageItem = React.memo(function MemoMessageItem({
id,
anchor,
}: {
id: string
anchor: boolean
}) {
return (
<MessageScrollerItem
messageId={id}
scrollAnchor={anchor}
style={{ height: ITEM_HEIGHT, flex: "none" }}
>
{id}
</MessageScrollerItem>
)
})
function JumpButton({ messageId }: { messageId: string }) {
const { scrollToMessage } = useMessageScroller()
return (
<button
type="button"
onClick={() => {
scrollToMessage(messageId, { align: "start", behavior: "auto" })
}}
>
Jump to message
</button>
)
}
// Resolve after a few real animation frames so the component's rAF-scheduled
// scroll work and the browser's native anchoring both settle.
function settle(frames = 4) {
return new Promise<void>((resolve) => {
let remaining = frames
const tick = () =>
remaining-- <= 0 ? resolve() : requestAnimationFrame(tick)
requestAnimationFrame(tick)
})
}
function viewportOffsetOf(messageId: string, viewport: HTMLElement) {
const item = document.querySelector(
`[data-message-id="${messageId}"]`
) as HTMLElement
return Math.round(
item.getBoundingClientRect().top - viewport.getBoundingClientRect().top
)
}
function getViewport() {
return document.querySelector('[aria-label="viewport"]') as HTMLElement
}
function getDistanceToBottom(viewport: HTMLElement) {
return Math.round(
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
)
}
function getScrollTop(viewport: HTMLElement) {
return Math.round(viewport.scrollTop)
}
function getCurrentAnchor() {
return document
.querySelector('[data-testid="visibility"]')!
.getAttribute("data-current-anchor")
}
function getVisibleIds() {
const value =
document
.querySelector('[data-testid="visibility"]')!
.getAttribute("data-visible") ?? ""
return value ? value.split(",") : []
}
async function renderThread(props: React.ComponentProps<typeof Thread>) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<Thread {...props} />)
})
await settle()
}
function createItems(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
}))
}
test("keeps the visible message in place when older messages are prepended", async () => {
const initial = createItems(8)
await renderThread({ items: initial })
const viewport = getViewport()
// Scroll into the middle so there is content above and below; m3 lands at the
// top of the viewport and becomes the preserved anchor.
viewport.scrollTop = 3 * ITEM_HEIGHT
await settle()
const offsetBefore = viewportOffsetOf("m3", viewport)
// Prepend three older rows above the current scroll position.
flushSync(() => {
root!.render(
<Thread items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]} />
)
})
await settle()
const offsetAfter = viewportOffsetOf("m3", viewport)
// The tracked message must not move within the viewport. With the old
// content-relative restore it jumps up by the prepended height (~240px).
expect(Math.abs(offsetAfter - offsetBefore)).toBeLessThanOrEqual(1)
})
test("opens at the bottom by default", async () => {
await renderThread({ items: createItems(8) })
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
})
test("keeps auto-scroll pinned when the final message grows", async () => {
const initial = createItems(6)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
autoScroll
items={initial.map((item, index) =>
index === initial.length - 1 ? { ...item, height: 240 } : item
)}
/>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("keeps the end pinned when bulk appending anchored turns with autoScroll", async () => {
const initial = createItems(6)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
autoScroll
items={[
...initial,
{ id: "new-user-1", scrollAnchor: true },
{ id: "new-assistant-1" },
{ id: "new-user-2", scrollAnchor: true },
{ id: "new-assistant-2" },
]}
/>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("does not keep the end pinned when appending after the default bottom open", async () => {
const initial = createItems(6)
await renderThread({ items: initial })
const viewport = getViewport()
const scrollTop = getScrollTop(viewport)
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(<Thread items={[...initial, { id: "new" }]} />)
})
await settle()
expect(getScrollTop(viewport)).toBe(scrollTop)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("scroll button moves the viewport to the end", async () => {
await renderThread({
defaultScrollPosition: "start",
items: createItems(8),
showButton: true,
})
const viewport = getViewport()
expect(getScrollTop(viewport)).toBe(0)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
const button = document.querySelector("button") as HTMLButtonElement
button.click()
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("restores the last scroll anchor when the final turn overflows", async () => {
await renderThread({
defaultScrollPosition: "last-anchor",
items: [
{ id: "m0" },
{ id: "m1" },
{ id: "last-user", scrollAnchor: true },
{ id: "last-assistant", height: 360 },
],
scrollPreviousItemPeek: 0,
})
const viewport = getViewport()
expect(viewportOffsetOf("last-user", viewport)).toBeLessThanOrEqual(1)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("falls back to the end when the last anchored turn fits", async () => {
await renderThread({
defaultScrollPosition: "last-anchor",
items: [
...createItems(5),
{ id: "last-user", scrollAnchor: true },
{ id: "last-assistant" },
],
scrollPreviousItemPeek: 0,
})
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
})
test("scrolls to a mounted message by id", async () => {
await renderThread({
defaultScrollPosition: "start",
items: createItems(8),
showJumpButton: true,
})
const viewport = getViewport()
const button = document.querySelector("button") as HTMLButtonElement
button.click()
await settle()
expect(viewportOffsetOf("m5", viewport)).toBeLessThanOrEqual(1)
})
test("preserves a scrolled-to turn across a prepend (command-path anchor)", async () => {
// Characterization lock for the Phase-5 anchor-ref boundary: scrollToMessage
// sets the preserve anchor OUTSIDE a content change (the command path), unlike
// every other prepend test which scrolls directly. Jumping then prepending
// must keep the jumped-to turn fixed, not lose its anchor.
const initial = createItems(12)
await renderThread({
defaultScrollPosition: "start",
items: initial,
showJumpButton: true,
})
const viewport = getViewport()
;(document.querySelector("button") as HTMLButtonElement).click()
await settle()
const offsetBefore = viewportOffsetOf("m5", viewport)
expect(offsetBefore).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
defaultScrollPosition="start"
items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]}
showJumpButton
/>
)
})
await settle()
expect(
Math.abs(viewportOffsetOf("m5", viewport) - offsetBefore)
).toBeLessThanOrEqual(1)
})
test("user scroll intent cancels follow-bottom", async () => {
const initial = createItems(8)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
viewport.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: -ITEM_HEIGHT })
)
viewport.scrollTop = 0
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
await settle()
expect(getScrollTop(viewport)).toBe(0)
flushSync(() => {
root!.render(<Thread autoScroll items={[...initial, { id: "new" }]} />)
})
await settle()
expect(getScrollTop(viewport)).toBe(0)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("tracks the current anchor as it scrolls above the viewport", async () => {
// Every tenth row is a turn-start anchor: m0, m10, m20.
const items = Array.from({ length: 30 }, (_, index) => ({
id: `m${index}`,
scrollAnchor: index % 10 === 0,
}))
await renderThread({
defaultScrollPosition: "start",
items,
showVisibility: true,
})
const viewport = getViewport()
// Opened at the top → the first anchor is current. The subscribed probe
// settles on a post-mount rAF, so the value is only correct after the settle()
// that renderThread awaits.
expect(getCurrentAnchor()).toBe("m0")
// Scroll until m10 is fully above the viewport. It must stay current even
// though it is no longer visible — the behavior the old activeMessageId (and
// jsdom) could not express.
viewport.scrollTop = 900
await settle()
expect(getCurrentAnchor()).toBe("m10")
expect(getVisibleIds()).not.toContain("m10")
// Scrolling past the next anchor advances the cursor.
viewport.scrollTop = 1700
await settle()
expect(getCurrentAnchor()).toBe("m20")
})
test("keeps the anchor at the reading line current over lower visible anchors", async () => {
// Every row is a turn-start anchor, so several are on screen at once — the
// scrollToMessage case. Jumping to a turn must keep IT current even though
// newer anchors sit below it and are still visible (the regression where the
// lowest visible anchor wrongly stole "current").
const items = Array.from({ length: 12 }, (_, index) => ({
id: `m${index}`,
scrollAnchor: true,
}))
await renderThread({
defaultScrollPosition: "start",
items,
showVisibility: true,
})
const viewport = getViewport()
// Bring m5 up to the reading line; m6 and m7 sit below it, also visible.
viewport.scrollTop = 5 * ITEM_HEIGHT
await settle()
expect(getCurrentAnchor()).toBe("m5")
// m6 is on screen but below the line — it must not steal current.
expect(getVisibleIds()).toContain("m6")
})
test("visibility populates under StrictMode (frame ref + lifecycle survive remount)", async () => {
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
// StrictMode double-invokes mount → unmount → remount on the same refs, which
// is what wedges the rAF scheduler and tears the subscription in Next dev.
flushSync(() => {
root!.render(
<React.StrictMode>
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{ids.map((id, index) => (
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
</React.StrictMode>
)
})
await settle()
// Force a recompute after StrictMode's mount churn.
getViewport().scrollTop = 0
getViewport().dispatchEvent(new Event("scroll", { bubbles: true }))
await settle()
expect(getCurrentAnchor()).toBe("m0")
expect(getVisibleIds().length).toBeGreaterThan(0)
})
test("tracks visibility through memoized item components", async () => {
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{ids.map((id, index) => (
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
)
})
await settle()
// currentAnchorId scans the DOM (memo-independent). visibleMessageIds rides
// registration + the real IntersectionObserver — the path memo could break.
expect(getCurrentAnchor()).toBe("m0")
expect(getVisibleIds().length).toBeGreaterThan(0)
expect(getVisibleIds()).toContain("m0")
})
test("an anchored turn holds at the top when content below it collapses", async () => {
const peek = 32
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
const base = [
{ id: "m0", height: 300 },
{ id: "m1", height: 300 },
{ id: "m2", height: 300 },
]
flushSync(() => {
root!.render(<Thread items={base} scrollPreviousItemPeek={peek} />)
})
await settle()
// A new turn (anchor) arrives with a transient marker below it — like the
// demo's "Thinking..." placeholder — so it has content below to reach the top.
flushSync(() => {
root!.render(
<Thread
scrollPreviousItemPeek={peek}
items={[
...base,
{ id: "turn", height: 80, scrollAnchor: true },
{ id: "marker", height: 100 },
]}
/>
)
})
await settle()
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
// The marker is replaced by an empty reply that will stream in: the content
// below the turn collapses. The turn must stay pinned at the top, not drop as
// the browser clamps scrollTop to the shorter content.
flushSync(() => {
root!.render(
<Thread
scrollPreviousItemPeek={peek}
items={[
...base,
{ id: "turn", height: 80, scrollAnchor: true },
{ id: "reply", height: 0 },
]}
/>
)
})
await settle()
await settle()
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
})
test("auto-scroll and content updates survive a StrictMode remount", async () => {
// StrictMode mounts, unmounts, then remounts on the same refs. The existing
// StrictMode test covers the visibility frame; this one covers the rest of the
// machinery — the state + scroll frame refs must reset on the remount so
// follow-bottom keeps working, not just on first mount.
const items = createItems(6)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(
<React.StrictMode>
<Thread autoScroll items={items} showVisibility />
</React.StrictMode>
)
})
await settle()
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
// A new message after the remount still drives the content-change + scroll path.
flushSync(() => {
root!.render(
<React.StrictMode>
<Thread autoScroll items={[...items, { id: "new" }]} showVisibility />
</React.StrictMode>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
expect(getVisibleIds().length).toBeGreaterThan(0)
})

View File

@@ -0,0 +1,841 @@
import * as React from "react"
import { flushSync } from "react-dom"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, expect, test } from "vitest"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScrollerVisibility,
} from "."
import { getMessageScrollerScrollable } from "./geometry"
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
const MessageScroller = MessageScrollerPrimitive.Root
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
const MessageScrollerContent = MessageScrollerPrimitive.Content
const MessageScrollerItem = MessageScrollerPrimitive.Item
// Performance proof for MessageScroller at scale. Two layers:
//
// Layer 1 isolates the algorithm: getMessageScrollerScrollable() drives the O(n)
// getContentBottom() scan that runs on every scroll commit. We measure its cost
// against a real laid-out DOM as message count grows, forcing a reflow each
// iteration the way real scrolling does.
//
// Layer 2 is the integration proof: a real top-to-bottom scroll on a large
// transcript, timing the synchronous scroll handler (where the geometry runs)
// and the resulting frame cadence, with both scroll state and visibility
// subscribed like a real chat app.
//
// jsdom cannot run either: it stubs getBoundingClientRect to a free property
// read, so the forced layout that IS the cost here would not exist.
const ITEM_HEIGHT = 48
const VIEWPORT_HEIGHT = 480
const FRAME_BUDGET = 16 // One 60fps frame in ms.
let root: Root | null = null
let container: HTMLDivElement | null = null
// Performance benchmarks are not React act() tests: they drive real
// requestAnimationFrame / IntersectionObserver and measure wall-clock, and never
// assert on rendered output. Opt this file out of the act environment that
// vitest.browser.setup.ts enables globally — vitest isolates files, so the
// behavior tests keep it on. Renders we time are flushed synchronously with
// flushSync (which runs layout effects in-band); everything else settles on real
// frames. Without this flag, the live visibility subscriber's rAF/IO-driven store
// updates — which act() cannot wrap — would log spurious "not wrapped in act"
// warnings.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = false
afterEach(() => {
root?.unmount()
container?.remove()
root = null
container = null
})
function VisibilityProbe() {
// Subscribing turns on the visibility recompute path, so Layer 2 reflects a
// real app that reads currentAnchorId, not just the scroll-state path.
const { currentAnchorId } = useMessageScrollerVisibility()
return (
<div data-testid="visibility" data-current-anchor={currentAnchorId ?? ""} />
)
}
function Thread({
count,
showVisibility = false,
}: {
count: number
showVisibility?: boolean
}) {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{Array.from({ length: count }, (_, index) => (
<MessageScrollerItem
key={index}
messageId={`m${index}`}
// Every eighth row is a turn-start anchor, like real transcripts.
scrollAnchor={index % 8 === 0}
style={{ height: ITEM_HEIGHT, flex: "none" }}
>
{`m${index}`}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
{showVisibility ? <VisibilityProbe /> : null}
</MessageScroller>
</MessageScrollerProvider>
)
}
function nextFrame() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
async function mountThread(count: number, showVisibility = false) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<Thread count={count} showVisibility={showVisibility} />)
})
// Let the component's mount-time scroll work settle before measuring.
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = document.querySelector(
'[aria-label="viewport"]'
) as HTMLElement
const content = viewport.querySelector('[role="log"]') as HTMLElement
const spacer = content.querySelector(
"[data-message-scroller-spacer]"
) as HTMLElement
return { content, spacer, viewport }
}
function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
function percentile(values: number[], p: number) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[
Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))
]
}
function round(value: number) {
return Math.round(value * 1000) / 1000
}
test("Layer 1 — getContentBottom scan stays sub-frame as message count grows", async () => {
const counts = [100, 500, 1000, 2000]
const iterations = 200
const rows: Array<{
n: number
medianMs: number
p95Ms: number
maxMs: number
}> = []
for (const n of counts) {
const { content, spacer, viewport } = await mountThread(n)
const samples: number[] = []
// Warm up so JIT and first-layout costs do not pollute the samples.
for (let i = 0; i < 20; i++) {
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
}
for (let i = 0; i < iterations; i++) {
// Toggle scrollTop to dirty layout, so the getBoundingClientRect calls
// inside getMessageScrollerScrollable force a real reflow — exactly what
// happens frame-to-frame during a real scroll. Without this the browser
// would serve cached rects and the benchmark would measure nothing.
viewport.scrollTop = 1000 + (i % 2)
const start = performance.now()
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
samples.push(performance.now() - start)
}
rows.push({
n,
medianMs: round(median(samples)),
p95Ms: round(percentile(samples, 95)),
maxMs: round(Math.max(...samples)),
})
root?.unmount()
container?.remove()
root = null
container = null
}
// eslint-disable-next-line no-console
console.log(
"\n[Layer 1] getMessageScrollerScrollable cost per scroll commit (real reflow):\n" +
rows
.map(
(r) =>
` n=${String(r.n).padStart(4)} median ${String(
r.medianMs
).padStart(
6
)}ms p95 ${String(r.p95Ms).padStart(6)}ms max ${String(
r.maxMs
).padStart(6)}ms`
)
.join("\n") +
"\n"
)
// Proof: even at 2000 rendered rows a single commit stays under one frame,
// with generous CI slack. The logged median is the real headline number.
const worst = rows[rows.length - 1]
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
})
test("Layer 2 — real top-to-bottom scroll on 1000 messages holds frame budget", async () => {
const COUNT = 1000
const STEPS = 120
const { viewport } = await mountThread(COUNT, true)
// Capture any main-thread long task (>50ms by spec) during the scroll.
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask not supported — frame timing below is still the primary proof.
}
const max = viewport.scrollHeight - viewport.clientHeight
const handlerCosts: number[] = []
const frameDeltas: number[] = []
let last = performance.now()
for (let i = 1; i <= STEPS; i++) {
viewport.scrollTop = Math.round((max * i) / STEPS)
// The scroll handler runs synchronously inside dispatch:
// syncAfterScroll -> commitScrollState -> getContentBottom. Timing the
// dispatch is timing the exact main-thread block a user scroll causes.
const start = performance.now()
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
handlerCosts.push(performance.now() - start)
await nextFrame()
const now = performance.now()
frameDeltas.push(now - last)
last = now
}
observer?.disconnect()
const stats = {
handlerMedianMs: round(median(handlerCosts)),
handlerP95Ms: round(percentile(handlerCosts, 95)),
handlerMaxMs: round(Math.max(...handlerCosts)),
frameMedianMs: round(median(frameDeltas)),
frameP95Ms: round(percentile(frameDeltas, 95)),
frameMaxMs: round(Math.max(...frameDeltas)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
// eslint-disable-next-line no-console
console.log(
`\n[Layer 2] scrolling ${COUNT} messages top-to-bottom over ${STEPS} steps` +
" (scroll state + visibility live):\n" +
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
` frame interval median ${stats.frameMedianMs}ms p95 ${stats.frameP95Ms}ms max ${stats.frameMaxMs}ms\n` +
` long tasks ${stats.longTasks}${
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
}\n`
)
// Proof: the synchronous scroll handler never spends a whole frame, so it
// cannot drop one. Median is the headline; p95 carries generous CI slack.
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
})
test("Regression guard — one scroll commit does O(items) layout reads, not O(items^2)", async () => {
// Timing budgets are a coarse, hardware-dependent backstop. THIS is the real
// regression guard: the number of forced layout reads per scroll commit is
// deterministic and machine-independent. getContentBottom reads one rect per
// item (plus a small constant), so the ratio stays ~O(1) per item. An O(n^2)
// regression — e.g. a nested rect loop — would push it far past the bound.
const ITEMS = 2000
const { viewport } = await mountThread(ITEMS)
const originalRect = Element.prototype.getBoundingClientRect
let calls = 0
Element.prototype.getBoundingClientRect = function getBoundingClientRectSpy(
this: Element
) {
calls++
return originalRect.call(this)
}
try {
// One synchronous scroll commit (handleScroll runs inside dispatch).
viewport.scrollTop = 1234
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
} finally {
Element.prototype.getBoundingClientRect = originalRect
}
const perItem = round(calls / ITEMS)
// eslint-disable-next-line no-console
console.log(
`\n[Regression guard] ${calls} getBoundingClientRect calls for ${ITEMS} items` +
` (${perItem}/item) in one scroll commit\n`
)
// A handful of rect reads per item is fine; anything that scales with item
// count (O(n) per item -> O(n^2) total) blows past this at 2000 items.
expect(perItem).toBeLessThan(4)
})
// ===========================================================================
// Layer 3 — streaming. The one path where heavy DOM should actually bite: an
// assistant reply GROWS token-by-token at the bottom of a long transcript.
// Growth dirties layout, so handleContentChange + the ResizeObserver-driven
// handleResize run while a full reflow is genuinely required — unlike steady
// scroll, which never dirties layout.
//
// To isolate the SCROLLER's cost from the markdown's own render cost (they
// interleave in one React commit), we run the identical growth twice: once
// inside MessageScroller (auto-follow) and once inside a plain scroll div
// (manual follow). Prior history is memoized in both, like a real app, so only
// the growing reply re-renders. The delta is what the scroller adds per token.
// ===========================================================================
const PRIOR_TURNS = 150
const STREAM_CHUNKS = 60
function StreamingReplyBody({ chunks }: { chunks: number }) {
// Each token batch appends a block, so the reply grows and dirties layout.
return (
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
{Array.from({ length: chunks }, (_, index) =>
index % 5 === 4 ? (
<CodeBlock key={index} lines={6} />
) : (
<p key={index} style={{ margin: "0 0 8px" }}>
{`Streaming sentence ${index} that adds height to the reply as it grows. `.repeat(
2
)}
</p>
)
)}
</div>
)
}
// Memoized prior history — stable across token renders, so streaming only
// re-renders the growing reply, exactly like a real memoized message list.
const PriorTurnsScroller = React.memo(function PriorTurnsScroller() {
return (
<>
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
<React.Fragment key={index}>
<MessageScrollerItem
messageId={`u${index}`}
scrollAnchor
style={{
flex: "none",
padding: "12px 16px",
background: "#fafafa",
}}
>
{`Question ${index}`}
</MessageScrollerItem>
<MessageScrollerItem messageId={`a${index}`} style={{ flex: "none" }}>
<HeavyAssistantMessage seed={index} />
</MessageScrollerItem>
</React.Fragment>
))}
</>
)
})
const PriorTurnsPlain = React.memo(function PriorTurnsPlain() {
return (
<>
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
<React.Fragment key={index}>
<div style={{ padding: "12px 16px", background: "#fafafa" }}>
{`Question ${index}`}
</div>
<div>
<HeavyAssistantMessage seed={index} />
</div>
</React.Fragment>
))}
</>
)
})
function ScrollerStream({ chunks }: { chunks: number }) {
return (
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
<PriorTurnsScroller />
<MessageScrollerItem messageId="streaming" style={{ flex: "none" }}>
<StreamingReplyBody chunks={chunks} />
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
)
}
function PlainStream({ chunks }: { chunks: number }) {
return (
<div
data-testid="plain-viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<PriorTurnsPlain />
<div>
<StreamingReplyBody chunks={chunks} />
</div>
</div>
</div>
)
}
async function runStream(
renderTree: (chunks: number) => React.ReactElement,
follow?: (viewport: HTMLElement) => void
) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(renderTree(1))
})
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = (container.querySelector('[aria-label="viewport"]') ??
container.querySelector('[data-testid="plain-viewport"]')) as HTMLElement
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask unsupported.
}
const commits: number[] = []
for (let chunk = 2; chunk <= STREAM_CHUNKS; chunk++) {
// Time the synchronous React commit: render + layout effects, where the
// scroller's handleContentChange runs. The reply is one chunk taller, so
// layout is dirty going in.
const start = performance.now()
flushSync(() => {
root!.render(renderTree(chunk))
})
commits.push(performance.now() - start)
follow?.(viewport)
await nextFrame()
}
observer?.disconnect()
root?.unmount()
container?.remove()
root = null
container = null
return {
commitMedianMs: round(median(commits)),
commitP95Ms: round(percentile(commits, 95)),
commitMaxMs: round(Math.max(...commits)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
}
test("Layer 3 — streaming a heavy reply: scroller overhead vs plain baseline", async () => {
// Baseline: same growth in a plain scroll div, manually following the bottom.
const baseline = await runStream(
(chunks) => <PlainStream chunks={chunks} />,
(viewport) => {
viewport.scrollTop = viewport.scrollHeight
}
)
// Scroller: identical growth, auto-follow does the work.
const scroller = await runStream((chunks) => (
<ScrollerStream chunks={chunks} />
))
const overheadMedian = round(
scroller.commitMedianMs - baseline.commitMedianMs
)
// eslint-disable-next-line no-console
console.log(
`\n[Layer 3] streaming a reply to ${STREAM_CHUNKS} chunks below ${PRIOR_TURNS} memoized heavy turns,` +
" per-token commit (render + scroller content-change handling):\n" +
` plain div baseline median ${baseline.commitMedianMs}ms p95 ${baseline.commitP95Ms}ms max ${baseline.commitMaxMs}ms longTasks ${baseline.longTasks}\n` +
` MessageScroller median ${scroller.commitMedianMs}ms p95 ${scroller.commitP95Ms}ms max ${scroller.commitMaxMs}ms longTasks ${scroller.longTasks}\n` +
` scroller overhead median ${overheadMedian}ms per token\n`
)
// Proof: the scroller's per-token streaming work fits in a frame, and the
// overhead it adds over a plain scroll div is a small fraction of one.
expect(scroller.commitMedianMs).toBeLessThan(FRAME_BUDGET)
expect(overheadMedian).toBeLessThan(FRAME_BUDGET)
})
// ---------------------------------------------------------------------------
// Heavy-content variant. Real chat turns are not one text node — they are full
// markdown subtrees: headings, paragraphs, tokenized code blocks, tables and
// lists, hundreds of DOM nodes each. The scroller's forced reflow recomputes
// layout over that whole subtree, so this is the honest stress for the geometry
// hot path. (This does NOT measure the markdown library's own render cost —
// that is the content's concern, not the scroller's.)
// ---------------------------------------------------------------------------
function CodeBlock({ lines }: { lines: number }) {
return (
<pre
style={{
whiteSpace: "pre",
overflowX: "auto",
background: "#f5f5f5",
padding: 12,
margin: "8px 0",
fontFamily: "monospace",
fontSize: 13,
}}
>
<code>
{Array.from({ length: lines }, (_, line) => (
<div key={line}>
{Array.from({ length: 8 }, (_, token) => (
<span key={token} style={{ color: token % 2 ? "#07a" : "#905" }}>
{`tok${token} `}
</span>
))}
</div>
))}
</code>
</pre>
)
}
function MarkdownTable({ rows }: { rows: number }) {
return (
<table
style={{ borderCollapse: "collapse", margin: "8px 0", width: "100%" }}
>
<thead>
<tr>
{["Name", "Type", "Default", "Description"].map((header) => (
<th key={header} style={{ border: "1px solid #ddd", padding: 6 }}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }, (_, row) => (
<tr key={row}>
{Array.from({ length: 4 }, (_, cell) => (
<td key={cell} style={{ border: "1px solid #ddd", padding: 6 }}>
{`r${row}c${cell}`}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
function HeavyAssistantMessage({ seed }: { seed: number }) {
// Vary the sizes by turn so heights are non-uniform like real replies.
const paragraphs = 2 + (seed % 3)
const codeLines = 12 + (seed % 14)
const tableRows = 3 + (seed % 5)
return (
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
<h2 style={{ fontSize: 18, margin: "0 0 8px" }}>{`Reply ${seed}`}</h2>
{Array.from({ length: paragraphs }, (_, index) => (
<p key={index} style={{ margin: "0 0 8px" }}>
{`This is paragraph ${index} of a fairly long assistant reply that wraps across multiple lines so the layout engine has real flow work to do when the surrounding scroll position changes during a scroll. `.repeat(
2
)}
</p>
))}
<ul style={{ margin: "0 0 8px", paddingLeft: 20 }}>
{Array.from({ length: 5 }, (_, index) => (
<li key={index}>{`List item ${index} with some inline text.`}</li>
))}
</ul>
<CodeBlock lines={codeLines} />
<MarkdownTable rows={tableRows} />
</div>
)
}
function HeavyThread({ turns }: { turns: number }) {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{Array.from({ length: turns }, (_, index) => (
<React.Fragment key={index}>
{/* User turn: short, and the scroll anchor. */}
<MessageScrollerItem
messageId={`u${index}`}
scrollAnchor
style={{
flex: "none",
padding: "12px 16px",
background: "#fafafa",
}}
>
{`Question ${index}: how does this work?`}
</MessageScrollerItem>
{/* Assistant turn: heavy markdown subtree. */}
<MessageScrollerItem
messageId={`a${index}`}
style={{ flex: "none" }}
>
<HeavyAssistantMessage seed={index} />
</MessageScrollerItem>
</React.Fragment>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
)
}
async function mountHeavyThread(turns: number) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<HeavyThread turns={turns} />)
})
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = document.querySelector(
'[aria-label="viewport"]'
) as HTMLElement
const content = viewport.querySelector('[role="log"]') as HTMLElement
const spacer = content.querySelector(
"[data-message-scroller-spacer]"
) as HTMLElement
const nodeCount = content.querySelectorAll("*").length
return { content, nodeCount, spacer, viewport }
}
test("Layer 1 (heavy markdown) — commit cost over full markdown subtrees", async () => {
const turnCounts = [50, 150, 300]
const iterations = 150
const rows: Array<{
turns: number
items: number
nodes: number
medianMs: number
p95Ms: number
maxMs: number
}> = []
for (const turns of turnCounts) {
const { content, nodeCount, spacer, viewport } =
await mountHeavyThread(turns)
const samples: number[] = []
for (let i = 0; i < 20; i++) {
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
}
for (let i = 0; i < iterations; i++) {
viewport.scrollTop = 1000 + (i % 2)
const start = performance.now()
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
samples.push(performance.now() - start)
}
rows.push({
turns,
items: turns * 2,
nodes: nodeCount,
medianMs: round(median(samples)),
p95Ms: round(percentile(samples, 95)),
maxMs: round(Math.max(...samples)),
})
root?.unmount()
container?.remove()
root = null
container = null
}
// eslint-disable-next-line no-console
console.log(
"\n[Layer 1 · heavy markdown] getMessageScrollerScrollable cost per commit:\n" +
rows
.map(
(r) =>
` turns=${String(r.turns).padStart(3)} items=${String(
r.items
).padStart(3)} domNodes=${String(r.nodes).padStart(
6
)} median ${String(r.medianMs).padStart(6)}ms p95 ${String(
r.p95Ms
).padStart(6)}ms max ${String(r.maxMs).padStart(6)}ms`
)
.join("\n") +
"\n"
)
const worst = rows[rows.length - 1]
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
})
test("Layer 2 (heavy markdown) — real scroll over 300 markdown turns", async () => {
const TURNS = 300
const STEPS = 120
const { nodeCount, viewport } = await mountHeavyThread(TURNS)
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask unsupported — handler timing below is still the proof.
}
const max = viewport.scrollHeight - viewport.clientHeight
const handlerCosts: number[] = []
for (let i = 1; i <= STEPS; i++) {
viewport.scrollTop = Math.round((max * i) / STEPS)
const start = performance.now()
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
handlerCosts.push(performance.now() - start)
await nextFrame()
}
observer?.disconnect()
const stats = {
handlerMedianMs: round(median(handlerCosts)),
handlerP95Ms: round(percentile(handlerCosts, 95)),
handlerMaxMs: round(Math.max(...handlerCosts)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
// eslint-disable-next-line no-console
console.log(
`\n[Layer 2 · heavy markdown] scrolling ${TURNS} turns (${
TURNS * 2
} messages, ${nodeCount} DOM nodes) top-to-bottom:\n` +
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
` long tasks ${stats.longTasks}${
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
}\n`
)
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
import { EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE } from "./types"
import type {
MessageScrollerScrollable,
MessageScrollerStore,
MessageScrollerVisibilityState,
MessageScrollerVisibilityStore,
} from "./types"
// Generic useSyncExternalStore backing: a stable snapshot (referentially equal
// while isEqual holds, so subscribers only re-render on real transitions) plus
// optional reference-counting via the subscribe-time lifecycle callbacks.
function createExternalStore<T>(
initialSnapshot: T,
isEqual: (a: T, b: T) => boolean
) {
let snapshot = initialSnapshot
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
hasListeners: () => listeners.size > 0,
setSnapshot: (nextSnapshot: T) => {
if (isEqual(snapshot, nextSnapshot)) {
return
}
snapshot = nextSnapshot
listeners.forEach((listener) => listener())
},
subscribe: (
listener: () => void,
onFirstSubscribe?: () => void,
onLastUnsubscribe?: () => void
) => {
const wasEmpty = listeners.size === 0
listeners.add(listener)
if (wasEmpty) {
onFirstSubscribe?.()
}
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
onLastUnsubscribe?.()
}
}
},
}
}
function createMessageScrollerStore<T>(
initialSnapshot: T,
isEqual: (a: T, b: T) => boolean
): MessageScrollerStore<T> {
return createExternalStore(initialSnapshot, isEqual)
}
function createMessageScrollerVisibilityStore(): MessageScrollerVisibilityStore {
return createExternalStore(
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
areVisibilityStatesEqual
)
}
function areScrollStatesEqual(
current: MessageScrollerScrollable,
next: MessageScrollerScrollable
) {
return current.start === next.start && current.end === next.end
}
function areVisibilityStatesEqual(
current: MessageScrollerVisibilityState,
next: MessageScrollerVisibilityState
) {
if (current.currentAnchorId !== next.currentAnchorId) {
return false
}
if (current.visibleMessageIds.length !== next.visibleMessageIds.length) {
return false
}
return current.visibleMessageIds.every(
(messageId, index) => messageId === next.visibleMessageIds[index]
)
}
export {
areScrollStatesEqual,
areVisibilityStatesEqual,
createMessageScrollerStore,
createMessageScrollerVisibilityStore,
}

View File

@@ -0,0 +1,240 @@
import * as React from "react"
import type { UseRenderComponentProps } from "../use-render"
// Default scrollEdgeThreshold. Sub-pixel tolerance so edge detection does not
// flicker across engines that round scrollTop differently.
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8
// Default scrollPreviousItemPeek. Pixels of the previous item kept visible above
// a newly anchored row.
const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64
// Default scrollMargin for scrollToMessage and programmatic targets.
const DEFAULT_SCROLL_MARGIN = 0
// Two fractional scrollTop values within this range are treated as equal, to
// absorb zoom and HiDPI rounding drift.
const SCROLL_POSITION_EPSILON = 0.5
// How long (ms) data-autoscrolling stays set during a programmatic smooth scroll
// before clearing.
const AUTOSCROLLING_CLEAR_DELAY = 180
// Viewport keys that count as deliberate scroll intent and release follow-bottom.
const USER_SCROLL_KEYS = new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" ", // Space key.
])
// Internal scroll mode. Derived from intent and commands; decides how the
// viewport reacts to content and resize.
type MessageScrollerMode =
| "following-bottom" // autoScroll on, pinned to the latest message.
| "free-scrolling" // reader scrolled away; position left alone (prepends still preserved).
| "anchored-to-message" // holding a turn at the reading line while it streams.
| "settling-jump" // a programmatic jump is animating; intent detection suppressed until it settles.
// Where a saved transcript opens on the first non-empty render.
type MessageScrollerDefaultScrollPosition = "start" | "end" | "last-anchor"
// Which transcript edge MessageScrollerButton scrolls toward.
type MessageScrollerButtonDirection = "start" | "end"
// Viewport alignment for scrollToMessage and programmatic jumps.
type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest"
// Options for scrollToMessage, scrollToEnd, and scrollToStart.
type MessageScrollerScrollOptions = {
// Viewport edge or center to align the target to.
align?: MessageScrollerScrollAlign
// Native scroll behavior.
behavior?: ScrollBehavior
// Margin on the aligned edge, in pixels. Defaults to the provider scrollMargin.
scrollMargin?: number
}
// Scroll snapshot from useMessageScrollerScrollable: which edges the viewport can
// still scroll toward.
type MessageScrollerScrollable = {
// The viewport can scroll toward the start (content is hidden above).
start: boolean
// The viewport can scroll toward the end (content is hidden below).
end: boolean
}
// Visibility snapshot from useMessageScrollerVisibility.
type MessageScrollerVisibilityState = {
// The anchored turn the reader is in, or null. Stays set after the anchor
// scrolls above the viewport.
currentAnchorId: string | null
// messageId values intersecting the viewport, in document order.
visibleMessageIds: string[]
}
// Headless provider for a chat transcript scroller. Owns scroll behavior and
// state; renders no DOM.
type MessageScrollerProviderProps = {
children?: React.ReactNode
// Follow new content at the bottom while the viewport is already at the end.
autoScroll?: boolean
// Opening position on the first non-empty render, applied once.
defaultScrollPosition?: MessageScrollerDefaultScrollPosition
// Distance from an edge that still counts as at-top/at-bottom. Defaults to 8.
scrollEdgeThreshold?: number
// Extra top margin for a newly anchored row, added to scrollMargin. Defaults to 64.
scrollPreviousItemPeek?: number
// Default margin on the aligned edge for commands and visibility. Defaults to 0.
scrollMargin?: number
}
// Frame container for a chat transcript scroller. Must render inside a
// MessageScrollerProvider.
type MessageScrollerProps = React.ComponentProps<"div">
// Scrollable viewport. Owns native scroll events and prepend preservation.
type MessageScrollerViewportProps = React.ComponentProps<"div"> & {
// Keep the first visible messageId row stable on prepend. Defaults to true.
preserveScrollOnPrepend?: boolean
}
// Transcript row container. Every direct child should be a MessageScrollerItem.
type MessageScrollerContentProps = React.ComponentProps<"div"> & {
// Class name for the internal tail spacer used when anchoring turns near the top.
spacerClassName?: string
}
// One transcript row: a message, marker, typing row, separator, or load-more row.
type MessageScrollerItemProps = React.ComponentProps<"div"> & {
// Stable row id for scrollToMessage, visibility, and prepend preservation.
messageId?: string
// Marks a turn boundary that newly appended anchors and last-anchor restore use.
scrollAnchor?: boolean
}
// State passed to MessageScrollerButton's render prop.
type MessageScrollerButtonRenderState = {
// Whether overflow exists toward this button's direction.
active: boolean
direction: MessageScrollerButtonDirection
}
// Scroll control for the start or end of the transcript.
type MessageScrollerButtonProps = UseRenderComponentProps<
"button",
MessageScrollerButtonRenderState
> & {
// Native scroll behavior when clicked. Defaults to "smooth".
behavior?: ScrollBehavior
// Transcript edge to scroll toward. Defaults to "end".
direction?: MessageScrollerButtonDirection
}
// Minimal external store backing useMessageScrollerScrollable, consumed via
// useSyncExternalStore.
type MessageScrollerStore<T> = {
getSnapshot: () => T
setSnapshot: (nextSnapshot: T) => void
subscribe: (listener: () => void) => () => void
}
// External store backing useMessageScrollerVisibility. Adds reference-counting so
// tracking stays lazy.
type MessageScrollerVisibilityStore = {
getSnapshot: () => MessageScrollerVisibilityState
hasListeners: () => boolean
setSnapshot: (nextSnapshot: MessageScrollerVisibilityState) => void
subscribe: (
listener: () => void,
onFirstSubscribe: () => void,
onLastUnsubscribe: () => void
) => () => void
}
// Registers (or, with removedElement, unregisters) a MessageScrollerItem node by
// messageId.
type MessageScrollerRegisterMessage = (
messageId: string,
element: HTMLElement | null,
removedElement?: HTMLElement | null
) => void
// Internal context wiring the parts together. Not part of the public API.
type MessageScrollerContextValue = {
handleContentChange: () => void
handleResize: () => void
observeVisibility: () => void
preserveScrollOnPrependRef: React.RefObject<boolean>
scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean
scrollToMessage: (
messageId: string,
options?: MessageScrollerScrollOptions
) => boolean
scrollToStart: (options?: MessageScrollerScrollOptions) => boolean
setContentElement: (element: HTMLDivElement | null) => void
setRootElement: (element: HTMLDivElement | null) => void
setSpacerElement: (element: HTMLDivElement | null) => void
setViewportElement: (element: HTMLDivElement | null) => void
pendingDefaultScrollStore: MessageScrollerStore<boolean>
stateStore: MessageScrollerStore<MessageScrollerScrollable>
syncAfterScroll: () => void
unobserveVisibility: () => void
userScrollIntent: () => void
viewportRef: React.RefObject<HTMLDivElement | null>
visibilityStore: MessageScrollerVisibilityStore
}
// Initial MessageScrollerScrollable before measurement. Stable reference for the
// server and first-render snapshot.
const EMPTY_MESSAGE_SCROLLER_SCROLLABLE: MessageScrollerScrollable = {
start: false,
end: false,
}
// Shared empty array so empty visibility snapshots stay referentially stable.
const EMPTY_VISIBLE_MESSAGE_IDS: string[] = []
// Initial MessageScrollerVisibilityState. Nothing tracked, no current anchor.
const EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE: MessageScrollerVisibilityState =
{
currentAnchorId: null,
visibleMessageIds: EMPTY_VISIBLE_MESSAGE_IDS,
}
export {
AUTOSCROLLING_CLEAR_DELAY,
DEFAULT_SCROLL_EDGE_THRESHOLD,
DEFAULT_SCROLL_MARGIN,
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
EMPTY_VISIBLE_MESSAGE_IDS,
SCROLL_POSITION_EPSILON,
USER_SCROLL_KEYS,
}
export type {
MessageScrollerButtonDirection,
MessageScrollerButtonProps,
MessageScrollerButtonRenderState,
MessageScrollerContentProps,
MessageScrollerContextValue,
MessageScrollerDefaultScrollPosition,
MessageScrollerItemProps,
MessageScrollerMode,
MessageScrollerProps,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerScrollAlign,
MessageScrollerScrollOptions,
MessageScrollerScrollable,
MessageScrollerStore,
MessageScrollerViewportProps,
MessageScrollerVisibilityState,
MessageScrollerVisibilityStore,
}

View File

@@ -0,0 +1,328 @@
import * as React from "react"
import {
getElementScrollTop,
getElementViewportTop,
getMaxScrollTop,
getTailSpacerHeight,
} from "./geometry"
import { AUTOSCROLLING_CLEAR_DELAY, SCROLL_POSITION_EPSILON } from "./types"
import type { MessageScrollerScrollOptions } from "./types"
import {
markDefaultScrollPositionApplied,
type MessageScrollerRefs,
} from "./use-message-scroller-refs"
// Imperative scroll primitives, split from the controller so the move mechanics
// live apart from the policy that decides when to run them. Each command resolves
// a target scrollTop and returns false when the viewport is not mounted yet.
function useMessageScrollerCommands({
refs,
commitScrollState,
scheduleStateCommit,
scheduleVisibilitySync,
}: {
refs: MessageScrollerRefs
commitScrollState: () => void
scheduleStateCommit: () => void
scheduleVisibilitySync: () => void
}) {
const {
streamingTurnRef,
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
contentRef,
itemCountRef,
messageElementsRef,
modeRef,
pendingScrollToMessageRef,
prependRestoreRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
viewportRef,
} = refs
const setAutoScrolling = React.useCallback(
(autoscrolling: boolean) => {
if (autoscrollingTimeoutRef.current !== null) {
window.clearTimeout(autoscrollingTimeoutRef.current)
autoscrollingTimeoutRef.current = null
}
if (autoscrollingRef.current !== autoscrolling) {
autoscrollingRef.current = autoscrolling
commitScrollState()
}
if (autoscrolling) {
autoscrollingTimeoutRef.current = window.setTimeout(() => {
autoscrollingTimeoutRef.current = null
autoscrollingRef.current = false
commitScrollState()
}, AUTOSCROLLING_CLEAR_DELAY)
}
},
[commitScrollState]
)
const setTailSpacerHeight = React.useCallback((height: number) => {
const spacer = spacerRef.current
if (!spacer) {
return
}
const nextHeight = Math.max(0, Math.ceil(height))
if (spacerHeightRef.current === nextHeight) {
return
}
spacerHeightRef.current = nextHeight
spacer.hidden = nextHeight === 0
spacer.style.height = `${nextHeight}px`
spacer.style.marginTop = nextHeight > 0 ? `${-spacerGapRef.current}px` : ""
}, [])
const scrollToPosition = React.useCallback(
(
scrollTop: number,
{
behavior = "auto",
autoscrolling = false,
}: {
behavior?: ScrollBehavior
autoscrolling?: boolean
} = {}
) => {
const viewport = viewportRef.current
if (!viewport) {
return
}
const nextScrollTop = Math.max(0, scrollTop)
if (
Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON
) {
viewport.scrollTop = nextScrollTop
commitScrollState()
return
}
if (autoscrolling) {
setAutoScrolling(true)
}
viewport.scrollTo({
top: nextScrollTop,
behavior,
})
scheduleStateCommit()
},
[commitScrollState, scheduleStateCommit, setAutoScrolling]
)
const scrollToStart = React.useCallback(
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
if (!viewportRef.current) {
return false
}
setTailSpacerHeight(0)
streamingTurnRef.current = null
modeRef.current = "free-scrolling"
scrollToPosition(0, { behavior })
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const scrollToEnd = React.useCallback(
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
const viewport = viewportRef.current
if (!viewport) {
return false
}
setTailSpacerHeight(0)
streamingTurnRef.current = null
modeRef.current = autoScrollRef.current
? "following-bottom"
: "free-scrolling"
scrollToPosition(getMaxScrollTop(viewport), {
autoscrolling: true,
behavior,
})
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const scrollToElement = React.useCallback(
(
element: HTMLElement,
{
align = "start",
behavior = "auto",
scrollMargin = scrollMarginRef.current,
}: MessageScrollerScrollOptions = {},
{
keepPreviousPeek = false,
}: {
keepPreviousPeek?: boolean
} = {}
) => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport || !content.contains(element)) {
return false
}
const scrollTop = getElementScrollTop({
align,
element,
scrollMargin: keepPreviousPeek
? scrollMargin + scrollPreviousItemPeekRef.current
: scrollMargin,
spacer: spacerRef.current,
viewport,
})
const nextSpacerHeight = getTailSpacerHeight({
content,
scrollTop,
spacer: spacerRef.current,
viewport,
})
setTailSpacerHeight(nextSpacerHeight)
// Seed the prepend anchor with the jump target so a prepend that lands
// before this scroll settles still preserves the jumped-to row; once it
// settles, syncAfterScroll's capturePrependAnchor re-captures it from the
// first visible row.
prependRestoreRef.current = {
element,
viewportTop: getElementViewportTop(element, viewport),
}
modeRef.current = keepPreviousPeek
? "anchored-to-message"
: "settling-jump"
streamingTurnRef.current = keepPreviousPeek ? element : null
scrollToPosition(scrollTop, { behavior })
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const reanchorToAnchoredMessage = React.useCallback(() => {
const element = streamingTurnRef.current
if (
!element ||
!element.isConnected ||
modeRef.current !== "anchored-to-message"
) {
return false
}
// Re-run the placement so the tail spacer is recomputed for the new content
// height and the turn is held at the reading line.
return scrollToElement(
element,
{ align: "start" },
{ keepPreviousPeek: true }
)
}, [scrollToElement])
// The target row may not be mounted yet (e.g. an async-loaded transcript).
// When it is missing the request is queued in pendingScrollToMessageRef and
// flushed later — on registerMessage for that id, or on the next content
// change. An explicit jump also marks the mount default as applied, so
// defaultScrollPosition does not override it.
const scrollToMessage = React.useCallback(
(messageId: string, options?: MessageScrollerScrollOptions) => {
const element = messageElementsRef.current.get(messageId)
if (!element) {
if (itemCountRef.current === 0) {
pendingScrollToMessageRef.current = {
messageId,
options,
}
markDefaultScrollPositionApplied(refs)
return true
}
return false
}
markDefaultScrollPositionApplied(refs)
if (scrollToElement(element, options)) {
pendingScrollToMessageRef.current = null
return true
}
pendingScrollToMessageRef.current = {
messageId,
options,
}
return true
},
[scrollToElement]
)
const flushPendingScrollToMessage = React.useCallback(() => {
const pending = pendingScrollToMessageRef.current
if (!pending) {
return false
}
const element = messageElementsRef.current.get(pending.messageId)
if (!element) {
return false
}
const handled = scrollToElement(element, pending.options)
if (!handled) {
return false
}
pendingScrollToMessageRef.current = null
markDefaultScrollPositionApplied(refs)
return true
}, [scrollToElement])
return {
flushPendingScrollToMessage,
reanchorToAnchoredMessage,
scrollToElement,
scrollToEnd,
scrollToMessage,
scrollToStart,
}
}
export { useMessageScrollerCommands }

View File

@@ -0,0 +1,774 @@
import * as React from "react"
import {
getContentBottom,
getElementTop,
getElementViewportTop,
getFirstVisibleMessageItem,
getFlexGap,
getLastScrollAnchor,
getMessageScrollerItems,
getMessageScrollerScrollable,
getMessageScrollerVisibilityState,
getNewScrollAnchor,
getUnanchoredScrollAnchor,
hasMultipleNewScrollAnchors,
} from "./geometry"
import {
DEFAULT_SCROLL_EDGE_THRESHOLD,
DEFAULT_SCROLL_MARGIN,
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
SCROLL_POSITION_EPSILON,
} from "./types"
import type {
MessageScrollerContextValue,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerScrollable,
} from "./types"
import { useMessageScrollerCommands } from "./use-message-scroller-commands"
import {
clearPendingDefaultScroll,
markDefaultScrollPositionApplied,
useMessageScrollerRefs,
} from "./use-message-scroller-refs"
// Builds a ref callback that stores the node and runs onMount once it attaches.
function useElementRef(
elementRef: React.RefObject<HTMLDivElement | null>,
onMount: () => void
) {
return React.useCallback(
(element: HTMLDivElement | null) => {
elementRef.current = element
if (element) {
onMount()
}
},
[elementRef, onMount]
)
}
// Orchestrator hook. Decides when to scroll and delegates the moves to
// useMessageScrollerCommands; state and visibility commits are coalesced on a
// requestAnimationFrame and torn down on cleanup for StrictMode safety.
function useMessageScrollerController({
autoScroll = false,
defaultScrollPosition = "end",
scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD,
scrollPreviousItemPeek = DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
scrollMargin = DEFAULT_SCROLL_MARGIN,
}: MessageScrollerProviderProps) {
const refs = useMessageScrollerRefs({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollMargin,
scrollPreviousItemPeek,
})
const {
streamingTurnRef,
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
contentRef,
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,
pendingScrollToMessageRef,
prependRestoreRef,
preserveScrollOnPrependRef,
pendingDefaultScrollStore,
rootRef,
scrollEdgeThresholdRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
stateFrameRef,
stateStore,
viewportRef,
visibilityFrameRef,
visibilityObserverRef,
visibilityStore,
visibleMessageIdsRef,
handledScrollAnchorsRef,
} = refs
const previousDefaultScrollPositionRef = React.useRef(defaultScrollPosition)
if (previousDefaultScrollPositionRef.current !== defaultScrollPosition) {
previousDefaultScrollPositionRef.current = defaultScrollPosition
defaultScrollPositionAppliedRef.current = false
// The pending-scroll hold is mount-only. A live prop change re-applies the
// opening position in layout; it does not hide the viewport again.
}
const writeStateAttributes = React.useCallback(
(state: MessageScrollerScrollable) => {
const root = rootRef.current
const viewport = viewportRef.current
const scrollable = [state.start && "start", state.end && "end"]
.filter(Boolean)
.join(" ")
const autoScrolling = autoscrollingRef.current
for (const element of [root, viewport]) {
if (!element) {
continue
}
if (scrollable) {
element.setAttribute("data-scrollable", scrollable)
} else {
element.removeAttribute("data-scrollable")
}
element.toggleAttribute("data-autoscrolling", autoScrolling)
}
},
[]
)
// Owns the one follow-bottom transition: arm at the bottom, release on any
// scroll away (including a scrollbar drag), suppressed during a programmatic
// scroll so the auto-scroll animation cannot release itself. Arming also
// skips the anchored-to-message hold: the tail spacer makes a freshly
// anchored turn read as "at the end", and re-arming there would let the
// first streamed chunk yank the reader off the anchor. The hold hands back
// to following in handleResize, once the reply consumes the tail spacer.
const reconcileFollowMode = React.useCallback(
(scrollable: MessageScrollerScrollable) => {
const scrollTop = viewportRef.current?.scrollTop ?? 0
// Content growing past the live edge also reads as "not at the end", but
// only a scrollbar drag moves scrollTop up. Growth must not release
// follow-output: the resize handler is coalesced onto a frame, so a state
// commit can observe the grown content before follow catches up.
const scrolledUp =
scrollTop < lastScrollTopRef.current - SCROLL_POSITION_EPSILON
lastScrollTopRef.current = scrollTop
if (
autoScrollRef.current &&
!scrollable.end &&
modeRef.current !== "settling-jump" &&
modeRef.current !== "anchored-to-message"
) {
modeRef.current = "following-bottom"
} else if (
modeRef.current === "following-bottom" &&
scrollable.end &&
scrolledUp &&
!autoscrollingRef.current
) {
modeRef.current = "free-scrolling"
}
},
[]
)
const commitScrollState = React.useCallback(() => {
const nextState = getMessageScrollerScrollable({
content: contentRef.current,
scrollEdgeThreshold: scrollEdgeThresholdRef.current,
spacer: spacerRef.current,
viewport: viewportRef.current,
})
reconcileFollowMode(nextState)
// While follow-output is engaged the scroller is already closing any gap a
// streamed chunk just opened, so publishing it as scrollable toward the
// end would strobe the scroll button once per chunk. Reconcile runs on the
// raw geometry first, so a commit that releases follow still publishes the
// gap it released over.
const publishedState =
modeRef.current === "following-bottom"
? { ...nextState, end: false }
: nextState
writeStateAttributes(publishedState)
stateStore.setSnapshot(publishedState)
}, [reconcileFollowMode, stateStore, writeStateAttributes])
const scheduleStateCommit = React.useCallback(() => {
if (stateFrameRef.current !== null) {
return
}
stateFrameRef.current = window.requestAnimationFrame(() => {
stateFrameRef.current = null
commitScrollState()
})
}, [commitScrollState])
const scheduleVisibilitySync = React.useCallback(() => {
if (!visibilityStore.hasListeners()) {
return
}
if (visibilityFrameRef.current !== null) {
return
}
visibilityFrameRef.current = window.requestAnimationFrame(() => {
visibilityFrameRef.current = null
// A frame can outlive the last unsubscribe. Recomputing here would
// overwrite the EMPTY snapshot that teardown just wrote, leaving a stale
// value for the next subscriber to read.
if (!visibilityStore.hasListeners()) {
return
}
visibilityStore.setSnapshot(
getMessageScrollerVisibilityState({
content: contentRef.current,
scrollMargin: scrollMarginRef.current,
scrollPreviousItemPeek: scrollPreviousItemPeekRef.current,
spacer: spacerRef.current,
viewport: viewportRef.current,
visibleMessageIds: visibleMessageIdsRef.current,
})
)
})
}, [visibilityStore])
const {
flushPendingScrollToMessage,
reanchorToAnchoredMessage,
scrollToElement,
scrollToEnd,
scrollToMessage,
scrollToStart,
} = useMessageScrollerCommands({
refs,
commitScrollState,
scheduleStateCommit,
scheduleVisibilitySync,
})
const restorePrependedAnchor = React.useCallback(() => {
const anchor = prependRestoreRef.current
const viewport = viewportRef.current
if (!anchor || !viewport || !anchor.element.isConnected) {
return false
}
// Compare the anchor relative to the viewport, not to the content. Native
// scroll anchoring leaves the viewport-relative position unchanged, so this
// is a no-op where the browser already handled the prepend and only corrects
// the scroll where it did not (e.g. Safari) — without trusting a capability
// flag, which some engines report incorrectly.
const nextViewportTop = getElementViewportTop(anchor.element, viewport)
const delta = nextViewportTop - anchor.viewportTop
if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) {
return false
}
viewport.scrollTop += delta
anchor.viewportTop = getElementViewportTop(anchor.element, viewport)
scheduleStateCommit()
scheduleVisibilitySync()
return true
}, [scheduleStateCommit, scheduleVisibilitySync])
const capturePrependAnchor = React.useCallback(() => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport) {
prependRestoreRef.current = null
return
}
const anchor = getFirstVisibleMessageItem({
content,
spacer: spacerRef.current,
viewport,
})
prependRestoreRef.current = anchor
? {
element: anchor,
viewportTop: getElementViewportTop(anchor, viewport),
}
: null
}, [])
const schedulePendingScrollToMessageFlush = React.useCallback(() => {
if (pendingScrollFrameRef.current !== null) {
return
}
pendingScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingScrollFrameRef.current = null
if (flushPendingScrollToMessage()) {
capturePrependAnchor()
}
})
}, [capturePrependAnchor, flushPendingScrollToMessage])
const applyDefaultScrollPosition = React.useCallback(() => {
if (
!defaultScrollPosition ||
defaultScrollPositionAppliedRef.current ||
itemCountRef.current === 0
) {
return false
}
let handled = false
if (defaultScrollPosition === "last-anchor") {
const content = contentRef.current
const viewport = viewportRef.current
const anchor =
content && viewport
? getLastScrollAnchor(
getMessageScrollerItems(content, spacerRef.current)
)
: null
if (!content || !viewport || !anchor) {
handled = scrollToEnd({ behavior: "auto" })
} else {
const anchorTop = getElementTop(anchor, viewport)
const contentBottom = getContentBottom({
content,
spacer: spacerRef.current,
viewport,
})
// A short last turn already fits below the anchor, so opening at the end
// shows the whole turn without leaving a blank gap beneath it.
const lastTurnFits = contentBottom - anchorTop <= viewport.clientHeight
handled = lastTurnFits
? scrollToEnd({ behavior: "auto" })
: scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
}
} else {
handled =
defaultScrollPosition === "end"
? scrollToEnd({ behavior: "auto" })
: scrollToStart({ behavior: "auto" })
}
if (!handled) {
return false
}
markDefaultScrollPositionApplied(refs)
return true
}, [defaultScrollPosition, scrollToElement, scrollToEnd, scrollToStart])
const handleContentChange = React.useCallback(() => {
const content = contentRef.current
if (!content) {
return
}
const items = getMessageScrollerItems(content, spacerRef.current)
const previousItemCount = itemCountRef.current
const previousFirstItem = firstItemRef.current
itemCountRef.current = items.length
firstItemRef.current = items[0] ?? null
// Reconcile the scroll position with the new content. Every path re-captures
// the prepend anchor afterward, so each branch just returns.
//
// Branch order is load-bearing: first-content, prepended, appended, updated.
const reconcileScrollPosition = () => {
if (flushPendingScrollToMessage()) {
return
}
if (previousItemCount === 0) {
if (applyDefaultScrollPosition()) {
return
}
if (
items.length > 0 &&
autoScrollRef.current &&
scrollToEnd({ behavior: "auto" })
) {
return
}
commitScrollState()
scheduleVisibilitySync()
return
}
const previousFirstItemIndex = previousFirstItem
? items.indexOf(previousFirstItem)
: -1
const didPrepend =
preserveScrollOnPrependRef.current && previousFirstItemIndex > 0
if (didPrepend) {
// Prepended rows are not new appends. Restore the prior scroll position.
// The restore is a no-op where native scroll anchoring already did it.
restorePrependedAnchor()
return
}
if (items.length > previousItemCount) {
const anchor = getNewScrollAnchor(items, previousItemCount)
if (anchor) {
// While the reader is following the live end, a batch of several
// anchored turns arriving at once should keep following the end — not
// yank back to anchor the first turn of the batch. A single new anchor
// still moves to the top as usual.
if (
autoScrollRef.current &&
modeRef.current === "following-bottom" &&
hasMultipleNewScrollAnchors(items, previousItemCount)
) {
scrollToEnd({ behavior: "auto" })
return
}
scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
handledScrollAnchorsRef.current.add(anchor)
return
}
}
if (items.length === previousItemCount) {
const anchor = getUnanchoredScrollAnchor(
items,
handledScrollAnchorsRef.current
)
if (anchor) {
scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
handledScrollAnchorsRef.current.add(anchor)
return
}
}
// Appends with no new anchor (and content-only updates) fall through here:
// keep following the end if we still are, otherwise just recommit state.
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
scrollToEnd({ behavior: "auto" })
} else {
commitScrollState()
scheduleVisibilitySync()
}
}
reconcileScrollPosition()
capturePrependAnchor()
}, [
applyDefaultScrollPosition,
capturePrependAnchor,
commitScrollState,
flushPendingScrollToMessage,
restorePrependedAnchor,
scheduleVisibilitySync,
scrollToElement,
scrollToEnd,
])
const handleResize = React.useCallback(() => {
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
scrollToEnd({ behavior: "auto" })
return
}
// Hold the anchored turn in place as content below it resizes (a reply
// streaming in, or a transient marker collapsing) — otherwise the shrinking
// content lets the browser clamp scrollTop and the turn drops.
const previousSpacerHeight = spacerHeightRef.current
if (reanchorToAnchoredMessage()) {
// The reply streaming below the anchor consumes the tail spacer as it
// grows. Once the last of it is gone the reply has filled the viewport
// and the reader is genuinely at the live edge, so autoScroll hands off
// from the anchor hold to following the bottom. Requiring the >0 → 0
// transition keeps a turn taller than the viewport (placed with no
// spacer) held instead of yanked to the end.
if (
autoScrollRef.current &&
previousSpacerHeight > 0 &&
spacerHeightRef.current === 0
) {
scrollToEnd({ behavior: "auto" })
}
return
}
scheduleStateCommit()
scheduleVisibilitySync()
}, [
reanchorToAnchoredMessage,
scheduleStateCommit,
scheduleVisibilitySync,
scrollToEnd,
])
const observeVisibility = React.useCallback(() => {
const viewport = viewportRef.current
if (!viewport || !visibilityStore.hasListeners()) {
return
}
if (typeof IntersectionObserver === "undefined") {
scheduleVisibilitySync()
return
}
if (!visibilityObserverRef.current) {
visibilityObserverRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const messageId = (entry.target as HTMLElement).dataset.messageId
if (!messageId) {
continue
}
if (entry.isIntersecting) {
visibleMessageIdsRef.current.add(messageId)
} else {
visibleMessageIdsRef.current.delete(messageId)
}
}
scheduleVisibilitySync()
},
{
root: viewport,
// Shrink the root's top edge to the anchoring line so a previous turn
// peeking in the scrollMargin + peek band is not reported as visible,
// keeping visibleMessageIds consistent with currentAnchorId. Captured
// at observe time; a prop change rebuilds the observer on resubscribe.
rootMargin: `${-(
scrollMarginRef.current + scrollPreviousItemPeekRef.current
)}px 0px 0px 0px`,
threshold: [0, 0.01, 0.5, 1],
}
)
}
messageElementsRef.current.forEach((element) => {
visibilityObserverRef.current?.observe(element)
})
scheduleVisibilitySync()
}, [scheduleVisibilitySync, visibilityStore])
const unobserveVisibility = React.useCallback(() => {
if (visibilityFrameRef.current !== null) {
window.cancelAnimationFrame(visibilityFrameRef.current)
visibilityFrameRef.current = null
}
visibilityObserverRef.current?.disconnect()
visibilityObserverRef.current = null
visibleMessageIdsRef.current.clear()
visibilityStore.setSnapshot(EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE)
}, [visibilityStore])
const registerMessage = React.useCallback<MessageScrollerRegisterMessage>(
(messageId, element, removedElement) => {
if (element) {
messageElementsRef.current.set(messageId, element)
visibilityObserverRef.current?.observe(element)
scheduleVisibilitySync()
if (pendingScrollToMessageRef.current?.messageId === messageId) {
schedulePendingScrollToMessageFlush()
}
return
}
if (
removedElement &&
messageElementsRef.current.get(messageId) === removedElement
) {
messageElementsRef.current.delete(messageId)
visibleMessageIdsRef.current.delete(messageId)
visibilityObserverRef.current?.unobserve(removedElement)
scheduleVisibilitySync()
}
},
[schedulePendingScrollToMessageFlush, scheduleVisibilitySync]
)
const userScrollIntent = React.useCallback(() => {
if (
modeRef.current === "following-bottom" ||
modeRef.current === "anchored-to-message" ||
modeRef.current === "settling-jump"
) {
// A deliberate gesture releases auto-follow, turn-anchoring, and an in-flight
// programmatic jump so re-pinning (and re-arming) never fights the reader.
streamingTurnRef.current = null
modeRef.current = "free-scrolling"
}
}, [])
const mirrorStateAttributes = React.useCallback(
() => writeStateAttributes(stateStore.getSnapshot()),
[stateStore, writeStateAttributes]
)
const setRootElement = useElementRef(rootRef, mirrorStateAttributes)
const setViewportElement = useElementRef(viewportRef, mirrorStateAttributes)
const setContentElement = React.useCallback(
(element: HTMLDivElement | null) => {
contentRef.current = element
},
[]
)
const setSpacerElement = React.useCallback(
(element: HTMLDivElement | null) => {
spacerRef.current = element
spacerGapRef.current = getFlexGap(element?.parentElement ?? null)
},
[]
)
const syncAfterScroll = React.useCallback(() => {
commitScrollState()
scheduleVisibilitySync()
capturePrependAnchor()
}, [capturePrependAnchor, commitScrollState, scheduleVisibilitySync])
const context = React.useMemo<MessageScrollerContextValue>(
() => ({
handleContentChange,
handleResize,
observeVisibility,
pendingDefaultScrollStore,
preserveScrollOnPrependRef,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setRootElement,
setSpacerElement,
setViewportElement,
stateStore,
syncAfterScroll,
unobserveVisibility,
userScrollIntent,
viewportRef,
visibilityStore,
}),
[
handleContentChange,
handleResize,
observeVisibility,
pendingDefaultScrollStore,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setRootElement,
setSpacerElement,
setViewportElement,
stateStore,
syncAfterScroll,
unobserveVisibility,
userScrollIntent,
visibilityStore,
]
)
React.useLayoutEffect(() => {
if (applyDefaultScrollPosition()) {
return
}
if (itemCountRef.current === 0) {
clearPendingDefaultScroll(refs)
}
}, [applyDefaultScrollPosition])
React.useEffect(() => {
return () => {
// Reset every ref after cancelling. StrictMode replays effects on the same
// refs (unmount then remount), so a frame id left non-null here makes the
// scheduler on remount think a frame is still pending and never reschedule.
if (stateFrameRef.current !== null) {
window.cancelAnimationFrame(stateFrameRef.current)
stateFrameRef.current = null
}
if (visibilityFrameRef.current !== null) {
window.cancelAnimationFrame(visibilityFrameRef.current)
visibilityFrameRef.current = null
}
if (autoscrollingTimeoutRef.current !== null) {
window.clearTimeout(autoscrollingTimeoutRef.current)
autoscrollingTimeoutRef.current = null
}
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current)
pendingScrollFrameRef.current = null
}
visibilityObserverRef.current?.disconnect()
visibilityObserverRef.current = null
}
}, [])
React.useLayoutEffect(() => {
if (
autoScroll &&
modeRef.current === "following-bottom" &&
itemCountRef.current > 0
) {
scrollToEnd({ behavior: "auto" })
return
}
commitScrollState()
}, [autoScroll, commitScrollState, scrollToEnd])
return {
context,
registerMessage,
}
}
export { useMessageScrollerController }

View File

@@ -0,0 +1,200 @@
import * as React from "react"
import {
areScrollStatesEqual,
createMessageScrollerStore,
createMessageScrollerVisibilityStore,
} from "./stores"
import { EMPTY_MESSAGE_SCROLLER_SCROLLABLE } from "./types"
import type {
MessageScrollerDefaultScrollPosition,
MessageScrollerMode,
MessageScrollerScrollable,
MessageScrollerScrollOptions,
MessageScrollerStore,
MessageScrollerVisibilityStore,
} from "./types"
// Shared mutable ref bag for one MessageScroller, closed over by both the
// controller and the commands so writes are visible across them without prop
// threading. stateStore and visibilityStore fan out via useSyncExternalStore.
type MessageScrollerRefs = {
autoScrollRef: React.RefObject<boolean>
autoscrollingRef: React.RefObject<boolean>
autoscrollingTimeoutRef: React.RefObject<number | null>
streamingTurnRef: React.RefObject<HTMLElement | null>
contentRef: React.RefObject<HTMLDivElement | null>
defaultScrollPositionAppliedRef: React.RefObject<boolean>
firstItemRef: React.RefObject<HTMLElement | null>
itemCountRef: React.RefObject<number>
lastScrollTopRef: React.RefObject<number>
messageElementsRef: React.RefObject<Map<string, HTMLElement>>
modeRef: React.RefObject<MessageScrollerMode>
pendingScrollFrameRef: React.RefObject<number | null>
pendingScrollToMessageRef: React.RefObject<{
messageId: string
options?: MessageScrollerScrollOptions
} | null>
prependRestoreRef: React.RefObject<{
element: HTMLElement
viewportTop: number
} | null>
preserveScrollOnPrependRef: React.RefObject<boolean>
pendingDefaultScrollStore: MessageScrollerStore<boolean>
rootRef: React.RefObject<HTMLDivElement | null>
scrollEdgeThresholdRef: React.RefObject<number>
scrollMarginRef: React.RefObject<number>
scrollPreviousItemPeekRef: React.RefObject<number>
spacerGapRef: React.RefObject<number>
spacerHeightRef: React.RefObject<number>
spacerRef: React.RefObject<HTMLDivElement | null>
stateFrameRef: React.RefObject<number | null>
stateStore: MessageScrollerStore<MessageScrollerScrollable>
viewportRef: React.RefObject<HTMLDivElement | null>
visibilityFrameRef: React.RefObject<number | null>
visibilityObserverRef: React.RefObject<IntersectionObserver | null>
visibilityStore: MessageScrollerVisibilityStore
visibleMessageIdsRef: React.RefObject<Set<string>>
handledScrollAnchorsRef: React.RefObject<WeakSet<HTMLElement>>
}
// Builds the per-instance ref bag: the two external stores constructed once, and
// the latest prop values mirrored onto refs each render so callbacks stay stable.
function useMessageScrollerRefs({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollMargin,
scrollPreviousItemPeek,
}: {
autoScroll: boolean
defaultScrollPosition: MessageScrollerDefaultScrollPosition
scrollEdgeThreshold: number
scrollMargin: number
scrollPreviousItemPeek: number
}): MessageScrollerRefs {
const autoScrollRef = React.useRef(autoScroll)
const autoscrollingRef = React.useRef(false)
const contentRef = React.useRef<HTMLDivElement | null>(null)
const defaultScrollPositionAppliedRef = React.useRef(false)
const scrollEdgeThresholdRef = React.useRef(scrollEdgeThreshold)
const itemCountRef = React.useRef(0)
const firstItemRef = React.useRef<HTMLElement | null>(null)
// The scrollTop seen by the previous state commit, so follow-release can tell
// a reader scrolling up from content growing past the live edge.
const lastScrollTopRef = React.useRef(0)
const modeRef = React.useRef<MessageScrollerMode>(
autoScroll ? "following-bottom" : "free-scrolling"
)
const messageElementsRef = React.useRef(new Map<string, HTMLElement>())
const pendingScrollToMessageRef = React.useRef<{
messageId: string
options?: MessageScrollerScrollOptions
} | null>(null)
// The row to hold steady on the next prepend: the first visible row, or a jump
// target seeded by scrollToElement. restorePrependedAnchor reads only this.
const prependRestoreRef = React.useRef<{
element: HTMLElement
viewportTop: number
} | null>(null)
// The turn held at the reading line so a reply streaming in below it can re-pin
// it instead of letting scrollTop clamp it loose.
const streamingTurnRef = React.useRef<HTMLElement | null>(null)
const scrollPreviousItemPeekRef = React.useRef(scrollPreviousItemPeek)
const preserveScrollOnPrependRef = React.useRef(true)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const scrollMarginRef = React.useRef(scrollMargin)
const pendingScrollFrameRef = React.useRef<number | null>(null)
const spacerGapRef = React.useRef(0)
const spacerHeightRef = React.useRef(0)
const spacerRef = React.useRef<HTMLDivElement | null>(null)
const stateFrameRef = React.useRef<number | null>(null)
const pendingDefaultScrollStoreRef =
React.useRef<MessageScrollerStore<boolean> | null>(null)
const stateStoreRef =
React.useRef<MessageScrollerStore<MessageScrollerScrollable> | null>(null)
const autoscrollingTimeoutRef = React.useRef<number | null>(null)
const viewportRef = React.useRef<HTMLDivElement | null>(null)
const visibilityFrameRef = React.useRef<number | null>(null)
const visibilityObserverRef = React.useRef<IntersectionObserver | null>(null)
const visibilityStoreRef =
React.useRef<MessageScrollerVisibilityStore | null>(null)
const visibleMessageIdsRef = React.useRef(new Set<string>())
const handledScrollAnchorsRef = React.useRef(new WeakSet<HTMLElement>())
if (pendingDefaultScrollStoreRef.current === null) {
pendingDefaultScrollStoreRef.current = createMessageScrollerStore(
defaultScrollPosition === "end" ||
defaultScrollPosition === "last-anchor",
(current, next) => current === next
)
}
if (stateStoreRef.current === null) {
stateStoreRef.current = createMessageScrollerStore(
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
areScrollStatesEqual
)
}
if (visibilityStoreRef.current === null) {
visibilityStoreRef.current = createMessageScrollerVisibilityStore()
}
// Track the latest prop values on every render so callbacks read fresh values
// without being recreated (the useLatest pattern).
autoScrollRef.current = autoScroll
scrollEdgeThresholdRef.current = scrollEdgeThreshold
scrollMarginRef.current = scrollMargin
scrollPreviousItemPeekRef.current = scrollPreviousItemPeek
return {
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
streamingTurnRef,
contentRef,
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,
pendingScrollToMessageRef,
prependRestoreRef,
preserveScrollOnPrependRef,
pendingDefaultScrollStore: pendingDefaultScrollStoreRef.current,
rootRef,
scrollEdgeThresholdRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
stateFrameRef,
stateStore: stateStoreRef.current,
viewportRef,
visibilityFrameRef,
visibilityObserverRef,
visibilityStore: visibilityStoreRef.current,
visibleMessageIdsRef,
handledScrollAnchorsRef,
}
}
function clearPendingDefaultScroll(refs: MessageScrollerRefs) {
refs.pendingDefaultScrollStore.setSnapshot(false)
}
function markDefaultScrollPositionApplied(refs: MessageScrollerRefs) {
refs.defaultScrollPositionAppliedRef.current = true
clearPendingDefaultScroll(refs)
}
export {
clearPendingDefaultScroll,
markDefaultScrollPositionApplied,
useMessageScrollerRefs,
}
export type { MessageScrollerRefs }

View File

@@ -0,0 +1,11 @@
import * as React from "react"
function useLatest<T>(value: T) {
const ref = React.useRef(value)
ref.current = value
return ref
}
export { useLatest }