Skip to content

fix(web/chat): scope items-effect deps to actual items-change triggers#32399

Merged
dvargasfuertes merged 1 commit into
mainfrom
apollo/atl-fix-load-older-chain-on-loading-flip
May 29, 2026
Merged

fix(web/chat): scope items-effect deps to actual items-change triggers#32399
dvargasfuertes merged 1 commit into
mainfrom
apollo/atl-fix-load-older-chain-on-loading-flip

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

@vellum-apollo-bot vellum-apollo-bot Bot commented May 28, 2026

Summary

Fixes a load-older multi-fire bug where scrolling to the top of the transcript triggered older-page fetches two-at-a-time, despite PR #32386's synchronous in-flight lock.

Root Cause

The items-useLayoutEffect in use-transcript-scroll.ts listed isLoadingOlder (plus 5 other mutable values) in its dep array. When the parent's isLoadingOlder mirror useEffect in use-conversation-history.ts commits at urgent priority before the prepend's setMessages (which runs inside startTransition), the items-effect re-fires on the same items reference. In that intermediate commit the body:

  1. Releases the in-flight lock (isLoadingOlder true→false transition).
  2. Calls decideItemsChangeAction which returns "anchor-correct" because the saved anchor key is still present in the unchanged items — the apply branch consumes savedAnchorRef on a heightDelta = 0 no-op, defeating scroll preservation when the real prepend lands.
  3. Re-classifies scroll position, sees the user still near the top + the just-released lock, and fires onLoadOlder() again.

The deeper issue: the items-effect's dep array was over-specified by exhaustive-deps to include values the body reads but whose changes are not semantically "items-change events." The dep list was lying about the effect's purpose, and that mismatch was the bug surface.

Fix (Structural, Not Defensive)

  1. Lock release moves to its own useLayoutEffect keyed on [isLoadingOlder], declared before the items-effect. When both change in the same commit (underfilled-viewport chain-load), it runs first and the items-effect sees the released lock. When only isLoadingOlder transitions, only this effect fires — the items-effect doesn't run at all.

  2. latestRef sync moves from useEffect (post-paint) to useLayoutEffect (commit phase), declared before the items-effect so the mirrored values are fresh by the time the items-effect reads them.

  3. Items-effect deps trimmed to [items, conversationId, transcriptRef, engageAutoPin] — only what semantically determines "this is an items-change event." hasMore, isLoadingOlder, onLoadOlder, isPinnedToLatest, showScrollToLatest move to latestRef reads. As a side benefit, this eliminates the isPinnedToLatest / showScrollToLatest self-trigger loop (state the effect itself sets via guard-compare).

Regression Test

isLoadingOlder transition without items change does not auto-fire onLoadOlder in use-transcript-scroll.burst.test.tsx rerenders the hook with isLoadingOlder=true then false WITHOUT changing items and asserts onLoadOlder is not called. Fails on the old code, passes on the fix.

All 45 scroll-hook tests pass — including the underfilled-viewport chain-load: false→true→false sequence kicks again when still underfilled case which exercises the legitimate path where lock-release and chain-load must coordinate within a single commit.

Lesson Codified

When exhaustive-deps forces you to list a value in a dep array, ask whether the effect's stated purpose actually wants to re-run for changes to that value. If the effect is named "items change handler" but re-runs on isLoadingOlder changes, the dep list is lying about the effect's purpose — and that mismatch produces bugs. The structural answer is usually to split the effect by responsibility or route stable closure reads through a latestRef mirror, not to add if (a === b) return guards.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65cc1bdfb1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Lock release above is intentional and must always run; chain
// re-fires on a real underfilled-viewport prepend still work
// because that case has `items !== prev`.
if (items === prev) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear stale anchors when older-page loads finish empty

When an older-page request fails or returns no new items, isLoadingOlder still transitions true→false with the same items reference. This new early return releases the in-flight lock but leaves savedAnchorRef intact; the next unrelated items change (for example a new message/streaming append while the user is still scrolled up) will run decideItemsChangeAction, treat that stale anchor as a prepend anchor, and adjust scrollTop by the later height delta, causing an unexpected viewport jump. The previous path consumed/cleared the anchor on the no-item settle, so the bailout needs to avoid preserving anchors for genuine failed/empty loads while still handling the intermediate transition case.

Useful? React with 👍 / 👎.

Bug: scrolling to the top of the transcript loaded older pages
two-at-a-time despite PR #32386's synchronous in-flight lock.

Root cause: the items-`useLayoutEffect` listed `isLoadingOlder` (and
five other mutable values) in its dep array. When the parent's
`isLoadingOlder` mirror useEffect (urgent priority in
`use-conversation-history.ts`) commits BEFORE the prepend's
`setMessages` (which runs inside `startTransition`), the items-effect
re-fires on the same `items` reference. In that intermediate commit
the body:
  - releases the in-flight lock (true→false transition), then
  - has `decideItemsChangeAction` return "anchor-correct" because
    the saved anchor key is still present in the unchanged items,
    consuming `savedAnchorRef` on a heightDelta=0 no-op (defeating
    scroll preservation when the actual prepend arrives), then
  - re-classifies the scroll position, sees the user still near the
    top + the just-released lock, and fires `onLoadOlder()` again.

The fix is structural, not defensive:

1. Lock release moves to its own `useLayoutEffect` keyed on
   `[isLoadingOlder]`, declared BEFORE the items-effect. When both
   change in the same commit (the underfilled-viewport chain-load
   case) it runs first and the items-effect sees the released lock.
   When only `isLoadingOlder` transitions, only this effect fires.

2. `latestRef` sync moves from `useEffect` (post-paint) to
   `useLayoutEffect` (commit phase), declared before the items-effect
   so all mutable values it mirrors are fresh by the time the
   items-effect reads them.

3. Items-effect deps trim to `[items, conversationId, transcriptRef,
   engageAutoPin]` — only what semantically determines "this is an
   items-change event". `hasMore`, `isLoadingOlder`, `onLoadOlder`,
   `isPinnedToLatest`, `showScrollToLatest` move to `latestRef` reads.
   `isPinnedToLatest` / `showScrollToLatest` self-trigger loop (state
   this effect itself sets via guard-compare) is eliminated as a
   side benefit.

Regression test asserts: rerender with `isLoadingOlder=true` then
`false` WITHOUT changing items does NOT auto-fire `onLoadOlder`. All
45 scroll-hook tests pass including the underfilled-viewport
`chain-load: false→true→false sequence kicks again when still
underfilled` case.
@vellum-apollo-bot vellum-apollo-bot Bot force-pushed the apollo/atl-fix-load-older-chain-on-loading-flip branch from 65cc1bd to 414e509 Compare May 29, 2026 14:22
@vellum-apollo-bot vellum-apollo-bot Bot changed the title fix(web/chat): gate items-effect heavy work on actual items change fix(web/chat): scope items-effect deps to actual items-change triggers May 29, 2026
@dvargasfuertes dvargasfuertes merged commit e030436 into main May 29, 2026
7 checks passed
@dvargasfuertes dvargasfuertes deleted the apollo/atl-fix-load-older-chain-on-loading-flip branch May 29, 2026 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant