diff --git a/__tests__/core/doMaintainScrollAtEnd.test.ts b/__tests__/core/doMaintainScrollAtEnd.test.ts index 7a265212..276952eb 100644 --- a/__tests__/core/doMaintainScrollAtEnd.test.ts +++ b/__tests__/core/doMaintainScrollAtEnd.test.ts @@ -183,6 +183,7 @@ describe("doMaintainScrollAtEnd", () => { mockState.pendingNativeMVCPAdjust = { amount: -40, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 100, }; @@ -199,6 +200,7 @@ describe("doMaintainScrollAtEnd", () => { mockState.pendingNativeMVCPAdjust = { amount: -40, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 100, }; diff --git a/__tests__/core/mvcp.test.ts b/__tests__/core/mvcp.test.ts index 589d00df..57ae295f 100644 --- a/__tests__/core/mvcp.test.ts +++ b/__tests__/core/mvcp.test.ts @@ -93,6 +93,150 @@ describe("mvcp helpers", () => { } }); + // Builds a native list whose viewport overlaps the bottom inset zone. The anchor item + // (item-1) is the first item in view; moving its position up (simulating an above-viewport + // shrink) makes MVCP want a negative adjust. With a bottom inset this is the geometry where + // the spacer-only adjust fights the native end-clamp and must be routed through the handoff. + // + // NOTE: these unit mocks do not shrink `totalSize`/`sizes` when `positions` is hand-edited, + // so `getContentSize`/maxScroll stay at their pre-shrink values. That faithfully exercises the + // ARMING decision and the isResize routing, but not the partial native-clamp split (where + // predictedNativeClamp is a non-zero fraction of the amount) — that split is covered by the + // on-device verification and the resolve-path tests below that set the pending directly. + const buildNativeResizeAgainstInsetContext = (anchoredEndSpaceVisible: boolean) => { + const SCROLL_LENGTH = 300; + const ANCHORED_END_SPACE = 250; + const RAW_CONTENT = 580; + + const mockCtx = createMockContext( + { + anchoredEndSpaceSize: anchoredEndSpaceVisible ? ANCHORED_END_SPACE : 0, + readyToRender: true, + totalSize: RAW_CONTENT, + }, + { + didContainersLayout: true, + didFinishInitialScroll: true, + hasScrolled: true, + idCache: ["item-0", "item-1", "item-2"], + idsInView: ["item-1", "item-2"], + indexByKey: new Map([ + ["item-0", 0], + ["item-1", 1], + ["item-2", 2], + ]), + positions: [0, 400, 500], + props: { + anchoredEndSpace: anchoredEndSpaceVisible ? { anchorIndex: 1, includeInEndInset: true } : undefined, + data: [{ id: 0 }, { id: 1 }, { id: 2 }], + keyExtractor: (item: { id: number }) => `item-${item.id}`, + maintainVisibleContentPosition: normalizeMaintainVisibleContentPosition(true), + }, + scrollLength: SCROLL_LENGTH, + sizes: new Map([ + ["item-0", 400], + ["item-1", 100], + ["item-2", 80], + ]), + }, + ); + + // Scroll to the end (content size includes the blank inset). + const contentSize = RAW_CONTENT + (anchoredEndSpaceVisible ? ANCHORED_END_SPACE : 0); + mockCtx.state.scroll = Math.max(0, contentSize - SCROLL_LENGTH); + return mockCtx; + }; + + it("routes a resize in the bottom-inset zone through the native-clamp handoff", () => { + Platform.OS = "ios"; + const mockCtx = buildNativeResizeAgainstInsetContext(/* anchoredEndSpaceVisible */ true); + + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + try { + const adjustFunction = prepareMVCP(mockCtx); + // Item-0 above the viewport shrank by 200, so the anchor (item-1) recomputes up by 200. + mockCtx.state.positions[1] = 200; + + adjustFunction?.(); + + // The resize against a bottom inset is routed through the native-clamp handoff (a pending + // adjust is queued with isResize) rather than a plain spacer adjust, so it can reconcile + // against native instead of being eaten by the end-clamp. + expect(mockCtx.state.pendingNativeMVCPAdjust).toBeDefined(); + expect(mockCtx.state.pendingNativeMVCPAdjust?.amount).toBeCloseTo(-200, 1); + expect(mockCtx.state.pendingNativeMVCPAdjust?.isResize).toBe(true); + } finally { + requestAdjustSpy.mockRestore(); + } + }); + + it("does not queue a handoff for a resize with no bottom inset", () => { + Platform.OS = "ios"; + const mockCtx = buildNativeResizeAgainstInsetContext(/* anchoredEndSpaceVisible */ false); + + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + try { + const adjustFunction = prepareMVCP(mockCtx); + mockCtx.state.positions[1] = 200; + + adjustFunction?.(); + + // Without a bottom inset there is no end-clamp to fight, so the plain adjust path runs. + expect(mockCtx.state.pendingNativeMVCPAdjust).toBeUndefined(); + expect(requestAdjustSpy).toHaveBeenCalledWith(mockCtx, -200, undefined); + } finally { + requestAdjustSpy.mockRestore(); + } + }); + + it("does not queue a handoff for a mid-list resize against a bottom inset", () => { + Platform.OS = "ios"; + const mockCtx = buildNativeResizeAgainstInsetContext(/* anchoredEndSpaceVisible */ true); + // Move well away from the end (viewport entirely above the inset zone) so the native clamp + // would not eat the adjustment and the plain spacer path is correct. + mockCtx.state.scroll = 100; + + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + try { + const adjustFunction = prepareMVCP(mockCtx); + mockCtx.state.positions[1] = 200; + + adjustFunction?.(); + + expect(mockCtx.state.pendingNativeMVCPAdjust).toBeUndefined(); + expect(requestAdjustSpy).toHaveBeenCalledWith(mockCtx, -200, undefined); + } finally { + requestAdjustSpy.mockRestore(); + } + }); + + it("arms the handoff for a resize while only PARTIALLY into the inset zone", () => { + // Regression for the partial-inset upward-shift bug: the viewport overlaps the bottom inset + // but is not pinned hard at the end, so the handoff must arm even though the shrink does not + // exceed the distance to the end. Pre-fix this fell through to a plain requestAdjust(-200) + // which over-compensated upward (native had room and absorbed nothing). + Platform.OS = "ios"; + const mockCtx = buildNativeResizeAgainstInsetContext(/* anchoredEndSpaceVisible */ true); + // contentSize=830, realContentEnd=830-250=580. Park partway into the inset zone: the + // viewport [430,730] covers 150px of real content + 150px of inset. + mockCtx.state.scroll = 430; + + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + try { + const adjustFunction = prepareMVCP(mockCtx); + mockCtx.state.positions[1] = 200; + + adjustFunction?.(); + + // It arms the handoff (isResize) instead of taking the plain spacer path. + expect(mockCtx.state.pendingNativeMVCPAdjust).toBeDefined(); + expect(mockCtx.state.pendingNativeMVCPAdjust?.amount).toBeCloseTo(-200, 1); + expect(mockCtx.state.pendingNativeMVCPAdjust?.isResize).toBe(true); + } finally { + requestAdjustSpy.mockRestore(); + } + }); + it("settles immediately when only the manual native MVCP adjustment remained", () => { const mockCtx = createMockContext( { totalSize: 300 }, @@ -100,6 +244,7 @@ describe("mvcp helpers", () => { pendingNativeMVCPAdjust: { amount: -80, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: -80, startScroll: 420, }, @@ -117,4 +262,59 @@ describe("mvcp helpers", () => { requestAdjustSpy.mockRestore(); } }); + + // When native reaches its true max with a large remaining amount (a big shrink scrolled deep + // into the now-gone content), a resize must settle WITHOUT a further spacer adjust — applying + // the leftover would force native to re-clamp and overshoot the visible position. A data change + // in the same situation keeps its tuned behavior of applying the remainder. These two tests pin + // that divergence (the hardest-won part of the fix); reverting the resize branch fails the first. + const buildClampSettleContext = (isResize: boolean) => + createMockContext( + // totalSize 300 + scrollLength 100 => native max scroll is 200. + { totalSize: 300 }, + { + pendingNativeMVCPAdjust: { + amount: -300, + furthestProgressTowardAmount: 0, + isResize, + manualApplied: -80, + startScroll: 420, + }, + scrollLength: 100, + }, + ); + + it("settles a resize at the native clamp without applying a further spacer adjust", () => { + Platform.OS = "ios"; + const mockCtx = buildClampSettleContext(/* isResize */ true); + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + try { + // newScroll === expectedNativeClampScroll (200) => native has clamped to its true max. + const didSettle = resolvePendingNativeMVCPAdjust(mockCtx as StateContext, 200); + + expect(didSettle).toBe(true); + expect(mockCtx.state.pendingNativeMVCPAdjust).toBeUndefined(); + // The resize must NOT nudge the spacer further once native is pinned at the clamp. + expect(requestAdjustSpy).not.toHaveBeenCalled(); + } finally { + requestAdjustSpy.mockRestore(); + } + }); + + it("applies the remaining amount for a data change at the native clamp (unchanged behavior)", () => { + Platform.OS = "ios"; + const mockCtx = buildClampSettleContext(/* isResize */ false); + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + try { + // Same geometry, but a data change still applies the leftover remainder via settle. + const didSettle = resolvePendingNativeMVCPAdjust(mockCtx as StateContext, 200); + + expect(didSettle).toBe(true); + expect(mockCtx.state.pendingNativeMVCPAdjust).toBeUndefined(); + // remainingAfterManual(-220) - nativeDelta(200 - (420 + -80) = -140) = -80 applied. + expect(requestAdjustSpy).toHaveBeenCalledWith(mockCtx, -80, true); + } finally { + requestAdjustSpy.mockRestore(); + } + }); }); diff --git a/__tests__/core/prepareMVCP.test.ts b/__tests__/core/prepareMVCP.test.ts index 31fa07ae..9293723e 100644 --- a/__tests__/core/prepareMVCP.test.ts +++ b/__tests__/core/prepareMVCP.test.ts @@ -316,6 +316,7 @@ describe("prepareMVCP", () => { mockState.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 420, }; @@ -349,6 +350,7 @@ describe("prepareMVCP", () => { mockState.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 420, }; @@ -376,6 +378,7 @@ describe("prepareMVCP", () => { mockState.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 420, }; diff --git a/__tests__/core/updateScroll.test.ts b/__tests__/core/updateScroll.test.ts index 9d6392f1..d22f82c7 100644 --- a/__tests__/core/updateScroll.test.ts +++ b/__tests__/core/updateScroll.test.ts @@ -51,6 +51,7 @@ describe("updateScroll large user jumps", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -500, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 0, }; @@ -84,6 +85,7 @@ describe("updateScroll large user jumps", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: 500, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 0, }; @@ -177,6 +179,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 420, }; @@ -196,6 +199,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 420, }; @@ -215,6 +219,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 420, }; @@ -241,6 +246,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 420, }; @@ -260,6 +266,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: -80, startScroll: 420, }; @@ -286,6 +293,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: -80, startScroll: 420, }; @@ -305,6 +313,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: -80, startScroll: 420, }; @@ -324,6 +333,7 @@ describe("updateScroll mvcp active mode", () => { mockCtx.state.pendingNativeMVCPAdjust = { amount: -300, furthestProgressTowardAmount: 120, + isResize: false, manualApplied: 0, startScroll: 420, }; @@ -346,6 +356,7 @@ describe("updateScroll mvcp active mode", () => { pendingNativeMVCPAdjust: { amount: -20, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 100, }, @@ -385,6 +396,7 @@ describe("updateScroll mvcp active mode", () => { pendingNativeMVCPAdjust: { amount: -92.25, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: -37.91664632161462, startScroll: 984.6666666666666, }, @@ -427,6 +439,7 @@ describe("updateScroll mvcp active mode", () => { pendingNativeMVCPAdjust: { amount: -100, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: -38.16664632161451, startScroll: 1813.6666666666667, }, @@ -468,6 +481,7 @@ describe("updateScroll mvcp active mode", () => { pendingNativeMVCPAdjust: { amount: -20, furthestProgressTowardAmount: 0, + isResize: false, manualApplied: 0, startScroll: 100, }, diff --git a/example/screens/fixtures/ai-chat-keyboard.tsx b/example/screens/fixtures/ai-chat-keyboard.tsx index 45fcf623..12ea53f9 100644 --- a/example/screens/fixtures/ai-chat-keyboard.tsx +++ b/example/screens/fixtures/ai-chat-keyboard.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, StyleSheet, Text, TextInput, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { KeyboardController, KeyboardGestureArea, @@ -66,10 +66,14 @@ const AIResponse = ({ text, isPlaceholder, timeStamp, + expanded, + onToggle, }: { text: string; isPlaceholder: boolean; timeStamp: number; + expanded: boolean; + onToggle: () => void; }) => { if (isPlaceholder) { return ( @@ -87,12 +91,18 @@ const AIResponse = ({ } return ( - - {text} + + + {text} + {new Date(timeStamp).toLocaleTimeString()} - + {expanded ? "▲ Collapse" : "▼ Expand"} + ); }; @@ -124,6 +134,12 @@ function pickReply(input: string, userMessage: string): string { const AILegendListChat = () => { const [messages, setMessages] = useState(INITIAL_MESSAGES); + // Whether messages are expanded by default (toggled by the button above the + // list). Per-message taps override this default in `overrides`. + const [defaultExpanded, setDefaultExpanded] = useState(false); + // Per-message expand/collapse overrides keyed by message id: true = expanded, + // false = collapsed. Absent = follow `defaultExpanded`. + const [overrides, setOverrides] = useState>(new Map()); const [inputText, setInputText] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [liftBehavior, setLiftBehavior] = useState("whenAtEnd"); @@ -150,6 +166,55 @@ const AILegendListChat = () => { setIsStreaming(false); }, []); + // Clear a message's expand/collapse override so it follows the current + // default again. + const resetOverride = useCallback((id: string) => { + setOverrides((prev) => { + if (!prev.has(id)) { + return prev; + } + + const next = new Map(prev); + + next.delete(id); + + return next; + }); + }, []); + + const toggleMessage = useCallback( + (id: string) => { + setOverrides((prev) => { + const isExpanded = prev.has(id) ? prev.get(id)! : defaultExpanded; + const next = new Map(prev); + + next.set(id, !isExpanded); + + // In default-contracted mode, an expand is temporary: re-contract it + // after 5s so the list returns to its compact state on its own. + if (!defaultExpanded && !isExpanded) { + schedule(() => resetOverride(id), 5000); + } + + return next; + }); + }, + [defaultExpanded, resetOverride, schedule], + ); + + const toggleDefaultMode = useCallback(() => { + // Switching the default resets per-message overrides so everything follows + // the new default uniformly. + setDefaultExpanded((prev) => !prev); + setOverrides(new Map()); + }, []); + + // LegendList recycles rows and only re-renders them when `data` or `extraData` + // changes. Expand/collapse lives in `defaultExpanded`/`overrides` (not in the + // message data), so feed them through `extraData` to force affected rows to + // re-render with the new expanded state. + const expandState = useMemo(() => ({ defaultExpanded, overrides }), [defaultExpanded, overrides]); + const doSendMessage = (text: string, rawInput: string) => { setAnchorAtStartIndex(messages.length); @@ -240,6 +305,11 @@ const AILegendListChat = () => { ))} + + + {defaultExpanded ? "Default: expanded" : "Default: contracted (5s)"} + + { contentContainerStyle={styles.contentContainer} contentInsetEndAdjustment={contentInsetEndAdjustment} data={messages} + extraData={expandState} initialScrollAtEnd keyboardLiftBehavior={liftBehavior} keyboardOffset={insets.bottom} @@ -273,7 +344,9 @@ const AILegendListChat = () => { ) : ( toggleMessage(item.id)} text={item.text} timeStamp={item.timeStamp} /> @@ -351,6 +424,11 @@ const styles = StyleSheet.create({ marginHorizontal: 2, width: 8, }, + expandToggle: { + color: "#007AFF", + fontSize: 13, + marginTop: 6, + }, input: { backgroundColor: "white", borderColor: "#ccc", diff --git a/src/core/mvcp.ts b/src/core/mvcp.ts index 3d695575..d5feea82 100644 --- a/src/core/mvcp.ts +++ b/src/core/mvcp.ts @@ -1,5 +1,6 @@ import { IsNewArchitecture } from "@/constants-platform"; import { Platform } from "@/platform/Platform"; +import { getContentInsetEnd } from "@/state/getContentInsetEnd"; import { getContentSize } from "@/state/getContentSize"; import { peek$, type StateContext } from "@/state/state"; import { getId } from "@/utils/getId"; @@ -84,23 +85,40 @@ function updateAnchorLock( } function shouldQueueNativeMVCPAdjust( + ctx: StateContext, dataChanged: boolean | undefined, - state: StateContext["state"], positionDiff: number, prevTotalSize: number, prevScroll: number, scrollTarget: number | undefined, ) { - if ( - !dataChanged || - Platform.OS === "web" || - !state.props.maintainVisibleContentPosition.data || - scrollTarget !== undefined || - positionDiff >= -MVCP_POSITION_EPSILON - ) { + const state = ctx.state; + const mvcp = state.props.maintainVisibleContentPosition; + // The handoff applies to whichever MVCP mode drove this pass: data changes use mvcp.data, + // item resizes use mvcp.size. Gating only on mvcp.data would miss size-only callers. + const mvcpEnabled = dataChanged ? mvcp.data : mvcp.size; + + if (Platform.OS === "web" || !mvcpEnabled || scrollTarget !== undefined || positionDiff >= -MVCP_POSITION_EPSILON) { return false; } + if (!dataChanged) { + // Item resizes only need the handoff when the viewport overlaps the bottom inset zone, + // because that is the only place the native end-clamp interacts with the spacer adjust. + // Without an inset (or with the viewport entirely above real content) the plain + // requestAdjust path is correct and must be preserved. + const contentInsetEnd = getContentInsetEnd(ctx); + if (contentInsetEnd <= MVCP_POSITION_EPSILON) { + return false; + } + + // Arm whenever any part of the inset is scrollable below the real content end, not just + // when fully pinned at the end — a partial overlap still lets native clamp eat part of the + // adjust, which the handoff reconciles via getPredictedNativeClamp. + const realContentEnd = prevTotalSize - contentInsetEnd; + return prevScroll + state.scrollLength > realContentEnd - MVCP_POSITION_EPSILON; + } + const distanceFromEnd = prevTotalSize - prevScroll - state.scrollLength; return distanceFromEnd < Math.abs(positionDiff) - MVCP_POSITION_EPSILON; } @@ -147,10 +165,16 @@ function maybeApplyPredictedNativeMVCPAdjust(ctx: StateContext) { const totalSize = getContentSize(ctx); const predictedNativeClamp = getPredictedNativeClamp(state, pending.amount, totalSize); - if (Math.abs(predictedNativeClamp) <= MVCP_POSITION_EPSILON) { + + // For resizes, when the native end-clamp will absorb nothing (clamp ≈ 0 — the viewport has + // room below in the inset zone), native moves nothing on its own, so we must apply the full + // amount manually rather than waiting for a native move that never comes. For data changes, + // preserve the original behavior of deferring to native when the clamp is ~0. + if (!pending.isResize && Math.abs(predictedNativeClamp) <= MVCP_POSITION_EPSILON) { return; } + // The manual remainder is whatever the native end-clamp will NOT absorb. const manualDesired = pending.amount - predictedNativeClamp; if (Math.abs(manualDesired) <= MVCP_POSITION_EPSILON) { return; @@ -197,6 +221,14 @@ export function resolvePendingNativeMVCPAdjust(ctx: StateContext, newScroll: num const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { + if (pending.isResize) { + // For a resize, native has reached its true max — the overflow is fully absorbed and the + // list is pinned at the end. A further spacer adjust here cannot be realized (it would + // only force native to re-clamp and overshoot the visible position), so settle without + // adjusting. (Data changes keep applying the remainder, which is their tuned behavior.) + state.pendingNativeMVCPAdjust = undefined; + return true; + } settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -406,12 +438,11 @@ export function prepareMVCP(ctx: StateContext, dataChanged?: boolean): (() => vo positionDiff, }); - if ( - shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget) - ) { + if (shouldQueueNativeMVCPAdjust(ctx, dataChanged, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { state.pendingNativeMVCPAdjust = { amount: positionDiff, furthestProgressTowardAmount: 0, + isResize: !dataChanged, manualApplied: 0, startScroll: prevScroll, }; diff --git a/src/types.internal.ts b/src/types.internal.ts index bb701c1b..9859cc74 100644 --- a/src/types.internal.ts +++ b/src/types.internal.ts @@ -196,6 +196,7 @@ export interface InternalState { furthestProgressTowardAmount: number; manualApplied: number; startScroll: number; + isResize: boolean; }; pendingMaintainScrollAtEnd?: boolean; pendingDataComparison?: PendingDataComparison;