fix(word-addin): WKWebView completion scroll jump + streaming performance (stacked on #299) - #311
Merged
Merged
Conversation
… position, and cut streaming render cost
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>
willchen96
approved these changes
Aug 12, 2026
willchen96
approved these changes
Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #299 — review only the top commit; the base branch carries the pin-geometry work this builds on.
What users saw
Sending a message pinned it correctly near the top of the pane. The instant the activity strip flipped from “Working” to “Completed in 1 step”, the transcript visibly scrolled away from the pinned turn. Streaming was also sluggish throughout.
The jump: the engine, not the app, moves the scroll
Word’s task pane runs in WKWebView, which never implemented
overflow-anchor: none— its scroll-anchoring heuristics cannot be turned off. When a response completes, the strip’s streamed rows (trailing “Thinking…”, stuck “reading” rows, the thought-process body) unmount in the same React commit that flips the label. WebKit had latched onto one of those nodes as its internal scroll anchor; when it vanished, the engine resetscrollTopto 0 with no JavaScript write and zero geometry change (the min-height spacer holds the row’s size, so no ResizeObserver watchdog can see it either).Measured under a WebKit Playwright run on the parent commit: pinned turn at 80px → 2228px,
scrollTop2148 → 0, at exactly the “Completed” commit. Chromium honours the opt-out, which is why the existing (Chromium-only) suite stayed green while real Word misbehaved.The fix
ChatView.tsx): every position the app writes — pin-animation frames included — is mirrored into a ref before the write; user input opens an ownership window (pointer/touch hold it while pressed, touch release keeps it through momentum, wheel/keys get a grace window) during which the ref follows the user. A scroll event matching neither owner can only be engine-initiated and is snapped straight back. This also fixes a second WebKit habit the tests exposed: a bottom-resting scroller being dragged along with streamed growth after pressing the scroll-to-bottom arrow.wordChatEvents.ts,AssistantMessage.tsx): streamed events get a creation-timekey, so completion’s event cleanup no longer shifts index-derived React keys, remounts surviving rows, or destroys the nodes the engine anchored to (this also removes a one-frame reasoning-block flash).The slowness
projectRedlineStreamre-parsed the full accumulated answer ≥3× per chunk → O(n²) over a streamredline.ts)useWordAssistantChat.ts)handleChatidentity churnReact.memoonAssistantMessage/UserMessage/Markdown, hoisted markdown props, stable handlers, ref-basedhandleChatdepsbackdrop-blurbehind opaque edit cards/sectionsmessageStyles.ts)context.syncround trips per tracked edit — dominant wall-clock on edit turnsVerification
chat-layout.spec.ts) streams a doc-read + multi-step reasoning strip and asserts the pinned turn holds through completion — fails on the parent commit exactly as reported, passes here. Run withnpx playwright test --config playwright.webkit.temp.config.ts e2e/chat-layout.spec.ts.docs/word-addin-chat-scroll-report.md(base branch).🤖 Generated with Claude Code