@rn-tools/navigation 3.0.2 → 3.0.4

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.2",
3
+ "version": "3.0.4",
4
4
  "main": "./src/index.ts",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -8,25 +8,25 @@
8
8
  ],
9
9
  "scripts": {
10
10
  "build": "tsc",
11
- "test": "NODE_OPTIONS='--no-experimental-detect-module' vitest",
11
+ "test": "jest",
12
12
  "lint": "eslint -c ./.eslintrc.js ."
13
13
  },
14
14
  "devDependencies": {
15
15
  "@rn-tools/eslint-config": "*",
16
- "@testing-library/react": "^14.2.1",
16
+ "@testing-library/react-native": "^13.3.3",
17
+ "@types/jest": "^29.5.14",
17
18
  "@types/react": "~18.2.79",
18
19
  "@types/react-native": "^0.72.8",
19
- "@types/react-test-renderer": "^18",
20
20
  "@typescript-eslint/eslint-plugin": "^6.8.0",
21
21
  "@typescript-eslint/parser": "^6.8.0",
22
22
  "babel-preset-expo": "~11.0.0",
23
23
  "eslint": "^8.56.0",
24
24
  "expo": "~52.0.0",
25
+ "jest": "^29.7.0",
26
+ "jest-expo": "~52.0.4",
25
27
  "lint-staged": "^15.2.0",
26
28
  "prettier": "^3.2.1",
27
- "react-test-renderer": "18.2.0",
28
- "typescript": "~5.3.3",
29
- "vitest": "^1.6.0"
29
+ "typescript": "~5.3.3"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "react": "*",
@@ -34,6 +34,7 @@
34
34
  "react-native-screens": "*"
35
35
  },
