Skip to content

Commit cfc62b7

Browse files
authored
feat: synchronous handler mount (#538)
## 📜 Description Mount workletized handlers synchronously 😎 ## 💡 Motivation and Context Previously we were storing all worklet handlers in a global object and broadcasted events through all of them. However such approach had one big downside: updating shared value from JS is asynchronous (and if JS thread becomes busy, then mounting can take significant time). In most of the cases it's not a big problem, **but** if keyboard moves when handler is not attached yet, then such keyboard movement is not getting tracked and as a result an actual keyboard position is not synchronized with shared values (if we are using components, such as `KeyboardAvoidingView` then they will not handle keyboard appearance properly). I've considered two approaches how to fix it: ### 1️⃣ Distinguish which events were not sent to particular handler and send them after actual mount That was the first idea and I thought it's quite perspective, but when I implemented it, I quickly realized, that: - it bring more hidden complexity; - it produces more race conditions - we can verify whether event was handled only in JS (only there we know which handlers are mounted and from UI thread we can send all ids of handlers that handled event), but we may have a situation, when handler skipped 10/30 events and handled last 20 events. In this case we shouldn't send these events, but we don't distinguish whether these events belong to the same group of events or not, so if we send them from JS to worklet, we may have a situation, where we handle last 20 events and only after that we handle first 10 events. So at this point of time I realized, that it's not straightforward appraoch and started to look into different solutions. ### 2️⃣ Attach handler through JSI function Another approach that I considered is attaching worklet handlers to the view directly (without intermediate handlers). I discovered, that we call JSI function, which means that we have no delays or async stuff and was very inspired by that fact. I did some experiments and indeed it proved, that handlers can be attached synchronously. However I discovered one use case - we still attach handler from `useEffect` (which is executed asynchronously) and worklet handlers are added via `addUiBlock`, so it's again asynchronous. So even with JSI we could have a delay up to 2 frames, which wasn't acceptable in certain cases. At this point of time I thought that it's unreal to complete my objective, but then decided to try to use `useSyncEffect`. And with `useSyncEffect` it looks like we have only `addUIBlock` asynchronous and it's kind of acceptable (my handlers gets mounted faster, than keyboard events arrive). So I re-worked code, added unit tests for `useSyncEffect`, run e2e and CI and everything seems to be pretty good so fat. I know, that it's not very highly desirable to run synchronous events in react, but I feel like this approach is a last resort and want to try it. I also did performance benchmarks and didn't notice anything - UI gives 59/60 FPS and JS thread give 55 FPS (when navigating between screens). So I think this approach is an acceptable option that worth to try 🚀 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### Docs - mention that only `react-native-reanimated@3.0.0` is a minimal supported version; ### JS - added infrastructure for hook testing in `src` folder; - removed `useSharedHandlers` hook; - added `useEventHandlerRegistration` hook; - changed signature for `setKeyboardHandler` and `setInputHandlers` method; - assign `ref` to `KeyboardControllerView`; - add `event-mapping` and `event-handler` files; - added `useSyncEffect` hook; - removed `useFocusedInputTextHandler`, `useFocusedInputSelectionHandler` and `uuid` functions; ## 🤔 How Has This Been Tested? Tested manually on iPhone 15 Pro. Also verified that e2e tests are not failing. ## 📸 Screenshots (if appropriate): |Before|After| |-------|-----| |<video src="https://github.com/user-attachments/assets/b839212e-16da-4437-85e6-187b97a3ea55">|<video src="https://github.com/user-attachments/assets/f78c2726-e103-4720-8041-9aafd812b30f">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 6861faf commit cfc62b7

17 files changed

+328
-180
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ docs/build/
44
lib/
55
node_modules/
66
vendor/
7+
.gradle/
78

89
*.lottie.json
910

docs/docs/guides/compatibility.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ This library supports as minimal `react-native` version as possible. However it
4545

4646
This library is heavily relies on `react-native-reanimated` primitives to bring advanced concepts for keyboard handling.
4747

48-
The minimal supported version of `react-native-reanimated` is `2.11.0`.
48+
The minimum supported version of `react-native-reanimated` is `3.0.0` (as officially supported by `react-native-reanimated` team).
4949

5050
## Third-party libraries compatibility
5151

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@commitlint/config-conventional": "^11.0.0",
7474
"@react-native/eslint-config": "^0.74.85",
7575
"@release-it/conventional-changelog": "^2.0.0",
76+
"@testing-library/react-hooks": "^8.0.1",
7677
"@types/jest": "^29.2.1",
7778
"@types/react": "^18.2.6",
7879
"@typescript-eslint/eslint-plugin": "^6.7.4",
@@ -96,6 +97,7 @@
9697
"react-native": "0.74.3",
9798
"react-native-builder-bob": "^0.18.0",
9899
"react-native-reanimated": "3.12.1",
100+
"react-test-renderer": "18.2.0",
99101
"release-it": "^14.2.2",
100102
"typescript": "5.0.4"
101103
},

