| 1 | import { |
| 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"; |
| 16 | import { |
| 17 | NativeStackView, |
| 18 | type NativeStackNavigationEventMap, |
| 19 | type NativeStackNavigationOptions, |
| 20 | type NativeStackNavigatorProps, |
| 21 | type NativeStackTypeBag, |
| 22 | } from "@react-navigation/native-stack"; |
| 23 | import { useCallback, useRef, useState, type ComponentProps } from "react"; |
| 24 | import { StyleSheet, View } from "react-native"; |
| 25 | import { FormSheet, Stack } from "react-native-screens"; |
| 26 | import { NativeScrollEdgeEffectsContext } from "./NativeScrollViewMarker"; |
| 27 | import { V5StackHeader } from "./V5StackHeader.ios"; |
| 28 | import type { AppNativeStackNavigationOptions } from "./StackHeader"; |
| 29 | import { NativeColumnContent } from "./NativeColumnContent.ios"; |
| 30 | import { |
| 31 | nativeStackPopAction, |
| 32 | nativeWorkspacePopAction, |
| 33 | nativeWorkspacePopCount, |
| 34 | partitionStackPresentations, |
| 35 | reconcileStackScreens, |
| 36 | } from "./workspace-stack-projection"; |
| 37 | |
| 38 | export type V5StackViewProps = ComponentProps<typeof NativeStackView>; |
| 39 | |
| 40 | /** The presentation envelope must not mount a second search bar or header. */ |
| 41 | export 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. */ |
| 56 | export 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. */ |
| 262 | export 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. */ |
| 310 | export 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 | |
| 419 | function 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 | |
| 463 | function V5StackNavigator(props: NativeStackNavigatorProps) { |
| 464 | return <V5Navigator {...props} />; |
| 465 | } |
| 466 | |
| 467 | function V5SheetStackNavigator(props: NativeStackNavigatorProps) { |
| 468 | return <V5Navigator {...props} nativeSheets />; |
| 469 | } |
| 470 | |
| 471 | type V5TypeBag<ParamList extends ParamListBase, NavigatorID extends string | undefined> = Omit< |
| 472 | NativeStackTypeBag<ParamList, NavigatorID>, |
| 473 | "Navigator" |
| 474 | > & { Navigator: typeof V5StackNavigator }; |
| 475 | export 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. */ |
| 485 | export 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 | } |