Skip to content

feat(web/chat): imperative load-older via ResizeObserver#32306

Closed
vellum-apollo-bot[bot] wants to merge 1 commit into
mainfrom
apollo/transcript-load-older-imperative
Closed

feat(web/chat): imperative load-older via ResizeObserver#32306
vellum-apollo-bot[bot] wants to merge 1 commit into
mainfrom
apollo/transcript-load-older-imperative

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

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

What

Adds the load-older trigger for the imperative transcript-scroll controller. When the controller flag is on and pagination has more, a ResizeObserver on the transcript content fires onLoadOlder whenever the scroll container is within 200px of the top.

Gated behind TRANSCRIPT_SCROLL_CONTROLLER_ENABLED (OFF by default). Zero behavior change for everyone today.

Shape

useEffect(() => {
  if (!getTranscriptScrollControllerEnabled()) return;
  if (!hasMore || isLoadingOlder || !onLoadOlder) return;
  const container = scrollContainerRef.current;
  const content = contentRef.current;
  if (!container || !content) return;
  return attachLoadOlderOnTop({ container, content, onLoadOlder });
}, [scrollContainerRef, contentRef, hasMore, isLoadingOlder, onLoadOlder]);

attachLoadOlderOnTop is a pure imperative function:

const observer = new ResizeObserver(() => {
  if (container.scrollTop > NEAR_TOP_LOAD_OLDER_PX) return;
  onLoadOlder();
});
observer.observe(content);
return () => observer.disconnect();

No state, no anchor, no scroll listener. The React idiomatic re-run on prop change replaces the deprecated hook'''s latestRef mirror.

Why this shape

The deprecated hook uses a state-mirror pattern: a useEffect copies fresh props into a ref each render, and imperative handlers read from that ref. That pattern produced ATL-644, PR #31826 P1, and PR #31878 in the bug-shape audit.

An earlier version of this PR rebuilt the same pattern in miniature. Per review, replaced with a deps-driven useEffect that re-attaches with fresh closures on every prop change.

Covered by construction

  • Initial chain-loadobserve() fires once with current measurements. Underfilled transcripts trigger an older fetch.
  • Repeat chain-loadisLoadingOlder flipping back to false re-runs the effect; a fresh observer'''s initial tick measures the post-prepend layout. Continues until the viewport fills or hasMore flips false.
  • Streaming-triggered detection — any content-height change while the user is near the top fires the observer.

Out of scope (deliberate)

  • Pure scroll-to-top with no content changeResizeObserver doesn'''t fire on pure scroll events. A scroll-listener trigger ships in its own PR (S2 in the Solve-Chat-SSE plan).
  • Anchor save/restore after prepend — relying on the browser'''s native overflow-anchor for chat for now. If we hit a case where it doesn'''t hold the reading row, that'''s its own PR with explicit reasoning — not a generic latestRef rebuild.
  • PR refactor(web): scroll to bottom on transcript container DOM attach #32239 callback-ref order bugattachSnapToLatest reverted to exactly what'''s on main. The ref-order bug stays for a dedicated fix PR.

Tests

  • transcript-scroll.test.ts — 5 new pure-function tests: fire-on-initial-tick, threshold-skip, streaming-multi-tick, teardown-disconnects, RO-unavailable-fallback.
  • use-transcript-scroll-on-attach.test.tsx — 5 new React integration tests using @testing-library/react: gated when hasMore=false, gated while isLoadingOlder=true, fires onLoadOlder on initial RO tick, tears down + re-attaches on toggle, teardown on unmount.

All 10 new tests pass. tsc + eslint clean on touched files. Same 4 pre-existing TranscriptMessageBody failures present on origin/main are not regressions.

Stat

5 files changed, 452 insertions(+), 6 deletions(-)

AGENTS.md compliance

apps/web/AGENTS.md reviewed. New code stays inside domains/chat/transcript/ and the imperative module boundary. No cross-domain imports introduced.

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: 79fd2d4f27

ℹ️ 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".

