react-native-transformer-text-input 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +141 -0
  3. package/RNTransformerTextInput.podspec +35 -0
  4. package/android/build.gradle +96 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/spotless.gradle +19 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/java/com/appandflow/transformertextinput/TextState.kt +15 -0
  9. package/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputDecoratorView.kt +160 -0
  10. package/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputDecoratorViewManager.kt +44 -0
  11. package/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputJni.kt +25 -0
  12. package/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputModule.kt +22 -0
  13. package/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputPackage.kt +53 -0
  14. package/android/src/main/jni/CMakeLists.txt +62 -0
  15. package/android/src/main/jni/TransformerTextInputJni.cpp +94 -0
  16. package/android/src/main/jni/rntti.h +17 -0
  17. package/cpp/TransformerTextInputDecoratorViewComponentDescriptor.h +16 -0
  18. package/cpp/TransformerTextInputDecoratorViewShadowNode.cpp +21 -0
  19. package/cpp/TransformerTextInputDecoratorViewShadowNode.h +40 -0
  20. package/cpp/TransformerTextInputRuntime.cpp +86 -0
  21. package/cpp/TransformerTextInputRuntime.h +31 -0
  22. package/ios/TransformerTextInputDecoratorView.h +9 -0
  23. package/ios/TransformerTextInputDecoratorView.mm +256 -0
  24. package/ios/TransformerTextInputModule.h +8 -0
  25. package/ios/TransformerTextInputModule.mm +28 -0
  26. package/lib/module/NativeTransformerTextInputModule.js +5 -0
  27. package/lib/module/NativeTransformerTextInputModule.js.map +1 -0
  28. package/lib/module/Transformer.js +15 -0
  29. package/lib/module/Transformer.js.map +1 -0
  30. package/lib/module/TransformerTextInput.js +86 -0
  31. package/lib/module/TransformerTextInput.js.map +1 -0
  32. package/lib/module/TransformerTextInputDecoratorViewNativeComponent.ts +31 -0
  33. package/lib/module/formatters/phone-number.js +315 -0
  34. package/lib/module/formatters/phone-number.js.map +1 -0
  35. package/lib/module/index.js +5 -0
  36. package/lib/module/index.js.map +1 -0
  37. package/lib/module/package.json +1 -0
  38. package/lib/module/registry.js +83 -0
  39. package/lib/module/registry.js.map +1 -0
  40. package/lib/module/selection.js +48 -0
  41. package/lib/module/selection.js.map +1 -0
  42. package/lib/module/utils/useMergeRefs.js +49 -0
  43. package/lib/module/utils/useMergeRefs.js.map +1 -0
  44. package/lib/module/utils/useRefEffect.js +37 -0
  45. package/lib/module/utils/useRefEffect.js.map +1 -0
  46. package/lib/typescript/package.json +1 -0
  47. package/lib/typescript/src/NativeTransformerTextInputModule.d.ts +7 -0
  48. package/lib/typescript/src/NativeTransformerTextInputModule.d.ts.map +1 -0
  49. package/lib/typescript/src/Transformer.d.ts +19 -0
  50. package/lib/typescript/src/Transformer.d.ts.map +1 -0
  51. package/lib/typescript/src/TransformerTextInput.d.ts +247 -0
  52. package/lib/typescript/src/TransformerTextInput.d.ts.map +1 -0
  53. package/lib/typescript/src/TransformerTextInputDecoratorViewNativeComponent.d.ts +12 -0
  54. package/lib/typescript/src/TransformerTextInputDecoratorViewNativeComponent.d.ts.map +1 -0
  55. package/lib/typescript/src/formatters/phone-number.d.ts +18 -0
  56. package/lib/typescript/src/formatters/phone-number.d.ts.map +1 -0
  57. package/lib/typescript/src/index.d.ts +3 -0
  58. package/lib/typescript/src/index.d.ts.map +1 -0
  59. package/lib/typescript/src/registry.d.ts +17 -0
  60. package/lib/typescript/src/registry.d.ts.map +1 -0
  61. package/lib/typescript/src/selection.d.ts +4 -0
  62. package/lib/typescript/src/selection.d.ts.map +1 -0
  63. package/lib/typescript/src/utils/useMergeRefs.d.ts +20 -0
  64. package/lib/typescript/src/utils/useMergeRefs.d.ts.map +1 -0
  65. package/lib/typescript/src/utils/useRefEffect.d.ts +24 -0
  66. package/lib/typescript/src/utils/useRefEffect.d.ts.map +1 -0
  67. package/package.json +199 -0
  68. package/react-native.config.js +13 -0
  69. package/src/NativeTransformerTextInputModule.ts +10 -0
  70. package/src/Transformer.ts +32 -0
  71. package/src/TransformerTextInput.tsx +147 -0
  72. package/src/TransformerTextInputDecoratorViewNativeComponent.ts +31 -0
  73. package/src/formatters/phone-number.ts +327 -0
  74. package/src/index.tsx +10 -0
  75. package/src/registry.ts +120 -0
  76. package/src/selection.ts +62 -0
  77. package/src/utils/useMergeRefs.ts +59 -0
  78. package/src/utils/useRefEffect.ts +42 -0
