@rn-tools/navigation 3.0.1 → 3.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rn-tools/navigation",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "main": "./src/index.ts",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -34,6 +34,7 @@
34
34
  "react-native-screens": "*"
35
35
  },
36
36
  "dependencies": {
37
+ "@rn-tools/sheets": "*",
37
38
  "path-to-regexp": "^6.2.2",
38
39
  "zustand": "^4.5.2"
39
40
  }
package/readme.md CHANGED
@@ -54,9 +54,12 @@ export default function App() {
54
54
  Push and pop screens imperatively:
55
55
 
56
56
  ```tsx
57
- navigation.pushScreen(<DetailScreen />, { id: "detail" });
58
- navigation.popScreen();
59
- navigation.setActiveTab(1);
57
+ navigation.push(<DetailScreen />, { id: "detail" });
58
+ navigation.pop();
59
+ navigation.tab(1);
60
+ navigation.present(<EditSheet />, { id: "edit", snapPoints: [320, 520] });
61
+ navigation.dismiss();
62
+ navigation.dismissAll();
60
63
  ```
61
64
 
62
65
  When no explicit target is provided, these methods automatically resolve the deepest active stack or tabs instance.
@@ -48,9 +48,13 @@ describe("createNavigation", () => {
48
48
  const nav = createNavigation();
49
49
  expect(nav.store).toBeDefined();
50
50
  expect(nav.renderTreeStore).toBeDefined();
51
- expect(typeof nav.pushScreen).toBe("function");
52
- expect(typeof nav.popScreen).toBe("function");
53
- expect(typeof nav.setActiveTab).toBe("function");
51
+ expect(nav.sheetsStore).toBeDefined();
52
+ expect(typeof nav.push).toBe("function");
53
+ expect(typeof nav.pop).toBe("function");
54
+ expect(typeof nav.tab).toBe("function");
55
+ expect(typeof nav.present).toBe("function");
56
+ expect(typeof nav.dismiss).toBe("function");
57
+ expect(typeof nav.dismissAll).toBe("function");
54
58
  });
55
59
 
56
60
  it("initializes with empty state by default", () => {
@@ -71,6 +75,33 @@ describe("createNavigation", () => {
71
75
  });
72
76
  });
73
77
 
78
+ describe("sheet methods", () => {
79
+ it("present returns a key", () => {
80
+ const nav = createNavigation();
81
+ const key = nav.present(<span>sheet</span>);
82
+
83
+ expect(typeof key).toBe("string");
84
+ });
85
+
86
+ it("present reuses key when id is reused", () => {
87
+ const nav = createNavigation();
88
+ const key1 = nav.present(<span>sheet-a</span>, { id: "edit" });
89
+ const key2 = nav.present(<span>sheet-b</span>, { id: "edit" });
90
+
91
+ expect(key2).toBe(key1);
92
+ });
93
+
94
+ it("dismiss and dismissAll are callable via public API", () => {
95
+ const nav = createNavigation();
96
+ nav.present(<span>a</span>);
97
+ nav.present(<span>b</span>);
98
+
99
+ expect(() => nav.dismiss()).not.toThrow();
100
+ nav.dismissAll();
101
+ expect(() => nav.dismissAll()).not.toThrow();
102
+ });
103
+ });
104
+
74
105
  describe("loadNavigationState", () => {
75
106
  it("replaces the store state with normalized input", () => {
76
107
  const nav = createNavigation({
@@ -87,13 +118,13 @@ describe("loadNavigationState", () => {
87
118
  });
88
119
  });
89
120
 
90
- describe("pushScreen", () => {
121
+ describe("push", () => {
91
122
  it("pushes a screen to the specified stack", () => {
92
123
  const nav = createNavigation({
93
124
  stacks: { "stack-a": [] },
94
125
  });
95
126
 
96
- nav.pushScreen(<span>pushed</span>, { stackId: "stack-a" });
127
+ nav.push(<span>pushed</span>, { stack: "stack-a" });
97
128
 
98
129
  const screens = nav.store.getState().stacks.get("stack-a");
99
130
  expect(screens).toHaveLength(1);
@@ -102,7 +133,7 @@ describe("pushScreen", () => {
102
133
  it("creates the stack entry if it did not exist", () => {
103
134
  const nav = createNavigation();
104
135
 
105
- nav.pushScreen(<span>pushed</span>, { stackId: "stack-new" });
136
+ nav.push(<span>pushed</span>, { stack: "stack-new" });
106
137
 
107
138
  const screens = nav.store.getState().stacks.get("stack-new");
108
139
  expect(screens).toHaveLength(1);
@@ -115,7 +146,7 @@ describe("pushScreen", () => {
115
146
  },
116
147
  });
117
148
 
118
- nav.pushScreen(<span>dup</span>, { id: "screen-1", stackId: "stack-a" });
149
+ nav.push(<span>dup</span>, { id: "screen-1", stack: "stack-a" });
119
150
 
120
151
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
121
152
  });
@@ -127,22 +158,22 @@ describe("pushScreen", () => {
127
158
  },
128
159
  });
129
160
 
130
- nav.popScreen({ stackId: "stack-a" });
161
+ nav.pop({ stack: "stack-a" });
131
162
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(0);
132
163
 
133
- nav.pushScreen(<span>new</span>, { id: "screen-1", stackId: "stack-a" });
164
+ nav.push(<span>new</span>, { id: "screen-1", stack: "stack-a" });
134
165
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
135
166
  });
136
167
 
137
- it("throws when no stackId is provided and no stack is mounted", () => {
168
+ it("throws when no stack is provided and no stack is mounted", () => {
138
169
  const nav = createNavigation();
139
- expect(() => nav.pushScreen(<span>x</span>)).toThrow(
140
- "could not resolve stackId",
170
+ expect(() => nav.push(<span>x</span>)).toThrow(
171
+ "could not resolve stack",
141
172
  );
142
173
  });
143
174
  });
144
175
 
145
- describe("popScreen", () => {
176
+ describe("pop", () => {
146
177
  it("removes the top screen from the specified stack", () => {
147
178
  const nav = createNavigation({
148
179
  stacks: {
@@ -153,7 +184,7 @@ describe("popScreen", () => {
153
184
  },
154
185
  });
155
186
 
156
- nav.popScreen({ stackId: "stack-a" });
187
+ nav.pop({ stack: "stack-a" });
157
188
 
158
189
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
159
190
  });
@@ -164,25 +195,25 @@ describe("popScreen", () => {
164
195
  });
165
196
 
166
197
  const before = nav.store.getState();
167
- nav.popScreen({ stackId: "stack-a" });
198
+ nav.pop({ stack: "stack-a" });
168
199
  const after = nav.store.getState();
169
200
 
170
201
  expect(before).toBe(after);
171
202
  });
172
203
 
173
- it("throws when no stackId is provided and no stack is mounted", () => {
204
+ it("throws when no stack is provided and no stack is mounted", () => {
174
205
  const nav = createNavigation();
175
- expect(() => nav.popScreen()).toThrow("could not resolve stackId");
206
+ expect(() => nav.pop()).toThrow("could not resolve stack");
176
207
  });
177
208
  });
178
209
 
179
- describe("setActiveTab", () => {
210
+ describe("tab", () => {
180
211
  it("sets the active index for the specified tabs", () => {
181
212
  const nav = createNavigation({
182
213
  tabs: { "my-tabs": { activeIndex: 0 } },
183
214
  });
184
215
 
185
- nav.setActiveTab(2, { tabsId: "my-tabs" });
216
+ nav.tab(2, { tabs: "my-tabs" });
186
217
 
187
218
  expect(nav.store.getState().tabs.get("my-tabs")).toEqual({
188
219
  activeIndex: 2,
@@ -192,15 +223,15 @@ describe("setActiveTab", () => {
192
223
  it("creates the tabs entry if it did not exist", () => {
193
224
  const nav = createNavigation();
194
225
 
195
- nav.setActiveTab(1, { tabsId: "new-tabs" });
226
+ nav.tab(1, { tabs: "new-tabs" });
196
227
 
197
228
  expect(nav.store.getState().tabs.get("new-tabs")).toEqual({
198
229
  activeIndex: 1,
199
230
  });
200
231
  });
201
232
 
202
- it("throws when no tabsId is provided and no tabs are mounted", () => {
233
+ it("throws when no tabs is provided and no tabs are mounted", () => {
203
234
  const nav = createNavigation();
204
- expect(() => nav.setActiveTab(0)).toThrow("could not resolve tabsId");
235
+ expect(() => nav.tab(0)).toThrow("could not resolve tabs");
205
236
  });
206
237
  });
@@ -7,16 +7,18 @@ import {
7
7
  getRenderNodeDepth,
8
8
  } from "@rn-tools/core";
9
9
  import type { Store, RenderTreeStore } from "@rn-tools/core";
10
+ import { createSheets } from "@rn-tools/sheets";
11
+ import type { SheetOptions, SheetsClient } from "@rn-tools/sheets";
10
12
 
11
- export type PushScreenOptions = {
13
+ export type PushOptions = {
12
14
  id?: string;
13
- stackId?: string;
15
+ stack?: string;
14
16
  };
15
17
 
16
18
  export type NavigationScreenEntry = {
17
19
  element: React.ReactElement;
18
20
  id?: string;
19
- options?: PushScreenOptions;
21
+ options?: PushOptions;
20
22
  };
21
23
 
22
24
  export type TabState = {
@@ -61,9 +63,13 @@ export function loadNavigationState(
61
63
  export type NavigationClient = {
62
64
  store: NavigationStore;
63
65
  renderTreeStore: RenderTreeStore;
64
- pushScreen: (element: React.ReactElement, options?: PushScreenOptions) => void;
65
- popScreen: (options?: { stackId?: string }) => void;
66
- setActiveTab: (index: number, options?: { tabsId?: string }) => void;
66
+ sheetsStore: SheetsClient;
67
+ push: (element: React.ReactElement, options?: PushOptions) => void;
68
+ pop: (options?: { stack?: string }) => void;
69
+ tab: (index: number, options?: { tabs?: string }) => void;
70
+ present: (element: React.ReactElement, options?: SheetOptions) => string;
71
+ dismiss: (id?: string) => void;
72
+ dismissAll: () => void;
67
73
  };
68
74
 
69
75
  export function createNavigation(
@@ -73,6 +79,7 @@ export function createNavigation(
73
79
  normalizeNavigationState(initialState ?? { stacks: new Map() }),
74
80
  );
75
81
  const renderTreeStore = createRenderTreeStore();
82
+ const sheetsStore = createSheets();
76
83
 
77
84
  function getDeepestActiveNodeId(type: string): string | null {
78
85
  const tree = renderTreeStore.getState();
@@ -93,14 +100,14 @@ export function createNavigation(
93
100
  return deepestId;
94
101
  }
95
102
 
96
- function pushScreen(
103
+ function push(
97
104
  element: React.ReactElement,
98
- options?: PushScreenOptions,
105
+ options?: PushOptions,
99
106
  ) {
100
- const stackId = options?.stackId ?? getDeepestActiveNodeId("stack");
107
+ const stackId = options?.stack ?? getDeepestActiveNodeId("stack");
101
108
  if (!stackId) {
102
109
  throw new Error(
103
- "pushScreen: could not resolve stackId. Pass { stackId } explicitly or ensure a Stack is mounted and active.",
110
+ "push: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
104
111
  );
105
112
  }
106
113
 
@@ -122,11 +129,11 @@ export function createNavigation(
122
129
  });
123
130
  }
124
131
 
125
- function popScreen(options?: { stackId?: string }) {
126
- const stackId = options?.stackId ?? getDeepestActiveNodeId("stack");
132
+ function pop(options?: { stack?: string }) {
133
+ const stackId = options?.stack ?? getDeepestActiveNodeId("stack");
127
134
  if (!stackId) {
128
135
  throw new Error(
129
- "popScreen: could not resolve stackId. Pass { stackId } explicitly or ensure a Stack is mounted and active.",
136
+ "pop: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
130
137
  );
131
138
  }
132
139
 
@@ -139,11 +146,11 @@ export function createNavigation(
139
146
  });
140
147
  }
141
148
 
142
- function setActiveTab(index: number, options?: { tabsId?: string }) {
143
- const tabsId = options?.tabsId ?? getDeepestActiveNodeId("tabs");
149
+ function tab(index: number, options?: { tabs?: string }) {
150
+ const tabsId = options?.tabs ?? getDeepestActiveNodeId("tabs");
144
151
  if (!tabsId) {
145
152
  throw new Error(
146
- "setActiveTab: could not resolve tabsId. Pass { tabsId } explicitly or ensure a Tabs is mounted and active.",
153
+ "tab: could not resolve tabs. Pass { tabs } explicitly or ensure a Tabs is mounted and active.",
147
154
  );
148
155
  }
149
156
 
@@ -154,7 +161,29 @@ export function createNavigation(
154
161
  });
155
162
  }
156
163
 
157
- return { store: navStore, renderTreeStore, pushScreen, popScreen, setActiveTab };
164
+ function present(element: React.ReactElement, options?: SheetOptions): string {
165
+ return sheetsStore.present(element, options);
166
+ }
167
+
168
+ function dismiss(id?: string) {
169
+ sheetsStore.dismiss(id);
170
+ }
171
+
172
+ function dismissAll() {
173
+ sheetsStore.dismissAll();
174
+ }
175
+
176
+ return {
177
+ store: navStore,
178
+ renderTreeStore,
179
+ sheetsStore,
180
+ push,
181
+ pop,
182
+ tab,
183
+ present,
184
+ dismiss,
185
+ dismissAll,
186
+ };
158
187
  }
159
188
 
160
189
  export function useNavigation(): NavigationClient {
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { RenderTree } from "@rn-tools/core";
3
+ import { SheetsProvider } from "@rn-tools/sheets";
3
4
  import {
4
5
  NavigationContext,
5
6
  NavigationStoreContext,
@@ -30,11 +31,13 @@ export const Navigation = React.memo(function Navigation(
30
31
  ) {
31
32
  return (
32
33
  <RenderTree store={props.navigation.renderTreeStore}>
33
- <NavigationContext.Provider value={props.navigation}>
34
- <NavigationStoreContext.Provider value={props.navigation.store}>
35
- <RootStack>{props.children}</RootStack>
36
- </NavigationStoreContext.Provider>
37
- </NavigationContext.Provider>
34
+ <SheetsProvider sheets={props.navigation.sheetsStore}>
35
+ <NavigationContext.Provider value={props.navigation}>
36
+ <NavigationStoreContext.Provider value={props.navigation.store}>
37
+ <RootStack>{props.children}</RootStack>
38
+ </NavigationStoreContext.Provider>
39
+ </NavigationContext.Provider>
40
+ </SheetsProvider>
38
41
  </RenderTree>
39
42
  );
40
43
  });
@@ -110,7 +110,7 @@ describe("Stack", () => {
110
110
  expect(renderer.getByText("screen-b:true")).toBeTruthy();
111
111
  });
112
112
 
113
- it("pushScreen adds a new active screen to the stack", async () => {
113
+ it("push adds a new active screen to the stack", async () => {
114
114
  const { navigation, renderer } = await renderWithProviders(
115
115
  <Stack id="stack-a" />,
116
116
  {
@@ -132,13 +132,13 @@ describe("Stack", () => {
132
132
  );
133
133
 
134
134
  act(() => {
135
- navigation.pushScreen(
135
+ navigation.push(
136
136
  <RenderNodeProbe
137
137
  render={(data) => <span>{`screen-b:${String(data.active)}`}</span>}
138
138
  />,
139
139
  {
140
140
  id: "screen-b",
141
- stackId: "stack-a",
141
+ stack: "stack-a",
142
142
  },
143
143
  );
144
144
  });
@@ -227,7 +227,7 @@ describe("Stack", () => {
227
227
  expect(renderer.getByText("a1:true")).toBeTruthy();
228
228
  });
229
229
 
230
- it("pushScreen does not push a duplicate when a screen with the same id exists", async () => {
230
+ it("push does not push a duplicate when a screen with the same id exists", async () => {
231
231
  const { navigation, renderer } = await renderWithProviders(
232
232
  <Stack id="stack-a" />,
233
233
  {
@@ -243,9 +243,9 @@ describe("Stack", () => {
243
243
  );
244
244
 
245
245
  act(() => {
246
- navigation.pushScreen(<span>screen-a-dup</span>, {
246
+ navigation.push(<span>screen-a-dup</span>, {
247
247
  id: "screen-a",
248
- stackId: "stack-a",
248
+ stack: "stack-a",
249
249
  });
250
250
  });
251
251
 
@@ -253,16 +253,16 @@ describe("Stack", () => {
253
253
  expect(renderer.getByText("screen-a")).toBeTruthy();
254
254
 
255
255
  act(() => {
256
- navigation.popScreen({ stackId: "stack-a" });
256
+ navigation.pop({ stack: "stack-a" });
257
257
  });
258
258
 
259
259
  expect(navigation.store.getState().stacks.get("stack-a")).toHaveLength(0);
260
260
  expect(renderer.queryByText("screen-a")).toBeNull();
261
261
 
262
262
  act(() => {
263
- navigation.pushScreen(<span>screen-a-again</span>, {
263
+ navigation.push(<span>screen-a-again</span>, {
264
264
  id: "screen-a",
265
- stackId: "stack-a",
265
+ stack: "stack-a",
266
266
  });
267
267
  });
268
268
 
@@ -270,30 +270,99 @@ describe("Stack", () => {
270
270
  renderer.getByText("screen-a-again");
271
271
  });
272
272
 
273
- it("ref.pushScreen adds a screen and ref.popScreen removes it", async () => {
273
+ it("ref.push adds a screen and ref.pop removes it", async () => {
274
274
  const ref = React.createRef<StackHandle>();
275
- const { renderer } = await renderWithProviders(
275
+ const { renderer, store } = await renderWithProviders(
276
276
  <Stack ref={ref} id="stack-a" rootScreen={<span>root</span>} />,
277
277
  );
278
278
 
279
279
  expect(renderer.getByText("root")).toBeTruthy();
280
280
 
281
281
  act(() => {
282
- ref.current!.pushScreen(<span>pushed</span>, { id: "pushed-screen" });
282
+ ref.current!.push(<span>pushed</span>, { id: "pushed-screen" });
283
283
  });
284
284
 
285
285
  await waitFor(() => {
286
286
  expect(renderer.getByText("pushed")).toBeTruthy();
287
287
  });
288
288
 
289
+ expect(store.getState().stacks.get("stack-a")).toHaveLength(1);
290
+
289
291
  act(() => {
290
- ref.current!.popScreen();
292
+ ref.current!.pop();
291
293
  });
292
294
 
293
295
  await waitFor(() => {
294
296
  expect(renderer.queryByText("pushed")).toBeNull();
295
297
  expect(renderer.getByText("root")).toBeTruthy();
296
298
  });
299
+
300
+ expect(store.getState().stacks.get("stack-a")).toHaveLength(0);
301
+ });
302
+
303
+ it("ref.push targets the correct stack when id is not provided", async () => {
304
+ const ref = React.createRef<StackHandle>();
305
+ const { renderer, store } = await renderWithProviders(
306
+ <Stack ref={ref} rootScreen={<span>root</span>} />,
307
+ );
308
+
309
+ expect(renderer.getByText("root")).toBeTruthy();
310
+
311
+ act(() => {
312
+ ref.current!.push(<span>pushed</span>, { id: "pushed-screen" });
313
+ });
314
+
315
+ await waitFor(() => {
316
+ expect(renderer.getByText("pushed")).toBeTruthy();
317
+ });
318
+
319
+ // The screen should be pushed to the auto-generated stack id
320
+ const state = store.getState();
321
+ expect(state.stacks.size).toBe(1);
322
+ const [stackId, screens] = [...state.stacks.entries()][0];
323
+ expect(typeof stackId).toBe("string");
324
+ expect(screens).toHaveLength(1);
325
+
326
+ act(() => {
327
+ ref.current!.pop();
328
+ });
329
+
330
+ await waitFor(() => {
331
+ expect(renderer.queryByText("pushed")).toBeNull();
332
+ expect(renderer.getByText("root")).toBeTruthy();
333
+ });
334
+
335
+ expect(store.getState().stacks.get(stackId)).toHaveLength(0);
336
+ });
337
+
338
+ it("ref.push targets the inner stack when nested without an explicit id", async () => {
339
+ const innerRef = React.createRef<StackHandle>();
340
+
341
+ function InnerStack() {
342
+ return <Stack ref={innerRef} rootScreen={<span>inner-root</span>} />;
343
+ }
344
+
345
+ const { renderer, store } = await renderWithProviders(
346
+ <Stack id="outer" rootScreen={<InnerStack />} />,
347
+ );
348
+
349
+ await waitFor(() => {
350
+ expect(renderer.getByText("inner-root")).toBeTruthy();
351
+ });
352
+
353
+ act(() => {
354
+ innerRef.current!.push(<span>inner-pushed</span>, {
355
+ id: "inner-screen",
356
+ });
357
+ });
358
+
359
+ await waitFor(() => {
360
+ expect(renderer.getByText("inner-pushed")).toBeTruthy();
361
+ });
362
+
363
+ // The screen was pushed to the inner stack's auto-generated id, not "outer"
364
+ const state = store.getState();
365
+ expect(state.stacks.has("outer")).toBe(false);
297
366
  });
298
367
 
299
368
  it("resolves the deepest active stack and falls back when a subtree becomes inactive", async () => {
@@ -314,9 +383,9 @@ describe("Stack", () => {
314
383
  expect(result.getByText("nested-root")).toBeTruthy();
315
384
  });
316
385
 
317
- // pushScreen with no stackId should target the deepest active stack: right-nested
386
+ // push with no stack should target the deepest active stack: right-nested
318
387
  act(() => {
319
- navigation.pushScreen(<span>first-push</span>);
388
+ navigation.push(<span>first-push</span>);
320
389
  });
321
390
 
322
391
  const stateAfterFirst = navigation.store.getState();
@@ -332,7 +401,7 @@ describe("Stack", () => {
332
401
  );
333
402
 
334
403
  act(() => {
335
- navigation.pushScreen(<span>second-push</span>);
404
+ navigation.push(<span>second-push</span>);
336
405
  });
337
406
 
338
407
  const stateAfterSecond = navigation.store.getState();
package/src/stack.tsx CHANGED
@@ -1,9 +1,13 @@
1
1
  import * as React from "react";
2
- import { RenderTreeNode, useRenderNode } from "@rn-tools/core";
2
+ import {
3
+ RenderTreeNode,
4
+ useRenderNode,
5
+ nextRenderTreeIdForType,
6
+ } from "@rn-tools/core";
3
7
  import {
4
8
  useStackScreens,
5
9
  useNavigation,
6
- type PushScreenOptions,
10
+ type PushOptions,
7
11
  } from "./navigation-client";
8
12
 
9
13
  // TODO - replace with custom implementation
@@ -11,8 +15,11 @@ import * as RNScreens from "react-native-screens";
11
15
  import { StyleSheet } from "react-native";
12
16
 
13
17
  export type StackHandle = {
14
- pushScreen: (element: React.ReactElement, options?: PushScreenOptions) => void;
15
- popScreen: () => void;
18
+ push: (
19
+ element: React.ReactElement,
20
+ options?: PushOptions,
21
+ ) => void;
22
+ pop: () => void;
16
23
  };
17
24
 
18
25
  export type StackProps = {
@@ -22,16 +29,36 @@ export type StackProps = {
22
29
  children?: React.ReactNode;
23
30
  };
24
31
 
25
- const StackRoot = React.memo(function StackRoot(props: StackProps) {
26
- return (
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>
33
- );
34
- });
32
+ const StackRoot = React.memo(
33
+ React.forwardRef<StackHandle, StackProps>(function StackRoot(props, ref) {
34
+ const stackId = React.useRef(
35
+ props.id ?? nextRenderTreeIdForType("stack"),
36
+ ).current;
37
+ const navigation = useNavigation();
38
+
39
+ React.useImperativeHandle(
40
+ ref,
41
+ () => ({
42
+ push(element: React.ReactElement, options?: PushOptions) {
43
+ navigation.push(element, { ...options, stack: stackId });
44
+ },
45
+ pop() {
46
+ navigation.pop({ stack: stackId });
47
+ },
48
+ }),
49
+ [stackId, navigation],
50
+ );
51
+
52
+ return (
53
+ <RenderTreeNode type="stack" id={stackId} active={props.active}>
54
+ <RNScreens.ScreenStack style={StyleSheet.absoluteFill}>
55
+ {props.rootScreen && <StackScreen>{props.rootScreen}</StackScreen>}
56
+ {props.children}
57
+ </RNScreens.ScreenStack>
58
+ </RenderTreeNode>
59
+ );
60
+ }),
61
+ );
35
62
 
36
63
  export type StackScreenProps = {
37
64
  id?: string;
@@ -72,25 +99,8 @@ const StackSlot = React.memo(function StackSlot() {
72
99
  export const Stack = React.memo(
73
100
  React.forwardRef<StackHandle, Omit<StackProps, "children">>(
74
101
  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
102
  return (
93
- <StackRoot {...props}>
103
+ <StackRoot ref={ref} {...props}>
94
104
  <StackSlot />
95
105
  </StackRoot>
96
106
  );
package/src/tabs.test.tsx CHANGED
@@ -62,14 +62,14 @@ describe("Tabs", () => {
62
62
  expect(renderer.getByText("tab-2:tab-screen:false")).toBeTruthy();
63
63
  });
64
64
 
65
- it("setActiveTab changes which screen is active", async () => {
65
+ it("tab changes which screen is active", async () => {
66
66
  const screens = makeScreens(3);
67
67
  const { navigation, renderer } = renderWithProviders(
68
68
  <Tabs id="my-tabs" screens={screens} />,
69
69
  );
70
70
 
71
71
  act(() => {
72
- navigation.setActiveTab(2);
72
+ navigation.tab(2);
73
73
  });
74
74
 
75
75
  await waitFor(() => {
@@ -88,17 +88,39 @@ describe("Tabs", () => {
88
88
  expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
89
89
  });
90
90
 
91
- it("ref.setActiveIndex changes the active tab", async () => {
91
+ it("ref.setActive changes the active tab", async () => {
92
92
  const screens = makeScreens(3);
93
93
  const ref = React.createRef<TabsHandle>();
94
- const { renderer } = renderWithProviders(
94
+ const { renderer, store } = renderWithProviders(
95
95
  <Tabs ref={ref} id="my-tabs" screens={screens} />,
96
96
  );
97
97
 
98
98
  expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
99
99
 
100
100
  act(() => {
101
- ref.current!.setActiveIndex(2);
101
+ ref.current!.setActive(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
+ expect(store.getState().tabs.get("my-tabs")).toEqual({ activeIndex: 2 });
111
+ });
112
+
113
+ it("ref.setActive targets the correct tabs when id is not provided", async () => {
114
+ const screens = makeScreens(3);
115
+ const ref = React.createRef<TabsHandle>();
116
+ const { renderer, store } = renderWithProviders(
117
+ <Tabs ref={ref} screens={screens} />,
118
+ );
119
+
120
+ expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
121
+
122
+ act(() => {
123
+ ref.current!.setActive(2);
102
124
  });
103
125
 
104
126
  await waitFor(() => {
@@ -106,6 +128,55 @@ describe("Tabs", () => {
106
128
  expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
107
129
  expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
108
130
  });
131
+
132
+ // The tab state should be set on the auto-generated tabs id
133
+ const state = store.getState();
134
+ expect(state.tabs.size).toBe(1);
135
+ const [, tabState] = [...state.tabs.entries()][0];
136
+ expect(tabState).toEqual({ activeIndex: 2 });
137
+ });
138
+
139
+ it("ref.setActive targets the inner tabs when nested inside a stack without an explicit id", async () => {
140
+ const ref = React.createRef<TabsHandle>();
141
+ const navigation = createNavigation();
142
+
143
+ const tabScreens: TabScreenOptions[] = [
144
+ {
145
+ id: "tab-a",
146
+ screen: <span>tab-a-content</span>,
147
+ tab: () => <span>tab-a</span>,
148
+ },
149
+ {
150
+ id: "tab-b",
151
+ screen: <span>tab-b-content</span>,
152
+ tab: () => <span>tab-b</span>,
153
+ },
154
+ ];
155
+
156
+ const result = render(
157
+ <Navigation navigation={navigation}>
158
+ <Stack
159
+ id="outer-stack"
160
+ rootScreen={<Tabs ref={ref} screens={tabScreens} />}
161
+ />
162
+ </Navigation>,
163
+ );
164
+
165
+ await waitFor(() => {
166
+ expect(result.getByText("tab-a-content")).toBeTruthy();
167
+ });
168
+
169
+ act(() => {
170
+ ref.current!.setActive(1);
171
+ });
172
+
173
+ // The tab switch should target the auto-generated tabs id, not "outer-stack"
174
+ const state = navigation.store.getState();
175
+ expect(state.tabs.has("outer-stack")).toBe(false);
176
+ expect(state.tabs.size).toBe(1);
177
+
178
+ const [, tabState] = [...state.tabs.entries()][0];
179
+ expect(tabState.activeIndex).toBe(1);
109
180
  });
110
181
 
111
182
  it("supports preloaded activeIndex from the navigation state", () => {
@@ -179,7 +250,7 @@ describe("TabBar", () => {
179
250
  });
180
251
 
181
252
  describe("Nested Stack + Tabs", () => {
182
- it("pushScreen targets the stack inside the active tab", async () => {
253
+ it("push targets the stack inside the active tab", async () => {
183
254
  const navigation = createNavigation();
184
255
 
185
256
  const screens: TabScreenOptions[] = [
@@ -205,9 +276,9 @@ describe("Nested Stack + Tabs", () => {
205
276
  expect(result.getByText("stack-a-root")).toBeTruthy();
206
277
  });
207
278
 
208
- // Tab 0 (stack-a) is active — pushScreen should target stack-a
279
+ // Tab 0 (stack-a) is active — push should target stack-a
209
280
  act(() => {
210
- navigation.pushScreen(<span>pushed-to-a</span>);
281
+ navigation.push(<span>pushed-to-a</span>);
211
282
  });
212
283
 
213
284
  const stateAfterFirst = navigation.store.getState();
@@ -215,7 +286,7 @@ describe("Nested Stack + Tabs", () => {
215
286
  expect(stateAfterFirst.stacks.has("stack-b")).toBe(false);
216
287
  });
217
288
 
218
- it("switching tabs redirects pushScreen to the newly active stack", async () => {
289
+ it("switching tabs redirects push to the newly active stack", async () => {
219
290
  const navigation = createNavigation();
220
291
 
221
292
  const screens: TabScreenOptions[] = [
@@ -239,11 +310,11 @@ describe("Nested Stack + Tabs", () => {
239
310
 
240
311
  // Switch to tab 1 (stack-b)
241
312
  act(() => {
242
- navigation.setActiveTab(1);
313
+ navigation.tab(1);
243
314
  });
244
315
 
245
316
  act(() => {
246
- navigation.pushScreen(<span>pushed-to-b</span>);
317
+ navigation.push(<span>pushed-to-b</span>);
247
318
  });
248
319
 
249
320
  const state = navigation.store.getState();
@@ -251,7 +322,7 @@ describe("Nested Stack + Tabs", () => {
251
322
  expect(state.stacks.has("stack-a")).toBe(false);
252
323
  });
253
324
 
254
- it("setActiveTab resolves the correct tabs when a stack wraps tabs", async () => {
325
+ it("tab resolves the correct tabs when a stack wraps tabs", async () => {
255
326
  const navigation = createNavigation();
256
327
 
257
328
  const tabScreens: TabScreenOptions[] = [
@@ -277,9 +348,9 @@ describe("Nested Stack + Tabs", () => {
277
348
  expect(result.getByText("tab-a-content")).toBeTruthy();
278
349
  });
279
350
 
280
- // setActiveTab with no explicit tabsId should resolve inner-tabs
351
+ // tab with no explicit tabs should resolve inner-tabs
281
352
  act(() => {
282
- navigation.setActiveTab(1);
353
+ navigation.tab(1);
283
354
  });
284
355
 
285
356
  const state = navigation.store.getState();
package/src/tabs.tsx CHANGED
@@ -3,8 +3,13 @@ import {
3
3
  RenderTreeNode,
4
4
  useRenderNode,
5
5
  useSafeAreaInsets,
6
+ nextRenderTreeIdForType,
6
7
  } from "@rn-tools/core";
7
- import { useTabActiveIndex, useNavigationStore, useNavigation } from "./navigation-client";
8
+ import {
9
+ useTabActiveIndex,
10
+ useNavigationStore,
11
+ useNavigation,
12
+ } from "./navigation-client";
8
13
 
9
14
  import * as RNScreens from "react-native-screens";
10
15
  import { StyleSheet, View, type ViewStyle } from "react-native";
@@ -20,7 +25,7 @@ export type TabScreenOptions = {
20
25
  };
21
26
 
22
27
  export type TabsHandle = {
23
- setActiveIndex: (index: number) => void;
28
+ setActive: (index: number) => void;
24
29
  };
25
30
 
26
31
  export type TabsProps = {
@@ -32,13 +37,30 @@ export type TabsProps = {
32
37
  children?: React.ReactNode;
33
38
  };
34
39
 
35
- const TabsRoot = React.memo(function TabsRoot(props: TabsProps) {
36
- return (
37
- <RenderTreeNode type="tabs" id={props.id} active={props.active}>
38
- {props.children}
39
- </RenderTreeNode>
40
- );
41
- });
40
+ const TabsRoot = React.memo(
41
+ React.forwardRef<TabsHandle, TabsProps>(function TabsRoot(props, ref) {
42
+ const tabsId = React.useRef(
43
+ props.id ?? nextRenderTreeIdForType("tabs"),
44
+ ).current;
45
+ const navigation = useNavigation();
46
+
47
+ React.useImperativeHandle(
48
+ ref,
49
+ () => ({
50
+ setActive(index: number) {
51
+ navigation.tab(index, { tabs: tabsId });
52
+ },
53
+ }),
54
+ [tabsId, navigation],
55
+ );
56
+
57
+ return (
58
+ <RenderTreeNode type="tabs" id={tabsId} active={props.active}>
59
+ {props.children}
60
+ </RenderTreeNode>
61
+ );
62
+ }),
63
+ );
42
64
 
43
65
  const TabBar = React.memo(function TabBar(props: {
44
66
  screens: TabScreenOptions[];
@@ -121,17 +143,6 @@ export const Tabs = React.memo(
121
143
  React.forwardRef<TabsHandle, Omit<TabsProps, "children">>(
122
144
  function Tabs(props, ref) {
123
145
  const position = props.tabbarPosition ?? "bottom";
124
- const navigation = useNavigation();
125
- const node = useRenderNode();
126
- const tabsId = props.id ?? node?.id ?? null;
127
-
128
- React.useImperativeHandle(ref, () => ({
129
- setActiveIndex(index: number) {
130
- if (tabsId) {
131
- navigation.setActiveTab(index, { tabsId });
132
- }
133
- },
134
- }), [tabsId, navigation]);
135
146
 
136
147
  const tabbar = React.useMemo(
137
148
  () => (
@@ -145,7 +156,7 @@ export const Tabs = React.memo(
145
156
  );
146
157
 
147
158
  return (
148
- <TabsRoot {...props}>
159
+ <TabsRoot ref={ref} {...props}>
149
160
  {position === "top" && tabbar}
150
161
  <View style={styles.slotContainer}>
151
162
  <TabsSlot screens={props.screens} />