fix(word-addin): stop WKWebView from stealing the transcript's scroll position, and cut streaming render cost

↗ view on GitHub · Amal · 2026-08-11 · 00aac4d0

WHY THIS MATTERS
In real Word (not in our Chromium e2e), the moment a response finished - the
activity strip flipping from "Working" to "Completed in N steps" - the whole
transcript jumped away from the pinned turn. Streaming also felt sluggish.
Both had the same underlying theme: work and behavior that only shows up in
the Office WebView under real streaming load.

WHAT IS SCROLL ANCHORING (and why the pane fought it)
Browsers try to keep what you're reading still when content above or around
it changes size, by silently adjusting the scroller's scrollTop - "scroll
anchoring". The pane opts out with `overflow-anchor: none` because it manages
its own pin geometry. Chromium honors the opt-out; WebKit (the engine inside
Word's task pane) never implemented the property. When a response completes,
the strip's streamed rows unmount in the same React commit that flips its
label - and WebKit, which had latched onto one of those DOM nodes as its
anchor, resets scrollTop to 0. Instrumentation showed the reset arrives with
no JavaScript write and zero geometry change (the min-height spacer holds the
row's size), so no ResizeObserver watchdog can see it. Under a WebKit
Playwright run the pinned turn measured 80px -> 2228px at completion; under
Chromium the bug is unobservable, which is why the suite was green while
users saw the jump.

HOW THE FIX WORKS - explicit scroll ownership (ChatView.tsx)
Scroll position now always has an owner, and scroll events are audited
against that owner:
  1. Every position the app writes (pin animation frames included, via
     animateScrollTo's onFrame callback) is mirrored into desiredScrollTopRef
     BEFORE the write, so the app's own scroll events match the record.
  2. User input opens an ownership window before its scroll events land:
     wheel/keyboard grant a short grace, pointer/touch hold ownership while
     pressed, and touch release keeps it through momentum. During the window,
     desiredScrollTopRef simply follows the user.
  3. Any other scroll event matches neither owner - it can only be
     engine-initiated - and is snapped back to the owned position.
This kills the completion reset, and also a second WebKit habit the tests
exposed: a scroller resting exactly at the bottom gets dragged along as
streamed content grows ("bottom-follow"), which made the view creep after
pressing the scroll-to-bottom arrow.

STABLE EVENT IDENTITIES (wordChatEvents.ts, AssistantMessage.tsx)
React keys for streamed rows were derived from event-array indices. At
completion, completeAssistantEvents() filters out transient rows (trailing
"thinking", stuck "reading"), shifting every later index - so surviving rows
remounted, destroying exactly the DOM nodes WebKit anchored to and flashing
the reasoning block open for one frame. Events are now stamped with a
creation-time `key` (a module counter) that survives streaming mutations, and
render keys prefer it: `event.key ?? index`. The field is inert in storage;
unit specs assert it with expect.any(String).

STREAMING PERFORMANCE - why it was quadratic
  1. projectRedlineStream() re-parsed the FULL accumulated answer on every
     chunk, and ran >=3x per chunk (edit controller + renderer + per-event
     map): O(n^2) over a stream. A single-entry memo (same text + same flag
     returns the cached projection) collapses those to one parse per change,
     with zero call-site churn (redline.ts).
  2. Every SSE event committed React state, re-rendering the whole transcript
     far more often than the screen paints. Publishes now coalesce onto one
     requestAnimationFrame, flushed synchronously at stream end/error so
     terminal UI state never lags (useWordAssistantChat.ts).
  3. Nothing was memoized: every settled message re-rendered - and
     react-markdown re-parsed its full text - on every chunk. AssistantMessage,
     UserMessage, and Markdown are now React.memo boundaries; Markdown's
     plugins/components props are hoisted to module scope (an inline object
     defeats react-markdown's own memoization); ChatView passes stable
     useCallback handlers; and handleChat reads messages via a render-synced
     ref instead of depending on them, so its identity stops churning per
     chunk (which was re-rendering the composer).
  4. Edit cards/sections painted a backdrop-blur behind a fully opaque
     bg-white - invisible, but a compositing layer per card in the Office
     WebView. Removed (messageStyles.ts).

VERIFICATION
- New WebKit regression spec (chat-layout.spec.ts) streams a doc-read plus a
  multi-step reasoning strip and asserts the pinned turn holds through
  completion: fails on the parent commit (80 -> 2228px, scrollTop 2148 -> 0),
  passes here. The bottom-arrow spec now asserts the settled position, since
  the corrector is deliberately eventually-consistent within a frame.
- Full Chromium suite: 107/107. WebKit chat-layout suite: 4/4. Typecheck clean.
- Deferred (tracked in docs/word-addin-chat-scroll-report.md): batching the
  ~9 serialized context.sync round trips Office needs per tracked edit - the
  dominant remaining wall-clock on edit turns - and the header/glass blur
  stack, which is a deliberate design choice asserted by e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repository open-legal-products/mike
Author Amal <mamalanand3@gmail.com>
Authored
Committed
Parents 078a5d5b
Stats 11 files changed , +586 , -191
Part of Bring the Word add-in chat experience in line with the web app

Capture this commit into my fork

Download a Markdown prompt that tells Claude how to port this exact commit into your working tree. Run it via claude -p < capture-commit-00aac4d0.md from inside the repo you want the change in.

⬇ Download capture-commit-00aac4d0.md