Comment on lines +241 to +242
const onScroll = (): void => {
if (savedAnchor !== null) return; // already pending a restore
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 pending anchor when older load does not prepend

When an older-page request fails, is cancelled, or returns no prepended items while hasMore remains true, savedAnchor never gets cleared because the only clear path is the ResizeObserver branch where the first item key changes. From then on, this early return suppresses every near-top scroll and the user cannot retry loading older history until the transcript remounts. Clear the pending anchor when the load settles without a prepend, or otherwise allow a retry once isLoadingOlder is false again.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dissolved by the simplification — the new shape has no saved anchor at all. attachLoadOlderOnTop is a stateless RO that checks container.scrollTop on every tick and calls onLoadOlder() if near the top. There's no savedAnchor ref to leak. Force-pushed 228f351915 with the rewrite; this thread's file:line no longer exists.

export function useTranscriptScrollOnAttach(args: {
scrollContainerRef: MutableRefObject<HTMLDivElement | null>;
contentRef: MutableRefObject<HTMLDivElement | null>;
items: ReadonlyArray<TranscriptItem>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks like we are reintroducing the same imperative issues especially with the useEffect below. Let's dramatically simplify this PR to focus on just the load older behavior when the resize observer detects we are at the top

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep — the useEffect mirroring props into stateRef is exactly the latestRef smell from the deprecated hook. Bug-shape audit thanks me later. Going to:

  • Rip out attachLoadOlderAnchor (scroll listener + anchor save/restore) and attachUnderfilledChainLoad entirely.
  • Add one new function: attachLoadOlderOnTop({ container, content, onLoadOlder }) — a ResizeObserver on the content, checks scrollTop < threshold on every tick, fires onLoadOlder(). No state. No anchor.
  • Wire it via a useEffect([hasMore, isLoadingOlder, onLoadOlder]) inside the hook. No state mirror — the effect re-runs naturally on prop change with fresh closures.
  • Drop the items prop from the hook surface (not needed anymore).
  • Revert attachSnapToLatest and the scrollContainerCallbackRef/contentCallbackRef plumbing to exactly what's on main from refactor(web): scroll to bottom on transcript container DOM attach #32239. The ref-order bug fix moves to its own PR.

Things naturally falling out:

  • Chain-load (feat: add top-level setup.sh script #7) — initial RO tick fires when observe() attaches with underfilled content. Loop continues because isLoadingOlder flipping false re-runs the effect, fresh RO does another initial tick, etc.
  • Anchor restore (Replace all references of GCP_REGION with us-central-1 #6) — drops out. Browser's native overflow-anchor handles it for chat. If we hit cases where it doesn't, that's a separate PR with explicit reasoning.
  • Codex P2 (clear-pending-anchor) — N/A because there's no saved anchor.

One known regression: user scrolling up to top with NO content change won't trigger (RO doesn't fire on pure scroll). Streaming/image-load/anything else fires it. Want me to add a scroll listener for that case here, or follow-up PR with the rest of the migration? I'll default to follow-up since you said RO-only.

Adds the load-older trigger for the imperative transcript-scroll
controller, gated behind the existing TRANSCRIPT_SCROLL_CONTROLLER
flag. When the controller is enabled and pagination has more, a
ResizeObserver on the transcript content fires `onLoadOlder`
whenever the scroll container is within 200px of the top.

### Why this shape

The deprecated hook uses a 'latestRef' state-mirror pattern: a
`useEffect` copies fresh props into a ref each render, and
imperative handlers read from that ref. This shape produced
ATL-644, PR #31826 P1, and PR #31878 in the bug-shape audit.

This PR uses the React-idiomatic shape: a `useEffect` whose deps
are the pagination props. When `hasMore`/`isLoadingOlder`/
`onLoadOlder` change, the effect tears down and re-attaches with
fresh closures. No state mirror, no stale-snapshot bug class.

### Covered by construction

- **Initial chain-load** — `observe()` fires once with current
  measurements; underfilled transcripts trigger an older fetch.
- **Repeat chain-load** — `isLoadingOlder` flipping back to
  false re-runs the effect; a fresh observer's initial tick
  measures the post-prepend layout.
- **Streaming-triggered detection** — any content height change
  while the user is near the top fires the observer.

### Out of scope (deliberate)

- **Pure scroll-to-top with no content change** — `ResizeObserver`
  doesn't fire on scroll. Scroll-listener trigger is a separate PR.
- **Anchor save/restore after prepend** — relying on the browser's
  native `overflow-anchor` for now. If we hit cases where it
  doesn't hold the reading row, that's its own PR with explicit
  reasoning, not a generic latestRef rebuild.
- **PR #32239 callback-ref order bug** — silent because the flag
  is off by default; will fix in a follow-up dedicated to that.

### Tests

- `transcript-scroll.test.ts` — 5 new pure-function tests
  covering fire-on-top-tick, threshold-skip, multi-tick
  behavior, teardown, and ResizeObserver-unavailable fallback.
- `use-transcript-scroll-on-attach.test.tsx` — 5 new React
  integration tests using `@testing-library/react` that mount
  the hook, exercise the prop-change lifecycle, and assert the
  observer is attached / torn down / re-attached correctly.

### AGENTS.md compliance

`apps/web/AGENTS.md` reviewed. New code stays inside
`domains/chat/transcript/` and the imperative module
boundary. No cross-domain imports introduced.
@vellum-apollo-bot vellum-apollo-bot Bot force-pushed the apollo/transcript-load-older-imperative branch from 79fd2d4 to 228f351 Compare May 27, 2026 21:31
@vellum-apollo-bot vellum-apollo-bot Bot changed the title feat(web/chat): imperative load-older, anchor restore, chain-load + fix #32239 ref-order bug feat(web/chat): imperative load-older via ResizeObserver May 27, 2026
@vellum-apollo-bot
Copy link
Copy Markdown
Contributor Author

Force-pushed 228f351915 with the simplification:

  • One new function — attachLoadOlderOnTop — a stateless ResizeObserver that fires onLoadOlder when scrollTop is near the top.
  • Hook wires it via useEffect([hasMore, isLoadingOlder, onLoadOlder]). No state mirror, no latestRef pattern.
  • Dropped: attachLoadOlderAnchor, attachUnderfilledChainLoad, the stateRef mirror, all anchor save/restore.
  • attachSnapToLatest + callback-ref plumbing reverted to exactly what's on main. PR refactor(web): scroll to bottom on transcript container DOM attach #32239 ref-order bug stays for a dedicated follow-up.
  • Codex's P2 dissolves — there's no saved anchor anymore.
  • Title and PR body updated to reflect the new (much narrower) scope.

Stat: 5 files changed, 452 insertions(+), 6 deletions(-) — down from the original.

Known follow-ups left explicit:

Re-requesting review.

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor Author

Closing — even in the simplified shape this isn't materially different from what the deprecated hook already does for the load-older trigger, just split across files. Migration cost without architectural win.

New direction: rip out the transcript-scroll.ts / transcript-scroll-flag.ts experiment entirely, rename useDeprecatedTranscriptScrolluseTranscriptScroll, and progressively simplify the existing hook in place once you've QA'd the remaining bugs. Cleanup PR incoming.

vellum-apollo-bot Bot added a commit that referenced this pull request May 28, 2026
…ted hook

The parallel imperative scroll module (`transcript-scroll.ts` +
`transcript-scroll-flag.ts`) was an experiment to migrate
`useDeprecatedTranscriptScroll` to a stateless attachable model. In
practice the simplified shape ended up functionally equivalent to a
chunk of the existing hook, just split across files — migration cost
without an architectural win.

Reverting that direction:

- Delete `transcript-scroll.ts`, `transcript-scroll-flag.ts`, and
  `transcript-scroll.test.ts` (introduced in PR #32239, extended +
  reverted in PR #32306).
- Remove the kill-switch + `DISABLED_RESULT` short-circuit at the
  top of the hook — there's no parallel implementation to flag-gate
  anymore.
- Remove the callback-ref plumbing in `transcript.tsx` (PR #32239
  shape) and go back to direct `ref={scrollRef}` / `ref={contentRef}`
  attachment.
- Drop `toggleTranscriptScrollController` from the debug-api surface
  and its setter import.
- Update the doc reference in `impersonate-version-flag.ts`.

Rename the deprecated hook to the canonical name now that there's no
migration in flight:

- `use-deprecated-transcript-scroll.ts` → `use-transcript-scroll.ts`
- `useDeprecatedTranscriptScroll` → `useTranscriptScroll`
- `UseDeprecatedTranscriptScrollArgs/Return` types renamed accordingly
- All import sites updated (`chat-route-content.tsx`, `debug-api.ts`,
  `debug-api.test.ts`, the hook's own test file)

Forward plan: QA the remaining bugs in the existing hook and
progressively simplify in place.
dvargasfuertes pushed a commit that referenced this pull request May 28, 2026
…ted hook (#32374)

The parallel imperative scroll module (`transcript-scroll.ts` +
`transcript-scroll-flag.ts`) was an experiment to migrate
`useDeprecatedTranscriptScroll` to a stateless attachable model. In
practice the simplified shape ended up functionally equivalent to a
chunk of the existing hook, just split across files — migration cost
without an architectural win.

Reverting that direction:

- Delete `transcript-scroll.ts`, `transcript-scroll-flag.ts`, and
  `transcript-scroll.test.ts` (introduced in PR #32239, extended +
  reverted in PR #32306).
- Remove the kill-switch + `DISABLED_RESULT` short-circuit at the
  top of the hook — there's no parallel implementation to flag-gate
  anymore.
- Remove the callback-ref plumbing in `transcript.tsx` (PR #32239
  shape) and go back to direct `ref={scrollRef}` / `ref={contentRef}`
  attachment.
- Drop `toggleTranscriptScrollController` from the debug-api surface
  and its setter import.
- Update the doc reference in `impersonate-version-flag.ts`.

Rename the deprecated hook to the canonical name now that there's no
migration in flight:

- `use-deprecated-transcript-scroll.ts` → `use-transcript-scroll.ts`
- `useDeprecatedTranscriptScroll` → `useTranscriptScroll`
- `UseDeprecatedTranscriptScrollArgs/Return` types renamed accordingly
- All import sites updated (`chat-route-content.tsx`, `debug-api.ts`,
  `debug-api.test.ts`, the hook's own test file)

Forward plan: QA the remaining bugs in the existing hook and
progressively simplify in place.

Co-authored-by: vellum-apollo-bot[bot] <242025090+vellum-apollo-bot[bot]@users.noreply.github.com>
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