1import { useNavigation, type ParamListBase } from "@react-navigation/native";
2import type {
3 NativeStackNavigationOptions,
4 NativeStackNavigationProp,
5} from "@react-navigation/native-stack";
6import { use, useEffect, useLayoutEffect, useMemo, useRef } from "react";
7import { NativeWorkspaceTabActivityContext } from "./v5-workspace-context";
8import type { ColorValue } from "react-native";
9
10export { NativeHeaderToolbar } from "./NativeHeaderToolbar";
11
12export {
13 nativeHeaderScrollEdgeEffects,
14 nativeTopScrollEdgeEffect,
15 type NativeHeaderScrollEdgeEffects,
16 type NativeTopScrollEdgeEffect,
17} from "./scrollEdgeEffects";
18
19export type AppNativeStackNavigationOptions = Omit<
20 NativeStackNavigationOptions,
21 "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems"
22> & {
23 /** Nested navigator leaves apply their own horizontal safe area. */
24 readonly nativeContentInsetHorizontally?: boolean;
25 readonly headerTintColor?: string | ColorValue;
26 readonly unstable_headerCenterItems?: unknown;
27 readonly unstable_headerLeftItems?: unknown;
28 readonly unstable_headerRightItems?: unknown;
29 readonly unstable_headerSubtitle?: unknown;
30 readonly unstable_headerToolbarItems?: unknown;
31 readonly unstable_navigationItemStyle?: unknown;
32};
33
34function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null {
35 return useNavigation<NativeStackNavigationProp<ParamListBase>>();
36}
37
38function normalizeScreenOptions(
39 options: AppNativeStackNavigationOptions | undefined,
40): NativeStackNavigationOptions | undefined {
41 if (!options) {
42 return options;
43 }
44
45 const normalized = { ...options } as NativeStackNavigationOptions & {
46 unstable_navigationItemStyle?: unknown;
47 unstable_headerCenterItems?: unknown;
48 unstable_headerSubtitle?: unknown;
49 unstable_headerToolbarItems?: unknown;
50 };
51
52 if (normalized.headerTintColor !== undefined) {
53 normalized.headerTintColor = String(normalized.headerTintColor);
54 }
55
56 return normalized as NativeStackNavigationOptions;
57}
58
59function optionsSignature(value: unknown, seen = new WeakSet<object>()): string {
60 if (value === null) return "null";
61 switch (typeof value) {
62 case "boolean":
63 case "number":
64 case "string":
65 return JSON.stringify(value);
66 case "undefined":
67 return "undefined";
68 case "function":
69 // Header factories are frequently recreated inline. Their source is
70 // stable across equivalent renders, while a reference comparison would
71 // make navigation.setOptions re-enter the navigator indefinitely.
72 return `function:${Function.prototype.toString.call(value)}`;
73 case "symbol":
74 return `symbol:${String(value)}`;
75 case "bigint":
76 return `bigint:${String(value)}`;
77 case "object": {
78 const object = value as object;
79 if (seen.has(object)) return "[circular]";
80 seen.add(object);
81 if (Array.isArray(value)) {
82 return `[${value.map((entry) => optionsSignature(entry, seen)).join(",")}]`;
83 }
84 // React refs carry mutable native instances that must not make static
85 // screen options appear different after every render.
86 if ("current" in object) return "[ref]";
87 return `{${Object.keys(value as Record<string, unknown>)
88 .sort()
89 .map(
90 (key) =>
91 `${JSON.stringify(key)}:${optionsSignature((value as Record<string, unknown>)[key], seen)}`,
92 )
93 .join(",")}}`;
94 }
95 }
96 return String(value);
97}
98
99function stabilizeOptionFunctions(
100 value: unknown,
101 path: string,
102 latestFunctions: Map<string, (...args: unknown[]) => unknown>,
103 wrappers: Map<string, (...args: unknown[]) => unknown>,
104 seen = new WeakSet<object>(),
105): unknown {
106 if (typeof value === "function") {
107 latestFunctions.set(path, value as (...args: unknown[]) => unknown);
108 let wrapper = wrappers.get(path);
109 if (!wrapper) {
110 wrapper = (...args: unknown[]) => {
111 return latestFunctions.get(path)?.(...args);
112 };
113 wrappers.set(path, wrapper);
114 }
115 return wrapper;
116 }
117 if (Array.isArray(value)) {
118 if (seen.has(value)) return value;
119 seen.add(value);
120 return value.map((entry, index) =>
121 stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers, seen),
122 );
123 }
124 if (value !== null && typeof value === "object") {
125 if (seen.has(value) || "current" in value) return value;
126 seen.add(value);
127 return Object.fromEntries(
128 Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
129 key,
130 stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers, seen),
131 ]),
132 );
133 }
134 return value;
135}
136
137export function NativeStackScreenOptions(props: {
138 readonly options?: AppNativeStackNavigationOptions;
139 /**
140 * Causes dynamic native header factories to be reapplied when their closed-over
141 * menu content changes. Factory functions are intentionally stabilized, so
142 * their source alone cannot capture a menu that was initially empty while
143 * asynchronous data was loading.
144 */
145 readonly optionsVersion?: unknown;
146 readonly listeners?: Record<string, (event: never) => void>;
147 readonly name?: string;
148}) {
149 const navigation = useNativeStackNavigation();
150 const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;
151 const lastAppliedOptionsSignatureRef = useRef<string | undefined>(undefined);
152 const latestOptionFunctionsRef = useRef(new Map<string, (...args: unknown[]) => unknown>());
153 const optionFunctionWrappersRef = useRef(new Map<string, (...args: unknown[]) => unknown>());
154 const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]);
155 const stableOptions = normalizedOptions
156 ? (stabilizeOptionFunctions(
157 normalizedOptions,
158 "options",
159 latestOptionFunctionsRef.current,
160 optionFunctionWrappersRef.current,
161 ) as NativeStackNavigationOptions)
162 : undefined;
163
164 useLayoutEffect(() => {
165 if (!tabActive) {
166 lastAppliedOptionsSignatureRef.current = undefined;
167 return;
168 }
169 if (!navigation || !stableOptions) {
170 return;
171 }
172 const signature = optionsSignature([stableOptions, props.optionsVersion]);
173 // Avoid re-entering navigation state when semantically equal options are
174 // reapplied every layout (common when callers pass unstable object literals).
175 if (lastAppliedOptionsSignatureRef.current === signature) {
176 return;
177 }
178 lastAppliedOptionsSignatureRef.current = signature;
179 navigation.setOptions(stableOptions);
180 }, [navigation, props.optionsVersion, stableOptions, tabActive]);
181
182 useEffect(() => {
183 if (!navigation || !props.listeners) {
184 return;
185 }
186 const subscriptions = Object.entries(props.listeners).map(([eventName, listener]) =>
187 navigation.addListener(eventName as never, listener as never),
188 );
189 return () => {
190 for (const unsubscribe of subscriptions) {
191 unsubscribe();
192 }
193 };
194 }, [navigation, props.listeners]);
195
196 return null;
197}