| ... | ... | @@ -20,12 +20,13 @@ import { |
| 20 | 20 | type NativeStackNavigatorProps, |
| 21 | 21 | type NativeStackTypeBag, |
| 22 | 22 | } from "@react-navigation/native-stack"; |
| 23 | | import { useCallback, useState, type ComponentProps } from "react"; |
| 23 | import { useCallback, useRef, useState, type ComponentProps } from "react"; |
| 24 | 24 | import { StyleSheet, View } from "react-native"; |
| 25 | 25 | import { FormSheet, Stack } from "react-native-screens"; |
| 26 | 26 | import { V5StackHeader } from "./V5StackHeader.ios"; |
| 27 | 27 | import { NativeColumnContent } from "./NativeColumnContent.ios"; |
| 28 | 28 | import { |
| 29 | nativeStackPopAction, |
| 29 | 30 | nativeWorkspacePopAction, |
| 30 | 31 | nativeWorkspacePopCount, |
| 31 | 32 | partitionStackPresentations, |
| ... | ... | @@ -52,8 +53,10 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) { |
| 52 | 53 | /** Keep outgoing screens until UIKit completes its pop, as required by v5. */ |
| 53 | 54 | export function V5CardStackView(props: V5StackViewProps) { |
| 54 | 55 | const { preventedRoutes } = usePreventRemoveContext(); |
| 56 | const nativePopSource = useRef<string | null>(null); |
| 55 | 57 | const [screens, setScreens] = useState({ |
| 56 | 58 | completedNativeDismissals: new Set<string>(), |
| 59 | nativeCompletingPops: new Set<string>(), |
| 57 | 60 | routes: props.state.routes, |
| 58 | 61 | descriptors: props.descriptors, |
| 59 | 62 | observedRoutes: props.state.routes, |
| ... | ... | @@ -71,6 +74,9 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 71 | 74 | const active = new Set(props.state.routes.map((route) => route.key)); |
| 72 | 75 | const retainedKeys = new Set(routes.map((route) => route.key)); |
| 73 | 76 | setScreens({ |
| 77 | nativeCompletingPops: new Set( |
| 78 | [...screens.nativeCompletingPops].filter((key) => retainedKeys.has(key)), |
| 79 | ), |
| 74 | 80 | completedNativeDismissals: new Set( |
| 75 | 81 | [...screens.completedNativeDismissals].filter((key) => active.has(key)), |
| 76 | 82 | ), |
| ... | ... | @@ -92,9 +98,11 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 92 | 98 | const attached = state.routes.some((route) => route.key === key); |
| 93 | 99 | setScreens((current) => { |
| 94 | 100 | const completedNativeDismissals = new Set(current.completedNativeDismissals); |
| 101 | const nativeCompletingPops = new Set(current.nativeCompletingPops); |
| 102 | nativeCompletingPops.delete(key); |
| 95 | 103 | if (attached) { |
| 96 | 104 | completedNativeDismissals.add(key); |
| 97 | | return { ...current, completedNativeDismissals }; |
| 105 | return { ...current, completedNativeDismissals, nativeCompletingPops }; |
| 98 | 106 | } |
| 99 | 107 | // A delayed callback needs immediate cleanup: the router already |
| 100 | 108 | // removed the route, so no further router update will follow. |
| ... | ... | @@ -104,11 +112,13 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 104 | 112 | return { |
| 105 | 113 | ...current, |
| 106 | 114 | completedNativeDismissals, |
| 115 | nativeCompletingPops, |
| 107 | 116 | routes: current.routes.filter((route) => route.key !== key), |
| 108 | 117 | descriptors, |
| 109 | 118 | }; |
| 110 | 119 | }); |
| 111 | | const action = nativeWorkspacePopAction(state, key); |
| 120 | if (nativePopSource.current === key) nativePopSource.current = null; |
| 121 | const action = nativeStackPopAction(state, key); |
| 112 | 122 | if (action) props.navigation.dispatch(action); |
| 113 | 123 | }, |
| 114 | 124 | [props.navigation, setScreens], |
| ... | ... | @@ -140,7 +150,9 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 140 | 150 | <Stack.Screen |
| 141 | 151 | key={route.key} |
| 142 | 152 | screenKey={route.key} |
| 143 | | activityMode={attached ? "attached" : "detached"} |
| 153 | activityMode={ |
| 154 | attached || screens.nativeCompletingPops.has(route.key) ? "attached" : "detached" |
| 155 | } |
| 144 | 156 | preventNativeDismiss={ |
| 145 | 157 | preventedRoutes[route.key]?.preventRemove || |
| 146 | 158 | descriptor.options.gestureEnabled === false |
| ... | ... | @@ -157,20 +169,51 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 157 | 169 | data: { closing: false }, |
| 158 | 170 | }) |
| 159 | 171 | } |
| 160 | | onDidAppear={() => |
| 172 | onDidAppear={() => { |
| 173 | const source = nativePopSource.current; |
| 174 | if (source === route.key) { |
| 175 | // A cancelled swipe restores its source without changing router history. |
| 176 | nativePopSource.current = null; |
| 177 | } else if (source) { |
| 178 | const state = props.navigation.getState(); |
| 179 | const action = nativeStackPopAction(state, source, route.key); |
| 180 | if (action) { |
| 181 | const sourceIndex = state.routes.findIndex((current) => current.key === source); |
| 182 | // UIKit owns this pop. Keep these children attached until its |
| 183 | // dismissal callback, so the early router update cannot enqueue a second pop. |
| 184 | const poppedKeys = state.routes |
| 185 | .slice(sourceIndex - action.payload.count + 1, sourceIndex + 1) |
| 186 | .map((popped) => popped.key); |
| 187 | setScreens((current) => ({ |
| 188 | ...current, |
| 189 | nativeCompletingPops: new Set([ |
| 190 | ...current.nativeCompletingPops, |
| 191 | ...poppedKeys, |
| 192 | ]), |
| 193 | })); |
| 194 | nativePopSource.current = null; |
| 195 | props.navigation.dispatch(action); |
| 196 | } |
| 197 | } |
| 161 | 198 | props.navigation.emit({ |
| 162 | 199 | type: "transitionEnd", |
| 163 | 200 | target: route.key, |
| 164 | 201 | data: { closing: false }, |
| 165 | | }) |
| 166 | | } |
| 167 | | onWillDisappear={() => |
| 202 | }); |
| 203 | }} |
| 204 | onWillDisappear={() => { |
| 205 | const state = props.navigation.getState(); |
| 206 | // JS pushes/pops already changed history. Only native Back leaves |
| 207 | // the disappearing card at the router's active index. |
| 208 | if (state.index > 0 && state.routes[state.index]?.key === route.key) { |
| 209 | nativePopSource.current = route.key; |
| 210 | } |
| 168 | 211 | props.navigation.emit({ |
| 169 | 212 | type: "transitionStart", |
| 170 | 213 | target: route.key, |
| 171 | 214 | data: { closing: true }, |
| 172 | | }) |
| 173 | | } |
| 215 | }); |
| 216 | }} |
| 174 | 217 | onDidDisappear={() => |
| 175 | 218 | props.navigation.emit({ |
| 176 | 219 | type: "transitionEnd", |