1import type {
2 NavigationState,
3 ParamListBase,
4 StackNavigationState,
5} from "@react-navigation/native";
6
7type Route = StackNavigationState<ParamListBase>["routes"][number];
8
9/** Project one router history into native columns and a modal presentation stack. */
10export function projectWorkspaceStack(
11 state: StackNavigationState<ParamListBase>,
12 isOverlay: (route: Route) => boolean,
13) {
14 const firstOverlay = state.routes.findIndex(isOverlay);
15 const workspaceRoutes = state.routes.slice(0, firstOverlay < 0 ? undefined : firstOverlay);
16 const primary = workspaceRoutes.find((route) => route.name === "Home");
17 const detail = workspaceRoutes.filter((route) => route.name !== "Home");
18 const overlays = firstOverlay < 0 ? [] : state.routes.slice(firstOverlay);
19 return { primary, detail, overlays };
20}
21
22/** Native callbacks can arrive after a JS pop or replace; those must not pop the next screen. */
23export function nativeWorkspacePopCount(
24 state: Pick<NavigationState, "index" | "routes">,
25 dismissedKey: string,
26): number {
27 const index = state.routes.findIndex((route) => route.key === dismissedKey);
28 return index <= 0 || index > state.index ? 0 : state.index - index + 1;
29}
30
31/** POP counts backwards from its source, so dismiss a group from the active descendant. */
32export function nativeWorkspacePopAction(
33 state: Pick<NavigationState, "key" | "index" | "routes">,
34 dismissedKey: string,
35) {
36 const count = nativeWorkspacePopCount(state, dismissedKey);
37 return count
38 ? {
39 type: "POP" as const,
40 payload: { count },
41 source: state.routes[state.index]!.key,
42 target: state.key,
43 }
44 : null;
45}
46
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
74/** Group pushes with the modal that owns their native stack. */
75export function partitionStackPresentations<T>(
76 routes: readonly T[],
77 isModal: (route: T) => boolean,
78): T[][] {
79 const groups: T[][] = [];
80 for (const route of routes) {
81 if (groups.length === 0 || isModal(route)) groups.push([]);
82 groups.at(-1)!.push(route);
83 }
84 return groups;
85}
86
87/** Keep outgoing screens in place until dismissal; Fabric moves enqueue native pop/push operations. */
88export function reconcileStackScreens<T extends { readonly key: string }>(
89 previous: readonly T[],
90 current: readonly T[],
91 completedNativeDismissals: ReadonlySet<string> = new Set(),
92): T[] {
93 const active = new Set(current.map((route) => route.key));
94 const screens = [...current];
95 previous.forEach((route, index) => {
96 if (!active.has(route.key) && !completedNativeDismissals.has(route.key)) {
97 screens.splice(Math.min(index, screens.length), 0, route);
98 }
99 });
100 return screens;
101}