diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index 413bd8712704..7dcbe73ab769 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -20,12 +20,13 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { useCallback, useState, type ComponentProps } from "react"; +import { useCallback, useRef, useState, type ComponentProps } from "react"; import { StyleSheet, View } from "react-native"; import { FormSheet, Stack } from "react-native-screens"; import { V5StackHeader } from "./V5StackHeader.ios"; import { NativeColumnContent } from "./NativeColumnContent.ios"; import { + nativeStackPopAction, nativeWorkspacePopAction, nativeWorkspacePopCount, partitionStackPresentations, @@ -52,8 +53,10 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) { /** Keep outgoing screens until UIKit completes its pop, as required by v5. */ export function V5CardStackView(props: V5StackViewProps) { const { preventedRoutes } = usePreventRemoveContext(); + const nativePopSource = useRef(null); const [screens, setScreens] = useState({ completedNativeDismissals: new Set(), + nativeCompletingPops: new Set(), routes: props.state.routes, descriptors: props.descriptors, observedRoutes: props.state.routes, @@ -71,6 +74,9 @@ export function V5CardStackView(props: V5StackViewProps) { const active = new Set(props.state.routes.map((route) => route.key)); const retainedKeys = new Set(routes.map((route) => route.key)); setScreens({ + nativeCompletingPops: new Set( + [...screens.nativeCompletingPops].filter((key) => retainedKeys.has(key)), + ), completedNativeDismissals: new Set( [...screens.completedNativeDismissals].filter((key) => active.has(key)), ), @@ -92,9 +98,11 @@ export function V5CardStackView(props: V5StackViewProps) { const attached = state.routes.some((route) => route.key === key); setScreens((current) => { const completedNativeDismissals = new Set(current.completedNativeDismissals); + const nativeCompletingPops = new Set(current.nativeCompletingPops); + nativeCompletingPops.delete(key); if (attached) { completedNativeDismissals.add(key); - return { ...current, completedNativeDismissals }; + return { ...current, completedNativeDismissals, nativeCompletingPops }; } // A delayed callback needs immediate cleanup: the router already // removed the route, so no further router update will follow. @@ -104,11 +112,13 @@ export function V5CardStackView(props: V5StackViewProps) { return { ...current, completedNativeDismissals, + nativeCompletingPops, routes: current.routes.filter((route) => route.key !== key), descriptors, }; }); - const action = nativeWorkspacePopAction(state, key); + if (nativePopSource.current === key) nativePopSource.current = null; + const action = nativeStackPopAction(state, key); if (action) props.navigation.dispatch(action); }, [props.navigation, setScreens], @@ -140,7 +150,9 @@ export function V5CardStackView(props: V5StackViewProps) { + onDidAppear={() => { + const source = nativePopSource.current; + if (source === route.key) { + // A cancelled swipe restores its source without changing router history. + nativePopSource.current = null; + } else if (source) { + const state = props.navigation.getState(); + const action = nativeStackPopAction(state, source, route.key); + if (action) { + const sourceIndex = state.routes.findIndex((current) => current.key === source); + // UIKit owns this pop. Keep these children attached until its + // dismissal callback, so the early router update cannot enqueue a second pop. + const poppedKeys = state.routes + .slice(sourceIndex - action.payload.count + 1, sourceIndex + 1) + .map((popped) => popped.key); + setScreens((current) => ({ + ...current, + nativeCompletingPops: new Set([ + ...current.nativeCompletingPops, + ...poppedKeys, + ]), + })); + nativePopSource.current = null; + props.navigation.dispatch(action); + } + } props.navigation.emit({ type: "transitionEnd", target: route.key, data: { closing: false }, - }) - } - onWillDisappear={() => + }); + }} + onWillDisappear={() => { + const state = props.navigation.getState(); + // JS pushes/pops already changed history. Only native Back leaves + // the disappearing card at the router's active index. + if (state.index > 0 && state.routes[state.index]?.key === route.key) { + nativePopSource.current = route.key; + } props.navigation.emit({ type: "transitionStart", target: route.key, data: { closing: true }, - }) - } + }); + }} onDidDisappear={() => props.navigation.emit({ type: "transitionEnd", diff --git a/apps/mobile/src/native/workspace-stack-projection.test.ts b/apps/mobile/src/native/workspace-stack-projection.test.ts index 1f5c00c65551..6e9585586866 100644 --- a/apps/mobile/src/native/workspace-stack-projection.test.ts +++ b/apps/mobile/src/native/workspace-stack-projection.test.ts @@ -3,6 +3,7 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati import { describe, expect, it } from "vite-plus/test"; import { + nativeStackPopAction, nativeWorkspacePopAction, nativeWorkspacePopCount, projectWorkspaceStack, @@ -128,6 +129,63 @@ describe("native workspace dismissal", () => { }); }); +describe("native card pop handoff", () => { + const routerOptions = { + routeNames: history([home]).routeNames, + routeParamList: {}, + routeGetIdList: {}, + }; + it("keeps a newly opened screen when an earlier swipe dismissal arrives late", () => { + const state = history([home, thread, files, settings]); + const next = StackRouter({}).getStateForAction( + state, + nativeStackPopAction(state, files.key)!, + routerOptions, + ); + expect(next?.routes).toEqual([home, thread, settings]); + expect(next?.index).toBe(2); + if (!next || next.stale !== false) throw new Error("Expected a rehydrated router history"); + expect(nativeStackPopAction(next, files.key)).toBeNull(); + }); + it("acknowledges a native pop to an earlier page without consuming a newer push", () => { + const state = history([home, thread, files, settings]); + const next = StackRouter({}).getStateForAction( + state, + nativeStackPopAction(state, files.key, home.key)!, + routerOptions, + ); + expect(next?.routes).toEqual([home, settings]); + expect(next?.index).toBe(1); + }); + it("allows reopening the same page after its destination finishes appearing", () => { + const state = history([home, thread, files]); + const router = StackRouter({}); + const popped = router.getStateForAction( + state, + nativeStackPopAction(state, files.key, thread.key)!, + routerOptions, + ); + if (!popped || popped.stale !== false) throw new Error("Expected a rehydrated router history"); + const reopened = router.getStateForAction( + popped, + { type: "NAVIGATE", payload: { name: files.name, params: files.params } }, + routerOptions, + ); + expect(reopened?.routes.map((route) => route.name)).toEqual(["Home", "Thread", "ThreadFiles"]); + expect(reopened?.routes.at(-1)?.key).not.toBe(files.key); + if (!reopened || reopened.stale !== false) + throw new Error("Expected a rehydrated router history"); + expect(nativeStackPopAction(reopened, files.key)).toBeNull(); + }); + it("leaves history intact when a swipe is cancelled or its source is already gone", () => { + const state = history([home, thread, files]); + expect(nativeStackPopAction(state, files.key, files.key)).toBeNull(); + expect(nativeStackPopAction(state, files.key, settings.key)).toBeNull(); + expect(nativeStackPopAction(state, home.key)).toBeNull(); + expect(nativeStackPopAction({ ...state, index: 1 }, files.key)).toBeNull(); + }); +}); + describe("v5 stack handoff", () => { it("releases a completed native pop when the router acknowledges it", () => { const completed = new Set([files.key]); diff --git a/apps/mobile/src/native/workspace-stack-projection.ts b/apps/mobile/src/native/workspace-stack-projection.ts index 79e66290fe6f..e3e716c8c1c2 100644 --- a/apps/mobile/src/native/workspace-stack-projection.ts +++ b/apps/mobile/src/native/workspace-stack-projection.ts @@ -44,6 +44,33 @@ export function nativeWorkspacePopAction( : null; } +/** A native card pop must preserve pushes made before its completion event reaches JS. */ +export function nativeStackPopAction( + state: Pick, + dismissedKey: string, + destinationKey?: string, +) { + const sourceIndex = state.routes.findIndex((route) => route.key === dismissedKey); + const destinationIndex = + destinationKey === undefined + ? sourceIndex - 1 + : state.routes.findIndex((route) => route.key === destinationKey); + if ( + sourceIndex <= 0 || + sourceIndex > state.index || + destinationIndex < 0 || + destinationIndex >= sourceIndex + ) { + return null; + } + return { + type: "POP" as const, + payload: { count: sourceIndex - destinationIndex }, + source: dismissedKey, + target: state.key, + }; +} + /** Group pushes with the modal that owns their native stack. */ export function partitionStackPresentations( routes: readonly T[],