Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions apps/mobile/src/native/createV5StackNavigator.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string | null>(null);
const [screens, setScreens] = useState({
completedNativeDismissals: new Set<string>(),
nativeCompletingPops: new Set<string>(),
routes: props.state.routes,
descriptors: props.descriptors,
observedRoutes: props.state.routes,
Expand All @@ -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)),
),
Expand All @@ -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.
Expand All @@ -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],
Expand Down Expand Up @@ -140,7 +150,9 @@ export function V5CardStackView(props: V5StackViewProps) {
<Stack.Screen
key={route.key}
screenKey={route.key}
activityMode={attached ? "attached" : "detached"}
activityMode={
attached || screens.nativeCompletingPops.has(route.key) ? "attached" : "detached"
}
preventNativeDismiss={
preventedRoutes[route.key]?.preventRemove ||
descriptor.options.gestureEnabled === false
Expand All @@ -157,20 +169,51 @@ export function V5CardStackView(props: V5StackViewProps) {
data: { closing: false },
})
}
onDidAppear={() =>
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",
Expand Down
58 changes: 58 additions & 0 deletions apps/mobile/src/native/workspace-stack-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/native/workspace-stack-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavigationState, "key" | "index" | "routes">,
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<T>(
routes: readonly T[],
Expand Down
Loading