src/animated.tsx

Lines changed: 15 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
/* eslint react/jsx-sort-props: off */
2-
import React, { useEffect, useMemo, useState } from "react";
2+
import React, { useEffect, useMemo, useRef, useState } from "react";
33
import { Animated, Platform, StyleSheet } from "react-native";
44
import Reanimated, { useSharedValue } from "react-native-reanimated";
55

66
import { KeyboardControllerView } from "./bindings";
77
import { KeyboardContext } from "./context";
8-
import { useAnimatedValue, useSharedHandlers } from "./internal";
8+
import { focusedInputEventsMap, keyboardEventsMap } from "./event-mappings";
9+
import { useAnimatedValue, useEventHandlerRegistration } from "./internal";
910
import { applyMonkeyPatch, revertMonkeyPatch } from "./monkey-patch";
1011
import {
1112
useAnimatedKeyboardHandler,
1213
useFocusedInputLayoutHandler,
13-
useFocusedInputSelectionHandler,
14-
useFocusedInputTextHandler,
1514
} from "./reanimated";
1615

1716
import type { KeyboardAnimationContext } from "./context";
@@ -25,9 +24,7 @@ import type {
2524
import type { ViewStyle } from "react-native";
2625

2726
const KeyboardControllerViewAnimated = Reanimated.createAnimatedComponent(
28-
Animated.createAnimatedComponent(
29-
KeyboardControllerView,
30-
) as React.FC<KeyboardControllerProps>,
27+
Animated.createAnimatedComponent(KeyboardControllerView),
3128
);
3229

3330
type Styles = {
@@ -84,6 +81,8 @@ export const KeyboardProvider = ({
8481
navigationBarTranslucent,
8582
enabled: initiallyEnabled = true,
8683
}: KeyboardProviderProps) => {
84+
// ref
85+
const viewTagRef = useRef<React.Component<KeyboardControllerProps>>(null);
8786
// state
8887
const [enabled, setEnabled] = useState(initiallyEnabled);
8988
// animated values
@@ -93,10 +92,14 @@ export const KeyboardProvider = ({
9392
const progressSV = useSharedValue(0);
9493
const heightSV = useSharedValue(0);
9594
const layout = useSharedValue<FocusedInputLayoutChangedEvent | null>(null);
96-
const [setKeyboardHandlers, broadcastKeyboardEvents] =
97-
useSharedHandlers<KeyboardHandler>();
98-
const [setInputHandlers, broadcastInputEvents] =
99-
useSharedHandlers<FocusedInputHandler>();
95+
const setKeyboardHandlers = useEventHandlerRegistration<KeyboardHandler>(
96+
keyboardEventsMap,
97+
viewTagRef,
98+
);
99+
const setInputHandlers = useEventHandlerRegistration<FocusedInputHandler>(
100+
focusedInputEventsMap,
101+
viewTagRef,
102+
);
100103
// memo
101104
const context = useMemo<KeyboardAnimationContext>(
102105
() => ({
@@ -147,25 +150,17 @@ export const KeyboardProvider = ({
147150
onKeyboardMoveStart: (event: NativeEvent) => {
148151
"worklet";
149152

150-
broadcastKeyboardEvents("onStart", event);
151153
updateSharedValues(event, ["ios"]);
152154
},
153155
onKeyboardMove: (event: NativeEvent) => {
154156
"worklet";
155157

156-
broadcastKeyboardEvents("onMove", event);
157158
updateSharedValues(event, ["android"]);
158159
},
159-
onKeyboardMoveEnd: (event: NativeEvent) => {
160-
"worklet";
161-
162-
broadcastKeyboardEvents("onEnd", event);
163-
},
164160
onKeyboardMoveInteractive: (event: NativeEvent) => {
165161
"worklet";
166162

167163
updateSharedValues(event, ["android", "ios"]);
168-
broadcastKeyboardEvents("onInteractive", event);
169164
},
170165
},
171166
[],
@@ -184,26 +179,6 @@ export const KeyboardProvider = ({
184179
},
185180
[],
186181
);
187-
const inputTextHandler = useFocusedInputTextHandler(
188-
{
189-
onFocusedInputTextChanged: (e) => {
190-
"worklet";
191-
192-
broadcastInputEvents("onChangeText", e);
193-
},
194-
},
195-
[],
196-
);
197-
const inputSelectionHandler = useFocusedInputSelectionHandler(
198-
{
199-
onFocusedInputSelectionChanged: (e) => {
200-
"worklet";
201-
202-
broadcastInputEvents("onSelectionChange", e);
203-
},
204-
},
205-
[],
206-
);
207182

208183
// effects
209184
useEffect(() => {
@@ -217,6 +192,7 @@ export const KeyboardProvider = ({
217192
return (
218193
<KeyboardContext.Provider value={context}>
219194
<KeyboardControllerViewAnimated
195+
ref={viewTagRef}
220196
enabled={enabled}
221197
navigationBarTranslucent={navigationBarTranslucent}
222198
statusBarTranslucent={statusBarTranslucent}
@@ -227,8 +203,6 @@ export const KeyboardProvider = ({
227203
onKeyboardMove={OS === "android" ? onKeyboardMove : undefined}
228204
onKeyboardMoveInteractive={onKeyboardMove}
229205
onFocusedInputLayoutChangedReanimated={inputLayoutHandler}
230-
onFocusedInputSelectionChangedReanimated={inputSelectionHandler}
231-
onFocusedInputTextChangedReanimated={inputTextHandler}
232206
>
233207
{children}
234208
</KeyboardControllerViewAnimated>

src/context.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { createContext, useContext } from "react";
22
import { Animated } from "react-native";
33

44
import type {
5-
FocusedInputHandlers,
5+
FocusedInputHandler,
66
FocusedInputLayoutChangedEvent,
7-
KeyboardHandlers,
7+
KeyboardHandler,
88
} from "./types";
99
import type React from "react";
1010
import type { SharedValue } from "react-native-reanimated";
@@ -22,11 +22,12 @@ export type KeyboardAnimationContext = {
2222
animated: AnimatedContext;
2323
reanimated: ReanimatedContext;
2424
layout: SharedValue<FocusedInputLayoutChangedEvent | null>;
25-
setKeyboardHandlers: (handlers: KeyboardHandlers) => void;
26-
setInputHandlers: (handlers: FocusedInputHandlers) => void;
25+
setKeyboardHandlers: (handlers: KeyboardHandler) => () => void;
26+
setInputHandlers: (handlers: FocusedInputHandler) => () => void;
2727
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
2828
};
2929
const NOOP = () => {};
30+
const NESTED_NOOP = () => NOOP;
3031
const withSharedValue = <T>(value: T): SharedValue<T> => ({
3132
value,
3233
addListener: NOOP,
@@ -48,8 +49,8 @@ const defaultContext: KeyboardAnimationContext = {
4849
height: DEFAULT_SHARED_VALUE,
4950
},
5051
layout: DEFAULT_LAYOUT,
51-
setKeyboardHandlers: NOOP,
52-
setInputHandlers: NOOP,
52+
setKeyboardHandlers: NESTED_NOOP,
53+
setInputHandlers: NESTED_NOOP,
5354
setEnabled: NOOP,
5455
};
5556

src/event-handler.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
let REACore = null;
2+
3+
try {
4+
REACore = require("react-native-reanimated/src/core");
5+
} catch (e1) {
6+
try {
7+
REACore = require("react-native-reanimated/src/reanimated2/core");
8+
} catch (e2) {
9+
console.warn("Failed to load REACore from both paths");
10+
}
11+
}
12+
const registerEventHandler = REACore.registerEventHandler;
13+
const unregisterEventHandler = REACore.unregisterEventHandler;
14+
15+
export { registerEventHandler, unregisterEventHandler };

src/event-handler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
declare function registerEventHandler(
2+
handler: (event: never) => void,
3+
eventName: string,
4+
viewTag: number,
5+
): number;
6+
declare function unregisterEventHandler(id: number): void;
7+
8+
export { registerEventHandler, unregisterEventHandler };

src/event-mappings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { FocusedInputHandler, KeyboardHandler } from "./types";
2+
3+
export const keyboardEventsMap = new Map<keyof KeyboardHandler, string>([
4+
["onStart", "onKeyboardMoveStart"],
5+
["onMove", "onKeyboardMove"],
6+
["onEnd", "onKeyboardMoveEnd"],
7+
["onInteractive", "onKeyboardMoveInteractive"],
8+
]);
9+
export const focusedInputEventsMap = new Map<keyof FocusedInputHandler, string>(
10+
[
11+
["onChangeText", "onFocusedInputTextChanged"],
12+
["onSelectionChange", "onFocusedInputSelectionChanged"],
13+
],
14+
);

src/hooks/index.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { useEffect } from "react";
33
import { KeyboardController } from "../bindings";
44
import { AndroidSoftInputModes } from "../constants";
55
import { useKeyboardContext } from "../context";
6-
import { uuid } from "../utils";
6+
7+
import useSyncEffect from "./useSyncEffect";
78

89
import type { AnimatedContext, ReanimatedContext } from "../context";
910
import type { FocusedInputHandler, KeyboardHandler } from "../types";
@@ -39,14 +40,10 @@ export function useGenericKeyboardHandler(
3940
) {
4041
const context = useKeyboardContext();
4142

42-
useEffect(() => {
43-
const key = uuid();
44-
45-
context.setKeyboardHandlers({ [key]: handler });
43+
useSyncEffect(() => {
44+
const cleanup = context.setKeyboardHandlers(handler);
4645

47-
return () => {
48-
context.setKeyboardHandlers({ [key]: undefined });
49-
};
46+
return () => cleanup();
5047
}, deps);
5148
}
5249

@@ -71,19 +68,15 @@ export function useReanimatedFocusedInput() {
7168
}
7269

7370
export function useFocusedInputHandler(
74-
handler?: FocusedInputHandler,
71+
handler: FocusedInputHandler,
7572
deps?: DependencyList,
7673
) {
7774
const context = useKeyboardContext();
7875

79-
useEffect(() => {
80-
const key = uuid();
81-
82-
context.setInputHandlers({ [key]: handler });
76+
useSyncEffect(() => {
77+
const cleanup = context.setInputHandlers(handler);
8378

84-
return () => {
85-
context.setInputHandlers({ [key]: undefined });
86-
};
79+
return () => cleanup();
8780
}, deps);
8881
}
8982

0 commit comments

Comments
 (0)