1import * as NodeModule from "node:module";
2import type {
3 StackNavigationState,
4 StackRouter as StackRouterType,
5 StackActions as StackActionsType,
6} from "@react-navigation/native";
7import { describe, expect, it, vi } from "vite-plus/test";
8
9function loadRouters() {
10 const require = NodeModule.createRequire(import.meta.url);
11 const nativePackage = require.resolve("@react-navigation/native/package.json");
12 const requireFromNative = NodeModule.createRequire(nativePackage);
13 const corePackage = requireFromNative.resolve("@react-navigation/core/package.json");
14 const requireFromCore = NodeModule.createRequire(corePackage);
15 return requireFromCore("@react-navigation/routers") as {
16 readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
17 readonly StackActions: typeof StackActionsType;
18 readonly StackRouter: typeof StackRouterType;
19 };
20}
21
22vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } }));
23
24vi.mock("@react-navigation/native", () => {
25 const { CommonActions, StackActions } = loadRouters();
26 return { CommonActions, StackActions };
27});
28
29import { createHomeThreadNavigationAction } from "./home-thread-navigation";
30
31const { StackActions, StackRouter } = loadRouters();
32const routeNames = ["Home", "Thread"];
33const routeParamList = {
34 Home: undefined,
35 Thread: undefined,
36};
37const router = StackRouter({});
38const routerOptions = {
39 routeNames,
40 routeParamList,
41 routeGetIdList: {},
42};
43
44type ThreadSelection = Parameters<typeof createHomeThreadNavigationAction>[0]["thread"];
45
46function thread(id: string): ThreadSelection {
47 return {
48 environmentId: "environment-1",
49 id,
50 } as ThreadSelection;
51}
52
53function initialState() {
54 return router.getInitialState(routerOptions);
55}
56
57function apply(
58 state: StackNavigationState<Record<string, object | undefined>>,
59 action: Parameters<typeof router.getStateForAction>[1],
60) {
61 const nextState = router.getStateForAction(state, action, routerOptions);
62 expect(nextState).not.toBeNull();
63 return nextState as StackNavigationState<Record<string, object | undefined>>;
64}
65
66function selectThread(
67 state: ReturnType<typeof initialState>,
68 selectedThread: ThreadSelection,
69 dismissingRouteKey: string | null = null,
70) {
71 return apply(
72 state,
73 createHomeThreadNavigationAction({
74 state,
75 dismissingRouteKey,
76 thread: selectedThread,
77 }),
78 );
79}
80
81function dismissRoute(state: ReturnType<typeof initialState>, routeKey: string) {
82 return apply(state, {
83 ...StackActions.pop(),
84 source: routeKey,
85 target: state.key,
86 });
87}
88
89describe("createHomeThreadNavigationAction", () => {
90 it("coalesces ordinary repeat selections onto the current thread route", () => {
91 const firstState = selectThread(initialState(), thread("thread-a"));
92 const threadRouteKey = firstState.routes[firstState.index]?.key;
93 const secondAction = createHomeThreadNavigationAction({
94 state: firstState,
95 dismissingRouteKey: null,
96 thread: thread("thread-b"),
97 });
98
99 const secondState = apply(firstState, secondAction);
100 expect(secondState.routes).toHaveLength(2);
101 expect(secondState.routes[secondState.index]).toMatchObject({
102 key: threadRouteKey,
103 name: "Thread",
104 params: { environmentId: "environment-1", threadId: "thread-b" },
105 });
106 });
107
108 it("keeps an overlap selection after native dismisses the outgoing route", () => {
109 const outgoingState = selectThread(initialState(), thread("thread-a"));
110 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
111 expect(outgoingRouteKey).toBeDefined();
112
113 const overlapAction = createHomeThreadNavigationAction({
114 state: outgoingState,
115 dismissingRouteKey: outgoingRouteKey ?? null,
116 thread: thread("thread-b"),
117 });
118 const overlapState = apply(outgoingState, overlapAction);
119 const incomingRoute = overlapState.routes[overlapState.index];
120 expect(incomingRoute?.key).not.toBe(outgoingRouteKey);
121
122 const dismissedState = dismissRoute(overlapState, outgoingRouteKey!);
123 expect(dismissedState.routes).toHaveLength(2);
124 expect(dismissedState.routes[dismissedState.index]).toMatchObject({
125 key: incomingRoute?.key,
126 name: "Thread",
127 params: { environmentId: "environment-1", threadId: "thread-b" },
128 });
129 });
130
131 it("uses a fresh key for the same thread selected during dismissal", () => {
132 const outgoingState = selectThread(initialState(), thread("thread-a"));
133 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
134 expect(outgoingRouteKey).toBeDefined();
135
136 const overlapState = selectThread(outgoingState, thread("thread-a"), outgoingRouteKey ?? null);
137 const incomingRouteKey = overlapState.routes[overlapState.index]?.key;
138 expect(incomingRouteKey).not.toBe(outgoingRouteKey);
139
140 const dismissedState = dismissRoute(overlapState, outgoingRouteKey!);
141 expect(dismissedState.routes[dismissedState.index]).toMatchObject({
142 key: incomingRouteKey,
143 params: { environmentId: "environment-1", threadId: "thread-a" },
144 });
145 });
146
147 it("coalesces a second overlap selection onto the fresh incoming route", () => {
148 const outgoingState = selectThread(initialState(), thread("thread-a"));
149 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
150 expect(outgoingRouteKey).toBeDefined();
151
152 const firstOverlapState = selectThread(
153 outgoingState,
154 thread("thread-b"),
155 outgoingRouteKey ?? null,
156 );
157 const incomingRouteKey = firstOverlapState.routes[firstOverlapState.index]?.key;
158 const secondOverlapAction = createHomeThreadNavigationAction({
159 state: firstOverlapState,
160 dismissingRouteKey: outgoingRouteKey ?? null,
161 thread: thread("thread-c"),
162 });
163
164 const secondOverlapState = apply(firstOverlapState, secondOverlapAction);
165 expect(secondOverlapState.routes).toHaveLength(3);
166 expect(secondOverlapState.routes[secondOverlapState.index]).toMatchObject({
167 key: incomingRouteKey,
168 params: { environmentId: "environment-1", threadId: "thread-c" },
169 });
170
171 const dismissedState = dismissRoute(secondOverlapState, outgoingRouteKey!);
172 expect(dismissedState.routes).toHaveLength(2);
173 expect(dismissedState.routes[dismissedState.index]).toMatchObject({
174 key: incomingRouteKey,
175 params: { environmentId: "environment-1", threadId: "thread-c" },
176 });
177 });
178
179 it("uses ordinary navigation when native pops before the selection", () => {
180 const outgoingState = selectThread(initialState(), thread("thread-a"));
181 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
182 expect(outgoingRouteKey).toBeDefined();
183
184 const poppedState = dismissRoute(outgoingState, outgoingRouteKey!);
185 const action = createHomeThreadNavigationAction({
186 state: poppedState,
187 dismissingRouteKey: outgoingRouteKey ?? null,
188 thread: thread("thread-b"),
189 });
190
191 const selectedState = apply(poppedState, action);
192 expect(selectedState.routes).toHaveLength(2);
193 expect(selectedState.routes[selectedState.index]).toMatchObject({
194 name: "Thread",
195 params: { environmentId: "environment-1", threadId: "thread-b" },
196 });
197 });
198});