snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
176
packages/react/src/message-scroller/PERFORMANCE.md
Normal file
176
packages/react/src/message-scroller/PERFORMANCE.md
Normal 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.
|
||||
73
packages/react/src/message-scroller/README.md
Normal file
73
packages/react/src/message-scroller/README.md
Normal 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)
|
||||
```
|
||||
446
packages/react/src/message-scroller/components.tsx
Normal file
446
packages/react/src/message-scroller/components.tsx
Normal 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,
|
||||
}
|
||||
451
packages/react/src/message-scroller/geometry.test.ts
Normal file
451
packages/react/src/message-scroller/geometry.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
387
packages/react/src/message-scroller/geometry.ts
Normal file
387
packages/react/src/message-scroller/geometry.ts
Normal 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,
|
||||
}
|
||||
31
packages/react/src/message-scroller/index.ts
Normal file
31
packages/react/src/message-scroller/index.ts
Normal 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"
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
1971
packages/react/src/message-scroller/message-scroller.test.tsx
Normal file
1971
packages/react/src/message-scroller/message-scroller.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
97
packages/react/src/message-scroller/stores.ts
Normal file
97
packages/react/src/message-scroller/stores.ts
Normal 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,
|
||||
}
|
||||
240
packages/react/src/message-scroller/types.ts
Normal file
240
packages/react/src/message-scroller/types.ts
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
200
packages/react/src/message-scroller/use-message-scroller-refs.ts
Normal file
200
packages/react/src/message-scroller/use-message-scroller-refs.ts
Normal 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 }
|
||||
11
packages/react/src/message-scroller/utils.ts
Normal file
11
packages/react/src/message-scroller/utils.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user