@@ -0,0 +1,327 @@
1
+ import { Transformer, type Selection } from '../Transformer';
2
+
3
+ export type PhoneNumberTransformerOptions = {
4
+ /**
5
+ * Country code for phone number formatting.
6
+ * Currently only 'US' is supported.
7
+ * @default 'US'
8
+ */
9
+ country?: 'US';
10
+ /**
11
+ * Enable debug logging for transformer operations.
12
+ * @default false
13
+ */
14
+ debug?: boolean;
15
+ };
16
+
17
+ // Check if char is a digit
18
+ const isDigit = (char: string | undefined): boolean => {
19
+ 'worklet';
20
+ return char !== undefined && char >= '0' && char <= '9';
21
+ };
22
+
23
+ // Count national digits before a position
24
+ // Always skips the first "1" digit when we have/will have +1 prefix
25
+ const countNationalDigitsBefore = (text: string, pos: number): number => {
26
+ 'worklet';
27
+ let count = 0;
28
+ let skippedCountryOne = false;
29
+ for (let i = 0; i < Math.min(pos, text.length); i++) {
30
+ if (isDigit(text[i])) {
31
+ // Skip the "1" in "+1" prefix
32
+ if (
33
+ !skippedCountryOne &&
34
+ text[i] === '1' &&
35
+ i > 0 &&
36
+ text[i - 1] === '+'
37
+ ) {
38
+ skippedCountryOne = true;
39
+ } else {
40
+ count++;
41
+ }
42
+ }
43
+ }
44
+ return count;
45
+ };
46
+
47
+ // Find position in formatted text after N national digits
48
+ const positionAfterNationalDigit = (
49
+ text: string,
50
+ targetCount: number,
51
+ ): number => {
52
+ 'worklet';
53
+ if (targetCount <= 0) {
54
+ // Position after "+1 " prefix if present, else 0
55
+ if (text.startsWith('+1 ')) return 3;
56
+ if (text.startsWith('+1')) return 2;
57
+ return 0;
58
+ }
59
+ let count = 0;
60
+ let skippedCountryOne = false;
61
+ for (let i = 0; i < text.length; i++) {
62
+ if (isDigit(text[i])) {
63
+ if (
64
+ !skippedCountryOne &&
65
+ text[i] === '1' &&
66
+ i > 0 &&
67
+ text[i - 1] === '+'
68
+ ) {
69
+ skippedCountryOne = true;
70
+ } else {
71
+ count++;
72
+ if (count === targetCount) return i + 1;
73
+ }
74
+ }
75
+ }
76
+ return text.length;
77
+ };
78
+
79
+ // Format national digits into US phone format
80
+ const formatUSPhoneNumber = (digits: string): string => {
81
+ 'worklet';
82
+ const area = digits.slice(0, 3);
83
+ const exchange = digits.slice(3, 6);
84
+ const subscriber = digits.slice(6, 10);
85
+
86
+ let formatted = '+1 ';
87
+
88
+ if (area.length > 0) {
89
+ formatted += '(' + area;
90
+ if (area.length === 3) {
91
+ formatted += ') ';
92
+ }
93
+ }
94
+
95
+ if (exchange.length > 0) {
96
+ formatted += exchange;
97
+ if (exchange.length === 3 && subscriber.length > 0) {
98
+ formatted += '-';
99
+ }
100
+ }
101
+
102
+ if (subscriber.length > 0) {
103
+ formatted += subscriber;
104
+ }
105
+
106
+ return formatted;
107
+ };
108
+
109
+ export class PhoneNumberTransformer extends Transformer {
110
+ constructor({
111
+ country = 'US',
112
+ debug = false,
113
+ }: PhoneNumberTransformerOptions = {}) {
114
+ if (country !== 'US') {
115
+ throw new Error(
116
+ `[PhoneNumberTransformer] Country "${country}" is not supported. Only "US" is currently supported.`,
117
+ );
118
+ }
119
+
120
+ const worklet = (input: {
121
+ value: string;
122
+ previousValue: string;
123
+ selection: Selection;
124
+ previousSelection: Selection;
125
+ }) => {
126
+ 'worklet';
127
+
128
+ const { value, selection, previousValue, previousSelection } = input;
129
+
130
+ // Extract all digits from input
131
+ const allDigits = value.replace(/\D/g, '');
132
+
133
+ // Empty input - clear everything
134
+ if (allDigits.length === 0) {
135
+ return { value: '', selection: { start: 0, end: 0 } };
136
+ }
137
+
138
+ // Get national digits - strip leading "1" if enabled since we always add +1 prefix
139
+ const nationalDigits = (
140
+ allDigits.startsWith('1') ? allDigits.slice(1) : allDigits
141
+ ).slice(0, 10);
142
+
143
+ // If no national digits left, check if user is deleting
144
+ if (nationalDigits.length === 0) {
145
+ // If value got shorter (user is deleting), clear everything
146
+ if (value.length < previousValue.length) {
147
+ if (debug) {
148
+ console.log('[PhoneNumberTransformer] -> case: deleted to empty');
149
+ }
150
+ return { value: '', selection: { start: 0, end: 0 } };
151
+ }
152
+ // Otherwise user just typed "1", show the prefix
153
+ return { value: '+1 ', selection: { start: 3, end: 3 } };
154
+ }
155
+
156
+ const formatted = formatUSPhoneNumber(nationalDigits);
157
+
158
+ // Calculate cursor position
159
+ const isCaret = selection.start === selection.end;
160
+ const cursorAtEnd = selection.end >= value.length;
161
+ const prevCursorAtEnd = previousSelection.end >= previousValue.length;
162
+
163
+ // Count national digits
164
+ const digitsBeforeCursor = countNationalDigitsBefore(
165
+ value,
166
+ selection.start,
167
+ );
168
+ const prevDigitsBeforeCursor = countNationalDigitsBefore(
169
+ previousValue,
170
+ previousSelection.start,
171
+ );
172
+ const totalDigits = countNationalDigitsBefore(value, value.length);
173
+ const prevTotalDigits = countNationalDigitsBefore(
174
+ previousValue,
175
+ previousValue.length,
176
+ );
177
+
178
+ const isDeleting = totalDigits < prevTotalDigits;
179
+ const isInserting = totalDigits > prevTotalDigits;
180
+
181
+ // Detect deletion of formatting character (e.g., deleting space, dash, or paren)
182
+ // User expects this to delete the digit before cursor
183
+ const deletedFormattingChar =
184
+ isCaret &&
185
+ value.length < previousValue.length &&
186
+ totalDigits === prevTotalDigits &&
187
+ totalDigits > 0;
188
+
189
+ // Log for debugging
190
+ if (debug) {
191
+ console.log('[PhoneNumberTransformer]', {
192
+ input: { value, selection },
193
+ prev: { previousValue, previousSelection },
194
+ digits: { nationalDigits, totalDigits, prevTotalDigits },
195
+ cursor: { digitsBeforeCursor, prevDigitsBeforeCursor },
196
+ flags: {
197
+ isCaret,
198
+ cursorAtEnd,
199
+ prevCursorAtEnd,
200
+ isDeleting,
201
+ isInserting,
202
+ deletedFormattingChar,
203
+ },
204
+ output: formatted,
205
+ });
206
+ }
207
+
208
+ // Case 0: Deleted a formatting char - remove the digit before cursor
209
+ if (deletedFormattingChar) {
210
+ // Figure out which digit to remove: the one before the cursor
211
+ const digitToRemove = digitsBeforeCursor;
212
+
213
+ if (digitToRemove <= 0) {
214
+ // Cursor was before all digits, nothing to delete
215
+ if (debug) {
216
+ console.log(
217
+ '[PhoneNumberTransformer] -> case: deleted formatting char but cursor before all digits',
218
+ );
219
+ }
220
+ return { value: formatted, selection: { start: 3, end: 3 } };
221
+ }
222
+
223
+ // Remove the digit at position (digitToRemove - 1) from nationalDigits
224
+ const trimmedDigits =
225
+ nationalDigits.slice(0, digitToRemove - 1) +
226
+ nationalDigits.slice(digitToRemove);
227
+
228
+ if (debug) {
229
+ console.log(
230
+ '[PhoneNumberTransformer] -> case: deleted formatting char',
231
+ {
232
+ digitToRemove,
233
+ trimmedDigits,
234
+ },
235
+ );
236
+ }
237
+
238
+ if (trimmedDigits.length === 0) {
239
+ return { value: '', selection: { start: 0, end: 0 } };
240
+ }
241
+
242
+ const trimmedFormatted = formatUSPhoneNumber(trimmedDigits);
243
+
244
+ // Position cursor where the deleted digit was
245
+ const newCursorDigits = digitToRemove - 1;
246
+ const newPos = positionAfterNationalDigit(
247
+ trimmedFormatted,
248
+ newCursorDigits,
249
+ );
250
+
251
+ return {
252
+ value: trimmedFormatted,
253
+ selection: { start: newPos, end: newPos },
254
+ };
255
+ }
256
+
257
+ // Case 1: Typing at end (most common)
258
+ if (isCaret && cursorAtEnd && prevCursorAtEnd) {
259
+ if (debug) {
260
+ console.log('[PhoneNumberTransformer] -> case: typing at end');
261
+ }
262
+ return {
263
+ value: formatted,
264
+ selection: { start: formatted.length, end: formatted.length },
265
+ };
266
+ }
267
+
268
+ // Case 2: Deletion - use current cursor position (previous is stale if user moved cursor)
269
+ if (isDeleting && isCaret) {
270
+ const newPos = positionAfterNationalDigit(
271
+ formatted,
272
+ digitsBeforeCursor,
273
+ );
274
+ if (debug) {
275
+ console.log('[PhoneNumberTransformer] -> case: deletion', {
276
+ digitsBeforeCursor,
277
+ newPos,
278
+ });
279
+ }
280
+ return {
281
+ value: formatted,
282
+ selection: { start: newPos, end: newPos },
283
+ };
284
+ }
285
+
286
+ // Case 3: Insertion in middle - use current cursor position
287
+ if (isInserting && isCaret) {
288
+ const newPos = positionAfterNationalDigit(
289
+ formatted,
290
+ digitsBeforeCursor,
291
+ );
292
+ if (debug) {
293
+ console.log('[PhoneNumberTransformer] -> case: insertion', {
294
+ digitsBeforeCursor,
295
+ newPos,
296
+ });
297
+ }
298
+ return {
299
+ value: formatted,
300
+ selection: { start: newPos, end: newPos },
301
+ };
302
+ }
303
+
304
+ // Case 4: Default / selection handling
305
+ const clampedDigits = Math.min(digitsBeforeCursor, nationalDigits.length);
306
+ const newStart = positionAfterNationalDigit(formatted, clampedDigits);
307
+ const endDigits = countNationalDigitsBefore(value, selection.end);
308
+ const clampedEndDigits = Math.min(endDigits, nationalDigits.length);
309
+ const newEnd = positionAfterNationalDigit(formatted, clampedEndDigits);
310
+
311
+ if (debug) {
312
+ console.log('[PhoneNumberTransformer] -> case: default', {
313
+ clampedDigits,
314
+ newStart,
315
+ clampedEndDigits,
316
+ newEnd,
317
+ });
318
+ }
319
+ return {
320
+ value: formatted,
321
+ selection: { start: newStart, end: newEnd },
322
+ };
323
+ };
324
+
325
+ super(worklet);
326
+ }
327
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ Transformer,
3
+ type TransformerWorklet,
4
+ type Selection,
5
+ } from './Transformer';
6
+ export {
7
+ TransformerTextInput,
8
+ type TransformerTextInputInstance,
9
+ type TransformerTextInputProps,
10
+ } from './TransformerTextInput';
@@ -0,0 +1,120 @@
1
+ import { runOnUI } from 'react-native-worklets';
2
+ import NativeTransformerTextInputModule from './NativeTransformerTextInputModule';
3
+ import { type Selection, type Transformer } from './Transformer';
4
+ import { computeUncontrolledSelection, validateSelection } from './selection';
5
+
6
+ type TransformerWrapper = (
7
+ input: string,
8
+ selectionStart: number,
9
+ selectionEnd: number,
10
+ ) => { value: string; selection: Selection };
11
+
12
+ type ReactNativeTextInputTransformerRegistry = {
13
+ register(id: number, transformer: TransformerWrapper): void;
14
+ unregister(transformerId: number): void;
15
+ get(transformerId: number): TransformerWrapper | undefined;
16
+ };
17
+
18
+ declare global {
19
+ var __rntti_registerTransformerRegistry:
20
+ | ReactNativeTextInputTransformerRegistry
21
+ | undefined;
22
+ }
23
+
24
+ let initialized = false;
25
+
26
+ function initializeIfNeeded() {
27
+ if (initialized) {
28
+ return;
29
+ }
30
+
31
+ // Important that `runOnUI` is called first to make sure the UI runtime is initialized.
32
+ runOnUI(() => {
33
+ 'worklet';
34
+
35
+ const transformersMap = new Map<number, TransformerWrapper>();
36
+
37
+ globalThis.__rntti_registerTransformerRegistry = {
38
+ register(id, transformer) {
39
+ transformersMap.set(id, transformer);
40
+ },
41
+ unregister(transformerId) {
42
+ transformersMap.delete(transformerId);
43
+ },
44
+ get(transformerId) {
45
+ return transformersMap.get(transformerId);
46
+ },
47
+ };
48
+ })();
49
+
50
+ NativeTransformerTextInputModule.install();
51
+
52
+ initialized = true;
53
+ }
54
+
55
+ // Start counting ids at 1 to avoid using 0 as it is the default int value.
56
+ let currentId = 1;
57
+
58
+ export function registerTransformer(transformer: Transformer): number {
59
+ initializeIfNeeded();
60
+
61
+ const id = currentId++;
62
+ const worklet = transformer.worklet;
63
+
64
+ runOnUI(() => {
65
+ 'worklet';
66
+
67
+ let previousValue: string | null = null;
68
+ let previousSelection: Selection | null = null;
69
+
70
+ const transformerWrapper: TransformerWrapper = (
71
+ value,
72
+ selectionStart,
73
+ selectionEnd,
74
+ ) => {
75
+ const result = worklet({
76
+ value,
77
+ previousValue: previousValue ?? value,
78
+ selection: { start: selectionStart, end: selectionEnd },
79
+ previousSelection: previousSelection ?? {
80
+ start: selectionStart,
81
+ end: selectionEnd,
82
+ },
83
+ });
84
+ const newValue = result?.value ?? value;
85
+ let newSelection: Selection;
86
+ if (result?.selection != null) {
87
+ newSelection = result.selection;
88
+ validateSelection(newSelection, newValue.length);
89
+ } else {
90
+ newSelection = computeUncontrolledSelection(
91
+ value,
92
+ newValue,
93
+ selectionStart,
94
+ selectionEnd,
95
+ );
96
+ }
97
+ previousValue = newValue;
98
+ previousSelection = newSelection;
99
+ return {
100
+ value: newValue,
101
+ selection: newSelection,
102
+ };
103
+ };
104
+
105
+ globalThis.__rntti_registerTransformerRegistry?.register(
106
+ id,
107
+ transformerWrapper,
108
+ );
109
+ })();
110
+
111
+ return id;
112
+ }
113
+
114
+ export function unregisterTransformer(transformerId: number) {
115
+ runOnUI(() => {
116
+ 'worklet';
117
+
118
+ global.__rntti_registerTransformerRegistry?.unregister(transformerId);
119
+ })();
120
+ }
@@ -0,0 +1,62 @@
1
+ import { type Selection } from './Transformer';
2
+
3
+ export const computeUncontrolledSelection = (
4
+ oldValue: string,
5
+ newValue: string,
6
+ selectionStart: number,
7
+ selectionEnd: number,
8
+ ): Selection => {
9
+ 'worklet';
10
+ const oldLength = oldValue.length;
11
+ const newLength = newValue.length;
12
+ const delta = newLength - oldLength;
13
+ let rawStart: number;
14
+ let rawEnd: number;
15
+ if (selectionStart === selectionEnd) {
16
+ if (selectionEnd >= oldLength) {
17
+ rawStart = newLength;
18
+ rawEnd = newLength;
19
+ } else {
20
+ const next = selectionEnd + delta;
21
+ rawStart = next;
22
+ rawEnd = next;
23
+ }
24
+ } else {
25
+ rawStart = selectionStart + delta;
26
+ rawEnd = selectionEnd + delta;
27
+ }
28
+
29
+ if (
30
+ rawStart < 0 ||
31
+ rawEnd < 0 ||
32
+ rawStart > newLength ||
33
+ rawEnd > newLength ||
34
+ rawStart > rawEnd
35
+ ) {
36
+ return { start: newLength, end: newLength };
37
+ }
38
+
39
+ return { start: rawStart, end: rawEnd };
40
+ };
41
+
42
+ export const validateSelection = (
43
+ selection: Selection,
44
+ valueLength: number,
45
+ ) => {
46
+ 'worklet';
47
+ if (selection.start < 0 || selection.end < 0) {
48
+ throw new Error(
49
+ `[rntti] Returned selection must be non-negative. Received start=${selection.start}, end=${selection.end}, valueLength=${valueLength}`,
50
+ );
51
+ }
52
+ if (selection.end < selection.start) {
53
+ throw new Error(
54
+ `[rntti] Returned selection end must be >= selection start. Received start=${selection.start}, end=${selection.end}, valueLength=${valueLength}`,
55
+ );
56
+ }
57
+ if (selection.start > valueLength || selection.end > valueLength) {
58
+ throw new Error(
59
+ `[rntti] Returned selection is out of bounds for the returned value. Received start=${selection.start}, end=${selection.end}, valueLength=${valueLength}`,
60
+ );
61
+ }
62
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import useRefEffect from './useRefEffect';
9
+ import { useCallback } from 'react';
10
+ import type { MutableRefObject } from 'react';
11
+
12
+ /**
13
+ * Constructs a new ref that forwards new values to each of the given refs. The
14
+ * given refs will always be invoked in the order that they are supplied.
15
+ *
16
+ * WARNING: A known problem of merging refs using this approach is that if any
17
+ * of the given refs change, the returned callback ref will also be changed. If
18
+ * the returned callback ref is supplied as a `ref` to a React element, this may
19
+ * lead to problems with the given refs being invoked more times than desired.
20
+ */
21
+ type RefWithCleanup<T> =
22
+ | ((instance: T | null) => void | (() => void))
23
+ | MutableRefObject<T | null>
24
+ | null
25
+ | undefined;
26
+
27
+ export default function useMergeRefs<Instance>(
28
+ ...refs: ReadonlyArray<RefWithCleanup<Instance>>
29
+ ): (instance: Instance | null) => void {
30
+ const refEffect = useCallback(
31
+ (current: Instance) => {
32
+ const cleanups: Array<void | (() => void)> = refs.map((ref) => {
33
+ if (ref == null) {
34
+ return undefined;
35
+ }
36
+ if (typeof ref === 'function') {
37
+ const cleanup = ref(current);
38
+ return typeof cleanup === 'function'
39
+ ? cleanup
40
+ : () => {
41
+ ref(null);
42
+ };
43
+ }
44
+ ref.current = current;
45
+ return () => {
46
+ ref.current = null;
47
+ };
48
+ });
49
+
50
+ return () => {
51
+ for (const cleanup of cleanups) {
52
+ cleanup?.();
53
+ }
54
+ };
55
+ },
56
+ [...refs], // eslint-disable-line react-hooks/exhaustive-deps
57
+ );
58
+ return useRefEffect(refEffect);
59
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import { useCallback, useRef } from 'react';
9
+
10
+ type CallbackRef<T> = (instance: T | null) => void;
11
+
12
+ /**
13
+ * Constructs a callback ref that provides similar semantics as `useEffect`. The
14
+ * supplied `effect` callback will be called with non-null component instances.
15
+ * The `effect` callback can also optionally return a cleanup function.
16
+ *
17
+ * When a component is updated or unmounted, the cleanup function is called. The
18
+ * `effect` callback will then be called again, if applicable.
19
+ *
20
+ * When a new `effect` callback is supplied, the previously returned cleanup
21
+ * function will be called before the new `effect` callback is called with the
22
+ * same instance.
23
+ *
24
+ * WARNING: The `effect` callback should be stable (e.g. using `useCallback`).
25
+ */
26
+ export default function useRefEffect<TInstance>(
27
+ effect: (instance: TInstance) => (() => void) | void,
28
+ ): CallbackRef<TInstance> {
29
+ const cleanupRef = useRef<(() => void) | void>(undefined);
30
+ return useCallback(
31
+ (instance: null | TInstance) => {
32
+ if (cleanupRef.current) {
33
+ cleanupRef.current();
34
+ cleanupRef.current = undefined;
35
+ }
36
+ if (instance != null) {
37
+ cleanupRef.current = effect(instance);
38
+ }
39
+ },
40
+ [effect],
41
+ );
42
+ }