1import {
2 createNavigatorFactory,
3 NavigationContext,
4 NavigationRouteContext,
5 StackRouter,
6 useNavigationBuilder,
7 usePreventRemoveContext,
8 type NavigatorTypeBagBase,
9 type ParamListBase,
10 type StackActionHelpers,
11 type StackNavigationState,
12 type StackRouterOptions,
13 type StaticConfig,
14 type TypedNavigator,
15} from "@react-navigation/native";
16import {
17 NativeStackView,
18 type NativeStackNavigationEventMap,
19 type NativeStackNavigationOptions,
20 type NativeStackNavigatorProps,
21 type NativeStackTypeBag,
22} from "@react-navigation/native-stack";
23import { useCallback, useRef, useState, type ComponentProps } from "react";
24import { StyleSheet, View } from "react-native";
25import { FormSheet, Stack } from "react-native-screens";
26import { NativeScrollEdgeEffectsContext } from "./NativeScrollViewMarker";
27import { V5StackHeader } from "./V5StackHeader.ios";
28import type { AppNativeStackNavigationOptions } from "./StackHeader";
29import { NativeColumnContent } from "./NativeColumnContent.ios";
30import {
31 nativeStackPopAction,
32 nativeWorkspacePopAction,
33 nativeWorkspacePopCount,
34 partitionStackPresentations,
35 reconcileStackScreens,
36} from "./workspace-stack-projection";
37
38export type V5StackViewProps = ComponentProps<typeof NativeStackView>;
39
40/** The presentation envelope must not mount a second search bar or header. */
41export function modalEnvelopeOptions(options: NativeStackNavigationOptions) {
42 return {
43 ...Object.fromEntries(
44 Object.entries(options).filter(
45 ([key]) =>
46 !key.startsWith("header") &&
47 !key.startsWith("unstable_header") &&
48 key !== "unstable_navigationItemStyle",
49 ),
50 ),
51 headerShown: false,
52 };
53}
54
55/** Keep outgoing screens until UIKit completes its pop, as required by v5. */
56export function V5CardStackView(
57 props: V5StackViewProps & { readonly onRootVisibilityChange?: (visible: boolean) => void },
58) {
59 const { preventedRoutes } = usePreventRemoveContext();
60 const nativePopSource = useRef<string | null>(null);
61 const [screens, setScreens] = useState({
62 completedNativeDismissals: new Set<string>(),
63 nativeCompletingPops: new Set<string>(),
64 routes: props.state.routes,
65 descriptors: props.descriptors,
66 observedRoutes: props.state.routes,
67 observedDescriptors: props.descriptors,
68 });
69 if (
70 screens.observedRoutes !== props.state.routes ||
71 screens.observedDescriptors !== props.descriptors
72 ) {
73 const routes = reconcileStackScreens(
74 screens.routes,
75 props.state.routes,
76 screens.completedNativeDismissals,
77 );
78 const active = new Set(props.state.routes.map((route) => route.key));
79 const retainedKeys = new Set(routes.map((route) => route.key));
80 setScreens({
81 nativeCompletingPops: new Set(
82 [...screens.nativeCompletingPops].filter((key) => retainedKeys.has(key)),
83 ),
84 completedNativeDismissals: new Set(
85 [...screens.completedNativeDismissals].filter((key) => active.has(key)),
86 ),
87 routes,
88 descriptors: Object.fromEntries(
89 Object.entries({ ...screens.descriptors, ...props.descriptors }).filter(([key]) =>
90 retainedKeys.has(key),
91 ),
92 ),
93 observedRoutes: props.state.routes,
94 observedDescriptors: props.descriptors,
95 });
96 }
97 // Keep the real descriptor through dismissal. describe(route, true) creates
98 // a placeholder whose navigation rejects setOptions and dispatches.
99 const nativeDismiss = useCallback(
100 (key: string) => {
101 const state = props.navigation.getState();
102 const attached = state.routes.some((route) => route.key === key);
103 setScreens((current) => {
104 const completedNativeDismissals = new Set(current.completedNativeDismissals);
105 const nativeCompletingPops = new Set(current.nativeCompletingPops);
106 nativeCompletingPops.delete(key);
107 if (attached) {
108 completedNativeDismissals.add(key);
109 return { ...current, completedNativeDismissals, nativeCompletingPops };
110 }
111 // A delayed callback needs immediate cleanup: the router already
112 // removed the route, so no further router update will follow.
113 completedNativeDismissals.delete(key);
114 const descriptors = { ...current.descriptors };
115 delete descriptors[key];
116 return {
117 ...current,
118 completedNativeDismissals,
119 nativeCompletingPops,
120 routes: current.routes.filter((route) => route.key !== key),
121 descriptors,
122 };
123 });
124 if (nativePopSource.current === key) nativePopSource.current = null;
125 const action = nativeStackPopAction(state, key);
126 if (action) props.navigation.dispatch(action);
127 },
128 [props.navigation, setScreens],
129 );
130 const removeDismissed = useCallback(
131 (key: string) => {
132 if (!props.navigation.getState().routes.some((route) => route.key === key)) {
133 setScreens((current) => {
134 const descriptors = { ...current.descriptors };
135 delete descriptors[key];
136 return {
137 ...current,
138 routes: current.routes.filter((route) => route.key !== key),
139 descriptors,
140 };
141 });
142 }
143 },
144 [props.navigation, setScreens],
145 );
146 return (
147 <View className="flex-1 bg-screen">
148 <Stack.Host>
149 {screens.routes.map((route, index) => {
150 const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key];
151 if (!descriptor) return null;
152 const attached = props.state.routes.some((current) => current.key === route.key);
153 return (
154 <Stack.Screen
155 key={route.key}
156 screenKey={route.key}
157 activityMode={
158 attached || screens.nativeCompletingPops.has(route.key) ? "attached" : "detached"
159 }
160 preventNativeDismiss={
161 preventedRoutes[route.key]?.preventRemove ||
162 descriptor.options.gestureEnabled === false
163 }
164 onDismiss={removeDismissed}
165 onNativeDismiss={nativeDismiss}
166 onNativeDismissPrevented={() => {
167 if (preventedRoutes[route.key]?.preventRemove) descriptor.navigation.goBack();
168 }}
169 onWillAppear={() => {
170 if (index === 0) props.onRootVisibilityChange?.(true);
171 props.navigation.emit({
172 type: "transitionStart",
173 target: route.key,
174 data: { closing: false },
175 });
176 }}
177 onDidAppear={() => {
178 const source = nativePopSource.current;
179 if (source === route.key) {
180 // A cancelled swipe restores its source without changing router history.
181 nativePopSource.current = null;
182 } else if (source) {
183 const state = props.navigation.getState();
184 const action = nativeStackPopAction(state, source, route.key);
185 if (action) {
186 const sourceIndex = state.routes.findIndex((current) => current.key === source);
187 // UIKit owns this pop. Keep these children attached until its
188 // dismissal callback, so the early router update cannot enqueue a second pop.
189 const poppedKeys = state.routes
190 .slice(sourceIndex - action.payload.count + 1, sourceIndex + 1)
191 .map((popped) => popped.key);
192 setScreens((current) => ({
193 ...current,
194 nativeCompletingPops: new Set([
195 ...current.nativeCompletingPops,
196 ...poppedKeys,
197 ]),
198 }));
199 nativePopSource.current = null;
200 props.navigation.dispatch(action);
201 }
202 }
203 props.navigation.emit({
204 type: "transitionEnd",
205 target: route.key,
206 data: { closing: false },
207 });
208 }}
209 onWillDisappear={() => {
210 if (index === 0) props.onRootVisibilityChange?.(false);
211 const state = props.navigation.getState();
212 // JS pushes/pops already changed history. Only native Back leaves
213 // the disappearing card at the router's active index.
214 if (state.index > 0 && state.routes[state.index]?.key === route.key) {
215 nativePopSource.current = route.key;
216 }
217 props.navigation.emit({
218 type: "transitionStart",
219 target: route.key,
220 data: { closing: true },
221 });
222 }}
223 onDidDisappear={() =>
224 props.navigation.emit({
225 type: "transitionEnd",
226 target: route.key,
227 data: { closing: true },
228 })
229 }
230 >
231 <NavigationContext value={descriptor.navigation}>
232 <NavigationRouteContext value={descriptor.route}>
233 <V5StackHeader
234 options={descriptor.options}
235 canGoBack={
236 attached
237 ? props.state.routes.findIndex((current) => current.key === route.key) > 0
238 : index > 0
239 }
240 />
241 <NativeColumnContent
242 insetHorizontally={
243 (descriptor.options as AppNativeStackNavigationOptions)
244 .nativeContentInsetHorizontally
245 }
246 >
247 <NativeScrollEdgeEffectsContext value={descriptor.options.scrollEdgeEffects}>
248 {descriptor.render()}
249 </NativeScrollEdgeEffectsContext>
250 </NativeColumnContent>
251 </NavigationRouteContext>
252 </NavigationContext>
253 </Stack.Screen>
254 );
255 })}
256 </Stack.Host>
257 </View>
258 );
259}
260
261/** v5 owns pushes and headers; the legacy renderer only presents modal groups. */
262export function V5StackView(props: V5StackViewProps) {
263 const groups = partitionStackPresentations(props.state.routes, (route) => {
264 const presentation = props.descriptors[route.key]?.options.presentation;
265 return (
266 ["SettingsSheet", "NewTaskSheet"].includes(route.name) ||
267 (presentation !== undefined && presentation !== "card")
268 );
269 });
270 const routes = groups.map((group) => group[0]!);
271 const descriptors = Object.fromEntries(
272 groups.map((group) => {
273 const first = group[0]!;
274 const descriptor = props.descriptors[first.key]!;
275 const visibleRoute = group.at(-1)!;
276 return [
277 first.key,
278 {
279 ...descriptor,
280 navigation: props.descriptors[visibleRoute.key]!.navigation,
281 // Focus belongs to the pushed page inside this presentation group.
282 route: visibleRoute,
283 options: modalEnvelopeOptions(descriptor.options),
284 render: () => (
285 <V5CardStackView
286 {...props}
287 state={{
288 ...props.state,
289 routes: group,
290 index: group.length - 1,
291 preloadedRoutes: [],
292 }}
293 />
294 ),
295 },
296 ];
297 }),
298 );
299 return (
300 <NativeStackView
301 presentationEnvelope
302 {...props}
303 descriptors={descriptors}
304 state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }}
305 />
306 );
307}
308
309/** Keep a direct card host mounted while v5 form sheets present above it. */
310export function V5SheetStackView(props: V5StackViewProps) {
311 const { preventedRoutes } = usePreventRemoveContext();
312 const groups = partitionStackPresentations(
313 props.state.routes,
314 (route) => props.descriptors[route.key]?.options.presentation === "formSheet",
315 );
316 const [retained, setRetained] = useState({
317 groups: groups.slice(1),
318 descriptors: props.descriptors,
319 observedRoutes: props.state.routes,
320 observedDescriptors: props.descriptors,
321 });
322 if (
323 retained.observedRoutes !== props.state.routes ||
324 retained.observedDescriptors !== props.descriptors
325 ) {
326 const active = new Set(groups.slice(1).map((group) => group[0]!.key));
327 const sheets = [
328 ...groups.slice(1),
329 ...retained.groups.filter((group) => !active.has(group[0]!.key)),
330 ];
331 const sheetKeys = new Set(sheets.flatMap((group) => group.map((route) => route.key)));
332 setRetained({
333 groups: sheets,
334 descriptors: Object.fromEntries(
335 Object.entries({ ...retained.descriptors, ...props.descriptors }).filter(([key]) =>
336 sheetKeys.has(key),
337 ),
338 ),
339 observedRoutes: props.state.routes,
340 observedDescriptors: props.descriptors,
341 });
342 }
343 const removeSheet = (key: string) => {
344 setRetained((current) => {
345 const sheets = current.groups.filter((group) => group[0]!.key !== key);
346 const sheetKeys = new Set(sheets.flatMap((group) => group.map((route) => route.key)));
347 return {
348 ...current,
349 groups: sheets,
350 descriptors: Object.fromEntries(
351 Object.entries(current.descriptors).filter(([routeKey]) => sheetKeys.has(routeKey)),
352 ),
353 };
354 });
355 };
356 const base = groups[0] ?? [];
357 return (
358 <View className="flex-1 bg-screen">
359 <V5CardStackView
360 {...props}
361 state={{ ...props.state, routes: base, index: base.length - 1, preloadedRoutes: [] }}
362 />
363 {retained.groups.map((group) => {
364 const first = group[0]!;
365 const descriptor = props.descriptors[first.key] ?? retained.descriptors[first.key];
366 if (!descriptor) return null;
367 const options = descriptor.options;
368 const attached = nativeWorkspacePopCount(props.state, first.key) > 0;
369 return (
370 <FormSheet
371 key={first.key}
372 isOpen={attached}
373 detents={options.sheetAllowedDetents}
374 initialDetentIndex={options.sheetInitialDetentIndex}
375 largestUndimmedDetentIndex={options.sheetLargestUndimmedDetentIndex}
376 prefersGrabberVisible={options.sheetGrabberVisible}
377 preferredCornerRadius={options.sheetCornerRadius}
378 prefersScrollingExpandsWhenScrolledToEdge={options.sheetExpandsWhenScrolledToEdge}
379 nativeContainerStyle={{
380 backgroundColor: StyleSheet.flatten(options.contentStyle)?.backgroundColor,
381 }}
382 preventNativeDismiss={
383 group.some((route) => preventedRoutes[route.key]?.preventRemove) ||
384 options.gestureEnabled === false
385 }
386 onNativeDismissPrevented={() => {
387 const guarded = group.findLast((route) => preventedRoutes[route.key]?.preventRemove);
388 if (guarded) {
389 const state = props.navigation.getState();
390 const action = nativeWorkspacePopAction(state, first.key);
391 if (action) props.navigation.dispatch(action);
392 }
393 }}
394 onDismiss={() => removeSheet(first.key)}
395 onNativeDismiss={() => {
396 const state = props.navigation.getState();
397 const action = nativeWorkspacePopAction(state, first.key);
398 if (action) props.navigation.dispatch(action);
399 removeSheet(first.key);
400 }}
401 >
402 <V5CardStackView
403 {...props}
404 descriptors={{ ...retained.descriptors, ...props.descriptors }}
405 state={{
406 ...props.state,
407 routes: group,
408 index: group.length - 1,
409 preloadedRoutes: [],
410 }}
411 />
412 </FormSheet>
413 );
414 })}
415 </View>
416 );
417}
418
419function V5Navigator({
420 nativeSheets,
421 id,
422 initialRouteName,
423 UNSTABLE_routeNamesChangeBehavior,
424 children,
425 layout,
426 screenListeners,
427 screenOptions,
428 screenLayout,
429 UNSTABLE_router,
430 ...rest
431}: NativeStackNavigatorProps & { readonly nativeSheets?: boolean }) {
432 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
433 StackNavigationState<ParamListBase>,
434 StackRouterOptions,
435 StackActionHelpers<ParamListBase>,
436 NativeStackNavigationOptions,
437 NativeStackNavigationEventMap
438 >(StackRouter, {
439 id,
440 initialRouteName,
441 UNSTABLE_routeNamesChangeBehavior,
442 children,
443 layout,
444 screenListeners,
445 screenOptions,
446 screenLayout,
447 UNSTABLE_router,
448 });
449 const StackView = nativeSheets ? V5SheetStackView : V5StackView;
450 return (
451 <NavigationContent>
452 <StackView
453 {...rest}
454 state={state}
455 describe={describe}
456 descriptors={descriptors}
457 navigation={navigation}
458 />
459 </NavigationContent>
460 );
461}
462
463function V5StackNavigator(props: NativeStackNavigatorProps) {
464 return <V5Navigator {...props} />;
465}
466
467function V5SheetStackNavigator(props: NativeStackNavigatorProps) {
468 return <V5Navigator {...props} nativeSheets />;
469}
470
471type V5TypeBag<ParamList extends ParamListBase, NavigatorID extends string | undefined> = Omit<
472 NativeStackTypeBag<ParamList, NavigatorID>,
473 "Navigator"
474> & { Navigator: typeof V5StackNavigator };
475export function createV5StackNavigator<
476 const ParamList extends ParamListBase,
477 const NavigatorID extends string | undefined = string | undefined,
478 const TypeBag extends NavigatorTypeBagBase = V5TypeBag<ParamList, NavigatorID>,
479 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
480>(config?: Config): TypedNavigator<TypeBag, Config> {
481 return createNavigatorFactory(V5StackNavigator)(config);
482}
483
484/** For navigators whose presentations are cards and form sheets only. */
485export function createV5SheetStackNavigator<
486 const ParamList extends ParamListBase,
487 const NavigatorID extends string | undefined = string | undefined,
488 const TypeBag extends NavigatorTypeBagBase = V5TypeBag<ParamList, NavigatorID>,
489 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
490>(config?: Config): TypedNavigator<TypeBag, Config> {
491 return createNavigatorFactory(V5SheetStackNavigator)(config);
492}