diff --git a/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx b/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx index e3fe639f71..639ae65a8c 100644 --- a/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx +++ b/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx @@ -47,8 +47,7 @@ function KeyboardChatScrollViewPlayground() { const flashRef = useRef>(null); const flatRef = useRef>(null); const scrollRef = useRef(null); - const textInputRef = useRef(null); - const textRef = useRef(""); + const [text, setText] = useState(""); const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT); const extraContentPadding = useSharedValue(0); const { inverted, messages, reversedMessages, addMessage, mode } = @@ -70,20 +69,19 @@ function KeyboardChatScrollViewPlayground() { }, [extraContentPadding], ); - const onInput = useCallback((text: string) => { - textRef.current = text; + const onInput = useCallback((value: string) => { + setText(value); }, []); const onSend = useCallback(() => { - const message = textRef.current.trim(); + const message = text.trim(); if (message === "") { return; } addMessage({ text: message, sender: true }); - textInputRef.current?.clear(); - textRef.current = ""; - }, [addMessage]); + setText(""); + }, [addMessage, text]); useEffect(() => { legendRef.current?.scrollToOffset({ @@ -177,11 +175,11 @@ function KeyboardChatScrollViewPlayground() { /> diff --git a/src/architecture.ts b/src/architecture.ts new file mode 100644 index 0000000000..1f7aedb89f --- /dev/null +++ b/src/architecture.ts @@ -0,0 +1 @@ +export const IS_FABRIC = "nativeFabricUIManager" in global; diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx index a64a28c02e..be5179ac81 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx @@ -69,6 +69,7 @@ const KeyboardChatScrollView = forwardRef< scroll, layout, size, + contentOffsetY, inverted, keyboardLiftBehavior, freeze, diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__fixtures__/setup.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__fixtures__/setup.ts index d72e39b671..e92e4030f1 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/__fixtures__/setup.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__fixtures__/setup.ts @@ -1,15 +1,25 @@ import { renderHook } from "@testing-library/react-native"; import { useAnimatedRef } from "react-native-reanimated"; +import { useExtraContentPadding } from ".."; import { sv } from "../../../../__fixtures__/sv"; -import type { useExtraContentPadding } from ".."; import type { SharedValue } from "react-native-reanimated"; import type Reanimated from "react-native-reanimated"; export const mockScrollTo = jest.fn(); export let reactionEffect: (current: number, previous: number | null) => void; +export const flushRAF = () => new Promise((resolve) => setTimeout(resolve, 0)); + +let mockForceLegacy = false; + +jest.mock("../../../../architecture", () => ({ + get IS_FABRIC() { + return !mockForceLegacy; + }, +})); + jest.mock("react-native-reanimated", () => ({ ...require("react-native-reanimated/mock"), scrollTo: (...args: unknown[]) => mockScrollTo(...args), @@ -31,15 +41,10 @@ type RenderOptions = Omit< export const createRender = () => { return function render(options: RenderOptions) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const mod = require("..") as { - useExtraContentPadding: typeof useExtraContentPadding; - }; - return renderHook(() => { const ref = useAnimatedRef(); - mod.useExtraContentPadding({ + useExtraContentPadding({ scrollViewRef: ref, blankSpace: options.blankSpace ?? sv(0), ...options, @@ -49,6 +54,11 @@ export const createRender = () => { }; beforeEach(() => { - jest.resetModules(); + mockForceLegacy = false; mockScrollTo.mockClear(); }); + +export const withLegacyArch = (fn: () => void) => { + mockForceLegacy = true; + fn(); +}; diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/always.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/always.spec.ts index 7fdc9dd1f9..3cc08d8ccf 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/always.spec.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/always.spec.ts @@ -1,12 +1,13 @@ import { sv } from "../../../../__fixtures__/sv"; import { createRender, + flushRAF, mockScrollTo, reactionEffect, } from "../__fixtures__/setup"; describe("useExtraContentPadding — always behavior", () => { - it("should scrollTo on grow when at end (non-inverted)", () => { + it("should scrollTo on grow when at end (non-inverted)", async () => { const render = createRender(); render({ @@ -21,6 +22,7 @@ describe("useExtraContentPadding — always behavior", () => { }); reactionEffect(20, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith( expect.anything(), @@ -30,7 +32,7 @@ describe("useExtraContentPadding — always behavior", () => { ); }); - it("should scrollTo on grow when NOT at end (non-inverted)", () => { + it("should scrollTo on grow when NOT at end (non-inverted)", async () => { const render = createRender(); render({ @@ -45,11 +47,12 @@ describe("useExtraContentPadding — always behavior", () => { }); reactionEffect(20, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false); }); - it("should scrollTo on shrink (non-inverted)", () => { + it("should scrollTo on shrink (non-inverted)", async () => { const render = createRender(); render({ @@ -64,6 +67,7 @@ describe("useExtraContentPadding — always behavior", () => { }); reactionEffect(0, 20); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith( expect.anything(), @@ -73,7 +77,7 @@ describe("useExtraContentPadding — always behavior", () => { ); }); - it("should scrollTo on grow (inverted)", () => { + it("should scrollTo on grow (inverted)", async () => { const render = createRender(); render({ @@ -88,6 +92,7 @@ describe("useExtraContentPadding — always behavior", () => { }); reactionEffect(20, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, -15, false); }); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/blankSpace.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/blankSpace.spec.ts index 34c1f87bc9..c51360c68f 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/blankSpace.spec.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/blankSpace.spec.ts @@ -1,6 +1,7 @@ import { sv } from "../../../../__fixtures__/sv"; import { createRender, + flushRAF, mockScrollTo, reactionEffect, } from "../__fixtures__/setup"; @@ -29,7 +30,7 @@ describe("useExtraContentPadding — blankSpace floor", () => { expect(mockScrollTo).not.toHaveBeenCalled(); }); - it("should scroll by effective delta when blankSpace partially absorbs", () => { + it("should scroll by effective delta when blankSpace partially absorbs", async () => { const render = createRender(); render({ @@ -50,11 +51,12 @@ describe("useExtraContentPadding — blankSpace floor", () => { // maxScroll = max(2000 - 800 + 500, 0) = 1700 // target = min(100 + 100, 1700) = 200 reactionEffect(300, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 200, false); }); - it("blankSpace=0 produces identical behavior to default", () => { + it("blankSpace=0 produces identical behavior to default", async () => { const render = createRender(); render({ @@ -75,6 +77,7 @@ describe("useExtraContentPadding — blankSpace floor", () => { // maxScroll = max(2000 - 800 + 320, 0) = 1520 // target = min(1200 + 20, 1520) = 1220 reactionEffect(20, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith( expect.anything(), @@ -107,7 +110,7 @@ describe("useExtraContentPadding — blankSpace floor", () => { expect(mockScrollTo).not.toHaveBeenCalled(); }); - it("should scroll when change exceeds blankSpace floor (inverted)", () => { + it("should scroll when change exceeds blankSpace floor (inverted)", async () => { const render = createRender(); render({ @@ -127,6 +130,7 @@ describe("useExtraContentPadding — blankSpace floor", () => { // effectiveDelta = 100 // target = max(5 - 100, -500) = max(-95, -500) = -95 reactionEffect(200, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, -95, false); }); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/contentOffsetY.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/contentOffsetY.spec.ts new file mode 100644 index 0000000000..67fe2e2420 --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/contentOffsetY.spec.ts @@ -0,0 +1,193 @@ +import { sv } from "../../../../__fixtures__/sv"; +import { + createRender, + flushRAF, + mockScrollTo, + reactionEffect, + withLegacyArch, +} from "../__fixtures__/setup"; + +describe("useExtraContentPadding — contentOffsetY (iOS atomic path)", () => { + it("should set contentOffsetY instead of scrollTo on grow (non-inverted)", () => { + const render = createRender(); + const contentOffsetY = sv(100); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY, + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(contentOffsetY.value).toBe(120); + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should set contentOffsetY instead of scrollTo on grow (inverted)", () => { + const render = createRender(); + const contentOffsetY = sv(5); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(5), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY, + inverted: true, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(contentOffsetY.value).toBe(-15); + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should set contentOffsetY instead of scrollTo on shrink (non-inverted)", () => { + const render = createRender(); + const contentOffsetY = sv(1220); + + render({ + extraContentPadding: sv(0), + keyboardPadding: sv(300), + scroll: sv(1220), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY, + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(0, 20); + + expect(contentOffsetY.value).toBe(1200); + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should clamp contentOffsetY to maxScroll (non-inverted)", () => { + const render = createRender(); + const contentOffsetY = sv(1490); + + render({ + extraContentPadding: sv(50), + keyboardPadding: sv(300), + scroll: sv(1490), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY, + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + // delta = 50, scroll + delta = 1540, maxScroll = 2000 - 800 + 300 + 50 = 1550 + reactionEffect(50, 0); + + expect(contentOffsetY.value).toBe(1540); + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should clamp contentOffsetY to -totalPadding (inverted)", () => { + const render = createRender(); + const contentOffsetY = sv(-280); + + render({ + extraContentPadding: sv(50), + keyboardPadding: sv(300), + scroll: sv(-280), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY, + inverted: true, + keyboardLiftBehavior: "always", + freeze: false, + }); + + // delta = 50, target = -280 - 50 = -330, clamp to -350 + reactionEffect(50, 0); + + expect(contentOffsetY.value).toBe(-330); + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should fall back to scrollTo when contentOffsetY is undefined", async () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY: undefined, + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, 0); + await flushRAF(); + + expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false); + }); +}); + +describe("useExtraContentPadding — iOS legacy arch (scrollTo path)", () => { + it("should use scrollTo directly when on legacy arch even if contentOffsetY is provided (non-inverted)", () => { + const render = createRender(); + const contentOffsetY = sv(100); + + withLegacyArch(() => { + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY, + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + }); + + reactionEffect(20, 0); + + expect(contentOffsetY.value).toBe(100); + expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false); + }); + + it("should use scrollTo directly when on legacy arch even if contentOffsetY is provided (inverted)", () => { + const render = createRender(); + const contentOffsetY = sv(5); + + withLegacyArch(() => { + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(5), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + contentOffsetY, + inverted: true, + keyboardLiftBehavior: "always", + freeze: false, + }); + }); + + reactionEffect(20, 0); + + expect(contentOffsetY.value).toBe(5); + expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, -15, false); + }); +}); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/edge-cases.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/edge-cases.spec.ts index 620ffb5674..0544d015c7 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/edge-cases.spec.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/edge-cases.spec.ts @@ -1,6 +1,7 @@ import { sv } from "../../../../__fixtures__/sv"; import { createRender, + flushRAF, mockScrollTo, reactionEffect, } from "../__fixtures__/setup"; @@ -63,7 +64,7 @@ describe("useExtraContentPadding — edge cases", () => { expect(mockScrollTo).not.toHaveBeenCalled(); }); - it("should clamp to maxScroll (non-inverted)", () => { + it("should clamp to maxScroll (non-inverted)", async () => { const render = createRender(); render({ @@ -79,6 +80,7 @@ describe("useExtraContentPadding — edge cases", () => { // delta = 50, scroll + delta = 1540, maxScroll = 2000 - 800 + 300 + 50 = 1550 reactionEffect(50, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith( expect.anything(), @@ -88,7 +90,7 @@ describe("useExtraContentPadding — edge cases", () => { ); }); - it("should clamp to -totalPadding (inverted)", () => { + it("should clamp to -totalPadding (inverted)", async () => { const render = createRender(); render({ @@ -104,6 +106,7 @@ describe("useExtraContentPadding — edge cases", () => { // delta = 50, target = -280 - 50 = -330, clamp to -350 reactionEffect(50, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith( expect.anything(), diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/persistent.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/persistent.spec.ts index 0c85f693dd..f4e73dcbcf 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/persistent.spec.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/persistent.spec.ts @@ -1,12 +1,13 @@ import { sv } from "../../../../__fixtures__/sv"; import { createRender, + flushRAF, mockScrollTo, reactionEffect, } from "../__fixtures__/setup"; describe("useExtraContentPadding — persistent behavior", () => { - it("should scrollTo on grow", () => { + it("should scrollTo on grow", async () => { const render = createRender(); render({ @@ -21,6 +22,7 @@ describe("useExtraContentPadding — persistent behavior", () => { }); reactionEffect(20, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false); }); @@ -44,7 +46,7 @@ describe("useExtraContentPadding — persistent behavior", () => { expect(mockScrollTo).not.toHaveBeenCalled(); }); - it("should scrollTo on shrink when at end", () => { + it("should scrollTo on shrink when at end", async () => { const render = createRender(); render({ @@ -59,6 +61,7 @@ describe("useExtraContentPadding — persistent behavior", () => { }); reactionEffect(0, 20); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalled(); }); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/whenAtEnd.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/whenAtEnd.spec.ts index bb66efc5f0..563d0195b4 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/whenAtEnd.spec.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/whenAtEnd.spec.ts @@ -1,12 +1,13 @@ import { sv } from "../../../../__fixtures__/sv"; import { createRender, + flushRAF, mockScrollTo, reactionEffect, } from "../__fixtures__/setup"; describe("useExtraContentPadding — whenAtEnd behavior", () => { - it("should scrollTo when at end (non-inverted)", () => { + it("should scrollTo when at end (non-inverted)", async () => { const render = createRender(); render({ @@ -21,6 +22,7 @@ describe("useExtraContentPadding — whenAtEnd behavior", () => { }); reactionEffect(20, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalled(); }); @@ -44,7 +46,7 @@ describe("useExtraContentPadding — whenAtEnd behavior", () => { expect(mockScrollTo).not.toHaveBeenCalled(); }); - it("should scrollTo when at end (inverted)", () => { + it("should scrollTo when at end (inverted)", async () => { const render = createRender(); render({ @@ -59,6 +61,7 @@ describe("useExtraContentPadding — whenAtEnd behavior", () => { }); reactionEffect(20, 0); + await flushRAF(); expect(mockScrollTo).toHaveBeenCalled(); }); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts index 76945567cd..24a25ae12f 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts @@ -1,5 +1,8 @@ +import { useCallback } from "react"; +import { Platform } from "react-native"; import { scrollTo, useAnimatedReaction } from "react-native-reanimated"; +import { IS_FABRIC } from "../../../architecture"; import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers"; import type { KeyboardLiftBehavior } from "../useChatKeyboard/types"; @@ -19,6 +22,8 @@ type UseExtraContentPaddingOptions = { layout: SharedValue<{ width: number; height: number }>; /** Total content dimensions. */ size: SharedValue<{ width: number; height: number }>; + /** IOS only — when provided, sets contentOffset atomically with contentInset. */ + contentOffsetY?: SharedValue; inverted: boolean; keyboardLiftBehavior: KeyboardLiftBehavior; freeze: boolean; @@ -47,11 +52,32 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { scroll, layout, size, + contentOffsetY, inverted, keyboardLiftBehavior, freeze, } = options; + const scrollToTarget = useCallback( + (target: number) => { + "worklet"; + + if (contentOffsetY && IS_FABRIC) { + // eslint-disable-next-line react-compiler/react-compiler + contentOffsetY.value = target; + } else if (Platform.OS === "android") { + // Defer scrollTo so the animatedProps inset commit lands first; + // otherwise the native ScrollView clamps to the old range. + requestAnimationFrame(() => { + scrollTo(scrollViewRef, 0, target, false); + }); + } else { + scrollTo(scrollViewRef, 0, target, false); + } + }, + [scrollViewRef, contentOffsetY], + ); + useAnimatedReaction( () => extraContentPadding.value, (current, previous) => { @@ -104,7 +130,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { if (inverted) { const target = Math.max(scroll.value - effectiveDelta, -currentTotal); - scrollTo(scrollViewRef, 0, target, false); + scrollToTarget(target); } else { const maxScroll = Math.max( size.value.height - layout.value.height + currentTotal, @@ -112,7 +138,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { ); const target = Math.min(scroll.value + effectiveDelta, maxScroll); - scrollTo(scrollViewRef, 0, target, false); + scrollToTarget(target); } }, [inverted, keyboardLiftBehavior, freeze],