| ... | @@ -20,12 +20,13 @@ import { | ... | @@ -20,12 +20,13 @@ import { |
| 20 | type NativeStackNavigatorProps, | 20 | type NativeStackNavigatorProps, |
| 21 | type NativeStackTypeBag, | 21 | type NativeStackTypeBag, |
| 22 | } from "@react-navigation/native-stack"; | 22 | } from "@react-navigation/native-stack"; |
| 23 | import { useCallback, useState, type ComponentProps } from "react"; | 23 | import { useCallback, useRef, useState, type ComponentProps } from "react"; |
| 24 | import { StyleSheet, View } from "react-native"; | 24 | import { StyleSheet, View } from "react-native"; |
| 25 | import { FormSheet, Stack } from "react-native-screens"; | 25 | import { FormSheet, Stack } from "react-native-screens"; |
| 26 | import { V5StackHeader } from "./V5StackHeader.ios"; | 26 | import { V5StackHeader } from "./V5StackHeader.ios"; |
| 27 | import { NativeColumnContent } from "./NativeColumnContent.ios"; | 27 | import { NativeColumnContent } from "./NativeColumnContent.ios"; |
| 28 | import { | 28 | import { |
| | 29 | nativeStackPopAction, |
| 29 | nativeWorkspacePopAction, | 30 | nativeWorkspacePopAction, |
| 30 | nativeWorkspacePopCount, | 31 | nativeWorkspacePopCount, |
| 31 | partitionStackPresentations, | 32 | partitionStackPresentations, |
| ... | @@ -52,8 +53,10 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) { | ... | @@ -52,8 +53,10 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) { |
| 52 | /** Keep outgoing screens until UIKit completes its pop, as required by v5. */ | 53 | /** Keep outgoing screens until UIKit completes its pop, as required by v5. */ |
| 53 | export function V5CardStackView(props: V5StackViewProps) { | 54 | export function V5CardStackView(props: V5StackViewProps) { |
| 54 | const { preventedRoutes } = usePreventRemoveContext(); | 55 | const { preventedRoutes } = usePreventRemoveContext(); |
| | 56 | const nativePopSource = useRef<string | null>(null); |
| 55 | const [screens, setScreens] = useState({ | 57 | const [screens, setScreens] = useState({ |
| 56 | completedNativeDismissals: new Set<string>(), | 58 | completedNativeDismissals: new Set<string>(), |
| | 59 | nativeCompletingPops: new Set<string>(), |
| 57 | routes: props.state.routes, | 60 | routes: props.state.routes, |
| 58 | descriptors: props.descriptors, | 61 | descriptors: props.descriptors, |
| 59 | observedRoutes: props.state.routes, | 62 | observedRoutes: props.state.routes, |
| ... | @@ -71,6 +74,9 @@ export function V5CardStackView(props: V5StackViewProps) { | ... | @@ -71,6 +74,9 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 71 | const active = new Set(props.state.routes.map((route) => route.key)); | 74 | const active = new Set(props.state.routes.map((route) => route.key)); |
| 72 | const retainedKeys = new Set(routes.map((route) => route.key)); | 75 | const retainedKeys = new Set(routes.map((route) => route.key)); |
| 73 | setScreens({ | 76 | setScreens({ |
| | 77 | nativeCompletingPops: new Set( |
| | 78 | [...screens.nativeCompletingPops].filter((key) => retainedKeys.has(key)), |
| | 79 | ), |
| 74 | completedNativeDismissals: new Set( | 80 | completedNativeDismissals: new Set( |
| 75 | [...screens.completedNativeDismissals].filter((key) => active.has(key)), | 81 | [...screens.completedNativeDismissals].filter((key) => active.has(key)), |
| 76 | ), | 82 | ), |
| ... | @@ -92,9 +98,11 @@ export function V5CardStackView(props: V5StackViewProps) { | ... | @@ -92,9 +98,11 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 92 | const attached = state.routes.some((route) => route.key === key); | 98 | const attached = state.routes.some((route) => route.key === key); |
| 93 | setScreens((current) => { | 99 | setScreens((current) => { |
| 94 | const completedNativeDismissals = new Set(current.completedNativeDismissals); | 100 | const completedNativeDismissals = new Set(current.completedNativeDismissals); |
| | 101 | const nativeCompletingPops = new Set(current.nativeCompletingPops); |
| | 102 | nativeCompletingPops.delete(key); |
| 95 | if (attached) { | 103 | if (attached) { |
| 96 | completedNativeDismissals.add(key); | 104 | completedNativeDismissals.add(key); |
| 97 | return { ...current, completedNativeDismissals }; | 105 | return { ...current, completedNativeDismissals, nativeCompletingPops }; |
| 98 | } | 106 | } |
| 99 | // A delayed callback needs immediate cleanup: the router already | 107 | // A delayed callback needs immediate cleanup: the router already |
| 100 | // removed the route, so no further router update will follow. | 108 | // removed the route, so no further router update will follow. |
| ... | @@ -104,11 +112,13 @@ export function V5CardStackView(props: V5StackViewProps) { | ... | @@ -104,11 +112,13 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 104 | return { | 112 | return { |
| 105 | ...current, | 113 | ...current, |
| 106 | completedNativeDismissals, | 114 | completedNativeDismissals, |
| | 115 | nativeCompletingPops, |
| 107 | routes: current.routes.filter((route) => route.key !== key), | 116 | routes: current.routes.filter((route) => route.key !== key), |
| 108 | descriptors, | 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 | if (action) props.navigation.dispatch(action); | 122 | if (action) props.navigation.dispatch(action); |
| 113 | }, | 123 | }, |
| 114 | [props.navigation, setScreens], | 124 | [props.navigation, setScreens], |
| ... | @@ -140,7 +150,9 @@ export function V5CardStackView(props: V5StackViewProps) { | ... | @@ -140,7 +150,9 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 140 | <Stack.Screen | 150 | <Stack.Screen |
| 141 | key={route.key} | 151 | key={route.key} |
| 142 | screenKey={route.key} | 152 | screenKey={route.key} |
| 143 | activityMode={attached ? "attached" : "detached"} | 153 | activityMode={ |
| | 154 | attached || screens.nativeCompletingPops.has(route.key) ? "attached" : "detached" |
| | 155 | } |
| 144 | preventNativeDismiss={ | 156 | preventNativeDismiss={ |
| 145 | preventedRoutes[route.key]?.preventRemove || | 157 | preventedRoutes[route.key]?.preventRemove || |
| 146 | descriptor.options.gestureEnabled === false | 158 | descriptor.options.gestureEnabled === false |
| ... | @@ -157,20 +169,51 @@ export function V5CardStackView(props: V5StackViewProps) { | ... | @@ -157,20 +169,51 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 157 | data: { closing: false }, | 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 | props.navigation.emit({ | 198 | props.navigation.emit({ |
| 162 | type: "transitionEnd", | 199 | type: "transitionEnd", |
| 163 | target: route.key, | 200 | target: route.key, |
| 164 | data: { closing: false }, | 201 | data: { closing: false }, |
| 165 | }) | 202 | }); |
| 166 | } | 203 | }} |
| 167 | onWillDisappear={() => | 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 | props.navigation.emit({ | 211 | props.navigation.emit({ |
| 169 | type: "transitionStart", | 212 | type: "transitionStart", |
| 170 | target: route.key, | 213 | target: route.key, |
| 171 | data: { closing: true }, | 214 | data: { closing: true }, |
| 172 | }) | 215 | }); |
| 173 | } | 216 | }} |
| 174 | onDidDisappear={() => | 217 | onDidDisappear={() => |
| 175 | props.navigation.emit({ | 218 | props.navigation.emit({ |
| 176 | type: "transitionEnd", | 219 | type: "transitionEnd", |