authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 12:02:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 12:02:21-07:00
log805967a878e6804d58c151f29d2a3d0a06828fd1
treedab39e965595344a481d2300b92d08222e508fe4
parent0b6ec43c54165a87941b7a070849be8979646040
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve navigation after native swipe back (#17268)


3 files changed, 138 insertions(+), 10 deletions(-)

apps/mobile/src/native/createV5StackNavigator.ios.tsx+53-10
...@@ -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";
23import { useCallback, useState, type ComponentProps } from "react";23import { useCallback, useRef, useState, type ComponentProps } from "react";
24import { StyleSheet, View } from "react-native";24import { StyleSheet, View } from "react-native";
25import { FormSheet, Stack } from "react-native-screens";25import { FormSheet, Stack } from "react-native-screens";
26import { V5StackHeader } from "./V5StackHeader.ios";26import { V5StackHeader } from "./V5StackHeader.ios";
27import { NativeColumnContent } from "./NativeColumnContent.ios";27import { NativeColumnContent } from "./NativeColumnContent.ios";
28import {28import {
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. */
53export function V5CardStackView(props: V5StackViewProps) {54export 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 already107 // 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.Screen150 <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 === false158 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",
apps/mobile/src/native/workspace-stack-projection.test.ts+58
...@@ -3,6 +3,7 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati...@@ -3,6 +3,7 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import {5import {
6 nativeStackPopAction,
6 nativeWorkspacePopAction,7 nativeWorkspacePopAction,
7 nativeWorkspacePopCount,8 nativeWorkspacePopCount,
8 projectWorkspaceStack,9 projectWorkspaceStack,
...@@ -128,6 +129,63 @@ describe("native workspace dismissal", () => {...@@ -128,6 +129,63 @@ describe("native workspace dismissal", () => {
128 });129 });
129});130});
130131
132describe("native card pop handoff", () => {
133 const routerOptions = {
134 routeNames: history([home]).routeNames,
135 routeParamList: {},
136 routeGetIdList: {},
137 };
138 it("keeps a newly opened screen when an earlier swipe dismissal arrives late", () => {
139 const state = history([home, thread, files, settings]);
140 const next = StackRouter({}).getStateForAction(
141 state,
142 nativeStackPopAction(state, files.key)!,
143 routerOptions,
144 );
145 expect(next?.routes).toEqual([home, thread, settings]);
146 expect(next?.index).toBe(2);
147 if (!next || next.stale !== false) throw new Error("Expected a rehydrated router history");
148 expect(nativeStackPopAction(next, files.key)).toBeNull();
149 });
150 it("acknowledges a native pop to an earlier page without consuming a newer push", () => {
151 const state = history([home, thread, files, settings]);
152 const next = StackRouter({}).getStateForAction(
153 state,
154 nativeStackPopAction(state, files.key, home.key)!,
155 routerOptions,
156 );
157 expect(next?.routes).toEqual([home, settings]);
158 expect(next?.index).toBe(1);
159 });
160 it("allows reopening the same page after its destination finishes appearing", () => {
161 const state = history([home, thread, files]);
162 const router = StackRouter({});
163 const popped = router.getStateForAction(
164 state,
165 nativeStackPopAction(state, files.key, thread.key)!,
166 routerOptions,
167 );
168 if (!popped || popped.stale !== false) throw new Error("Expected a rehydrated router history");
169 const reopened = router.getStateForAction(
170 popped,
171 { type: "NAVIGATE", payload: { name: files.name, params: files.params } },
172 routerOptions,
173 );
174 expect(reopened?.routes.map((route) => route.name)).toEqual(["Home", "Thread", "ThreadFiles"]);
175 expect(reopened?.routes.at(-1)?.key).not.toBe(files.key);
176 if (!reopened || reopened.stale !== false)
177 throw new Error("Expected a rehydrated router history");
178 expect(nativeStackPopAction(reopened, files.key)).toBeNull();
179 });
180 it("leaves history intact when a swipe is cancelled or its source is already gone", () => {
181 const state = history([home, thread, files]);
182 expect(nativeStackPopAction(state, files.key, files.key)).toBeNull();
183 expect(nativeStackPopAction(state, files.key, settings.key)).toBeNull();
184 expect(nativeStackPopAction(state, home.key)).toBeNull();
185 expect(nativeStackPopAction({ ...state, index: 1 }, files.key)).toBeNull();
186 });
187});
188
131describe("v5 stack handoff", () => {189describe("v5 stack handoff", () => {
132 it("releases a completed native pop when the router acknowledges it", () => {190 it("releases a completed native pop when the router acknowledges it", () => {
133 const completed = new Set([files.key]);191 const completed = new Set([files.key]);
apps/mobile/src/native/workspace-stack-projection.ts+27
...@@ -44,6 +44,33 @@ export function nativeWorkspacePopAction(...@@ -44,6 +44,33 @@ export function nativeWorkspacePopAction(
44 : null;44 : null;
45}45}
4646
47/** A native card pop must preserve pushes made before its completion event reaches JS. */
48export function nativeStackPopAction(
49 state: Pick<NavigationState, "key" | "index" | "routes">,
50 dismissedKey: string,
51 destinationKey?: string,
52) {
53 const sourceIndex = state.routes.findIndex((route) => route.key === dismissedKey);
54 const destinationIndex =
55 destinationKey === undefined
56 ? sourceIndex - 1
57 : state.routes.findIndex((route) => route.key === destinationKey);
58 if (
59 sourceIndex <= 0 ||
60 sourceIndex > state.index ||
61 destinationIndex < 0 ||
62 destinationIndex >= sourceIndex
63 ) {
64 return null;
65 }
66 return {
67 type: "POP" as const,
68 payload: { count: sourceIndex - destinationIndex },
69 source: dismissedKey,
70 target: state.key,
71 };
72}
73
47/** Group pushes with the modal that owns their native stack. */74/** Group pushes with the modal that owns their native stack. */
48export function partitionStackPresentations<T>(75export function partitionStackPresentations<T>(
49 routes: readonly T[],76 routes: readonly T[],