@rn-tools/navigation 2.2.6 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stack.tsx CHANGED
@@ -1,328 +1,99 @@
1
1
  import * as React from "react";
2
+ import { RenderTreeNode, useRenderNode } from "@rn-tools/core";
2
3
  import {
3
- BackHandler,
4
- Platform,
5
- StyleSheet,
6
- View,
7
- type ImageProps,
8
- type ViewProps,
9
- type ViewStyle,
10
- } from "react-native";
11
- import {
12
- ScreenStackProps as RNScreenStackProps,
13
- Screen as RNScreen,
14
- ScreenProps as RNScreenProps,
15
- ScreenStackHeaderConfig as RNScreenStackHeaderConfig,
16
- ScreenStackHeaderLeftView as RNScreenStackHeaderLeftView,
17
- ScreenStackHeaderRightView as RNScreenStackHeaderRightView,
18
- ScreenStackHeaderCenterView as RNScreenStackHeaderCenterView,
19
- ScreenStackHeaderConfigProps as RNScreenStackHeaderConfigProps,
20
- ScreenStackHeaderBackButtonImage as RNScreenStackHeaderBackButtonImage,
21
- type ScreenProps,
22
- } from "react-native-screens";
23
- import ScreenStackNativeComponent from "react-native-screens/src/fabric/ScreenStackNativeComponent";
24
-
25
- import {
26
- ActiveContext,
27
- DepthContext,
28
- TabIdContext,
29
- TabScreenIndexContext,
30
- } from "./contexts";
31
- import { DEFAULT_SLOT_NAME } from "./navigation-reducer";
32
- import { useNavigationDispatch, useNavigationState } from "./navigation-store";
33
- import type { StackItem } from "./types";
34
- import { generateStackId, useSafeAreaInsetsSafe } from "./utils";
35
-
36
- let StackIdContext = React.createContext<string>("");
37
- let ScreenIdContext = React.createContext<string>("");
38
-
39
- // Component returned from `react-native-screens` references `react-navigation` data structures in recent updates
40
- // This is a workaround to make it work with our custom navigation
41
- let RNScreenStack = React.memo(function RNScreenStack(
42
- props: RNScreenStackProps
43
- ) {
44
- let { children, gestureDetectorBridge, ...rest } = props;
45
- let ref = React.useRef(null);
46
-
47
- React.useEffect(() => {
48
- if (gestureDetectorBridge) {
49
- gestureDetectorBridge.current.stackUseEffectCallback(ref);
50
- }
51
- });
52
-
53
- return (
54
- <ScreenStackNativeComponent {...rest} ref={ref}>
55
- {children}
56
- </ScreenStackNativeComponent>
57
- );
58
- });
59
-
60
- export type StackRootProps = {
61
- children: React.ReactNode;
62
- id?: string;
4
+ useStackScreens,
5
+ useNavigation,
6
+ type PushScreenOptions,
7
+ } from "./navigation-client";
8
+
9
+ // TODO - replace with custom implementation
10
+ import * as RNScreens from "react-native-screens";
11
+ import { StyleSheet } from "react-native";
12
+
13
+ export type StackHandle = {
14
+ pushScreen: (element: React.ReactElement, options?: PushScreenOptions) => void;
15
+ popScreen: () => void;
63
16
  };
64
17
 
