| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | useEffect, |
| 12 | 12 | useLayoutEffect, |
| 13 | 13 | useMemo, |
| 14 | useRef, |
| 14 | 15 | type ReactElement, |
| 15 | 16 | type ReactNode, |
| 16 | 17 | } from "react"; |
| ... | ... | @@ -61,20 +62,116 @@ function normalizeScreenOptions( |
| 61 | 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 | 143 | export function NativeStackScreenOptions(props: { |
| 65 | 144 | readonly options?: AppNativeStackNavigationOptions; |
| 66 | 145 | readonly listeners?: Record<string, (event: never) => void>; |
| 67 | 146 | readonly name?: string; |
| 68 | 147 | }) { |
| 69 | 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 | 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 | 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 | 170 | return; |
| 75 | 171 | } |
| 76 | | navigation.setOptions(normalizedOptions); |
| 77 | | }, [navigation, normalizedOptions]); |
| 172 | lastAppliedOptionsSignatureRef.current = signature; |
| 173 | navigation.setOptions(stableOptions); |
| 174 | }, [navigation, stableOptions]); |
| 78 | 175 | |
| 79 | 176 | useEffect(() => { |
| 80 | 177 | if (!navigation || !props.listeners) { |