diff --git a/src/__tests__/animated.spec.tsx b/src/__tests__/animated.spec.tsx new file mode 100644 index 0000000000..f22fb587ee --- /dev/null +++ b/src/__tests__/animated.spec.tsx @@ -0,0 +1,108 @@ +import { act, render } from "@testing-library/react-native"; +import { useEffect } from "react"; + +import { KeyboardProvider } from "../animated"; +import { useKeyboardContext } from "../context"; + +import type { KeyboardAnimationContext } from "../context"; + +jest.mock("react-native-reanimated", () => { + const React = require("react"); + const mock = require("react-native-reanimated/mock"); + + return { + ...mock, + __esModule: true, + default: { + createAnimatedComponent: (Component: unknown) => Component, + }, + useSharedValue: (initialValue: T) => { + const ref = React.useRef({ value: initialValue }); + + return ref.current; + }, + }; +}); + +jest.mock("../bindings", () => { + const { View } = require("react-native"); + + return { + FocusedInputEvents: { + addListener: jest.fn(() => ({ remove: jest.fn() })), + }, + KeyboardControllerView: View, + KeyboardControllerViewCommands: { + synchronizeFocusedInputLayout: jest.fn(), + }, + KeyboardEvents: { + addListener: jest.fn(() => ({ remove: jest.fn() })), + }, + KeyboardControllerNative: { + dismiss: jest.fn(), + preload: jest.fn(), + setDefaultMode: jest.fn(), + setFocusTo: jest.fn(), + setInputMode: jest.fn(), + setTranslucent: jest.fn(), + }, + }; +}); + +jest.mock("../internal", () => ({ + ...jest.requireActual("../internal"), + useEventHandlerRegistration: jest.fn(() => jest.fn(() => jest.fn())), +})); + +jest.mock("../reanimated", () => ({ + useAnimatedKeyboardHandler: jest.fn(() => ({})), + useFocusedInputLayoutHandler: jest.fn(() => ({})), +})); + +jest.mock("react-native-is-edge-to-edge", () => ({ + controlEdgeToEdgeValues: jest.fn(), + isEdgeToEdge: jest.fn(() => false), +})); + +type ContextCaptureProps = { + onContext: (value: KeyboardAnimationContext) => void; +}; + +/** + * Captures the provider context exposed to descendant hooks. + * + * @param props - Receives each context value after it is committed. + * @param props.onContext - Callback invoked with the latest context value. + * @returns No rendered output. + */ +function ContextCapture({ onContext }: ContextCaptureProps) { + const value = useKeyboardContext(); + + useEffect(() => { + onContext(value); + }, [onContext, value]); + + return null; +} + +describe("KeyboardProvider enabled state", () => { + it("resets Reanimated keyboard values when disabled mid-transition", () => { + const capture = jest.fn(); + + render( + + + , + ); + const context = capture.mock.lastCall![0] as KeyboardAnimationContext; + + act(() => { + context.reanimated.progress.value = 0.5; + context.reanimated.height.value = -150; + context.setEnabled(false); + }); + + expect(context.reanimated.progress.value).toBe(0); + expect(context.reanimated.height.value).toBe(0); + }); +}); diff --git a/src/animated.tsx b/src/animated.tsx index 39d5494c87..2166fd08d9 100644 --- a/src/animated.tsx +++ b/src/animated.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -96,6 +97,18 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => { const layout = useSharedValue(null); const setKeyboardHandlers = useEventHandlerRegistration(viewRef); const setInputHandlers = useEventHandlerRegistration(viewRef); + + useLayoutEffect(() => { + if (!enabled) { + progress.setValue(0); + height.setValue(0); + // eslint-disable-next-line react-compiler/react-compiler + progressSV.value = 0; + heightSV.value = 0; + layout.value = null; + } + }, [enabled, height, heightSV, layout, progress, progressSV]); + const update = useCallback(async () => { KeyboardControllerViewCommands.synchronizeFocusedInputLayout( viewRef.current, @@ -153,7 +166,6 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => { "worklet"; if (platforms.includes(OS)) { - // eslint-disable-next-line react-compiler/react-compiler progressSV.value = event.progress; heightSV.value = -event.height; }