RFC: Anchor an arbitrary index and grow around it #1211
Replies: 1 comment
-
|
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.
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