| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | useEffect, | 11 | useEffect, |
| 12 | useLayoutEffect, | 12 | useLayoutEffect, |
| 13 | useMemo, | 13 | useMemo, |
| | 14 | useRef, |
| 14 | type ReactElement, | 15 | type ReactElement, |
| 15 | type ReactNode, | 16 | type ReactNode, |
| 16 | } from "react"; | 17 | } from "react"; |
| ... | @@ -61,20 +62,116 @@ function normalizeScreenOptions( | ... | @@ -61,20 +62,116 @@ function normalizeScreenOptions( |
| 61 | return normalized as NativeStackNavigationOptions; | 62 | return normalized as NativeStackNavigationOptions; |
| 62 | } | 63 | } |
| 63 | | 64 | |
| | 65 | function optionsSignature(value: unknown, seen = new WeakSet<object>()): string { |
| | 66 | if (value === null) return "null"; |
| | 67 | switch (typeof value) { |
| | 68 | case "boolean": |
| | 69 | case "number": |
| | 70 | case "string": |
| | 71 | return JSON.stringify(value); |
| | 72 | case "undefined": |
| | 73 | return "undefined"; |
| | 74 | case "function": |
| | 75 | // Header factories are frequently recreated inline. Their source is |
| | 76 | // stable across equivalent renders, while a reference comparison would |
| | 77 | // make navigation.setOptions re-enter the navigator indefinitely. |
| | 78 | return `function:${Function.prototype.toString.call(value)}`; |
| | 79 | case "symbol": |
| | 80 | return `symbol:${String(value)}`; |
| | 81 | case "bigint": |
| | 82 | return `bigint:${String(value)}`; |
| | 83 | case "object": { |
| | 84 | const object = value as object; |
| | 85 | if (seen.has(object)) return "[circular]"; |
| | 86 | seen.add(object); |
| | 87 | if (Array.isArray(value)) { |
| | 88 | return `[${value.map((entry) => optionsSignature(entry, seen)).join(",")}]`; |
| | 89 | } |
| | 90 | // React refs carry mutable native instances that must not make static |
| | 91 | // screen options appear different after every render. |
| | 92 | if ("current" in object) return "[ref]"; |
| | 93 | return `{${Object.keys(value as Record<string, unknown>) |
| | 94 | .sort() |
| | 95 | .map( |
| | 96 | (key) => |
| | 97 | `${JSON.stringify(key)}:${optionsSignature((value as Record<string, unknown>)[key], seen)}`, |
| | 98 | ) |
| | 99 | .join(",")}}`; |
| | 100 | } |
| | 101 | } |
| | 102 | return String(value); |
| | 103 | } |
| | 104 | |
| | 105 | function stabilizeOptionFunctions( |
| | 106 | value: unknown, |
| | 107 | path: string, |
| | 108 | latestFunctions: Map<string, (...args: unknown[]) => unknown>, |
| | 109 | wrappers: Map<string, (...args: unknown[]) => unknown>, |
| | 110 | seen = new WeakSet<object>(), |
| | 111 | ): unknown { |
| | 112 | if (typeof value === "function") { |
| | 113 | latestFunctions.set(path, value as (...args: unknown[]) => unknown); |
| | 114 | let wrapper = wrappers.get(path); |
| | 115 | if (!wrapper) { |
| | 116 | wrapper = (...args: unknown[]) => { |
| | 117 | return latestFunctions.get(path)?.(...args); |
| | 118 | }; |
| | 119 | wrappers.set(path, wrapper); |
| | 120 | } |
| | 121 | return wrapper; |
| | 122 | } |
| | 123 | if (Array.isArray(value)) { |
| | 124 | if (seen.has(value)) return value; |
| | 125 | seen.add(value); |
| | 126 | return value.map((entry, index) => |
| | 127 | stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers, seen), |
| | 128 | ); |
| | 129 | } |
| | 130 | if (value !== null && typeof value === "object") { |
| | 131 | if (seen.has(value) || "current" in value) return value; |
| | 132 | seen.add(value); |
| | 133 | return Object.fromEntries( |
| | 134 | Object.entries(value as Record<string, unknown>).map(([key, entry]) => [ |
| | 135 | key, |
| | 136 | stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers, seen), |
| | 137 | ]), |
| | 138 | ); |
| | 139 | } |
| | 140 | return value; |
| | 141 | } |
| | 142 | |
| 64 | export function NativeStackScreenOptions(props: { | 143 | export function NativeStackScreenOptions(props: { |
| 65 | readonly options?: AppNativeStackNavigationOptions; | 144 | readonly options?: AppNativeStackNavigationOptions; |
| 66 | readonly listeners?: Record<string, (event: never) => void>; | 145 | readonly listeners?: Record<string, (event: never) => void>; |
| 67 | readonly name?: string; | 146 | readonly name?: string; |
| 68 | }) { | 147 | }) { |
| 69 | const navigation = useNativeStackNavigation(); | 148 | const navigation = useNativeStackNavigation(); |
| | 149 | const lastAppliedOptionsSignatureRef = useRef<string | undefined>(undefined); |
| | 150 | const latestOptionFunctionsRef = useRef(new Map<string, (...args: unknown[]) => unknown>()); |
| | 151 | const optionFunctionWrappersRef = useRef(new Map<string, (...args: unknown[]) => unknown>()); |
| 70 | const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); | 152 | const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); |
| | 153 | const stableOptions = normalizedOptions |
| | 154 | ? (stabilizeOptionFunctions( |
| | 155 | normalizedOptions, |
| | 156 | "options", |
| | 157 | latestOptionFunctionsRef.current, |
| | 158 | optionFunctionWrappersRef.current, |
| | 159 | ) as NativeStackNavigationOptions) |
| | 160 | : undefined; |
| 71 | | 161 | |
| 72 | useLayoutEffect(() => { | 162 | useLayoutEffect(() => { |
| 73 | if (!navigation || !normalizedOptions) { | 163 | if (!navigation || !stableOptions) { |
| | 164 | return; |
| | 165 | } |
| | 166 | const signature = optionsSignature(stableOptions); |
| | 167 | // Avoid re-entering navigation state when semantically equal options are |
| | 168 | // reapplied every layout (common when callers pass unstable object literals). |
| | 169 | if (lastAppliedOptionsSignatureRef.current === signature) { |
| 74 | return; | 170 | return; |
| 75 | } | 171 | } |
| 76 | navigation.setOptions(normalizedOptions); | 172 | lastAppliedOptionsSignatureRef.current = signature; |
| 77 | }, [navigation, normalizedOptions]); | 173 | navigation.setOptions(stableOptions); |
| | 174 | }, [navigation, stableOptions]); |
| 78 | | 175 | |
| 79 | useEffect(() => { | 176 | useEffect(() => { |
| 80 | if (!navigation || !props.listeners) { | 177 | if (!navigation || !props.listeners) { |