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 {
2020 type NativeStackNavigatorProps,
2121 type NativeStackTypeBag,
2222} from "@react-navigation/native-stack";
23import { useCallback, useState, type ComponentProps } from "react";
23import { useCallback, useRef, useState, type ComponentProps } from "react";
2424import { StyleSheet, View } from "react-native";
2525import { FormSheet, Stack } from "react-native-screens";
2626import { V5StackHeader } from "./V5StackHeader.ios";
2727import { NativeColumnContent } from "./NativeColumnContent.ios";
2828import {
29 nativeStackPopAction,
2930 nativeWorkspacePopAction,
3031 nativeWorkspacePopCount,
3132 partitionStackPresentations,
......@@ -52,8 +53,10 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) {
5253/** Keep outgoing screens until UIKit completes its pop, as required by v5. */
5354export function V5CardStackView(props: V5StackViewProps) {
5455 const { preventedRoutes } = usePreventRemoveContext();
56 const nativePopSource = useRef<string | null>(null);
5557 const [screens, setScreens] = useState({
5658 completedNativeDismissals: new Set<string>(),
59 nativeCompletingPops: new Set<string>(),
5760 routes: props.state.routes,
5861 descriptors: props.descriptors,
5962 observedRoutes: props.state.routes,
......@@ -71,6 +74,9 @@ export function V5CardStackView(props: V5StackViewProps) {
7174 const active = new Set(props.state.routes.map((route) => route.key));
7275 const retainedKeys = new Set(routes.map((route) => route.key));
7376 setScreens({
77 nativeCompletingPops: new Set(
78 [...screens.nativeCompletingPops].filter((key) => retainedKeys.has(key)),
79 ),
7480 completedNativeDismissals: new Set(
7581 [...screens.completedNativeDismissals].filter((key) => active.has(key)),
7682 ),
......@@ -92,9 +98,11 @@ export function V5CardStackView(props: V5StackViewProps) {
9298 const attached = state.routes.some((route) => route.key === key);
9399 setScreens((current) => {
94100 const completedNativeDismissals = new Set(current.completedNativeDismissals);
101 const nativeCompletingPops = new Set(current.nativeCompletingPops);
102 nativeCompletingPops.delete(key);
95103 if (attached) {
96104 completedNativeDismissals.add(key);
97 return { ...current, completedNativeDismissals };
105 return { ...current, completedNativeDismissals, nativeCompletingPops };
98106 }
99107 // A delayed callback needs immediate cleanup: the router already
100108 // removed the route, so no further router update will follow.
......@@ -104,11 +112,13 @@ export function V5CardStackView(props: V5StackViewProps) {
104112 return {
105113 ...current,
106114 completedNativeDismissals,
115 nativeCompletingPops,
107116 routes: current.routes.filter((route) => route.key !== key),
108117 descriptors,
109118 };
110119 });
111 const action = nativeWorkspacePopAction(state, key);
120 if (nativePopSource.current === key) nativePopSource.current = null;
121 const action = nativeStackPopAction(state, key);
112122 if (action) props.navigation.dispatch(action);
113123 },
114124 [props.navigation, setScreens],
......@@ -140,7 +150,9 @@ export function V5CardStackView(props: V5StackViewProps) {
140150 <Stack.Screen
141151 key={route.key}
142152 screenKey={route.key}
143 activityMode={attached ? "attached" : "detached"}
153 activityMode={
154 attached || screens.nativeCompletingPops.has(route.key) ? "attached" : "detached"
155 }
144156 preventNativeDismiss={
145157 preventedRoutes[route.key]?.preventRemove ||
146158 descriptor.options.gestureEnabled === false
......@@ -157,20 +169,51 @@ export function V5CardStackView(props: V5StackViewProps) {
157169 data: { closing: false },
158170 })
159171 }
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 }
161198 props.navigation.emit({
162199 type: "transitionEnd",
163200 target: route.key,
164201 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 }
168211 props.navigation.emit({
169212 type: "transitionStart",
170213 target: route.key,
171214 data: { closing: true },
172 })
173 }
215 });
216 }}
174217 onDidDisappear={() =>
175218 props.navigation.emit({
176219 type: "transitionEnd",
apps/mobile/src/native/workspace-stack-projection.test.ts+58
......@@ -3,6 +3,7 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati
33import { describe, expect, it } from "vite-plus/test";
44
55import {
6 nativeStackPopAction,
67 nativeWorkspacePopAction,
78 nativeWorkspacePopCount,
89 projectWorkspaceStack,
......@@ -128,6 +129,63 @@ describe("native workspace dismissal", () => {
128129 });
129130});
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
131189describe("v5 stack handoff", () => {
132190 it("releases a completed native pop when the router acknowledges it", () => {
133191 const completed = new Set([files.key]);
apps/mobile/src/native/workspace-stack-projection.ts+27
......@@ -44,6 +44,33 @@ export function nativeWorkspacePopAction(
4444 : null;
4545}
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
4774/** Group pushes with the modal that owns their native stack. */
4875export function partitionStackPresentations<T>(
4976 routes: readonly T[],