From 8c7966ba9d8a9150e2312ce6dc84c04b2607d4e1 Mon Sep 17 00:00:00 2001 From: Marshall Gould Date: Thu, 9 Jul 2026 20:26:14 +0100 Subject: [PATCH 1/3] fix: stabilize keyboard layout tracking --- .../project.pbxproj | 2 +- .../movement/KeyboardTrackingView.swift | 13 +- .../__tests__/initialFrame.spec.tsx | 157 ++++++++++++++++++ src/components/KeyboardAvoidingView/index.tsx | 8 +- 4 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 src/components/KeyboardAvoidingView/__tests__/initialFrame.spec.tsx diff --git a/example/ios/KeyboardControllerExample.xcodeproj/project.pbxproj b/example/ios/KeyboardControllerExample.xcodeproj/project.pbxproj index 71b7a1bde5..9e49404760 100644 --- a/example/ios/KeyboardControllerExample.xcodeproj/project.pbxproj +++ b/example/ios/KeyboardControllerExample.xcodeproj/project.pbxproj @@ -335,7 +335,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\""; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n\"$WITH_ENVIRONMENT\" \"$REACT_NATIVE_XCODE\""; }; 2D02E4CB1E0B4B27006451C7 /* Bundle React Native Code And Images */ = { isa = PBXShellScriptBuildPhase; diff --git a/ios/observers/movement/KeyboardTrackingView.swift b/ios/observers/movement/KeyboardTrackingView.swift index 1b0e3e6a49..b6d9f8276d 100644 --- a/ios/observers/movement/KeyboardTrackingView.swift +++ b/ios/observers/movement/KeyboardTrackingView.swift @@ -138,12 +138,17 @@ public final class KeyboardTrackingView: UIView { // for `keyboardLayoutGuide` case we can just read keyboard position directly - no interpolation needed if KeyboardControllerConfiguration.usesKeyboardLayoutGuideTracking { - if keyboardPosition > keyboardHeight { + // when we are the top position KVO takes `inputAccessoryView` into consideration, + // so we handle it here. KVO reports coordinates on the screen's pixel + // grid, while `keyboardHeight` can include a fractional accessory-view + // offset (for example, a hairline border). Treat values within one pixel + // as the same fully-open position instead of relying on exact equality. + let screenScale = trackedView.window?.screen.scale ?? UIScreen.main.scale + let positionTolerance = 1 / max(screenScale, 1) + if keyboardPosition > keyboardHeight + positionTolerance { return Self.invalidPosition } - // when we are the top position KVO takes `inputAccessoryView` into consideration, - // so we handle it here - if keyboardPosition == keyboardHeight { + if abs(keyboardPosition - keyboardHeight) <= positionTolerance { return keyboardPosition - KeyboardAreaExtender.shared.offset } return keyboardPosition diff --git a/src/components/KeyboardAvoidingView/__tests__/initialFrame.spec.tsx b/src/components/KeyboardAvoidingView/__tests__/initialFrame.spec.tsx new file mode 100644 index 0000000000..8ed379fac6 --- /dev/null +++ b/src/components/KeyboardAvoidingView/__tests__/initialFrame.spec.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render } from "@testing-library/react-native"; +import { View } from "react-native"; + +import KeyboardAvoidingView from ".."; + +import type { LayoutRectangle } from "react-native"; + +const keyboard = { + heightWhenOpened: { value: 300 }, + progress: { value: 1 }, + isClosed: { value: true }, +}; +const translate = { value: 0 }; +const padding = { value: 0 }; +const animatedStyleUpdaters: Array<() => object> = []; + +jest.mock("react-native-reanimated", () => { + const React = require("react"); + const { View: NativeView } = require("react-native"); + + return { + __esModule: true, + default: { + View: React.forwardRef((props: object, ref: unknown) => + React.createElement(NativeView, { ...props, ref }), + ), + }, + interpolate: (value: number, input: number[], output: number[]) => { + const progress = (value - input[0]) / (input[1] - input[0]); + + return output[0] + progress * (output[1] - output[0]); + }, + runOnUI: (worklet: (...args: unknown[]) => unknown) => worklet, + useAnimatedStyle: (updater: () => object) => { + animatedStyleUpdaters.push(updater); + + return updater(); + }, + useDerivedValue: (updater: () => unknown) => ({ + get value() { + return updater(); + }, + }), + useSharedValue: (value: T) => ({ value }), + }; +}); + +jest.mock("../hooks", () => ({ + useKeyboardAnimation: () => keyboard, + useTranslateAnimation: () => ({ translate, padding }), +})); + +jest.mock("../../../hooks", () => ({ + useWindowDimensions: () => ({ height: 800 }), +})); + +jest.mock("../../../bindings", () => ({ + KeyboardControllerNative: { + viewPositionInWindow: jest.fn(), + }, +})); + +const restingLayout: LayoutRectangle = { + x: 0, + y: 275, + width: 320, + height: 280, +}; +const paddingAffectedLayout: LayoutRectangle = { + ...restingLayout, + y: 220, + height: 400, +}; + +/** + * Evaluates the latest animated-style updater registered by the component. + * + * @returns The rendered animated style. + */ +function renderedStyle(): Record { + return animatedStyleUpdaters.at(-1)?.() as Record; +} + +/** + * Returns the keyboard displacement property emitted by a given behavior. + * + * @param behavior - The `KeyboardAvoidingView` behavior being inspected. + * @returns The resulting keyboard displacement in points. + */ +function renderedDisplacement( + behavior: "padding" | "translate-with-padding" | "position", +): number { + const style = renderedStyle(); + + switch (behavior) { + case "padding": + return style.paddingBottom; + case "translate-with-padding": + return style.paddingTop; + case "position": + return style.bottom; + } +} + +describe("KeyboardAvoidingView initial frame", () => { + beforeEach(() => { + keyboard.isClosed.value = true; + padding.value = 0; + animatedStyleUpdaters.length = 0; + }); + + it.each(["padding", "translate-with-padding"] as const)( + "keeps the resting frame for %s while the keyboard is open", + (behavior) => { + const screen = render( + + + , + ); + + fireEvent(screen.getByTestId("avoiding-view"), "layout", { + nativeEvent: { layout: restingLayout }, + }); + keyboard.isClosed.value = false; + padding.value = 1; + + expect(renderedDisplacement(behavior)).toBe(55); + + // These are the self-generated layout changes caused by animated + // padding. They must not replace the resting frame. + fireEvent(screen.getByTestId("avoiding-view"), "layout", { + nativeEvent: { layout: paddingAffectedLayout }, + }); + + expect(renderedDisplacement(behavior)).toBe(55); + }, + ); + + it("continues to refresh the outer frame for position behavior", () => { + const screen = render( + + + , + ); + + fireEvent(screen.getByTestId("avoiding-view"), "layout", { + nativeEvent: { layout: restingLayout }, + }); + keyboard.isClosed.value = false; + + fireEvent(screen.getByTestId("avoiding-view"), "layout", { + nativeEvent: { layout: paddingAffectedLayout }, + }); + + expect(renderedDisplacement("position")).toBe(120); + }); +}); diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx index ec2ee2961c..38888751e3 100644 --- a/src/components/KeyboardAvoidingView/index.tsx +++ b/src/components/KeyboardAvoidingView/index.tsx @@ -120,10 +120,16 @@ const KeyboardAvoidingView = forwardRef< (layout: LayoutRectangle) => { "worklet"; + // `padding` and `translate-with-padding` change the layout of this + // very view. Recording their onLayout events while the keyboard is + // open turns the animated padding into the next animation's base + // frame, which feeds back into `relativeKeyboardHeight` and causes + // jitter. `position` animates an inner view instead, so its outer + // frame can still be refreshed safely. if ( keyboard.isClosed.value || initialFrame.value === null || - behavior !== "height" + behavior === "position" ) { // eslint-disable-next-line react-compiler/react-compiler initialFrame.value = layout; From ea13056b954ff518b4b2bf6e4d2884df6a78abe2 Mon Sep 17 00:00:00 2001 From: Marshall Gould Date: Thu, 9 Jul 2026 20:39:53 +0100 Subject: [PATCH 2/3] fix: reset keyboard values when disabled --- src/__tests__/animated.spec.tsx | 108 ++++++++++++++++++++++++++++++++ src/animated.tsx | 14 ++++- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/animated.spec.tsx 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; } From 4a95b1824f89134a9e8acd5bb3ebd2dc8a3ee65c Mon Sep 17 00:00:00 2001 From: Marshall Gould Date: Fri, 10 Jul 2026 11:57:10 +0100 Subject: [PATCH 3/3] ci: skip AI review comments for fork PRs --- .github/workflows/ai-review.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index e5cec6ef4d..c35920efb2 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -20,7 +20,11 @@ on: jobs: review: name: 🕵️ Finding bugs - if: github.event.pull_request.draft == false + # Fork pull requests receive a read-only GITHUB_TOKEN and therefore cannot + # upsert the review comment at the end of this job. Do not use + # pull_request_target here: this workflow processes contributor-controlled + # diffs and must not run with write credentials. + if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest env: @@ -48,7 +52,7 @@ jobs: - name: Pull model run: | - ollama pull $MODEL + ollama pull "$MODEL" - name: Build LLM context run: | @@ -56,7 +60,7 @@ jobs: FILES=$(git diff --name-status origin/main...HEAD | awk '{print $1 "|" $2}') - > prompt.txt + : > prompt.txt while IFS="|" read -r status file; do [ -z "$file" ] && continue