36
36
  "dependencies": {
37
+ "@rn-tools/notifications": "*",
37
38
  "@rn-tools/sheets": "*",
38
39
  "path-to-regexp": "^6.2.2",
39
40
  "zustand": "^4.5.2"
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rn-tools/navigation
2
2
 
3
- Navigation primitives for React Native. Built on `react-native-screens`.
3
+ Navigation primitives for React Native. Built on `react-native-screens` with integrated sheets and notifications support.
4
4
 
5
5
  ## Installation
6
6
 
@@ -51,21 +51,42 @@ export default function App() {
51
51
  }
52
52
  ```
53
53
 
54
- Push and pop screens imperatively:
54
+ Navigate screens, present sheets, and trigger notifications imperatively:
55
55
 
56
56
  ```tsx
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();
57
+ navigation.stack.push(<DetailScreen />, { id: "detail" });
58
+ navigation.stack.pop();
59
+ navigation.tabs.tab(1);
60
+ navigation.sheets.present(<EditSheet />, { id: "edit", snapPoints: [320, 520] });
61
+ navigation.sheets.dismiss();
62
+ navigation.sheets.dismissAll();
63
+ navigation.notifications.present(<SavedNotification />, { id: "saved", position: "top", durationMs: 3000 });
64
+ navigation.notifications.dismiss();
65
+ navigation.notifications.dismiss("bottom");
66
+ navigation.notifications.dismiss("saved");
63
67
  ```
64
68
 
65
- When no explicit target is provided, these methods automatically resolve the deepest active stack or tabs instance.
69
+ `durationMs` defaults to `3000`; pass `null` for persistent notifications.
70
+
71
+ Presented sheet and notification elements receive an injected optional `dismiss?: () => void` prop.
72
+
73
+ When no explicit target is provided:
74
+ - `push/pop/tab` resolve the deepest active stack/tabs node.
75
+ - `dismiss()` resolves the active sheet.
76
+ - `notifications.dismiss()` resolves the latest non-closing top-lane notification.
77
+
78
+ Hooks are also re-exported for convenience:
79
+ - `useSheetEntry` (from `@rn-tools/sheets`)
80
+ - `useNotificationEntry` (from `@rn-tools/notifications`)
81
+
82
+ Injected-prop typings are re-exported as well:
83
+ - `SheetInjectedProps`
84
+ - `NotificationInjectedProps`
66
85
 
67
86
  ## Docs
68
87
 
69
- - [Navigation](docs/navigation.md) — setup, `createNavigation`, `NavigationClient` API, hooks
88
+ - [Navigation](docs/navigation.md) — setup, `createNavigation`, `NavigationClient` API (screens + sheets + notifications), hooks
70
89
  - [Stack](docs/stack.md) — stack navigation, pushing/popping, refs, preloading, nesting
71
90
  - [Tabs](docs/tabs.md) — tab navigation, tab bar, refs, preloading, nesting with stacks
91
+ - [Sheets](../sheets/README.md) — sheet setup, API, and props
92
+ - [Notifications](../notifications/README.md) — notification setup, API, and props
package/src/index.ts CHANGED
@@ -2,3 +2,8 @@ export * from "./stack";
2
2
  export * from "./navigation-client";
3
3
  export { Navigation, type NavigationProps } from "./navigation";
4
4
  export * from "./tabs";
5
+ export { useSheetEntry, type SheetInjectedProps } from "@rn-tools/sheets";
6
+ export {
7
+ useNotificationEntry,
8
+ type NotificationInjectedProps,
9
+ } from "@rn-tools/notifications";
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { describe, expect, it } from "vitest";
2
+ import { Text } from "react-native";
3
3
  import {
4
4
  createNavigation,
5
5
  createNavigationState,
@@ -18,7 +18,7 @@ describe("createNavigationState", () => {
18
18
  it("normalizes Record inputs into Maps", () => {
19
19
  const state = createNavigationState({
20
20
  stacks: {
21
- "stack-a": [{ element: <span>a</span> }],
21
+ "stack-a": [{ element: <Text>a</Text> }],
22
22
  },
23
23
  tabs: {
24
24
  "my-tabs": { activeIndex: 2 },
@@ -33,7 +33,7 @@ describe("createNavigationState", () => {
33
33
 
34
34
  it("accepts Map inputs directly", () => {
35
35
  const stacks = new Map([
36
- ["stack-a", [{ element: <span>a</span> }]],
36
+ ["stack-a", [{ element: <Text>a</Text> }]],
37
37
  ]);
38
38
  const tabs = new Map([["my-tabs", { activeIndex: 1 }]]);
39
39
  const state = createNavigationState({ stacks, tabs });
@@ -49,12 +49,14 @@ describe("createNavigation", () => {
49
49
  expect(nav.store).toBeDefined();
50
50
  expect(nav.renderTreeStore).toBeDefined();
51
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");
52
+ expect(typeof nav.stack.push).toBe("function");
53
+ expect(typeof nav.stack.pop).toBe("function");
54
+ expect(typeof nav.tabs.tab).toBe("function");
55
+ expect(typeof nav.sheets.present).toBe("function");
56
+ expect(typeof nav.sheets.dismiss).toBe("function");
57
+ expect(typeof nav.sheets.dismissAll).toBe("function");
58
+ expect(typeof nav.notifications.present).toBe("function");
59
+ expect(typeof nav.notifications.dismiss).toBe("function");
58
60
  });
59
61
 
60
62
  it("initializes with empty state by default", () => {
@@ -66,7 +68,7 @@ describe("createNavigation", () => {
66
68
 
67
69
  it("initializes with the provided state", () => {
68
70
  const nav = createNavigation({
69
- stacks: { "stack-a": [{ element: <span>a</span> }] },
71
+ stacks: { "stack-a": [{ element: <Text>a</Text> }] },
70
72
  tabs: { "my-tabs": { activeIndex: 1 } },
71
73
  });
72
74
  const state = nav.store.getState();
@@ -78,38 +80,70 @@ describe("createNavigation", () => {
78
80
  describe("sheet methods", () => {
79
81
  it("present returns a key", () => {
80
82
  const nav = createNavigation();
81
- const key = nav.present(<span>sheet</span>);
83
+ const key = nav.sheets.present(<Text>sheet</Text>);
82
84
 
83
85
  expect(typeof key).toBe("string");
84
86
  });
85
87
 
86
88
  it("present reuses key when id is reused", () => {
87
89
  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
+ const key1 = nav.sheets.present(<Text>sheet-a</Text>, { id: "edit" });
91
+ const key2 = nav.sheets.present(<Text>sheet-b</Text>, { id: "edit" });
90
92
 
91
93
  expect(key2).toBe(key1);
92
94
  });
93
95
 
94
96
  it("dismiss and dismissAll are callable via public API", () => {
95
97
  const nav = createNavigation();
96
- nav.present(<span>a</span>);
97
- nav.present(<span>b</span>);
98
+ nav.sheets.present(<Text>a</Text>);
99
+ nav.sheets.present(<Text>b</Text>);
98
100
 
99
- expect(() => nav.dismiss()).not.toThrow();
100
- nav.dismissAll();
101
- expect(() => nav.dismissAll()).not.toThrow();
101
+ expect(() => nav.sheets.dismiss()).not.toThrow();
102
+ nav.sheets.dismissAll();
103
+ expect(() => nav.sheets.dismissAll()).not.toThrow();
104
+ });
105
+ });
106
+
107
+ describe("notification methods", () => {
108
+ it("notify returns a key", () => {
109
+ const nav = createNavigation();
110
+ const key = nav.notifications.present(<Text>notification</Text>, { durationMs: null });
111
+
112
+ expect(typeof key).toBe("string");
113
+ });
114
+
115
+ it("notify reuses key when id is reused", () => {
116
+ const nav = createNavigation();
117
+ const key1 = nav.notifications.present(<Text>notification-a</Text>, {
118
+ id: "welcome",
119
+ durationMs: null,
120
+ });
121
+ const key2 = nav.notifications.present(<Text>notification-b</Text>, {
122
+ id: "welcome",
123
+ durationMs: null,
124
+ });
125
+
126
+ expect(key2).toBe(key1);
127
+ });
128
+
129
+ it("dismissNotification is callable via public API", () => {
130
+ const nav = createNavigation();
131
+ nav.notifications.present(<Text>a</Text>, { durationMs: null });
132
+ nav.notifications.present(<Text>b</Text>, { position: "bottom", durationMs: null });
133
+
134
+ expect(() => nav.notifications.dismiss()).not.toThrow();
135
+ expect(() => nav.notifications.dismiss("bottom")).not.toThrow();
102
136
  });
103
137
  });
104
138
 
105
139
  describe("loadNavigationState", () => {
106
140
  it("replaces the store state with normalized input", () => {
107
141
  const nav = createNavigation({
108
- stacks: { "stack-a": [{ element: <span>old</span> }] },
142
+ stacks: { "stack-a": [{ element: <Text>old</Text> }] },
109
143
  });
110
144
 
111
145
  loadNavigationState(nav.store, {
112
- stacks: { "stack-b": [{ element: <span>new</span> }] },
146
+ stacks: { "stack-b": [{ element: <Text>new</Text> }] },
113
147
  });
114
148
 
115
149
  const state = nav.store.getState();
@@ -124,7 +158,7 @@ describe("push", () => {
124
158
  stacks: { "stack-a": [] },
125
159
  });
126
160
 
127
- nav.push(<span>pushed</span>, { stack: "stack-a" });
161
+ nav.stack.push(<Text>pushed</Text>, { stack: "stack-a" });
128
162
 
129
163
  const screens = nav.store.getState().stacks.get("stack-a");
130
164
  expect(screens).toHaveLength(1);
@@ -133,7 +167,7 @@ describe("push", () => {
133
167
  it("creates the stack entry if it did not exist", () => {
134
168
  const nav = createNavigation();
135
169
 
136
- nav.push(<span>pushed</span>, { stack: "stack-new" });
170
+ nav.stack.push(<Text>pushed</Text>, { stack: "stack-new" });
137
171
 
138
172
  const screens = nav.store.getState().stacks.get("stack-new");
139
173
  expect(screens).toHaveLength(1);
@@ -142,11 +176,11 @@ describe("push", () => {
142
176
  it("does not push a duplicate screen with the same id", () => {
143
177
  const nav = createNavigation({
144
178
  stacks: {
145
- "stack-a": [{ element: <span>a</span>, options: { id: "screen-1" } }],
179
+ "stack-a": [{ element: <Text>a</Text>, options: { id: "screen-1" } }],
146
180
  },
147
181
  });
148
182
 
149
- nav.push(<span>dup</span>, { id: "screen-1", stack: "stack-a" });
183
+ nav.stack.push(<Text>dup</Text>, { id: "screen-1", stack: "stack-a" });
150
184
 
151
185
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
152
186
  });
@@ -154,20 +188,20 @@ describe("push", () => {
154
188
  it("allows pushing after a screen with the same id was popped", () => {
155
189
  const nav = createNavigation({
156
190
  stacks: {
157
- "stack-a": [{ element: <span>a</span>, options: { id: "screen-1" } }],
191
+ "stack-a": [{ element: <Text>a</Text>, options: { id: "screen-1" } }],
158
192
  },
159
193
  });
160
194
 
161
- nav.pop({ stack: "stack-a" });
195
+ nav.stack.pop({ stack: "stack-a" });
162
196
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(0);
163
197
 
164
- nav.push(<span>new</span>, { id: "screen-1", stack: "stack-a" });
198
+ nav.stack.push(<Text>new</Text>, { id: "screen-1", stack: "stack-a" });
165
199
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
166
200
  });
167
201
 
168
202
  it("throws when no stack is provided and no stack is mounted", () => {
169
203
  const nav = createNavigation();
170
- expect(() => nav.push(<span>x</span>)).toThrow(
204
+ expect(() => nav.stack.push(<Text>x</Text>)).toThrow(
171
205
  "could not resolve stack",
172
206
  );
173
207
  });
@@ -178,13 +212,13 @@ describe("pop", () => {
178
212
  const nav = createNavigation({
179
213
  stacks: {
180
214
  "stack-a": [
181
- { element: <span>a</span> },
182
- { element: <span>b</span> },
215
+ { element: <Text>a</Text> },
216
+ { element: <Text>b</Text> },
183
217
  ],
184
218
  },
185
219
  });
186
220
 
187
- nav.pop({ stack: "stack-a" });
221
+ nav.stack.pop({ stack: "stack-a" });
188
222
 
189
223
  expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
190
224
  });
@@ -195,7 +229,7 @@ describe("pop", () => {
195
229
  });
196
230
 
197
231
  const before = nav.store.getState();
198
- nav.pop({ stack: "stack-a" });
232
+ nav.stack.pop({ stack: "stack-a" });
199
233
  const after = nav.store.getState();
200
234
 
201
235
  expect(before).toBe(after);
@@ -203,7 +237,7 @@ describe("pop", () => {
203
237
 
204
238
  it("throws when no stack is provided and no stack is mounted", () => {
205
239
  const nav = createNavigation();
206
- expect(() => nav.pop()).toThrow("could not resolve stack");
240
+ expect(() => nav.stack.pop()).toThrow("could not resolve stack");
207
241
  });
208
242
  });
209
243
 
@@ -213,7 +247,7 @@ describe("tab", () => {
213
247
  tabs: { "my-tabs": { activeIndex: 0 } },
214
248
  });
215
249
 
216
- nav.tab(2, { tabs: "my-tabs" });
250
+ nav.tabs.tab(2, { tabs: "my-tabs" });
217
251
 
218
252
  expect(nav.store.getState().tabs.get("my-tabs")).toEqual({
219
253
  activeIndex: 2,
@@ -223,7 +257,7 @@ describe("tab", () => {
223
257
  it("creates the tabs entry if it did not exist", () => {
224
258
  const nav = createNavigation();
225
259
 
226
- nav.tab(1, { tabs: "new-tabs" });
260
+ nav.tabs.tab(1, { tabs: "new-tabs" });
227
261
 
228
262
  expect(nav.store.getState().tabs.get("new-tabs")).toEqual({
229
263
  activeIndex: 1,
@@ -232,6 +266,6 @@ describe("tab", () => {
232
266
 
233
267
  it("throws when no tabs is provided and no tabs are mounted", () => {
234
268
  const nav = createNavigation();
235
- expect(() => nav.tab(0)).toThrow("could not resolve tabs");
269
+ expect(() => nav.tabs.tab(0)).toThrow("could not resolve tabs");
236
270
  });
237
271
  });
@@ -6,18 +6,27 @@ import {
6
6
  getRenderNodeActive,
7
7
  getRenderNodeDepth,
8
8
  } from "@rn-tools/core";
9
- import type { Store, RenderTreeStore } from "@rn-tools/core";
9
+ import type { Store, RenderTreeStore, BaseOverlayOptions } from "@rn-tools/core";
10
10
  import { createSheets } from "@rn-tools/sheets";
11
11
  import type { SheetOptions, SheetsClient } from "@rn-tools/sheets";
12
-
13
- export type PushOptions = {
14
- id?: string;
12
+ import { createNotifications } from "@rn-tools/notifications";
13
+ import type {
14
+ NotificationDismissTarget,
15
+ NotificationOptions,
16
+ NotificationsClient,
17
+ } from "@rn-tools/notifications";
18
+
19
+ export const STACK_TYPE = "stack";
20
+ export const SCREEN_TYPE = "screen";
21
+ export const TABS_TYPE = "tabs";
22
+ export const TAB_SCREEN_TYPE = "tab-screen";
23
+
24
+ export type PushOptions = BaseOverlayOptions & {
15
25
  stack?: string;
16
26
  };
17
27
 
18
28
  export type NavigationScreenEntry = {
19
29
  element: React.ReactElement;
20
- id?: string;
21
30
  options?: PushOptions;
22
31
  };
23
32
 
@@ -32,19 +41,18 @@ export type NavigationState = {
32
41
 
33
42
  export type NavigationStore = Store<NavigationState>;
34
43
 
35
- export const NavigationContext = React.createContext<NavigationClient | null>(null);
36
-
37
- export const NavigationStoreContext = React.createContext<NavigationStore | null>(
44
+ export const NavigationContext = React.createContext<NavigationClient | null>(
38
45
  null,
39
46
  );
40
47
 
48
+ export const NavigationStoreContext =
49
+ React.createContext<NavigationStore | null>(null);
50
+
41
51
  export type NavigationStateInput = {
42
52
  stacks?:
43
53
  | Map<string, NavigationScreenEntry[]>
44
54
  | Record<string, NavigationScreenEntry[]>;
45
- tabs?:
46
- | Map<string, TabState>
47
- | Record<string, TabState>;
55
+ tabs?: Map<string, TabState> | Record<string, TabState>;
48
56
  };
49
57
 
50
58
  export function createNavigationState(
@@ -64,12 +72,26 @@ export type NavigationClient = {
64
72
  store: NavigationStore;
65
73
  renderTreeStore: RenderTreeStore;
66
74
  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;
75
+ notificationsStore: NotificationsClient;
76
+ stack: {
77
+ push: (element: React.ReactElement, options?: PushOptions) => void;
78
+ pop: (options?: { stack?: string }) => void;
79
+ };
80
+ tabs: {
81
+ tab: (index: number, options?: { tabs?: string }) => void;
82
+ };
83
+ sheets: {
84
+ present: (element: React.ReactElement, options?: SheetOptions) => string;
85
+ dismiss: (id?: string) => void;
86
+ dismissAll: () => void;
87
+ };
88
+ notifications: {
89
+ present: (
90
+ element: React.ReactElement,
91
+ options?: NotificationOptions,
92
+ ) => string;
93
+ dismiss: (target?: NotificationDismissTarget) => void;
94
+ };
73
95
  };
74
96
 
75
97
  export function createNavigation(
@@ -79,7 +101,8 @@ export function createNavigation(
79
101
  normalizeNavigationState(initialState ?? { stacks: new Map() }),
80
102
  );
81
103
  const renderTreeStore = createRenderTreeStore();
82
- const sheetsStore = createSheets();
104
+ const sheetsStore = createSheets(renderTreeStore);
105
+ const notificationsStore = createNotifications(renderTreeStore);
83
106
 
84
107
  function getDeepestActiveNodeId(type: string): string | null {
85
108
  const tree = renderTreeStore.getState();
@@ -100,11 +123,8 @@ export function createNavigation(
100
123
  return deepestId;
101
124
  }
102
125
 
103
- function push(
104
- element: React.ReactElement,
105
- options?: PushOptions,
106
- ) {
107
- const stackId = options?.stack ?? getDeepestActiveNodeId("stack");
126
+ function push(element: React.ReactElement, options?: PushOptions) {
127
+ const stackId = options?.stack ?? getDeepestActiveNodeId(STACK_TYPE);
108
128
  if (!stackId) {
109
129
  throw new Error(
110
130
  "push: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
@@ -115,22 +135,17 @@ export function createNavigation(
115
135
  const stacks = new Map(prev.stacks);
116
136
  const existing = stacks.get(stackId) ?? [];
117
137
 
118
- if (
119
- options?.id &&
120
- existing.some(
121
- (s) => s.id === options.id || s.options?.id === options.id,
122
- )
123
- ) {
138
+ if (options?.id && existing.some((s) => s.options?.id === options.id)) {
124
139
  return prev;
125
140
  }
126
141
 
127
- stacks.set(stackId, [...existing, { element, id: options?.id, options }]);
142
+ stacks.set(stackId, [...existing, { element, options }]);
128
143
  return { ...prev, stacks };
129
144
  });
130
145
  }
131
146
 
132
147
  function pop(options?: { stack?: string }) {
133
- const stackId = options?.stack ?? getDeepestActiveNodeId("stack");
148
+ const stackId = options?.stack ?? getDeepestActiveNodeId(STACK_TYPE);
134
149
  if (!stackId) {
135
150
  throw new Error(
136
151
  "pop: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
@@ -147,7 +162,7 @@ export function createNavigation(
147
162
  }
148
163
 
149
164
  function tab(index: number, options?: { tabs?: string }) {
150
- const tabsId = options?.tabs ?? getDeepestActiveNodeId("tabs");
165
+ const tabsId = options?.tabs ?? getDeepestActiveNodeId(TABS_TYPE);
151
166
  if (!tabsId) {
152
167
  throw new Error(
153
168
  "tab: could not resolve tabs. Pass { tabs } explicitly or ensure a Tabs is mounted and active.",
@@ -161,7 +176,10 @@ export function createNavigation(
161
176
  });
162
177
  }
163
178
 
164
- function present(element: React.ReactElement, options?: SheetOptions): string {
179
+ function present(
180
+ element: React.ReactElement,
181
+ options?: SheetOptions,
182
+ ): string {
165
183
  return sheetsStore.present(element, options);
166
184
  }
167
185
 
@@ -173,16 +191,38 @@ export function createNavigation(
173
191
  sheetsStore.dismissAll();
174
192
  }
175
193
 
194
+ function notify(
195
+ element: React.ReactElement,
196
+ options?: NotificationOptions,
197
+ ): string {
198
+ return notificationsStore.show(element, options);
199
+ }
200
+
201
+ function dismissNotification(target?: NotificationDismissTarget) {
202
+ notificationsStore.dismiss(target);
203
+ }
204
+
176
205
  return {
177
206
  store: navStore,
178
207
  renderTreeStore,
179
208
  sheetsStore,
180
- push,
181
- pop,
182
- tab,
183
- present,
184
- dismiss,
185
- dismissAll,
209
+ notificationsStore,
210
+ stack: {
211
+ push,
212
+ pop,
213
+ },
214
+ tabs: {
215
+ tab,
216
+ },
217
+ sheets: {
218
+ present,
219
+ dismiss,
220
+ dismissAll,
221
+ },
222
+ notifications: {
223
+ present: notify,
224
+ dismiss: dismissNotification,
225
+ },
186
226
  };
187
227
  }
188
228
 
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { RenderTree } from "@rn-tools/core";
3
+ import { NotificationsProvider } from "@rn-tools/notifications";
3
4
  import { SheetsProvider } from "@rn-tools/sheets";
4
5
  import {
5
6
  NavigationContext,
@@ -31,13 +32,15 @@ export const Navigation = React.memo(function Navigation(
31
32
  ) {
32
33
  return (
33
34
  <RenderTree store={props.navigation.renderTreeStore}>
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>
35
+ <NotificationsProvider notifications={props.navigation.notificationsStore}>
36
+ <SheetsProvider sheets={props.navigation.sheetsStore}>
37
+ <NavigationContext.Provider value={props.navigation}>
38
+ <NavigationStoreContext.Provider value={props.navigation.store}>
39
+ <RootStack>{props.children}</RootStack>
40
+ </NavigationStoreContext.Provider>
41
+ </NavigationContext.Provider>
42
+ </SheetsProvider>
43
+ </NotificationsProvider>
41
44
  </RenderTree>
42
45
  );
43
46
  });
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
- import { describe, expect, it } from "vitest";
3
- import { act, render, waitFor } from "@testing-library/react";
2
+ import { act, render, waitFor } from "@testing-library/react-native";
3
+ import { Text } from "react-native";
4
4
  import { RenderNodeProbe } from "@rn-tools/core/mocks/render-node-probe";
5
5
 
6
6
  import {
@@ -25,7 +25,7 @@ async function renderWithProviders(
25
25
  describe("Stack", () => {
26
26
  it("renders the rootScreen inside a screen node", async () => {
27
27
  const { renderer } = await renderWithProviders(
28
- <Stack rootScreen={<RenderNodeProbe render={(data) => data.type} />} />,
28
+ <Stack rootScreen={<RenderNodeProbe render={(data) => <Text>{data.type}</Text>} />} />,
29
29
  );
30
30
 
31
31
  expect(renderer.getByText("screen")).toBeTruthy();
@@ -35,7 +35,7 @@ describe("Stack", () => {
35
35
  const { renderer } = await renderWithProviders(
36
36
  <Stack
37
37
  active={false}
38
- rootScreen={<RenderNodeProbe render={(data) => String(data.active)} />}
38
+ rootScreen={<RenderNodeProbe render={(data) => <Text>{String(data.active)}</Text>} />}
39
39
  />,
40
40
  );
41
41
 
@@ -54,7 +54,7 @@ describe("Stack", () => {
54
54
  {
55
55
  element: (
56
56
  <RenderNodeProbe
57
- render={(data) => <span>{`a:${data.node.parentId}`}</span>}
57
+ render={(data) => <Text>{`a:${data.node.parentId}`}</Text>}
58
58
  />
59
59
  ),
60
60
  options: { id: "screen-a" },
@@ -64,7 +64,7 @@ describe("Stack", () => {
64
64
  {
65
65
  element: (
66
66
  <RenderNodeProbe
67
- render={(data) => <span>{`b:${data.node.parentId}`}</span>}
67
+ render={(data) => <Text>{`b:${data.node.parentId}`}</Text>}
68
68
  />
69
69
  ),
70
70
  options: { id: "screen-b" },
@@ -86,7 +86,7 @@ describe("Stack", () => {
86
86
  element: (
87
87
  <RenderNodeProbe
88
88
  render={(data) => (
89
- <span>{`screen-a:${String(data.active)}`}</span>
89
+ <Text>{`screen-a:${String(data.active)}`}</Text>
90
90
  )}
91
91
  />
92
92
  ),
@@ -96,7 +96,7 @@ describe("Stack", () => {
96
96
  element: (
97
97
  <RenderNodeProbe
98
98
  render={(data) => (
99
- <span>{`screen-b:${String(data.active)}`}</span>
99
+ <Text>{`screen-b:${String(data.active)}`}</Text>
100
100
  )}
101
101
  />
102
102
  ),
@@ -120,7 +120,7 @@ describe("Stack", () => {
120
120
  element: (
121
121
  <RenderNodeProbe
122
122
  render={(data) => (
123
- <span>{`screen-a:${String(data.active)}`}</span>
123
+ <Text>{`screen-a:${String(data.active)}`}</Text>
124
124
  )}
125
125
  />
126
126
  ),
@@ -132,9 +132,9 @@ describe("Stack", () => {
132
132
  );
133
133
 
134
134
  act(() => {
135
- navigation.push(
135
+ navigation.stack.push(
136
136
  <RenderNodeProbe
137
- render={(data) => <span>{`screen-b:${String(data.active)}`}</span>}
137
+ render={(data) => <Text>{`screen-b:${String(data.active)}`}</Text>}
138
138
  />,
139
139
  {
140
140
  id: "screen-b",
@@ -162,7 +162,7 @@ describe("Stack", () => {
162
162
  element: (
163
163
  <RenderNodeProbe
164
164
  render={(data) => (
165
- <span>{`a1:${data.type}:${String(data.active)}`}</span>
165
+ <Text>{`a1:${data.type}:${String(data.active)}`}</Text>
166
166
  )}
167
167
  />
168
168
  ),
@@ -172,7 +172,7 @@ describe("Stack", () => {
172
172
  element: (
173
173
  <RenderNodeProbe
174
174
  render={(data) => (
175
- <span>{`a2:${data.type}:${String(data.active)}`}</span>
175
+ <Text>{`a2:${data.type}:${String(data.active)}`}</Text>
176
176
  )}
177
177
  />
178
178
  ),
@@ -184,7 +184,7 @@ describe("Stack", () => {
184
184
  element: (
185
185
  <RenderNodeProbe
186
186
  render={(data) => (
187
- <span>{`b1:${data.type}:${String(data.active)}`}</span>
187
+ <Text>{`b1:${data.type}:${String(data.active)}`}</Text>
188
188
  )}
189
189
  />
190
190
  ),
@@ -209,7 +209,7 @@ describe("Stack", () => {
209
209
  {
210
210
  element: (
211
211
  <RenderNodeProbe
212
- render={(data) => <span>{`a1:${String(data.active)}`}</span>}
212
+ render={(data) => <Text>{`a1:${String(data.active)}`}</Text>}
213
213
  />
214
214
  ),
215
215
  options: { id: "screen-a1" },
@@ -234,7 +234,7 @@ describe("Stack", () => {
234
234
  stacks: {
235
235
  "stack-a": [
236
236
  {
237
- element: <span>screen-a</span>,
237
+ element: <Text>screen-a</Text>,
238
238
  options: { id: "screen-a" },
239
239
  },
240
240
  ],
@@ -243,7 +243,7 @@ describe("Stack", () => {
243
243
  );
244
244
 
245
245
  act(() => {
246
- navigation.push(<span>screen-a-dup</span>, {
246
+ navigation.stack.push(<Text>screen-a-dup</Text>, {
247
247
  id: "screen-a",
248
248
  stack: "stack-a",
249
249
  });
@@ -253,14 +253,14 @@ describe("Stack", () => {
253
253
  expect(renderer.getByText("screen-a")).toBeTruthy();
254
254
 
255
255
  act(() => {
256
- navigation.pop({ stack: "stack-a" });
256
+ navigation.stack.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.push(<span>screen-a-again</span>, {
263
+ navigation.stack.push(<Text>screen-a-again</Text>, {
264
264
  id: "screen-a",
265
265
  stack: "stack-a",
266
266
  });
@@ -273,13 +273,13 @@ describe("Stack", () => {
273
273
  it("ref.push adds a screen and ref.pop removes it", async () => {
274
274
  const ref = React.createRef<StackHandle>();
275
275
  const { renderer, store } = await renderWithProviders(
276
- <Stack ref={ref} id="stack-a" rootScreen={<span>root</span>} />,
276
+ <Stack ref={ref} id="stack-a" rootScreen={<Text>root</Text>} />,
277
277
  );
278
278
 
279
279
  expect(renderer.getByText("root")).toBeTruthy();
280
280
 
281
281
  act(() => {
282
- ref.current!.push(<span>pushed</span>, { id: "pushed-screen" });
282
+ ref.current!.push(<Text>pushed</Text>, { id: "pushed-screen" });
283
283
  });
284
284
 
285
285
  await waitFor(() => {
@@ -303,13 +303,13 @@ describe("Stack", () => {
303
303
  it("ref.push targets the correct stack when id is not provided", async () => {
304
304
  const ref = React.createRef<StackHandle>();
305
305
  const { renderer, store } = await renderWithProviders(
306
- <Stack ref={ref} rootScreen={<span>root</span>} />,
306
+ <Stack ref={ref} rootScreen={<Text>root</Text>} />,
307
307
  );
308
308
 
309
309
  expect(renderer.getByText("root")).toBeTruthy();
310
310
 
311
311
  act(() => {
312
- ref.current!.push(<span>pushed</span>, { id: "pushed-screen" });
312
+ ref.current!.push(<Text>pushed</Text>, { id: "pushed-screen" });
313
313
  });
314
314
 
315
315
  await waitFor(() => {
@@ -339,7 +339,7 @@ describe("Stack", () => {
339
339
  const innerRef = React.createRef<StackHandle>();
340
340
 
341
341
  function InnerStack() {
342
- return <Stack ref={innerRef} rootScreen={<span>inner-root</span>} />;
342
+ return <Stack ref={innerRef} rootScreen={<Text>inner-root</Text>} />;
343
343
  }
344
344
 
345
345
  const { renderer, store } = await renderWithProviders(
@@ -351,7 +351,7 @@ describe("Stack", () => {
351
351
  });
352
352
 
353
353
  act(() => {
354
- innerRef.current!.push(<span>inner-pushed</span>, {
354
+ innerRef.current!.push(<Text>inner-pushed</Text>, {
355
355
  id: "inner-screen",
356
356
  });
357
357
  });
@@ -365,16 +365,40 @@ describe("Stack", () => {
365
365
  expect(state.stacks.has("outer")).toBe(false);
366
366
  });
367
367
 
368
+ it("renders unwrapped screen content when wrapped is false", async () => {
369
+ const { renderer } = await renderWithProviders(
370
+ <Stack id="stack-a" />,
371
+ {
372
+ stacks: {
373
+ "stack-a": [
374
+ {
375
+ element: (
376
+ <RenderNodeProbe
377
+ render={(data) => (
378
+ <Text>{`unwrapped:${data.type}:${String(data.active)}`}</Text>
379
+ )}
380
+ />
381
+ ),
382
+ options: { id: "screen-unwrapped", wrapped: false },
383
+ },
384
+ ],
385
+ },
386
+ },
387
+ );
388
+
389
+ expect(renderer.getByText("unwrapped:screen:true")).toBeTruthy();
390
+ });
391
+
368
392
  it("resolves the deepest active stack and falls back when a subtree becomes inactive", async () => {
369
393
  const navigation = createNavigation();
370
394
 
371
395
  function NestedRight() {
372
- return <Stack id="right-nested" rootScreen={<span>nested-root</span>} />;
396
+ return <Stack id="right-nested" rootScreen={<Text>nested-root</Text>} />;
373
397
  }
374
398
 
375
399
  const result = render(
376
400
  <Navigation navigation={navigation}>
377
- <Stack id="left" rootScreen={<span>left-root</span>} />
401
+ <Stack id="left" rootScreen={<Text>left-root</Text>} />
378
402
  <Stack id="right" active={true} rootScreen={<NestedRight />} />
379
403
  </Navigation>,
380
404
  );
@@ -385,7 +409,7 @@ describe("Stack", () => {
385
409
 
386
410
  // push with no stack should target the deepest active stack: right-nested
387
411
  act(() => {
388
- navigation.push(<span>first-push</span>);
412
+ navigation.stack.push(<Text>first-push</Text>);
389
413
  });
390
414
 
391
415
  const stateAfterFirst = navigation.store.getState();
@@ -395,13 +419,13 @@ describe("Stack", () => {
395
419
  // Deactivate the right subtree — left should become the active stack
396
420
  result.rerender(
397
421
  <Navigation navigation={navigation}>
398
- <Stack id="left" rootScreen={<span>left-root</span>} />
422
+ <Stack id="left" rootScreen={<Text>left-root</Text>} />
399
423
  <Stack id="right" active={false} rootScreen={<NestedRight />} />
400
424
  </Navigation>,
401
425
  );
402
426
 
403
427
  act(() => {
404
- navigation.push(<span>second-push</span>);
428
+ navigation.stack.push(<Text>second-push</Text>);
405
429
  });
406
430
 
407
431
  const stateAfterSecond = navigation.store.getState();
package/src/stack.tsx CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  import {
8
8
  useStackScreens,
9
9
  useNavigation,
10
+ STACK_TYPE,
11
+ SCREEN_TYPE,
10
12
  type PushOptions,
11
13
  } from "./navigation-client";
12
14
 
@@ -15,10 +17,7 @@ import * as RNScreens from "react-native-screens";
15
17
  import { StyleSheet } from "react-native";
16
18
 
17
19
  export type StackHandle = {
18
- push: (
19
- element: React.ReactElement,
20
- options?: PushOptions,
21
- ) => void;
20
+ push: (element: React.ReactElement, options?: PushOptions) => void;
22
21
  pop: () => void;
23
22
  };
24
23
 
@@ -32,7 +31,7 @@ export type StackProps = {
32
31
  const StackRoot = React.memo(
33
32
  React.forwardRef<StackHandle, StackProps>(function StackRoot(props, ref) {
34
33
  const stackId = React.useRef(
35
- props.id ?? nextRenderTreeIdForType("stack"),
34
+ props.id ?? nextRenderTreeIdForType(STACK_TYPE),
36
35
  ).current;
37
36
  const navigation = useNavigation();
38
37
 
@@ -40,17 +39,17 @@ const StackRoot = React.memo(
40
39
  ref,
41
40
  () => ({
42
41
  push(element: React.ReactElement, options?: PushOptions) {
43
- navigation.push(element, { ...options, stack: stackId });
42
+ navigation.stack.push(element, { ...options, stack: stackId });
44
43
  },
45
44
  pop() {
46
- navigation.pop({ stack: stackId });
45
+ navigation.stack.pop({ stack: stackId });
47
46
  },
48
47
  }),
49
48
  [stackId, navigation],
50
49
  );
51
50
 
52
51
  return (
53
- <RenderTreeNode type="stack" id={stackId} active={props.active}>
52
+ <RenderTreeNode type={STACK_TYPE} id={stackId} active={props.active}>
54
53
  <RNScreens.ScreenStack style={StyleSheet.absoluteFill}>
55
54
  {props.rootScreen && <StackScreen>{props.rootScreen}</StackScreen>}
56
55
  {props.children}
@@ -66,10 +65,17 @@ export type StackScreenProps = {
66
65
  children: React.ReactNode;
67
66
  };
68
67
 
68
+ const defaultStyle = StyleSheet.create({
69
+ screen: {
70
+ ...StyleSheet.absoluteFillObject,
71
+ backgroundColor: "white",
72
+ },
73
+ });
74
+
69
75
  const StackScreen = React.memo(function StackScreen(props: StackScreenProps) {
70
76
  return (
71
- <RNScreens.Screen style={StyleSheet.absoluteFill}>
72
- <RenderTreeNode type="screen" id={props.id} active={props.active}>
77
+ <RNScreens.Screen style={defaultStyle.screen}>
78
+ <RenderTreeNode type={SCREEN_TYPE} id={props.id} active={props.active}>
73
79
  {props.children}
74
80
  </RenderTreeNode>
75
81
  </RNScreens.Screen>
@@ -83,15 +89,30 @@ const StackSlot = React.memo(function StackSlot() {
83
89
 
84
90
  return (
85
91
  <React.Fragment>
86
- {screens.map((screen, index, arr) => (
87
- <StackScreen
88
- id={screen.options?.id}
89
- key={screen.options?.id ?? index}
90
- active={index === arr.length - 1}
91
- >
92
- {screen.element}
93
- </StackScreen>
94
- ))}
92
+ {screens.map((screen, index, arr) => {
93
+ if (screen.options?.wrapped === false) {
94
+ return (
95
+ <RenderTreeNode
96
+ key={screen.options?.id ?? index}
97
+ type={SCREEN_TYPE}
98
+ id={screen.options?.id}
99
+ active={index === arr.length - 1}
100
+ >
101
+ {screen.element}
102
+ </RenderTreeNode>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <StackScreen
108
+ id={screen.options?.id}
109
+ key={screen.options?.id ?? index}
110
+ active={index === arr.length - 1}
111
+ >
112
+ {screen.element}
113
+ </StackScreen>
114
+ );
115
+ })}
95
116
  </React.Fragment>
96
117
  );
97
118
  });
package/src/tabs.test.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
- import { describe, expect, it } from "vitest";
3
- import { act, render, waitFor, fireEvent } from "@testing-library/react";
2
+ import { act, render, waitFor, fireEvent } from "@testing-library/react-native";
3
+ import { Pressable, Text } from "react-native";
4
4
  import { RenderNodeProbe } from "@rn-tools/core/mocks/render-node-probe";
5
5
 
6
6
  import {
@@ -11,20 +11,47 @@ import { Navigation } from "./navigation";
11
11
  import { Tabs, type TabScreenOptions, type TabsHandle } from "./tabs";
12
12
  import { Stack } from "./stack";
13
13
 
14
+ function collectTextNodes(node: unknown, output: string[]) {
15
+ if (typeof node === "string") {
16
+ output.push(node);
17
+ return;
18
+ }
19
+
20
+ if (!node || typeof node !== "object") {
21
+ return;
22
+ }
23
+
24
+ if (Array.isArray(node)) {
25
+ node.forEach((item) => collectTextNodes(item, output));
26
+ return;
27
+ }
28
+
29
+ const children = (node as { children?: unknown[] }).children;
30
+ if (children) {
31
+ children.forEach((child) => collectTextNodes(child, output));
32
+ }
33
+ }
34
+
35
+ function textOrder(renderer: ReturnType<typeof render>) {
36
+ const text: string[] = [];
37
+ collectTextNodes(renderer.toJSON(), text);
38
+ return text;
39
+ }
40
+
14
41
  function makeScreens(count: number): TabScreenOptions[] {
15
42
  return Array.from({ length: count }, (_, i) => ({
16
43
  id: `tab-${i}`,
17
44
  screen: (
18
45
  <RenderNodeProbe
19
46
  render={(data) => (
20
- <span>{`tab-${i}:${data.type}:${String(data.active)}`}</span>
47
+ <Text>{`tab-${i}:${data.type}:${String(data.active)}`}</Text>
21
48
  )}
22
49
  />
23
50
  ),
24
51
  tab: ({ isActive, onPress }) => (
25
- <span data-testid={`tab-btn-${i}`} onClick={onPress}>
26
- {`tab-btn-${i}:${String(isActive)}`}
27
- </span>
52
+ <Pressable testID={`tab-btn-${i}`} onPress={onPress}>
53
+ <Text>{`tab-btn-${i}:${String(isActive)}`}</Text>
54
+ </Pressable>
28
55
  ),
29
56
  }));
30
57
  }
@@ -69,7 +96,7 @@ describe("Tabs", () => {
69
96
  );
70
97
 
71
98
  act(() => {
72
- navigation.tab(2);
99
+ navigation.tabs.tab(2);
73
100
  });
74
101
 
75
102
  await waitFor(() => {
@@ -129,7 +156,6 @@ describe("Tabs", () => {
129
156
  expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
130
157
  });
131
158
 
132
- // The tab state should be set on the auto-generated tabs id
133
159
  const state = store.getState();
134
160
  expect(state.tabs.size).toBe(1);
135
161
  const [, tabState] = [...state.tabs.entries()][0];
@@ -143,13 +169,13 @@ describe("Tabs", () => {
143
169
  const tabScreens: TabScreenOptions[] = [
144
170
  {
145
171
  id: "tab-a",
146
- screen: <span>tab-a-content</span>,
147
- tab: () => <span>tab-a</span>,
172
+ screen: <Text>tab-a-content</Text>,
173
+ tab: () => <Text>tab-a</Text>,
148
174
  },
149
175
  {
150
176
  id: "tab-b",
151
- screen: <span>tab-b-content</span>,
152
- tab: () => <span>tab-b</span>,
177
+ screen: <Text>tab-b-content</Text>,
178
+ tab: () => <Text>tab-b</Text>,
153
179
  },
154
180
  ];
155
181
 
@@ -170,7 +196,6 @@ describe("Tabs", () => {
170
196
  ref.current!.setActive(1);
171
197
  });
172
198
 
173
- // The tab switch should target the auto-generated tabs id, not "outer-stack"
174
199
  const state = navigation.store.getState();
175
200
  expect(state.tabs.has("outer-stack")).toBe(false);
176
201
  expect(state.tabs.size).toBe(1);
@@ -210,7 +235,7 @@ describe("TabBar", () => {
210
235
  <Tabs id="my-tabs" screens={screens} />,
211
236
  );
212
237
 
213
- fireEvent.click(renderer.getByTestId("tab-btn-2"));
238
+ fireEvent.press(renderer.getByTestId("tab-btn-2"));
214
239
 
215
240
  await waitFor(() => {
216
241
  expect(renderer.getByText("tab-btn-0:false")).toBeTruthy();
@@ -226,12 +251,10 @@ describe("TabBar", () => {
226
251
  <Tabs id="my-tabs" screens={screens} />,
227
252
  );
228
253
 
229
- // Screen content should appear before the tab bar in the DOM
230
- const screenNode = renderer.getByText("tab-0:tab-screen:true");
231
- const tabNode = renderer.getByText("tab-btn-0:true");
232
- const order = screenNode.compareDocumentPosition(tabNode);
233
- // DOCUMENT_POSITION_FOLLOWING = 4
234
- expect(order & 4).toBe(4);
254
+ const order = textOrder(renderer);
255
+ expect(order.indexOf("tab-0:tab-screen:true")).toBeLessThan(
256
+ order.indexOf("tab-btn-0:true"),
257
+ );
235
258
  });
236
259
 
237
260
  it("renders tabbar at top when tabbarPosition is top", () => {
@@ -240,12 +263,10 @@ describe("TabBar", () => {
240
263
  <Tabs id="my-tabs" screens={screens} tabbarPosition="top" />,
241
264
  );
242
265
 
243
- // Tab bar should appear before screen content in the DOM
244
- const tabNode = renderer.getByText("tab-btn-0:true");
245
- const screenNode = renderer.getByText("tab-0:tab-screen:true");
246
- const order = tabNode.compareDocumentPosition(screenNode);
247
- // DOCUMENT_POSITION_FOLLOWING = 4
248
- expect(order & 4).toBe(4);
266
+ const order = textOrder(renderer);
267
+ expect(order.indexOf("tab-btn-0:true")).toBeLessThan(
268
+ order.indexOf("tab-0:tab-screen:true"),
269
+ );
249
270
  });
250
271
  });
251
272
 
@@ -256,13 +277,13 @@ describe("Nested Stack + Tabs", () => {
256
277
  const screens: TabScreenOptions[] = [
257
278
  {
258
279
  id: "tab-a",
259
- screen: <Stack id="stack-a" rootScreen={<span>stack-a-root</span>} />,
260
- tab: () => <span>tab-a</span>,
280
+ screen: <Stack id="stack-a" rootScreen={<Text>stack-a-root</Text>} />,
281
+ tab: () => <Text>tab-a</Text>,
261
282
  },
262
283
  {
263
284
  id: "tab-b",
264
- screen: <Stack id="stack-b" rootScreen={<span>stack-b-root</span>} />,
265
- tab: () => <span>tab-b</span>,
285
+ screen: <Stack id="stack-b" rootScreen={<Text>stack-b-root</Text>} />,
286
+ tab: () => <Text>tab-b</Text>,
266
287
  },
267
288
  ];
268
289
 
@@ -276,9 +297,8 @@ describe("Nested Stack + Tabs", () => {
276
297
  expect(result.getByText("stack-a-root")).toBeTruthy();
277
298
  });
278
299
 
279
- // Tab 0 (stack-a) is active — push should target stack-a
280
300
  act(() => {
281
- navigation.push(<span>pushed-to-a</span>);
301
+ navigation.stack.push(<Text>pushed-to-a</Text>);
282
302
  });
283
303
 
284
304
  const stateAfterFirst = navigation.store.getState();
@@ -292,13 +312,13 @@ describe("Nested Stack + Tabs", () => {
292
312
  const screens: TabScreenOptions[] = [
293
313
  {
294
314
  id: "tab-a",
295
- screen: <Stack id="stack-a" rootScreen={<span>stack-a-root</span>} />,
296
- tab: () => <span>tab-a</span>,
315
+ screen: <Stack id="stack-a" rootScreen={<Text>stack-a-root</Text>} />,
316
+ tab: () => <Text>tab-a</Text>,
297
317
  },
298
318
  {
299
319
  id: "tab-b",
300
- screen: <Stack id="stack-b" rootScreen={<span>stack-b-root</span>} />,
301
- tab: () => <span>tab-b</span>,
320
+ screen: <Stack id="stack-b" rootScreen={<Text>stack-b-root</Text>} />,
321
+ tab: () => <Text>tab-b</Text>,
302
322
  },
303
323
  ];
304
324
 
@@ -308,13 +328,12 @@ describe("Nested Stack + Tabs", () => {
308
328
  </Navigation>,
309
329
  );
310
330
 
311
- // Switch to tab 1 (stack-b)
312
331
  act(() => {
313
- navigation.tab(1);
332
+ navigation.tabs.tab(1);
314
333
  });
315
334
 
316
335
  act(() => {
317
- navigation.push(<span>pushed-to-b</span>);
336
+ navigation.stack.push(<Text>pushed-to-b</Text>);
318
337
  });
319
338
 
320
339
  const state = navigation.store.getState();
@@ -328,19 +347,22 @@ describe("Nested Stack + Tabs", () => {
328
347
  const tabScreens: TabScreenOptions[] = [
329
348
  {
330
349
  id: "tab-a",
331
- screen: <span>tab-a-content</span>,
332
- tab: () => <span>tab-a</span>,
350
+ screen: <Text>tab-a-content</Text>,
351
+ tab: () => <Text>tab-a</Text>,
333
352
  },
334
353
  {
335
354
  id: "tab-b",
336
- screen: <span>tab-b-content</span>,
337
- tab: () => <span>tab-b</span>,
355
+ screen: <Text>tab-b-content</Text>,
356
+ tab: () => <Text>tab-b</Text>,
338
357
  },
339
358
  ];
340
359
 
341
360
  const result = render(
342
361
  <Navigation navigation={navigation}>
343
- <Stack id="outer-stack" rootScreen={<Tabs id="inner-tabs" screens={tabScreens} />} />
362
+ <Stack
363
+ id="outer-stack"
364
+ rootScreen={<Tabs id="inner-tabs" screens={tabScreens} />}
365
+ />
344
366
  </Navigation>,
345
367
  );
346
368
 
@@ -348,9 +370,8 @@ describe("Nested Stack + Tabs", () => {
348
370
  expect(result.getByText("tab-a-content")).toBeTruthy();
349
371
  });
350
372
 
351
- // tab with no explicit tabs should resolve inner-tabs
352
373
  act(() => {
353
- navigation.tab(1);
374
+ navigation.tabs.tab(1);
354
375
  });
355
376
 
356
377
  const state = navigation.store.getState();
package/src/tabs.tsx CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  useTabActiveIndex,
10
10
  useNavigationStore,
11
11
  useNavigation,
12
+ TABS_TYPE,
13
+ TAB_SCREEN_TYPE,
12
14
  } from "./navigation-client";
13
15
 
14
16
  import * as RNScreens from "react-native-screens";
@@ -40,7 +42,7 @@ export type TabsProps = {
40
42
  const TabsRoot = React.memo(
41
43
  React.forwardRef<TabsHandle, TabsProps>(function TabsRoot(props, ref) {
42
44
  const tabsId = React.useRef(
43
- props.id ?? nextRenderTreeIdForType("tabs"),
45
+ props.id ?? nextRenderTreeIdForType(TABS_TYPE),
44
46
  ).current;
45
47
  const navigation = useNavigation();
46
48
 
@@ -48,14 +50,14 @@ const TabsRoot = React.memo(
48
50
  ref,
49
51
  () => ({
50
52
  setActive(index: number) {
51
- navigation.tab(index, { tabs: tabsId });
53
+ navigation.tabs.tab(index, { tabs: tabsId });
52
54
  },
53
55
  }),
54
56
  [tabsId, navigation],
55
57
  );
56
58
 
57
59
  return (
58
- <RenderTreeNode type="tabs" id={tabsId} active={props.active}>
60
+ <RenderTreeNode type={TABS_TYPE} id={tabsId} active={props.active}>
59
61
  {props.children}
60
62
  </RenderTreeNode>
61
63
  );
@@ -127,7 +129,7 @@ const TabsSlot = React.memo(function TabsSlot(props: {
127
129
  style={StyleSheet.absoluteFill}
128
130
  >
129
131
  <RenderTreeNode
130
- type="tab-screen"
132
+ type={TAB_SCREEN_TYPE}
131
133
  id={`${tabsId}/${entry.id}`}
132
134
  active={index === activeIndex}
133
135
  >