From 06f8722f25560dc0e5856ae229dc5e415f3737c7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Mar 2026 11:13:57 +0700 Subject: [PATCH] Split stream rendering into platform-specific strategies --- package-lock.json | 28 + packages/app/package.json | 1 + packages/app/src/app/+html.tsx | 77 -- .../agent-stream-render-strategy.ts | 544 +------------- .../app/src/components/agent-stream-view.tsx | 662 +----------------- .../src/components/stream-strategy-native.tsx | 336 +++++++++ .../src/components/stream-strategy-web.tsx | 635 +++++++++++++++++ .../app/src/components/stream-strategy.ts | 286 ++++++++ .../use-bottom-anchor-controller.test.ts | 122 +++- .../use-bottom-anchor-controller.ts | 169 ++++- 10 files changed, 1580 insertions(+), 1280 deletions(-) delete mode 100644 packages/app/src/app/+html.tsx create mode 100644 packages/app/src/components/stream-strategy-native.tsx create mode 100644 packages/app/src/components/stream-strategy-web.tsx create mode 100644 packages/app/src/components/stream-strategy.ts diff --git a/package-lock.json b/package-lock.json index 48b53305f..be481e20b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6115,6 +6115,23 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.21.tgz", + "integrity": "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.21" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/router-core": { "version": "1.149.3", "license": "MIT", @@ -6363,6 +6380,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.21.tgz", + "integrity": "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-file-routes": { "version": "1.145.4", "license": "MIT", @@ -21597,6 +21624,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@tanstack/react-query": "^5.90.11", + "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.9.1", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", diff --git a/packages/app/package.json b/packages/app/package.json index 3563df269..2d5424db9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -51,6 +51,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@tanstack/react-query": "^5.90.11", + "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.9.1", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", diff --git a/packages/app/src/app/+html.tsx b/packages/app/src/app/+html.tsx deleted file mode 100644 index 95a3ea3fb..000000000 --- a/packages/app/src/app/+html.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ScrollViewStyleReset } from "expo-router/html"; -import type { PropsWithChildren } from "react"; - -// Ensure Unistyles runs before Expo Router statically renders each page. -import "../styles/unistyles"; - -const webEcosystemStyles = /* css */ ` - html { - touch-action: auto; - height: 100%; - background-color: #18181c; - color-scheme: dark; - } - - body { - min-height: 100%; - overflow: auto; - overscroll-behavior: contain; - -webkit-user-select: text; - user-select: text; - background-color: #18181c; - } - - body * { - -webkit-user-select: text; - user-select: text; - } - - [data-testid="sidebar-agent-list-scroll"], - [data-testid="agent-chat-scroll"], - [data-testid="git-diff-scroll"], - [data-testid="file-explorer-tree-scroll"] { - scrollbar-width: none; - -ms-overflow-style: none; - } - - [data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar, - [data-testid="agent-chat-scroll"]::-webkit-scrollbar, - [data-testid="git-diff-scroll"]::-webkit-scrollbar, - [data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar { - width: 0; - height: 0; - } - - #root { - min-height: 100%; - background-color: #18181c; - } -`; - -function WebRespectfulStyleReset() { - return ( - +
{ + scrollContainerRef.current = node + }} + data-testid="agent-chat-scroll" + id={`agent-chat-scroll-${shouldUseVirtualizer ? 'web-dom-virtualized' : 'web-dom-scroll'}`} + style={scrollContainerStyle} + > +
{ + contentRef.current = node + }} + style={contentContainerStyle} + > + {headerEdgeContent} + {webVirtualizedHistoryWindow ? ( +
+ {virtualRows.map((virtualRow) => { + const entry = virtualizedEntries[virtualRow.index] + if (!entry) { + return null + } + return ( +
+ {renderRow(entry.item, entry.index, rows)} +
+ ) + })} +
+ ) : null} + {mountedEntries.map((entry) => ( + {renderRow(entry.item, entry.index, rows)} + ))} + {rows.length === 0 ? listEmptyComponent : null} + {footerEdgeContent} +
+
+ { + const scrollContainer = scrollContainerRef.current + if (!scrollContainer) { + return + } + scrollContainer.scrollTo({ top: nextOffset, behavior: 'auto' }) + lastKnownScrollTopRef.current = scrollContainer.scrollTop + updateScrollMetrics() + }} + /> + + ) +} + +export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy { + return createStreamStrategy({ + render: (renderInput) => ( + + ), + orderTailReverse: false, + orderHeadReverse: false, + assistantTurnTraversalStep: -1, + edgeSlot: 'footer', + flatListInverted: false, + overlayScrollbarInverted: false, + maintainVisibleContentPosition: undefined, + bottomAnchorTransportBehavior: { + verificationDelayFrames: 0, + verificationRetryMode: 'rescroll', + }, + disableParentScrollOnInlineDetailsExpansion: false, + anchorBottomOnContentSizeChange: true, + animateManualScrollToBottom: false, + useVirtualizedList: false, + isNearBottom: (inputMetrics) => { + const distanceFromBottom = Math.max( + 0, + inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight) + ) + return distanceFromBottom <= inputMetrics.threshold + }, + getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight), + }) +} diff --git a/packages/app/src/components/stream-strategy.ts b/packages/app/src/components/stream-strategy.ts new file mode 100644 index 000000000..b801e3675 --- /dev/null +++ b/packages/app/src/components/stream-strategy.ts @@ -0,0 +1,286 @@ +import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; +import type { StreamItem } from "@/types/stream"; +import type { + BottomAnchorLocalRequest, + BottomAnchorRouteRequest, +} from "./use-bottom-anchor-controller"; +import { createNativeStreamStrategy } from "./stream-strategy-native"; +import { createWebStreamStrategy } from "./stream-strategy-web"; + +type EdgeSlot = "header" | "footer"; +type NeighborRelation = "above" | "below"; +type AssistantTurnTraversalStep = -1 | 1; + +export type MaintainVisibleContentPositionConfig = Readonly<{ + minIndexForVisible: number; + autoscrollToTopThreshold: number; +}>; + +export type BottomAnchorTransportBehavior = Readonly<{ + verificationDelayFrames: number; + verificationRetryMode: "rescroll" | "recheck"; +}>; + +export type StreamViewportMetrics = { + contentHeight: number; + viewportHeight: number; +}; + +export type StreamNearBottomInput = StreamViewportMetrics & { + offsetY: number; + threshold: number; +}; + +export type StreamEdgeSlotProps = { + ListHeaderComponent?: ReactElement | ComponentType | null; + ListHeaderComponentStyle?: StyleProp; + ListFooterComponent?: ReactElement | ComponentType | null; + ListFooterComponentStyle?: StyleProp; +}; + +export type StreamViewportHandle = { + scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; + prepareForViewportChange: () => void; +}; + +export type StreamRenderInput = { + agentId: string; + rows: StreamItem[]; + renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; + listEmptyComponent: ReactNode; + viewportRef: RefObject; + routeBottomAnchorRequest: BottomAnchorRouteRequest | null; + isAuthoritativeHistoryReady: boolean; + onNearBottomChange: (value: boolean) => void; + scrollEnabled: boolean; + listStyle: StyleProp; + baseListContentContainerStyle: StyleProp; + forwardListContentContainerStyle: StyleProp; + edgeSlotProps: StreamEdgeSlotProps; +}; + +export type ResolveStreamRenderStrategyInput = { + platform: string; + isMobileBreakpoint: boolean; +}; + +export interface StreamStrategy { + render: (input: StreamRenderInput) => ReactNode; + orderTail: (streamItems: StreamItem[]) => StreamItem[]; + orderHead: (streamHead: StreamItem[]) => StreamItem[]; + getNeighborIndex: (index: number, relation: NeighborRelation) => number; + getNeighborItem: ( + items: StreamItem[], + index: number, + relation: NeighborRelation + ) => StreamItem | undefined; + collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string; + isNearBottom: (input: StreamNearBottomInput) => boolean; + getBottomOffset: (metrics: StreamViewportMetrics) => number; + getEdgeSlotProps: ( + component: ReactElement | ComponentType | null, + gapSize: number + ) => StreamEdgeSlotProps; + getMaintainVisibleContentPosition: () => + | MaintainVisibleContentPositionConfig + | undefined; + getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; + getFlatListInverted: () => boolean; + getOverlayScrollbarInverted: () => boolean; + shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean; + shouldAnchorBottomOnContentSizeChange: () => boolean; + shouldAnimateManualScrollToBottom: () => boolean; + shouldUseVirtualizedList: () => boolean; +} + +type StreamStrategyConfig = { + render: StreamStrategy["render"]; + orderTailReverse: boolean; + orderHeadReverse: boolean; + assistantTurnTraversalStep: AssistantTurnTraversalStep; + edgeSlot: EdgeSlot; + flatListInverted: boolean; + overlayScrollbarInverted: boolean; + maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig; + bottomAnchorTransportBehavior: BottomAnchorTransportBehavior; + disableParentScrollOnInlineDetailsExpansion: boolean; + anchorBottomOnContentSizeChange: boolean; + animateManualScrollToBottom: boolean; + useVirtualizedList: boolean; + isNearBottom: (input: StreamNearBottomInput) => boolean; + getBottomOffset: (metrics: StreamViewportMetrics) => number; +}; + +const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4; + +export function createStreamStrategy( + config: StreamStrategyConfig +): StreamStrategy { + return { + render: config.render, + orderTail: (streamItems) => + config.orderTailReverse ? [...streamItems].reverse() : streamItems, + orderHead: (streamHead) => + config.orderHeadReverse ? [...streamHead].reverse() : streamHead, + getNeighborIndex: (index, relation) => + relation === "above" + ? index + config.assistantTurnTraversalStep + : index - config.assistantTurnTraversalStep, + getNeighborItem: (items, index, relation) => { + const neighborIndex = + relation === "above" + ? index + config.assistantTurnTraversalStep + : index - config.assistantTurnTraversalStep; + if (neighborIndex < 0 || neighborIndex >= items.length) { + return undefined; + } + return items[neighborIndex]; + }, + collectAssistantTurnContent: (items, startIndex) => { + const messages: string[] = []; + for ( + let index = startIndex; + index >= 0 && index < items.length; + index += config.assistantTurnTraversalStep + ) { + const currentItem = items[index]; + if (currentItem.kind === "user_message") { + break; + } + if (currentItem.kind === "assistant_message") { + messages.push(currentItem.text); + } + } + return messages.reverse().join("\n\n"); + }, + isNearBottom: (input) => config.isNearBottom(input), + getBottomOffset: (metrics) => config.getBottomOffset(metrics), + getEdgeSlotProps: (component, gapSize) => { + if (config.edgeSlot === "header") { + return { + ListHeaderComponent: component, + ListHeaderComponentStyle: { marginBottom: gapSize }, + }; + } + return { + ListFooterComponent: component, + ListFooterComponentStyle: { marginTop: gapSize }, + }; + }, + getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition, + getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior, + getFlatListInverted: () => config.flatListInverted, + getOverlayScrollbarInverted: () => config.overlayScrollbarInverted, + shouldDisableParentScrollOnInlineDetailsExpansion: () => + config.disableParentScrollOnInlineDetailsExpansion, + shouldAnchorBottomOnContentSizeChange: () => + config.anchorBottomOnContentSizeChange, + shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom, + shouldUseVirtualizedList: () => config.useVirtualizedList, + }; +} + +export function resolveStreamRenderStrategy( + input: ResolveStreamRenderStrategyInput +): StreamStrategy { + if (input.platform === "web") { + return createWebStreamStrategy({ + isMobileBreakpoint: input.isMobileBreakpoint, + }); + } + return createNativeStreamStrategy(); +} + +export function resolveBottomAnchorTransportBehavior(input: { + strategy: StreamStrategy; + isViewportSettling: boolean; +}): BottomAnchorTransportBehavior { + const baseBehavior = input.strategy.getBottomAnchorTransportBehavior(); + if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) { + return baseBehavior; + } + return { + verificationDelayFrames: Math.max( + baseBehavior.verificationDelayFrames, + NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES + ), + verificationRetryMode: "recheck", + }; +} + +export function orderTailForStreamRenderStrategy(params: { + strategy: StreamStrategy; + streamItems: StreamItem[]; +}): StreamItem[] { + return params.strategy.orderTail(params.streamItems); +} + +export function orderHeadForStreamRenderStrategy(params: { + strategy: StreamStrategy; + streamHead: StreamItem[]; +}): StreamItem[] { + return params.strategy.orderHead(params.streamHead); +} + +export function getStreamNeighborIndex(params: { + strategy: StreamStrategy; + index: number; + relation: NeighborRelation; +}): number { + return params.strategy.getNeighborIndex(params.index, params.relation); +} + +export function getStreamNeighborItem(params: { + strategy: StreamStrategy; + items: StreamItem[]; + index: number; + relation: NeighborRelation; +}): StreamItem | undefined { + return params.strategy.getNeighborItem( + params.items, + params.index, + params.relation + ); +} + +export function collectAssistantTurnContentForStreamRenderStrategy(params: { + strategy: StreamStrategy; + items: StreamItem[]; + startIndex: number; +}): string { + return params.strategy.collectAssistantTurnContent( + params.items, + params.startIndex + ); +} + +export function isNearBottomForStreamRenderStrategy( + params: StreamNearBottomInput & { strategy: StreamStrategy } +): boolean { + return params.strategy.isNearBottom({ + offsetY: params.offsetY, + threshold: params.threshold, + contentHeight: params.contentHeight, + viewportHeight: params.viewportHeight, + }); +} + +export function getBottomOffsetForStreamRenderStrategy( + params: StreamViewportMetrics & { + strategy: StreamStrategy; + } +): number { + return params.strategy.getBottomOffset({ + contentHeight: params.contentHeight, + viewportHeight: params.viewportHeight, + }); +} + +export function getStreamEdgeSlotProps(params: { + strategy: StreamStrategy; + component: ReactElement | ComponentType | null; + gapSize: number; +}): StreamEdgeSlotProps { + return params.strategy.getEdgeSlotProps(params.component, params.gapSize); +} diff --git a/packages/app/src/components/use-bottom-anchor-controller.test.ts b/packages/app/src/components/use-bottom-anchor-controller.test.ts index 75b897c75..84120424f 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.test.ts +++ b/packages/app/src/components/use-bottom-anchor-controller.test.ts @@ -133,6 +133,7 @@ function createDriverHarness(input?: { }); const modeChanges: BottomAnchorMode[] = []; const warnings: Array<{ agentId: string; reason: string }> = []; + const logs: Array<{ event: string; details: Record }> = []; const driver = __private__.createBottomAnchorControllerDriver({ getAgentId: () => context.agentId, getIsAuthoritativeHistoryReady: () => context.authoritativeReady, @@ -144,7 +145,9 @@ function createDriverHarness(input?: { onModeChange: (mode) => { modeChanges.push(mode); }, - log: () => {}, + log: (event, details) => { + logs.push({ event, details }); + }, warn: (details) => warnings.push(details), scheduleFrame: (params) => scheduler.schedule(params), cancelFrame: (handle) => scheduler.cancel(handle), @@ -156,6 +159,7 @@ function createDriverHarness(input?: { scheduler, scrollToBottom, modeChanges, + logs, warnings, }; } @@ -367,6 +371,122 @@ describe("bottom anchor controller driver", () => { expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); }); + it("does not stay blocked on post-layout verification after a retry-scroll request", () => { + const harness = createDriverHarness({ + measurementState: createMeasurementState({ + containerKey: "web-partial-virtualized", + viewportWidth: 828, + viewportHeight: 846, + contentHeight: 14322, + offsetY: 0, + viewportMeasuredForKey: "web-partial-virtualized", + contentMeasuredForKey: "web-partial-virtualized", + }), + isNearBottom: false, + }); + + harness.scrollToBottom.mockImplementation(() => { + harness.context.measurementState.offsetY = 13476; + }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "resume", + requestKey: "route:agent-1:resume", + }); + + harness.scheduler.flushFrame(); + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + + harness.context.measurementState.contentHeight = 14804; + harness.context.nearBottom = false; + harness.driver.handleContentSizeChange({ + previousContentHeight: 14322, + contentHeight: 14804, + }); + + harness.scheduler.flushFrame(); + + expect(harness.driver.getSnapshot()).toMatchObject({ + pendingRequest: { + reason: "resume", + }, + pendingVerification: { + requestId: 1, + retries: 1, + }, + }); + + harness.scheduler.flushFrame(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.driver.getSnapshot()).toMatchObject({ + blockedReason: "waiting_for_post_layout_verification", + pendingRequest: { + reason: "resume", + }, + pendingVerification: { + requestId: 1, + }, + }); + }); + + it("does not fulfill a web partial-virtualized resume request before a confirmation pass", () => { + const harness = createDriverHarness({ + measurementState: createMeasurementState({ + containerKey: "web-partial-virtualized", + viewportWidth: 828, + viewportHeight: 846, + contentHeight: 14322, + offsetY: 0, + viewportMeasuredForKey: "web-partial-virtualized", + contentMeasuredForKey: "web-partial-virtualized", + }), + isNearBottom: false, + }); + + harness.scrollToBottom.mockImplementation(() => { + harness.context.measurementState.offsetY = Math.max( + 0, + harness.context.measurementState.contentHeight - + harness.context.measurementState.viewportHeight + ); + harness.context.nearBottom = true; + }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "resume", + requestKey: "route:agent-1:resume-confirmation", + }); + + harness.scheduler.flushFrame(); + harness.scheduler.flushFrame(); + + expect(harness.driver.getSnapshot()).toMatchObject({ + pendingRequest: { + reason: "resume", + }, + blockedReason: "waiting_for_post_layout_verification", + }); + + harness.context.measurementState.contentHeight = 16230; + harness.context.nearBottom = false; + harness.driver.handleContentSizeChange({ + previousContentHeight: 14322, + contentHeight: 16230, + }); + + harness.scheduler.flushFrame(); + harness.scheduler.flushFrame(); + harness.scheduler.flushFrame(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({ + reason: "resume", + }); + }); + it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => { const harness = createDriverHarness(); harness.context.nearBottom = false; diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/components/use-bottom-anchor-controller.ts index b7076c9d5..99867341d 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.ts +++ b/packages/app/src/components/use-bottom-anchor-controller.ts @@ -44,6 +44,10 @@ type ControllerMeasurementState = { type AttemptContext = { requestId: number | null; retries: number; + confirmationPasses?: number; + startedContentHeight?: number; + startedOffsetY?: number; + startedViewportHeight?: number; }; type ScheduledFrameHandle = { @@ -55,12 +59,14 @@ type ScheduledFrameHandle = { type BottomAnchorEvent = | "request_created" + | "evaluate_called" | "attempt_started" | "attempt_verified" | "attempt_failed" | "request_fulfilled" | "request_cancelled" | "detached_by_user" + | "verification_scheduled" | "blocked_reason_changed"; type BottomAnchorControllerDriver = { @@ -115,6 +121,7 @@ type CreateBottomAnchorControllerDriverInput = { }; const MAX_VERIFICATION_RETRIES = 3; +const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1; const USER_SCROLL_AWAY_DELTA_PX = 24; const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); @@ -233,15 +240,41 @@ function deriveRetryDisposition(input: { : "retry-scroll"; } +function shouldRequireRouteRequestConfirmation(input: { + request: BottomAnchorRequest | null; + measurementState: ControllerMeasurementState; + confirmationPasses: number; +}): boolean { + if (!input.request) { + return false; + } + if ( + input.request.reason !== "initial-entry" && + input.request.reason !== "resume" + ) { + return false; + } + if (input.measurementState.containerKey !== "web-partial-virtualized") { + return false; + } + return input.confirmationPasses < 1; +} + function getDetailedMeasurementState( measurementState: ControllerMeasurementState ): Record { + const distanceFromBottom = Math.max( + 0, + measurementState.contentHeight - + (measurementState.offsetY + measurementState.viewportHeight) + ); return { containerKey: measurementState.containerKey, viewportWidth: measurementState.viewportWidth, viewportHeight: measurementState.viewportHeight, contentHeight: measurementState.contentHeight, offsetY: measurementState.offsetY, + distanceFromBottom, viewportMeasuredForKey: measurementState.viewportMeasuredForKey, contentMeasuredForKey: measurementState.contentMeasuredForKey, }; @@ -263,6 +296,11 @@ function createBottomAnchorControllerDriver( const getLogContext = (extra?: Record) => { const measurementState = input.getMeasurementState(); + const distanceFromBottom = Math.max( + 0, + measurementState.contentHeight - + (measurementState.offsetY + measurementState.viewportHeight) + ); return { agentId: input.getAgentId(), requestReason: pendingRequest?.reason ?? null, @@ -270,10 +308,12 @@ function createBottomAnchorControllerDriver( contentHeight: measurementState.contentHeight, viewportHeight: measurementState.viewportHeight, offset: measurementState.offsetY, + distanceFromBottom, renderStrategy: input.getRenderStrategy(), blockedReason, mode, containerKey: measurementState.containerKey, + transportBehavior: input.getTransportBehavior(), ...extra, }; }; @@ -339,13 +379,42 @@ function createBottomAnchorControllerDriver( setBlockedReason(null); }; - const scheduleVerification = (attemptContext: AttemptContext) => { + const deriveDriverBlockedReason = ( + measurementState: ControllerMeasurementState + ) => + deriveBottomAnchorBlockedReason({ + pendingRequest, + isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), + measurementState, + pendingVerificationRequestId: + verificationHandle !== null ? pendingVerification?.requestId ?? null : null, + }); + + const scheduleVerification = ( + attemptContext: AttemptContext, + delayFramesOverride?: number + ) => { + const scheduledMeasurementState = input.getMeasurementState(); if (verificationHandle) { input.cancelFrame(verificationHandle); } + input.log( + "verification_scheduled", + getLogContext({ + retries: attemptContext.retries, + startedContentHeight: attemptContext.startedContentHeight ?? null, + startedOffsetY: attemptContext.startedOffsetY ?? null, + startedViewportHeight: attemptContext.startedViewportHeight ?? null, + scheduledMeasurementState: + getDetailedMeasurementState(scheduledMeasurementState), + verificationDelayFrames: + delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames, + }) + ); verificationHandle = input.scheduleFrame({ kind: "verification", - delayFrames: input.getTransportBehavior().verificationDelayFrames, + delayFrames: + delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames, callback: () => { verificationHandle = null; const currentRequest = pendingRequest; @@ -388,11 +457,40 @@ function createBottomAnchorControllerDriver( verifiedNearBottom, retries: attemptContext.retries, retryDisposition, + contentHeightDeltaSinceAttempt: + measurementState.contentHeight - + (attemptContext.startedContentHeight ?? measurementState.contentHeight), + offsetDeltaSinceAttempt: + measurementState.offsetY - + (attemptContext.startedOffsetY ?? measurementState.offsetY), + viewportHeightDeltaSinceAttempt: + measurementState.viewportHeight - + (attemptContext.startedViewportHeight ?? + measurementState.viewportHeight), measurementState: getDetailedMeasurementState(measurementState), }) ); if (verifiedNearBottom) { + if ( + isRequestAttempt && + shouldRequireRouteRequestConfirmation({ + request: currentRequest, + measurementState, + confirmationPasses: attemptContext.confirmationPasses ?? 0, + }) + ) { + pendingVerification = { + ...attemptContext, + confirmationPasses: (attemptContext.confirmationPasses ?? 0) + 1, + }; + setBlockedReason("waiting_for_post_layout_verification"); + scheduleVerification( + pendingVerification, + WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES + ); + return; + } pendingVerification = null; markStickyMeasurementVerified(); if (isRequestAttempt) { @@ -418,7 +516,7 @@ function createBottomAnchorControllerDriver( requestId: attemptContext.requestId, retries: attemptContext.retries + 1, }; - evaluate(false); + evaluate(false, "retry_scroll"); return; } @@ -445,28 +543,51 @@ function createBottomAnchorControllerDriver( }; const runAttempt = (animated: boolean) => { + const measurementState = input.getMeasurementState(); const attemptContext: AttemptContext = { requestId: pendingRequest?.id ?? null, retries: pendingVerification?.retries ?? 0, + startedContentHeight: measurementState.contentHeight, + startedOffsetY: measurementState.offsetY, + startedViewportHeight: measurementState.viewportHeight, }; pendingVerification = attemptContext; input.log( "attempt_started", - getLogContext({ animated, retries: attemptContext.retries }) + getLogContext({ + animated, + retries: attemptContext.retries, + measurementState: getDetailedMeasurementState(measurementState), + }) ); input.scrollToBottom(animated); scheduleVerification(attemptContext); - setBlockedReason( - deriveBottomAnchorBlockedReason({ - pendingRequest, - isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), - measurementState: input.getMeasurementState(), - pendingVerificationRequestId: attemptContext.requestId, - }) - ); + setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState())); }; - const evaluate = (animated: boolean) => { + const evaluate = ( + animated: boolean, + reason: + | "request_created" + | "viewport_change" + | "content_size_change" + | "scroll_near_bottom_change" + | "history_readiness_change" + | "manual_reevaluate" + | "retry_scroll" + ) => { + input.log( + "evaluate_called", + getLogContext({ + evaluateReason: reason, + animated, + hasAttemptHandle: attemptHandle !== null, + hasVerificationHandle: verificationHandle !== null, + pendingVerificationRequestId: pendingVerification?.requestId ?? null, + pendingVerificationRetries: pendingVerification?.retries ?? null, + measurementState: getDetailedMeasurementState(input.getMeasurementState()), + }) + ); if (attemptHandle) { return; } @@ -475,12 +596,7 @@ function createBottomAnchorControllerDriver( callback: () => { attemptHandle = null; const measurementState = input.getMeasurementState(); - const nextBlockedReason = deriveBottomAnchorBlockedReason({ - pendingRequest, - isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), - measurementState, - pendingVerificationRequestId: pendingVerification?.requestId ?? null, - }); + const nextBlockedReason = deriveDriverBlockedReason(measurementState); setBlockedReason(nextBlockedReason); const shouldAttemptForPendingRequest = @@ -498,6 +614,7 @@ function createBottomAnchorControllerDriver( "attempt_started", getLogContext({ attemptPhase: "skipped", + evaluateReason: reason, nextBlockedReason, shouldAttemptForPendingRequest, shouldAttemptForStickyVerification, @@ -546,7 +663,7 @@ function createBottomAnchorControllerDriver( "request_created", getLogContext({ requestReason: request.reason }) ); - evaluate(request.reason === "jump-to-bottom"); + evaluate(request.reason === "jump-to-bottom", "request_created"); }; return { @@ -610,7 +727,7 @@ function createBottomAnchorControllerDriver( pendingVerification = { requestId: null, retries: 0 }; } if (shouldRestick || pendingRequest) { - evaluate(false); + evaluate(false, "viewport_change"); } }, handleContentSizeChange(params) { @@ -626,7 +743,7 @@ function createBottomAnchorControllerDriver( pendingVerification = { requestId: null, retries: 0 }; } if (shouldRestick || pendingRequest) { - evaluate(false); + evaluate(false, "content_size_change"); } }, prepareForStickyViewportChange() { @@ -673,21 +790,21 @@ function createBottomAnchorControllerDriver( if (!pendingRequest && !pendingVerification) { pendingVerification = { requestId: null, retries: 0 }; } - evaluate(false); + evaluate(false, "scroll_near_bottom_change"); return; } if (nextIsNearBottom && pendingRequest) { - evaluate(false); + evaluate(false, "scroll_near_bottom_change"); } }, notifyAuthoritativeHistoryMaybeChanged() { if (!pendingVerification && !pendingRequest) { return; } - evaluate(false); + evaluate(false, "history_readiness_change"); }, reevaluate(animated = false) { - evaluate(animated); + evaluate(animated, "manual_reevaluate"); }, }; }