RFC: Anchor an arbitrary index and grow around it #1211
Replies: 2 comments
-
|
Thanks for the unusually thorough RFC, this is one of the clearest write-ups of the streaming‑chat scroll problem I've seen. I took your use case and built a small reference example to pin down exactly what the package is missing vs. what's already possible. https://stackblitz.com/edit/vitejs-vite-tvutgddd Two things worth checking against your mental model. First: is this the behavior you're after?There seem to be two distinct "keep the question up top" models, and they need very different things from the library:
We found B is fully achievable today with no core changes, on existing primitives: const virtualizer = useVirtualizer({
// …
anchorTo: 'end', // follow the streamed answer; keeps prepend stable too
followOnAppend: true,
rangeExtractor: (range) => {
// active question = last user message at/above the viewport top
activeSticky.current =
userIndexes.findLast((i) => range.startIndex >= i) ?? 0
// keep it rendered even when scrolled out of range
return [...new Set([activeSticky.current, ...defaultRangeExtractor(range)])]
.sort((a, b) => a - b)
},
})
// render the active question with `position: sticky; top: 0`That already gives prepend stability (your point 1's common case), streaming follow, and sticky question headers — including "scroll past the current question and the previous one takes over." So the question for you: does B cover your product, or do you specifically need A's immediate pin (question at the top before any answer text)? Because A is the only variant that actually needs your "growth reserve" / movable‑anchor primitive — and even then it scopes down to essentially one thing (a built‑in bottom reserve tied to a target index that shrinks as content fills it), not the full movable‑anchor system in the RFC. Second: two gaps we hit building B that we'd like to fix regardless
Happy to contribute the example + these two improvements if the direction sounds right. Mostly want your read on A vs. B first, since it decides whether the growth reserve is worth adding at all. |
Beta Was this translation helpful? Give feedback.
-
|
Thanks for taking the time, and for the StackBlitz example. We need A. On submit we move the question to the top right away, before any answer text exists, and hold it there while the response streams into reserved space below. The reserve moves in both directionsOne correction. The reserve does not only shrink as tokens arrive. It also grows. The loader row is removed at first token. An inline artifact shell collapses when its stream ends. Tool chrome appears and swaps. So the reserve absorbs size changes below the anchor in both directions during a single turn. The primitive we want holds anchor index N at a fixed offset while the content below it resizes in both directions. The anchor is any row that starts a turnThis is our biggest difference from B. The B example picks the last user message with The new Shadcn Message Scroller block landed the same idea. Its A sticky header is the wrong shape for us. Our anchor is a real row in the flow, held by the reserve. A floating sticky element would sit on top of the artifact and tool cards we render right under the question. Embedded artifacts are what make the reserve hardBy artifacts we mean rich blocks like Claude Artifacts or ChatGPT Canvas. Claude puts those in a side panel. Ours render inline, embedded in the message flow, like ChatGPT. These blocks stream and resize. A card starts near a 320px estimate, grows as content arrives, then settles to a smaller final size when the stream ends. All of that happens below the pinned question, during the pinned turn. We absorb it before paint by adjusting the reserve so the question never jumps. This is the fix for the stream start and artifact end jitter. It is also why Scroll restoration of previous chatsTanStack Router provides this natively. Reopen a past thread in most chat apps and they land the scroller at the bottom. We wanted to restore the place the reader left. On unmount we call The awkward part is the handoff with the virtualizer. It reasserts What we would propose for the APIEach item below is something we already do by hand. A native version would let us delete most of our scroll layer. A pinned anchor with a growth reserveToday we set A native anchor option would own all of that. const virtualizer = useVirtualizer({
// ...
anchor: {
index: anchorIndex, // the row to hold
offset: 16, // px from the viewport top
reserve: true, // reserve space below so growth and shrink never move it
},
})The virtualizer owns the reserve inside Anchor identity from a consumer predicateToday we pick the active anchor with our own predicate and a backward walk from the viewport top, then feed that index into the pin and into the table of contents. A predicate option would let the virtualizer find it. useVirtualizer({
isAnchor: (index) => rows[index].startsTurn,
})
// virtualizer.getActiveAnchorIndex() -> last anchor at or above the viewport topThe pin target and the active turn id then come from one source.
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
We shipped a streaming AI chat on TanStack Virtual. We read the chat blog and started from the new toggles: anchorTo: 'end', followOnAppend, scrollEndThreshold. They solve the classic case well. Prepend stays stable. The tail follows only when you are already pinned.
But our chat is not tail anchored. When you send a message we pin your message to the top of the viewport. The answer then streams in below it. We hold your question in place until the turn settles, then we release. This is the ChatGPT and Claude behavior. The anchor is a chosen index near the bottom, not the end.
So we turned the new toggles off. We run anchorTo: 'start' with followOnAppend: false and we rebuilt the chat behavior on top. This RFC is what we had to build, and what the package could own instead.
What we wanted:
Pin index N to a fixed offset from the top. Let the last item grow below it. Issue no scroll compensation while it grows. Release the pin when streaming ends. Follow the tail only if the reader asked to.
What we had to build:
A growth reserve. We add a dynamic bottom pad as paddingEnd. It is one viewport tall at submit. As tokens arrive the content fills the reserve and the pad shrinks by the same amount. Total size stays constant, so virtual core issues no compensation of its own, and the anchored message does not move. This is the load bearing trick. It only works because we keep total size flat by hand.
An owned tween for the rise. We move the message to the top with our own rAF tween at 220ms. We tried scrollToIndex(index, { align: 'start', behavior: 'smooth' }). It degrades to an instant jump once the dynamic target falls under one viewport, so it slid halfway then snapped. We had to own the animation to stay smooth over a moving target.
Compensation before paint for shrink. When the loader row is removed at the first token, or an artifact shell collapses, the content below the viewport shrinks. The browser clamps scrollTop to the new smaller max, the pinned message visibly drops, and any later fix animates it back. That is the stream start jitter. We hold scrollTop in a layout effect that runs before paint to absorb it.
Intent rebuilt from deltas. stick to bottom is not a boolean for us. We model three mode to know why the viewport detached. Was it a user gesture, a resize, or content growth?
What the package could own:
Summary:
The new toggles nailed the tail anchored case. The streaming case wants a movable anchor. Pin an index, reserve room, grow around it, release. If virtual core owned the anchor and the reserve, the rest of our machine would mostly go away.
Beta Was this translation helpful? Give feedback.
All reactions