65
- let useStackInternal = (stackId = "") => {
66
- let stack: StackItem | undefined = useNavigationState(
67
- (state) => state.stacks.lookup[stackId]
68
- );
69
- return stack;
70
- };
71
-
72
- function StackRoot({ children, id }: StackRootProps) {
73
- let idRef = React.useRef(id || generateStackId());
74
- let stack = useStackInternal(idRef.current);
75
-
76
- let isActive = React.useContext(ActiveContext);
77
- let parentDepth = React.useContext(DepthContext);
78
- let parentStackId = React.useContext(StackIdContext);
79
-
80
- let depth = parentDepth + 1;
81
- let stackId = idRef.current;
82
- let parentTabId = React.useContext(TabIdContext);
83
- let tabIndex = React.useContext(TabScreenIndexContext);
84
-
85
- let dispatch = useNavigationDispatch();
86
-
87
- React.useLayoutEffect(() => {
88
- if (!stack) {
89
- dispatch({ type: "CREATE_STACK_INSTANCE", stackId: idRef.current });
90
- }
91
- }, [stack, dispatch]);
92
-
93
- React.useEffect(() => {
94
- if (stack != null) {
95
- dispatch({
96
- type: "REGISTER_STACK",
97
- depth,
98
- isActive,
99
- stackId: stack.id,
100
- parentStackId,
101
- parentTabId,
102
- tabIndex,
103
- });
104
- }
105
- }, [stack, depth, isActive, parentStackId, parentTabId, tabIndex, dispatch]);
106
-
107
- React.useEffect(() => {
108
- return () => {
109
- if (stackId != null) {
110
- dispatch({ type: "UNREGISTER_STACK", stackId });
111
- }
112
- };
113
- }, [stackId, dispatch]);
114
-
115
- if (!stack) {
116
- return null;
117
- }
118
-
119
- return (
120
- <StackIdContext.Provider value={stack.id}>
121
- <DepthContext.Provider value={depth}>
122
- <ActiveContext.Provider value={isActive}>
123
- {children}
124
- </ActiveContext.Provider>
125
- </DepthContext.Provider>
126
- </StackIdContext.Provider>
127
- );
128
- }
129
-
130
- export type StackScreensProps = RNScreenStackProps;
131
-
132
- function StackScreens({ style: styleProp, ...props }: StackScreensProps) {
133
- let style = React.useMemo(
134
- () => styleProp || StyleSheet.absoluteFill,
135
- [styleProp]
136
- );
137
-
138
- return <RNScreenStack {...props} style={style} />;
139
- }
140
-
141
- let defaultScreenStyle: ViewStyle = {
142
- ...StyleSheet.absoluteFillObject,
143
- backgroundColor: "white",
18
+ export type StackProps = {
19
+ id?: string;
20
+ active?: boolean;
21
+ rootScreen?: React.ReactElement;
22
+ children?: React.ReactNode;
144
23
  };
145
24
 
146
- export type StackScreenProps = RNScreenProps & {
147
- header?: React.ReactElement<StackScreenHeaderProps>
148
- }
149
-
150
- let HeaderHeightContext = React.createContext<number>(0);
151
-
152
- let StackScreen = React.memo(function StackScreen({
153
- children,
154
- style: styleProp,
155
- gestureEnabled = true,
156
- onDismissed: onDismissedProp,
157
- onHeaderHeightChange: onHeaderHeightChangeProp,
158
- header,
159
- ...props
160
- }: StackScreenProps) {
161
- let stackId = React.useContext(StackIdContext);
162
- let screenId = React.useContext(ScreenIdContext);
163
- let stack = useStackInternal(stackId);
164
-
165
- let dispatch = useNavigationDispatch();
166
-
167
- let isActive = React.useContext(ActiveContext);
168
-
169
- let onDismissed: RNScreenProps["onDismissed"] = React.useCallback(
170
- (e) => {
171
- dispatch({ type: "POP_SCREEN_BY_KEY", key: screenId });
172
- onDismissedProp?.(e);
173
- },
174
- [onDismissedProp, dispatch, screenId]
175
- );
176
-
177
- React.useEffect(() => {
178
- function backHandler() {
179
- if (gestureEnabled && isActive && stack?.screens.length > 0) {
180
- dispatch({ type: "POP_SCREEN_BY_KEY", key: screenId });
181
- return true;
182
- }
183
-
184
- return false;
185
- }
186
-
187
- BackHandler.addEventListener("hardwareBackPress", backHandler);
188
-
189
- return () => {
190
- BackHandler.removeEventListener("hardwareBackPress", backHandler);
191
- };
192
- }, [gestureEnabled, stack, screenId, isActive, dispatch]);
193
-
194
- let parentHeaderHeight = React.useContext(HeaderHeightContext);
195
- let [headerHeight, setHeaderHeight] = React.useState(parentHeaderHeight);
196
-
197
- let onHeaderHeightChange: ScreenProps["onHeaderHeightChange"] =
198
- React.useCallback(
199
- (e) => {
200
- Platform.OS === "ios" &&
201
- e.nativeEvent.headerHeight > 0 &&
202
- setHeaderHeight(e.nativeEvent.headerHeight);
203
- onHeaderHeightChangeProp?.(e);
204
- },
205
- [onHeaderHeightChangeProp]
206
- );
207
-
208
- let style = React.useMemo(
209
- () => [
210
- defaultScreenStyle,
211
- { paddingTop: headerHeight || parentHeaderHeight },
212
- styleProp,
213
- ],
214
- [styleProp, headerHeight, parentHeaderHeight]
215
- );
216
-
25
+ const StackRoot = React.memo(function StackRoot(props: StackProps) {
217
26
  return (
218
- <HeaderHeightContext.Provider value={headerHeight}>
219
- {/* @ts-expect-error - Ref typings in RNScreens */}
220
- <RNScreen
221
- {...props}
222
- style={style}
223
- activityState={isActive ? 2 : 0}
224
- gestureEnabled={gestureEnabled}
225
- onDismissed={onDismissed}
226
- onHeaderHeightChange={onHeaderHeightChange}
227
- >
228
- {header}
229
- {children}
230
- </RNScreen>
231
- </HeaderHeightContext.Provider>
27
+ <RenderTreeNode type="stack" id={props.id} active={props.active}>
28
+ <RNScreens.ScreenStack style={StyleSheet.absoluteFill}>
29
+ {props.rootScreen && <StackScreen>{props.rootScreen}</StackScreen>}
30
+ {props.children}
31
+ </RNScreens.ScreenStack>
32
+ </RenderTreeNode>
232
33
  );
233
34
  });
234
35
 
235
- let useStackScreens = (stackId = "", slotName: string) => {
236
- return useNavigationState((state) => {
237
- let stack = state.stacks.lookup[stackId];
238
- return (
239
- stack?.screens
240
- .map((screenId) => state.screens.lookup[screenId])
241
- .filter((s) => s && s.slotName === slotName) ?? []
242
- );
243
- });
36
+ export type StackScreenProps = {
37
+ id?: string;
38
+ active?: boolean;
39
+ children: React.ReactNode;
244
40
  };
245
41
 
246
- let StackSlot = React.memo(function StackSlot({
247
- slotName = DEFAULT_SLOT_NAME,
248
- }: {
249
- slotName?: string;
250
- }) {
251
- let stackId = React.useContext(StackIdContext);
252
- let screens = useStackScreens(stackId, slotName);
253
-
42
+ const StackScreen = React.memo(function StackScreen(props: StackScreenProps) {
254
43
  return (
255
- <>
256
- {screens.map((screen) => {
257
- return (
258
- <ScreenIdContext.Provider value={screen.id} key={screen.id}>
259
- {screen.element}
260
- </ScreenIdContext.Provider>
261
- );
262
- })}
263
- </>
44
+ <RNScreens.Screen style={StyleSheet.absoluteFill}>
45
+ <RenderTreeNode type="screen" id={props.id} active={props.active}>
46
+ {props.children}
47
+ </RenderTreeNode>
48
+ </RNScreens.Screen>
264
49
  );
265
50
  });
266
51
 
267
- export type StackScreenHeaderProps = RNScreenStackHeaderConfigProps;
268
-
269
- let StackScreenHeader = React.memo(function StackScreenHeader({
270
- ...props
271
- }: StackScreenHeaderProps) {
272
- return <RNScreenStackHeaderConfig {...props} />;
273
- });
274
-
275
- let StackScreenHeaderLeft = React.memo(function StackScreenHeaderLeft({
276
- ...props
277
- }: ViewProps) {
278
- return <RNScreenStackHeaderLeftView {...props} />;
279
- });
280
-
281
- let StackScreenHeaderCenter = React.memo(function StackScreenHeaderCenter({
282
- ...props
283
- }: ViewProps) {
284
- return <RNScreenStackHeaderCenterView {...props} />;
285
- });
286
-
287
- let StackScreenHeaderRight = React.memo(function StackScreenHeaderRight({
288
- ...props
289
- }: ViewProps) {
290
- return <RNScreenStackHeaderRightView {...props} />;
291
- });
292
-
293
- let ScreenStackHeaderBackButtonImage = React.memo(
294
- function ScreenStackHeaderBackButtonImage(props: ImageProps) {
295
- return <RNScreenStackHeaderBackButtonImage {...props} />;
296
- }
297
- );
298
-
299
- export type StackNavigatorProps = Omit<StackRootProps, "children"> & {
300
- rootScreen: React.ReactElement<unknown>;
301
- };
52
+ const StackSlot = React.memo(function StackSlot() {
53
+ const node = useRenderNode();
54
+ const stackKey = node?.id ?? null;
55
+ const screens = useStackScreens(stackKey);
302
56
 
303
- let StackNavigator = React.memo(function StackNavigator({
304
- rootScreen,
305
- ...rootProps
306
- }: StackNavigatorProps) {
307
57
  return (
308
- <Stack.Root {...rootProps}>
309
- <Stack.Screens>
310
- <Stack.Screen>{rootScreen}</Stack.Screen>
311
- <Stack.Slot />
312
- </Stack.Screens>
313
- </Stack.Root>
58
+ <React.Fragment>
59
+ {screens.map((screen, index, arr) => (
60
+ <StackScreen
61
+ id={screen.options?.id}
62
+ key={screen.options?.id ?? index}
63
+ active={index === arr.length - 1}
64
+ >
65
+ {screen.element}
66
+ </StackScreen>
67
+ ))}
68
+ </React.Fragment>
314
69
  );
315
70
  });
316
71
 
317
- export let Stack = {
318
- Root: StackRoot,
319
- Screens: StackScreens,
320
- Screen: StackScreen,
321
- Header: StackScreenHeader,
322
- HeaderLeft: StackScreenHeaderLeft,
323
- HeaderCenter: StackScreenHeaderCenter,
324
- HeaderRight: StackScreenHeaderRight,
325
- HeaderBackImage: ScreenStackHeaderBackButtonImage,
326
- Slot: StackSlot,
327
- Navigator: StackNavigator,
328
- };
72
+ export const Stack = React.memo(
73
+ React.forwardRef<StackHandle, Omit<StackProps, "children">>(
74
+ function Stack(props, ref) {
75
+ const navigation = useNavigation();
76
+ const node = useRenderNode();
77
+ const stackId = props.id ?? node?.id ?? null;
78
+
79
+ React.useImperativeHandle(ref, () => ({
80
+ pushScreen(element: React.ReactElement, options?: PushScreenOptions) {
81
+ if (stackId) {
82
+ navigation.pushScreen(element, { ...options, stackId });
83
+ }
84
+ },
85
+ popScreen() {
86
+ if (stackId) {
87
+ navigation.popScreen({ stackId });
88
+ }
89
+ },
90
+ }), [stackId, navigation]);
91
+
92
+ return (
93
+ <StackRoot {...props}>
94
+ <StackSlot />
95
+ </StackRoot>
96
+ );
97
+ },
98
+ ),
99
+ );
@@ -0,0 +1,288 @@
1
+ import * as React from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { act, render, waitFor, fireEvent } from "@testing-library/react";
4
+ import { RenderNodeProbe } from "@rn-tools/core/mocks/render-node-probe";
5
+
6
+ import {
7
+ createNavigation,
8
+ type NavigationStateInput,
9
+ } from "./navigation-client";
10
+ import { Navigation } from "./navigation";
11
+ import { Tabs, type TabScreenOptions, type TabsHandle } from "./tabs";
12
+ import { Stack } from "./stack";
13
+
14
+ function makeScreens(count: number): TabScreenOptions[] {
15
+ return Array.from({ length: count }, (_, i) => ({
16
+ id: `tab-${i}`,
17
+ screen: (
18
+ <RenderNodeProbe
19
+ render={(data) => (
20
+ <span>{`tab-${i}:${data.type}:${String(data.active)}`}</span>
21
+ )}
22
+ />
23
+ ),
24
+ tab: ({ isActive, onPress }) => (
25
+ <span data-testid={`tab-btn-${i}`} onClick={onPress}>
26
+ {`tab-btn-${i}:${String(isActive)}`}
27
+ </span>
28
+ ),
29
+ }));
30
+ }
31
+
32
+ function renderWithProviders(
33
+ node: React.ReactNode,
34
+ initialState?: NavigationStateInput,
35
+ ) {
36
+ const navigation = createNavigation(initialState);
37
+ const renderer = render(
38
+ <Navigation navigation={navigation}>{node}</Navigation>,
39
+ );
40
+ return { store: navigation.store, navigation, renderer };
41
+ }
42
+
43
+ describe("Tabs", () => {
44
+ it("renders each screen inside a screen render tree node", () => {
45
+ const screens = makeScreens(2);
46
+ const { renderer } = renderWithProviders(
47
+ <Tabs id="my-tabs" screens={screens} />,
48
+ );
49
+
50
+ expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
51
+ expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
52
+ });
53
+
54
+ it("defaults active tab index to 0", () => {
55
+ const screens = makeScreens(3);
56
+ const { renderer } = renderWithProviders(
57
+ <Tabs id="my-tabs" screens={screens} />,
58
+ );
59
+
60
+ expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
61
+ expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
62
+ expect(renderer.getByText("tab-2:tab-screen:false")).toBeTruthy();
63
+ });
64
+
65
+ it("setActiveTab changes which screen is active", async () => {
66
+ const screens = makeScreens(3);
67
+ const { navigation, renderer } = renderWithProviders(
68
+ <Tabs id="my-tabs" screens={screens} />,
69
+ );
70
+
71
+ act(() => {
72
+ navigation.setActiveTab(2);
73
+ });
74
+
75
+ await waitFor(() => {
76
+ expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
77
+ expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
78
+ expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
79
+ });
80
+ });
81
+
82
+ it("respects the active flag from the tabs container", () => {
83
+ const screens = makeScreens(1);
84
+ const { renderer } = renderWithProviders(
85
+ <Tabs id="my-tabs" active={false} screens={screens} />,
86
+ );
87
+
88
+ expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
89
+ });
90
+
91
+ it("ref.setActiveIndex changes the active tab", async () => {
92
+ const screens = makeScreens(3);
93
+ const ref = React.createRef<TabsHandle>();
94
+ const { renderer } = renderWithProviders(
95
+ <Tabs ref={ref} id="my-tabs" screens={screens} />,
96
+ );
97
+
98
+ expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
99
+
100
+ act(() => {
101
+ ref.current!.setActiveIndex(2);
102
+ });
103
+
104
+ await waitFor(() => {
105
+ expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
106
+ expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
107
+ expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
108
+ });
109
+ });
110
+
111
+ it("supports preloaded activeIndex from the navigation state", () => {
112
+ const screens = makeScreens(3);
113
+ const { renderer } = renderWithProviders(
114
+ <Tabs id="my-tabs" screens={screens} />,
115
+ { tabs: { "my-tabs": { activeIndex: 1 } } },
116
+ );
117
+
118
+ expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
119
+ expect(renderer.getByText("tab-1:tab-screen:true")).toBeTruthy();
120
+ expect(renderer.getByText("tab-2:tab-screen:false")).toBeTruthy();
121
+ });
122
+ });
123
+
124
+ describe("TabBar", () => {
125
+ it("renders tab items with correct isActive state", () => {
126
+ const screens = makeScreens(3);
127
+ const { renderer } = renderWithProviders(
128
+ <Tabs id="my-tabs" screens={screens} />,
129
+ );
130
+
131
+ expect(renderer.getByText("tab-btn-0:true")).toBeTruthy();
132
+ expect(renderer.getByText("tab-btn-1:false")).toBeTruthy();
133
+ expect(renderer.getByText("tab-btn-2:false")).toBeTruthy();
134
+ });
135
+
136
+ it("onPress switches the active tab", async () => {
137
+ const screens = makeScreens(3);
138
+ const { renderer } = renderWithProviders(
139
+ <Tabs id="my-tabs" screens={screens} />,
140
+ );
141
+
142
+ fireEvent.click(renderer.getByTestId("tab-btn-2"));
143
+
144
+ await waitFor(() => {
145
+ expect(renderer.getByText("tab-btn-0:false")).toBeTruthy();
146
+ expect(renderer.getByText("tab-btn-2:true")).toBeTruthy();
147
+ expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
148
+ expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
149
+ });
150
+ });
151
+
152
+ it("renders tabbar at bottom by default", () => {
153
+ const screens = makeScreens(1);
154
+ const { renderer } = renderWithProviders(
155
+ <Tabs id="my-tabs" screens={screens} />,
156
+ );
157
+
158
+ // Screen content should appear before the tab bar in the DOM
159
+ const screenNode = renderer.getByText("tab-0:tab-screen:true");
160
+ const tabNode = renderer.getByText("tab-btn-0:true");
161
+ const order = screenNode.compareDocumentPosition(tabNode);
162
+ // DOCUMENT_POSITION_FOLLOWING = 4
163
+ expect(order & 4).toBe(4);
164
+ });
165
+
166
+ it("renders tabbar at top when tabbarPosition is top", () => {
167
+ const screens = makeScreens(1);
168
+ const { renderer } = renderWithProviders(
169
+ <Tabs id="my-tabs" screens={screens} tabbarPosition="top" />,
170
+ );
171
+
172
+ // Tab bar should appear before screen content in the DOM
173
+ const tabNode = renderer.getByText("tab-btn-0:true");
174
+ const screenNode = renderer.getByText("tab-0:tab-screen:true");
175
+ const order = tabNode.compareDocumentPosition(screenNode);
176
+ // DOCUMENT_POSITION_FOLLOWING = 4
177
+ expect(order & 4).toBe(4);
178
+ });
179
+ });
180
+
181
+ describe("Nested Stack + Tabs", () => {
182
+ it("pushScreen targets the stack inside the active tab", async () => {
183
+ const navigation = createNavigation();
184
+
185
+ const screens: TabScreenOptions[] = [
186
+ {
187
+ id: "tab-a",
188
+ screen: <Stack id="stack-a" rootScreen={<span>stack-a-root</span>} />,
189
+ tab: () => <span>tab-a</span>,
190
+ },
191
+ {
192
+ id: "tab-b",
193
+ screen: <Stack id="stack-b" rootScreen={<span>stack-b-root</span>} />,
194
+ tab: () => <span>tab-b</span>,
195
+ },
196
+ ];
197
+
198
+ const result = render(
199
+ <Navigation navigation={navigation}>
200
+ <Tabs id="my-tabs" screens={screens} />
201
+ </Navigation>,
202
+ );
203
+
204
+ await waitFor(() => {
205
+ expect(result.getByText("stack-a-root")).toBeTruthy();
206
+ });
207
+
208
+ // Tab 0 (stack-a) is active — pushScreen should target stack-a
209
+ act(() => {
210
+ navigation.pushScreen(<span>pushed-to-a</span>);
211
+ });
212
+
213
+ const stateAfterFirst = navigation.store.getState();
214
+ expect(stateAfterFirst.stacks.get("stack-a")).toHaveLength(1);
215
+ expect(stateAfterFirst.stacks.has("stack-b")).toBe(false);
216
+ });
217
+
218
+ it("switching tabs redirects pushScreen to the newly active stack", async () => {
219
+ const navigation = createNavigation();
220
+
221
+ const screens: TabScreenOptions[] = [
222
+ {
223
+ id: "tab-a",
224
+ screen: <Stack id="stack-a" rootScreen={<span>stack-a-root</span>} />,
225
+ tab: () => <span>tab-a</span>,
226
+ },
227
+ {
228
+ id: "tab-b",
229
+ screen: <Stack id="stack-b" rootScreen={<span>stack-b-root</span>} />,
230
+ tab: () => <span>tab-b</span>,
231
+ },
232
+ ];
233
+
234
+ render(
235
+ <Navigation navigation={navigation}>
236
+ <Tabs id="my-tabs" screens={screens} />
237
+ </Navigation>,
238
+ );
239
+
240
+ // Switch to tab 1 (stack-b)
241
+ act(() => {
242
+ navigation.setActiveTab(1);
243
+ });
244
+
245
+ act(() => {
246
+ navigation.pushScreen(<span>pushed-to-b</span>);
247
+ });
248
+
249
+ const state = navigation.store.getState();
250
+ expect(state.stacks.get("stack-b")).toHaveLength(1);
251
+ expect(state.stacks.has("stack-a")).toBe(false);
252
+ });
253
+
254
+ it("setActiveTab resolves the correct tabs when a stack wraps tabs", async () => {
255
+ const navigation = createNavigation();
256
+
257
+ const tabScreens: TabScreenOptions[] = [
258
+ {
259
+ id: "tab-a",
260
+ screen: <span>tab-a-content</span>,
261
+ tab: () => <span>tab-a</span>,
262
+ },
263
+ {
264
+ id: "tab-b",
265
+ screen: <span>tab-b-content</span>,
266
+ tab: () => <span>tab-b</span>,
267
+ },
268
+ ];
269
+
270
+ const result = render(
271
+ <Navigation navigation={navigation}>
272
+ <Stack id="outer-stack" rootScreen={<Tabs id="inner-tabs" screens={tabScreens} />} />
273
+ </Navigation>,
274
+ );
275
+
276
+ await waitFor(() => {
277
+ expect(result.getByText("tab-a-content")).toBeTruthy();
278
+ });
279
+
280
+ // setActiveTab with no explicit tabsId should resolve inner-tabs
281
+ act(() => {
282
+ navigation.setActiveTab(1);
283
+ });
284
+
285
+ const state = navigation.store.getState();
286
+ expect(state.tabs.get("inner-tabs")).toEqual({ activeIndex: 1 });
287
+ });
288
+ });