Skip to content

feat(playground): preload thread in the route loader and delegate scroll restoration to router#2062

Merged
samuv merged 7 commits intomainfrom
playground-refactor-loading
Apr 22, 2026
Merged

feat(playground): preload thread in the route loader and delegate scroll restoration to router#2062
samuv merged 7 commits intomainfrom
playground-refactor-loading

Conversation

@samuv
Copy link
Copy Markdown
Collaborator

@samuv samuv commented Apr 21, 2026

Two issues compounded whenever the user opened a chat thread:

  1. Double loading flash. useChatStreaming fetched the message history in a useEffect after mount, so every thread switch bounced through an in-component "Loading chat history…" overlay even when the data was trivially small.
  2. Scroll position fought the DOM. useAutoScroll owned its own save/restore via sessionStorage and chased the bottom on every re-render. It lost track across back/forward navigation, collided with MCP iframes (bridge.onsizechange grows the iframe async, which shoved the viewport), and animated smoothly through long threads on restore.

This PR moves both concerns onto primitives we already have. Thread data is preloaded in the TanStack Router route loader and cached in React Query; scroll position is owned by TanStack Router's native element-level scroll restoration. useAutoScroll shrinks to the things only the chat actually needs (follow-the-bottom while streaming, the scroll-to-bottom button, and a brief "settling window" so MCP iframes finishing their size handshake don't strand the user on the wrong content).

Kapture.2026-04-22.at.10.54.04.mp4

Route layer

  • New renderer/src/features/chat/lib/thread-query.ts exposing chatThreadQueryOptions(threadId) — a queryOptions factory that loads getThread + getThreadMessagesForTransport in parallel with staleTime: 0 so invalidations fire cleanly on stream completion.
  • /playground/chat/$threadId: loader: ({ context, params }) => context.queryClient.ensureQueryData(chatThreadQueryOptions(params.threadId)) + pendingComponent: ChatLoadingDots. The in-component "Loading chat history…" overlay goes away; the route owns the pending state now.
  • /playground/ (index): redirect logic moves into beforeLoad using throw redirect(...), so the bounce from /playground to the active/most-recent thread never renders a blank frame.

Consuming the cache

  • useChatStreaming gains a useQuery(chatThreadQueryOptions(currentThreadId)) and hydrates the useChat instance exactly once per thread id via a ref guard (hydratedThreadRef). Streaming-in-progress token updates inside useChat are never overwritten by a cache refetch of the same thread.
  • useThreadManagement loses its loadMessages IPC path — it now only owns the "resolve current thread id" responsibility.
  • usePlaygroundThreads invalidates ['chat', 'thread', threadId] in addition to the existing per-thread refresh when the streaming-complete / thread-started cache signal fires, so the loader sees fresh data on the next navigation.

