Skip to content

Commit 6bf4eeb

Browse files
committed
Memoize hooks and sanitize options trim
useInAppBrowser: add useCallback/useMemo to memoize runTracked, open, openAuth and the returned object, ensuring stable identities for consumers (e.g. useEffect deps or React.memo). Also update import list and docs to clarify memoization behavior.\n\nutils/options: enhance trimStringFields to treat explicit undefined, non-string own properties, and extra enumerable keys as mutations (uses ownKeyCount and `key in source`), returning null only when the input is already bridge-safe; improves sanitization before crossing the bridge.
1 parent 2d30bd6 commit 6bf4eeb

2 files changed

Lines changed: 58 additions & 46 deletions

File tree

src/hooks/useInAppBrowser.ts

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22

33
import {
44
close as nativeClose,
@@ -59,9 +59,10 @@ const toError = (err: unknown): Error =>
5959
* APIs with loading and error state tracking. `close`, `closeAuth`, and
6060
* `isAvailable` are direct delegates to the stateless module API.
6161
*
62-
* Memoization is handled by the React Compiler at build time, so the returned
63-
* object identity is stable across re-renders without manual `useCallback` /
64-
* `useMemo` wrappers.
62+
* `open`/`openAuth` and the returned object are memoized with `useCallback` /
63+
* `useMemo` so consumers passing them to `useEffect` deps or `React.memo`
64+
* children get stable identities — independent of whether the host app has
65+
* `babel-plugin-react-compiler` enabled.
6566
*/
6667
export const useInAppBrowser = (): UseInAppBrowserReturn => {
6768
const [isLoading, setIsLoading] = useState(false)
@@ -77,38 +78,47 @@ export const useInAppBrowser = (): UseInAppBrowserReturn => {
7778
}
7879
}, [])
7980

80-
const runTracked = async <T>(fn: () => Promise<T>): Promise<T> => {
81-
if (isMountedRef.current) {
82-
setIsLoading(true)
83-
setError(null)
84-
}
85-
try {
86-
return await fn()
87-
} catch (err) {
88-
const next = toError(err)
89-
if (isMountedRef.current) setError(next)
90-
throw next
91-
} finally {
92-
if (isMountedRef.current) setIsLoading(false)
93-
}
94-
}
81+
const runTracked = useCallback(
82+
async <T>(fn: () => Promise<T>): Promise<T> => {
83+
if (isMountedRef.current) {
84+
setIsLoading(true)
85+
setError(null)
86+
}
87+
try {
88+
return await fn()
89+
} catch (err) {
90+
const next = toError(err)
91+
if (isMountedRef.current) setError(next)
92+
throw next
93+
} finally {
94+
if (isMountedRef.current) setIsLoading(false)
95+
}
96+
},
97+
[]
98+
)
9599

96-
const open = (url: string, options?: InAppBrowserOptions) =>
97-
runTracked(() => nativeOpen(url, options))
100+
const open = useCallback(
101+
(url: string, options?: InAppBrowserOptions) =>
102+
runTracked(() => nativeOpen(url, options)),
103+
[runTracked]
104+
)
98105

99-
const openAuth = (
100-
url: string,
101-
redirectUrl: string,
102-
options?: InAppBrowserOptions
103-
) => runTracked(() => nativeOpenAuth(url, redirectUrl, options))
106+
const openAuth = useCallback(
107+
(url: string, redirectUrl: string, options?: InAppBrowserOptions) =>
108+
runTracked(() => nativeOpenAuth(url, redirectUrl, options)),
109+
[runTracked]
110+
)
104111

105-
return {
106-
open,
107-
openAuth,
108-
close: nativeClose,
109-
closeAuth: nativeCloseAuth,
110-
isAvailable: nativeIsAvailable,
111-
isLoading,
112-
error,
113-
}
112+
return useMemo<UseInAppBrowserReturn>(
113+
() => ({
114+
open,
115+
openAuth,
116+
close: nativeClose,
117+
closeAuth: nativeCloseAuth,
118+
isAvailable: nativeIsAvailable,
119+
isLoading,
120+
error,
121+
}),
122+
[open, openAuth, isLoading, error]
123+
)
114124
}

src/utils/options.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,29 @@ const ANIMATION_KEYS = [
2626
] as const satisfies readonly (keyof BrowserAnimations)[]
2727

2828
/**
29-
* Whitelist-trim string fields. Returns `null` when no mutation was required
30-
* (caller can keep its original reference); otherwise returns the new payload
31-
* (or `undefined` when every entry was dropped).
29+
* Whitelist-trim string fields. Returns `null` when the input was already
30+
* bridge-safe (caller can keep its original reference); otherwise returns the
31+
* new payload (or `undefined` when every entry was dropped).
32+
*
33+
* "Bridge-safe" means: every own property is a non-empty trimmed string AND
34+
* is in the whitelist. Any explicit `undefined`, non-string value, or extra
35+
* enumerable key forces a fresh object so the value crossing JSI is clean.
3236
*/
3337
const trimStringFields = <T extends object>(
3438
source: T,
3539
keys: readonly (keyof T)[]
3640
): T | undefined | null => {
41+
const ownKeyCount = Object.keys(source).length
3742
let out: Partial<T> | null = null
3843
let mutated = false
3944
let kept = 0
40-
let originalKeyCount = 0
41-
42-
for (const key of Object.keys(source) as (keyof T)[]) {
43-
const v = source[key]
44-
if (v !== undefined) originalKeyCount++
45-
}
4645

4746
for (const key of keys) {
4847
const value = source[key]
4948
if (typeof value !== 'string') {
50-
if (value !== undefined) mutated = true
49+
// Any own non-string property (including explicit `undefined`) needs
50+
// to be stripped before bridging.
51+
if (key in source) mutated = true
5152
continue
5253
}
5354
const trimmed = value.trim()
@@ -61,7 +62,8 @@ const trimStringFields = <T extends object>(
6162
kept++
6263
}
6364

64-
if (!mutated && kept === originalKeyCount) return null
65+
// Extra (non-whitelisted) own keys also require sanitization.
66+
if (!mutated && kept === ownKeyCount) return null
6567
return out ? (compact(out) as T | undefined) : undefined
6668
}
6769

0 commit comments

Comments
 (0)