Skip to content

feat(MessageList,ChatContainer): allow disabling auto-scroll - #239

Draft
chelentos wants to merge 8 commits into
mainfrom
feat/message-list-auto-scroll-config
Draft

feat(MessageList,ChatContainer): allow disabling auto-scroll#239
chelentos wants to merge 8 commits into
mainfrom
feat/message-list-auto-scroll-config

Conversation

@chelentos

Copy link
Copy Markdown

Summary

Adds a single autoScroll?: boolean prop (default true) to MessageList and ChatContainer that switches off automatic scroll-to-bottom in the message list.

Omitting the prop is a strict no-op. Receipts, since that is the only question that matters for a published component library:

  • Both gates are prepended to existing short-circuit chains. In useVirtualStickToBottom the whole gate is !autoScrollRef.current || at the head of the two guards already inside pinToBottom, so with the default it evaluates to false || <original condition>.
  • Every behavior argument is unchanged — 'instant' on mount / resize / mutation, 'smooth' on status and messagesCount, and scrollToRow's {align: 'end', behavior: 'instant'} in the virtualized path. The tests assert the argument objects, not just that a call happened, so a future swap would fail.
  • The flag appears in zero dependency arrays. It lives in a ref read at fire time. The three dependency arrays that did grow received only autoScrollToBottom, which is a useCallback over a useCallback with [] deps — referentially stable for the component's lifetime, so no effect can re-fire that did not before. There is a test per hook that fails if the flag is ever added to a dependency array.

Motivation

Both scroll hooks pin the viewport to the bottom on five triggers: first mount, a message being appended, a status transition, streaming content growth, and a viewport resize. They already yield once the user scrolls up — but that guard is only armed by a real scroll event.

So the common case is not covered: a reader who never touches the wheel while a long answer streams in still gets pulled to the bottom as the content grows under them. Today there is no way out short of forking MessageList.

API

<ChatContainer autoScroll={false} ... />
<MessageList autoScroll={false} ... />

autoScroll is a top-level ChatContainer prop and is excluded from MessageListConfig, so it cannot be set in two places — matching how showActionsOnHover, transformOptions and mdxProps are already handled.

Why a boolean rather than per-trigger control

A per-trigger object ({initial, onNewMessage, onStreaming}) was implemented first and deliberately dropped. Separating the triggers turned out to create three distinct bug classes, all of them consequences of separability rather than of implementation quality:

  • initial and onNewMessage compete for the same moment whenever history loads asynchronously — the list mounts empty, so the initial pin has nothing to scroll to, and the arriving history gets claimed by onNewMessage. {initial: true, onNewMessage: false} would open at the top; {initial: false} would scroll anyway.
  • Fixing that needs a per-effect first-run guard, which React StrictMode then defeats: it preserves refs across its simulated remount, so the guard is spent by the first pass.
  • ResizeObserver.observe() delivers a callback immediately with the current size. That is not a resize, so it has to be suppressed too — but only when the resize trigger is independently switchable.

A single flag read at fire time is stateless and has none of them. Widening boolean to boolean | AutoScrollConfig later is a non-breaking type extension, so nothing is foreclosed if a concrete per-trigger need turns up.

Behavior worth knowing

  • With autoScroll={false} a chat with history opens scrolled to the top, not at the last message. The switch is all-or-nothing.
  • Re-enabling takes effect at the next scroll trigger; it does not scroll to the bottom immediately.
  • Unaffected at either setting: the user-scrolled-up guard, useScrollPreservation, and the prepend/anti-jump viewport preservation in the virtualized list — that last one is anti-jump behavior, not auto-scroll.

Testing

21 new unit tests across the two hooks (npm run test:unit: 302 passing). Both negative-test groups were mutation-checked — the gate was temporarily removed to confirm the tests actually fail without it, including the per-frame step() re-check in the virtualized hook, which is only reachable by advancing requestAnimationFrame.

No Playwright snapshots: the feature is behavioural and introduces no new static visual state.

Two things reviewers may trip over

  • docs/HOOKS.md correction (drive-by). The useSmartScroll block documented a signature that has never existed in this repo — options {threshold, enabled} returning {ref, isAtBottom, scrollToBottom}. The real hook takes {isStreaming, messagesCount, status} and returns {containerRef, scrollToBottom}. Corrected here since the block had to be touched anyway. The adjacent useScrollPreservation block is wrong in the same way; left alone as out of scope, happy to fix it in a follow-up.
  • llms-full.txt contains one hunk unrelated to this change. The file is generated by npm run generate:llms from the root README and docs/*.md, and the checked-in copy was stale: regenerating it after the docs/HOOKS.md edit also picked up a CSS-variable row sourced from docs/THEMING.md. THEMING.md itself is untouched by this branch.

🤖 Generated with Claude Code

Adds an autoScroll option (default true) to useSmartScroll and
useVirtualStickToBottom so consumers can disable automatic pinning to
the bottom while keeping user-scroll tracking and the imperative
scrollToBottom / prepend-restore paths working.
…guard

Address review findings on the auto-scroll gating tests:
- assert the behavior argument (instant vs smooth) on the four positive
  useSmartScroll cases, so a dropped 'smooth' is caught;
- add a disabled-to-enabled toggle test per hook (holding every other
  prop fixed) to prove autoScroll is read via ref rather than through
  an effect dependency array.
useSmartScroll has a dedicated effect that scrolls to bottom on any
status transition, gated by autoScroll like the other four triggers.
The prior autoScroll docs (README, JSDoc, HOOKS.md) enumerated mount /
new message / streaming / viewport resize but omitted it.
…ocument re-enable timing

MessageList/README.md and HOOKS.md claimed a consumer could disable auto-scroll and still
force-scroll via scrollToBottom; it still early-returns under the user-scrolled-up guard, and
MessageList/useVirtualStickToBottom never expose it at all. Also note in the autoScroll JSDoc
(MessageList, ChatContainer) that re-enabling only takes effect at the next scroll trigger, not
immediately - otherwise a consumer wiring a "follow along" toggle would see it do nothing on an
idle conversation. Shortened the ChatContainer README's autoScroll row so it no longer forces
prettier to re-align the whole props table, and moved the detail into a new Auto-scroll section
with a usage example.
…ions

useVirtualStickToBottom's pinToBottom re-applies scrollToRow across several animation frames, with
its own autoScrollRef re-check inside step(). No existing test advanced requestAnimationFrame, so
that per-frame guard had zero coverage - deleting it left all tests green. Add a test that captures
the scheduled rAF callback, disables autoScroll mid-flight, and invokes the callback directly to
prove the guard is load-bearing (verified: fails when the guard is removed, passes when restored).
Also tighten the four positive pin assertions from bare toHaveBeenCalled() to the exact
{index, align, behavior} argument object, matching the stricter style already used in the
useSmartScroll tests.
@gravity-ui

gravity-ui Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🚀 Prerelease version published!

Install this PR version:

npm i --save-dev @gravity-ui/aikit@2.19.2-beta.8bf8370c62216e1d8db334845ec2cb7fba2eef05.0

@gravity-ui-bot

Copy link
Copy Markdown

Preview is ready.

@gravity-ui

gravity-ui Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🎭 Component Tests Report is ready.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants