1import * as NodeModule from "node:module";
2import type { ParamListBase, StackNavigationState } from "@react-navigation/native";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 nativeStackPopAction,
7 nativeWorkspacePopAction,
8 nativeWorkspacePopCount,
9 projectWorkspaceStack,
10 partitionStackPresentations,
11 reconcileStackScreens,
12} from "./workspace-stack-projection";
13
14// Load the same router as native navigation without importing React Native into Node.
15const requireNavigation = NodeModule.createRequire(
16 NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"),
17);
18const { StackRouter } = requireNavigation("@react-navigation/routers") as {
19 StackRouter: typeof import("@react-navigation/native").StackRouter;
20};
21
22const home = { key: "home", name: "Home" };
23const thread = { key: "thread", name: "Thread", params: { threadId: "draft-thread" } };
24const files = { key: "files", name: "ThreadFiles", params: thread.params };
25const settings = { key: "settings", name: "SettingsSheet" };
26const legal = { key: "legal", name: "SettingsLegal" };
27
28function history(
29 routes: StackNavigationState<ParamListBase>["routes"],
30): StackNavigationState<ParamListBase> {
31 return {
32 key: "router",
33 type: "stack",
34 stale: false,
35 index: routes.length - 1,
36 routeNames: ["Home", "Thread", "ThreadFiles", "SettingsSheet", "SettingsLegal"],
37 routes,
38 preloadedRoutes: [],
39 };
40}
41
42describe("workspace router projection", () => {
43 it("keeps workspace flows beside Home and restores them after a modal closes", () => {
44 const draft = { key: "draft", name: "NewTaskSheet" };
45 const modal = { key: "connect", name: "ConnectOnboarding" };
46 const routes = [home, thread, draft, settings, legal];
47 const isOverlay = (route: { name: string }) => route.name === "ConnectOnboarding";
48 expect(projectWorkspaceStack(history([...routes, modal]), isOverlay)).toEqual({
49 primary: home,
50 detail: [thread, draft, settings, legal],
51 overlays: [modal],
52 });
53 expect(projectWorkspaceStack(history(routes), isOverlay)).toEqual({
54 primary: home,
55 detail: [thread, draft, settings, legal],
56 overlays: [],
57 });
58 });
59
60 it("keeps the thread and file history in the detail column when a modal is opened", () => {
61 const state = history([home, thread, files, settings, legal]);
62 const projection = projectWorkspaceStack(state, (route) => route.name === "SettingsSheet");
63 expect(projection).toEqual({
64 primary: home,
65 detail: [thread, files],
66 overlays: [settings, legal],
67 });
68 expect(projection.detail[0]).toBe(thread);
69 expect(state.routes).toEqual([home, thread, files, settings, legal]);
70 });
71
72 it("retains a cold-linked detail route without changing the router history", () => {
73 const state = history([thread, files]);
74 expect(projectWorkspaceStack(state, () => false)).toEqual({
75 primary: undefined,
76 detail: [thread, files],
77 overlays: [],
78 });
79 expect(state.routes[0]).toBe(thread);
80 });
81
82 it("restores the empty detail column after Back reaches the thread list", () => {
83 expect(projectWorkspaceStack(history([home]), () => false)).toEqual({
84 primary: home,
85 detail: [],
86 overlays: [],
87 });
88 });
89});
90
91describe("native workspace dismissal", () => {
92 it("dismisses a sheet and its pushed pages without removing the underlying draft", () => {
93 const state = history([home, thread, settings, legal]);
94 const action = nativeWorkspacePopAction(state, settings.key)!;
95 const next = StackRouter({}).getStateForAction(state, action, {
96 routeNames: state.routeNames,
97 routeParamList: {},
98 routeGetIdList: {},
99 });
100 expect(next?.routes).toEqual([home, thread]);
101 expect(next?.routes[1]).toBe(thread);
102 expect(next?.index).toBe(1);
103 });
104
105 it("does not dismiss retained sheets beyond the active index", () => {
106 const state = { ...history([home, thread, settings, legal]), index: 1 };
107 expect(nativeWorkspacePopAction(state, settings.key)).toBeNull();
108 expect(nativeWorkspacePopAction(state, legal.key)).toBeNull();
109 });
110 it("pops a native dismissed file while keeping the conversation and its draft mounted", () => {
111 expect(nativeWorkspacePopCount(history([home, thread, files]), files.key)).toBe(1);
112 });
113
114 it("removes the descendants when UIKit dismisses their parent conversation", () => {
115 expect(nativeWorkspacePopCount(history([home, thread, files]), thread.key)).toBe(2);
116 });
117
118 it("ignores delayed callbacks from JS removal and replacement", () => {
119 expect(nativeWorkspacePopCount(history([home, thread]), files.key)).toBe(0);
120 expect(
121 nativeWorkspacePopCount(history([home, { ...thread, key: "replacement" }]), thread.key),
122 ).toBe(0);
123 });
124
125 it("never removes the root or a route ahead of the active index", () => {
126 const state = history([home, thread, files]);
127 expect(nativeWorkspacePopCount(state, home.key)).toBe(0);
128 expect(nativeWorkspacePopCount({ ...state, index: 1 }, files.key)).toBe(0);
129 });
130});
131
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
189describe("v5 stack handoff", () => {
190 it("releases a completed native pop when the router acknowledges it", () => {
191 const completed = new Set([files.key]);
192 expect(reconcileStackScreens([home, thread, files], [home, thread, files], completed)).toEqual([
193 home,
194 thread,
195 files,
196 ]);
197 expect(reconcileStackScreens([home, thread, files], [home, thread], completed)).toEqual([
198 home,
199 thread,
200 ]);
201 });
202 it("still retains an unfinished JS pop beside a completed native pop", () => {
203 expect(reconcileStackScreens([home, thread, files], [home], new Set([files.key]))).toEqual([
204 home,
205 thread,
206 ]);
207 });
208 it("retains removed native screens through a pop followed immediately by a push", () => {
209 const popped = reconcileStackScreens([home, thread, files], [home, thread]);
210 const next = { ...files, key: "new-files" };
211 const screens = reconcileStackScreens(popped, [home, thread, next]);
212 expect(screens.map((route) => route.key)).toEqual(["home", "thread", "files", "new-files"]);
213 expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([
214 home,
215 thread,
216 next,
217 ]);
218 expect(screens[2]).toBe(files);
219 });
220 it("keeps the root and outgoing settings page in place until the JS pop finishes", () => {
221 const environments = { ...files, key: "environments", name: "SettingsEnvironments" };
222 const popped = reconcileStackScreens([settings, environments], [settings]);
223 expect(popped).toEqual([settings, environments]);
224 expect(reconcileStackScreens(popped, [settings])).toEqual(popped);
225 const next = { ...environments, key: "next-environments" };
226 expect(reconcileStackScreens(popped, [settings, next])).toEqual([settings, environments, next]);
227 expect(reconcileStackScreens(popped, [settings], new Set([environments.key]))).toEqual([
228 settings,
229 ]);
230 });
231 it("retains a multi-page pop in order without changing the active router order", () => {
232 const replacement = { ...thread, key: "replacement" };
233 const screens = reconcileStackScreens([home, thread, files], [home, replacement]);
234 expect(screens).toEqual([home, thread, files, replacement]);
235 expect(screens.filter((route) => route === home || route === replacement)).toEqual([
236 home,
237 replacement,
238 ]);
239 });
240 it("updates params without retaining duplicate copies of the same screen", () => {
241 const updated = { ...thread, params: { threadId: "another-thread" } };
242 expect(reconcileStackScreens([home, thread], [home, updated])).toEqual([home, updated]);
243 });
244 it("keeps card pushes inside the modal they belong to", () => {
245 const secondModal = { ...settings, key: "another-settings" };
246 expect(
247 partitionStackPresentations(
248 [home, thread, settings, legal, secondModal],
249 (route) => route.name === "SettingsSheet",
250 ),
251 ).toEqual([[home, thread], [settings, legal], [secondModal]]);
252 });
253});