Scroll restoration

  • router gets scrollRestoration: true, scrollRestorationBehavior: 'instant', and getScrollRestorationKey: (location) => location.pathname. Keying by pathname (not by TSR's default per-location state id) means the same /playground/chat/<id> URL restores to the same position regardless of whether the user arrived via back/forward, sidebar click, or deep link.
  • The chat messages container is tagged with data-scroll-restoration-id="chat-messages" and overflow-anchor: auto. The MCP app iframe wrapper in mcp-app-view.tsx also gets overflow-anchor: auto so bridge.onsizechange growing iframeHeight doesn't push surrounding messages downward.
  • useAutoScroll is rewritten against that contract:
    • Reads the saved scrollY via useElementScrollRestoration({ id: 'chat-messages', getKey: location.pathname }).
    • On first render per thread (or a useChat-driven hasContent flip, which happens on thread switch), records a placement target (saved scrollY or 'bottom') and runs a 5s settling window: a ResizeObserver on the inner content re-applies the target as MCP iframes report their final size. Long-thread restores land behavior: 'instant', not smooth, so there's no long animation.
    • Distinguishes overflow-anchor-driven scroll events from genuine user input by tracking the timestamp of the last wheel / touchstart / keydown / pointerdown and requiring it to be within 500 ms. Without this, the first iframe resize fires a non-programmatic scroll event that would otherwise look like a user scroll and cancel placement.
    • Merges the previously-separate "settling" and "follow-bottom-while-streaming" ResizeObservers into one effect that branches on isStreaming.

How to validate

  1. Open a long thread with multiple MCP iframes, scroll to the bottom, then navigate to Servers (or any unrelated route) and back. The thread should restore to the bottom once iframes have finished their size handshake — not to the top and not to an intermediate position.
  2. From the same thread, switch to another thread via the sidebar and back. Scroll position is preserved per-thread without the loading-dots flicker.
  3. Go back to a thread that's in the middle (saved scrollY landing mid-content); start scrolling up manually while iframes are still loading. The settling window should yield immediately — you stay where you scrolled.
  4. Navigate to /playground directly (deep link, new window, or history.pushState). The redirect resolves in beforeLoad and the page never paints a blank frame.
  5. pnpm test:nonInteractive — the full suite (1942 tests) is green.

Not changed in this PR (deliberate)

  • No change to IPC shape or thread storage — getThread / getThreadMessagesForTransport / updateThreadMessages all behave as before; the migration is renderer-side only.
  • No eviction policy for the React Query thread cache. TanStack Router preload / warmup will drive cache lifecycle, and staleTime: 0 guarantees fresh loads on re-entry; real cache-size tuning can be a follow-up if memory pressure from very long threads shows up.
  • No removal of the isPersistentLoading flag from useChatStreaming's return — other callers still read it via useChat to gate the composer, and it cleanly derives from the new query state now.
  • usePlaygroundThreads still owns the flat thread list via useState rather than React Query. A similar migration is straightforward but out of scope here.

samuv added 3 commits April 21, 2026 18:27
Resolve the target thread in the router's beforeLoad and throw redirect()
instead of mounting a component that runs the resolution in a useEffect.
Eliminates the one-frame blank render between /playground and the
chat sub-route.
Introduce chatThreadQueryOptions and consume it from the
/playground/chat/$threadId loader via ensureQueryData + pendingComponent.
useChatStreaming reads messages from the cache with useQuery and hydrates
the useChat instance once per thread via a ref guard, so switching
threads no longer flashes a loading state. usePlaygroundThreads
invalidates the new query key on streaming-complete to refresh titles
and messages. Remove the now-unused loadMessages from
useThreadManagement.
Enable router-level scrollRestoration keyed by location.pathname and
register the chat messages container with data-scroll-restoration-id so
TSR persists and restores its scroll position across navigations. Apply
overflow-anchor: auto on the scroll container and each MCP app iframe
wrapper so async height changes don't shove the viewport.

useAutoScroll is rewritten around the new contract: its responsibilities
shrink to following the bottom while streaming, the scroll-to-bottom
button, and an initial 5s settling window that re-applies the saved
scroll target as MCP iframes finish their size handshake. Distinguishes
overflow-anchor-driven scroll events from genuine user input (wheel /
touch / key / pointer) so the settling window isn't cancelled by the
first iframe resize. The in-component loading overlay is removed — the
route's pendingComponent now owns that state.
Copilot AI review requested due to automatic review settings April 21, 2026 16:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR moves chat thread hydration and scroll restoration to route-level primitives: thread data is preloaded in the TanStack Router loader and cached via React Query, while scroll position is delegated to TanStack Router’s element scroll restoration (with a revised useAutoScroll focusing on follow-bottom + a “settling” window for async iframe growth).

Changes:

  • Preload chat thread metadata + messages in /playground/chat/$threadId route loader via React Query ensureQueryData.
  • Update useChatStreaming to hydrate useChat from the query cache (removing the in-component “Loading chat history…” overlay behavior).
  • Replace custom sessionStorage-based scroll persistence with TanStack Router element scroll restoration and a reworked useAutoScroll (plus overflow-anchor tweaks for MCP iframes).

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
renderer/src/routes/playground.index.tsx Moves initial thread resolution/redirect into beforeLoad to avoid mount-then-redirect blank/flash.
renderer/src/routes/playground.chat.$threadId.tsx Adds loader + pendingComponent for loader-driven chat history warmup and pending UI.
renderer/src/routes/tests/playground.index.test.tsx Updates index-route tests to execute beforeLoad in a test router.
renderer/src/renderer.tsx Enables TanStack Router scroll restoration and keys restoration by pathname.
renderer/src/features/chat/lib/thread-query.ts Introduces chatThreadQueryOptions(threadId) queryOptions factory for thread preload/consumption.
renderer/src/features/chat/hooks/use-thread-management.ts Removes message-loading responsibility; keeps only “current thread id” resolution + clear messages.
renderer/src/features/chat/hooks/use-playground-threads.ts Invalidates the loader-primed thread query key on streaming-complete signals.
renderer/src/features/chat/hooks/use-chat-streaming.ts Consumes thread cache via useQuery and hydrates useChat once per thread id.
renderer/src/features/chat/hooks/use-auto-scroll.ts Rewrites auto-scroll around TSR element scroll restoration + a placement/settling window.
renderer/src/features/chat/hooks/tests/use-chat-streaming.test.ts Updates mocks for new thread query path (adds getThread mocking).
renderer/src/features/chat/hooks/tests/use-auto-scroll.test.ts Reworks tests to align with TSR scroll restoration + ResizeObserver-driven settling logic.
renderer/src/features/chat/components/mcp-app-view.tsx Adds overflowAnchor: auto to stabilize viewport around async iframe resizing.
renderer/src/features/chat/components/chat-interface.tsx Removes in-component loading overlay; registers scroll container for TSR restoration + overflow anchoring.
renderer/src/features/chat/components/tests/chat-interface.test.tsx Drops loading-overlay assertions; stubs TSR restoration hook and updates mocks accordingly.

Comment thread renderer/src/features/chat/hooks/use-auto-scroll.ts Outdated
Comment thread renderer/src/features/chat/hooks/use-chat-streaming.ts Outdated
Comment thread renderer/src/features/chat/hooks/__tests__/use-auto-scroll.test.ts Outdated
Comment thread renderer/src/features/chat/hooks/__tests__/use-auto-scroll.test.ts Outdated
@samuv samuv changed the title feat(playground): preload chat thread data in the route loader and delegate scroll restoration to TanStack Router feat(playground): preload thread in the route loader and delegate scroll restoration to router Apr 21, 2026
@samuv samuv self-assigned this Apr 21, 2026
samuv added 3 commits April 21, 2026 19:30
The placement effect gated on \`savedScroll.scrollY > 0\`, which treated a
valid restored \`scrollY: 0\` (user was scrolled to the very top of a
thread before leaving) the same as the "no saved entry" case and
force-scrolled to the bottom instead of restoring the top.

Only the absence of an entry should fall back to 'bottom'; a concrete
entry of 0 is a legitimate restore target.
After moving thread preload to the route loader + React Query, IPC or
query failures from \`chatThreadQueryOptions\` were silently swallowed:
\`useQuery\` only exposed \`data\`/\`isPending\` and the error never made it
into \`processedError\`, leaving the UI with empty messages and no
indication that loading had failed.

Destructure \`error\` from the query and fold it into \`persistentError\`
(which already flows into \`processedError\`), matching the pre-refactor
\`loadMessages\` try/catch behaviour.
- Swap the \`globalThis.ResizeObserver\` overwrite for
  \`vi.stubGlobal\` + \`vi.unstubAllGlobals()\` in afterEach so the stub
  doesn't leak into other test files sharing the worker.
- Update the scrollY=0 placement test to assert the corrected
restore-to-top behaviour (was previously encoding the bug).
- Fix a stale comment that referenced a module-level
"first-mount-per-thread" Set; the hook uses a per-instance
\`placedForThreadRef\` now.
Comment thread renderer/src/features/chat/components/mcp-app-view.tsx Outdated
\`overflow-anchor: auto\` is the CSS default for every element and
nothing in the tree (or Tailwind preflight) sets it to \`none\`, so
re-asserting it inline on the chat scroll container and the MCP app
wrapper was a no-op. Remove the inline \`style\` props, move the
\`width: 100%\` on the MCP app wrapper to a \`w-full\` utility, and
tighten the scroll-container comment to reflect that we rely on
native scroll anchoring rather than opting into it.
Copy link
Copy Markdown
Collaborator

@peppescg peppescg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚢

@samuv samuv merged commit 4b17321 into main Apr 22, 2026
20 checks passed
@samuv samuv deleted the playground-refactor-loading branch April 22, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants