@sigmela/router 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -148,10 +148,60 @@ const stack = new NavigationStack({ header: { largeTitle: true } })
148
148
 
149
149
  Key methods:
150
150
  - `addScreen(pathPattern, componentOrNode, options?)`
151
- - `addModal(pathPattern, component, options?)` (shorthand for `stackPresentation: 'modal'`)
152
- - `addSheet(pathPattern, component, options?)` (shorthand for `stackPresentation: 'sheet'`)
151
+ - `addModal(pathPattern, componentOrStack, options?)` (shorthand for `stackPresentation: 'modal'`)
152
+ - `addSheet(pathPattern, componentOrStack, options?)` (shorthand for `stackPresentation: 'sheet'`)
153
153
  - `addStack(prefixOrStack, maybeStack?)` — compose nested stacks under a prefix
154
154
 
155
+ ### Modal Stacks (Stack in Stack)
156
+
157
+ You can pass an entire `NavigationStack` to `addModal()` or `addSheet()` to create a multi-screen flow inside a modal:
158
+
159
+ ```tsx
160
+ // Define a flow with multiple screens
161
+ const emailVerifyStack = new NavigationStack()
162
+ .addScreen('/verify', EmailInputScreen)
163
+ .addScreen('/verify/sent', EmailSentScreen);
164
+
165
+ // Mount the entire stack as a modal
166
+ const rootStack = new NavigationStack()
167
+ .addScreen('/', HomeScreen)
168
+ .addModal('/verify', emailVerifyStack);
169
+ ```
170
+
171
+ **How it works:**
172
+ - Navigating to `/verify` opens the modal with `EmailInputScreen`
173
+ - Inside the modal, `router.navigate('/verify/sent')` pushes `EmailSentScreen` within the same modal
174
+ - `router.goBack()` navigates back inside the modal stack
175
+ - `router.dismiss()` closes the entire modal from any depth
176
+
177
+ **Example screen with navigation inside modal:**
178
+
179
+ ```tsx
180
+ function EmailInputScreen() {
181
+ const router = useRouter();
182
+
183
+ return (
184
+ <View>
185
+ <Button title="Next" onPress={() => router.navigate('/verify/sent')} />
186
+ <Button title="Close" onPress={() => router.dismiss()} />
187
+ </View>
188
+ );
189
+ }
190
+
191
+ function EmailSentScreen() {
192
+ const router = useRouter();
193
+
194
+ return (
195
+ <View>
196
+ <Button title="Back" onPress={() => router.goBack()} />
197
+ <Button title="Done" onPress={() => router.dismiss()} />
198
+ </View>
199
+ );
200
+ }
201
+ ```
202
+
203
+ This pattern works recursively — you can nest stacks inside stacks to any depth.
204
+
155
205
  ### `Router`
156
206
 
157
207
  The `Router` holds navigation state and performs path matching.
@@ -169,6 +219,7 @@ Navigation:
169
219
  - `router.navigate(path)` — push
170
220
  - `router.replace(path, dedupe?)` — replace top of the active stack
171
221
  - `router.goBack()` — pop top of the active stack
222
+ - `router.dismiss()` — close the nearest modal or sheet (including all screens in a modal stack)
172
223
  - `router.reset(path)` — **web-only**: rebuild Router state as if app loaded at `path`
173
224
  - `router.setRoot(rootKey, { transition? })` — swap root at runtime (`rootKey` from `config.roots`)
174
225
 
@@ -180,8 +231,6 @@ State/subscriptions:
180
231
  - `router.subscribeRoot(cb)` — notify when root is replaced via `setRoot`
181
232
  - `router.getStackHistory(stackId)` — slice of history for a stack
182
233
 
183
- > Note: `router.getGlobalStackId()` exists but currently returns `undefined`.
184
-
185
234
  ### `TabBar`
186
235
 
187
236
  `TabBar` is a container node that renders one tab at a time.
@@ -193,12 +242,16 @@ const tabBar = new TabBar({ component: CustomTabBar, initialIndex: 0 })
193
242
  ```
194
243
 
195
244
  Key methods:
196
- - `addTab({ key, stack?, screen?, title?, icon?, selectedIcon?, ... })`
245
+ - `addTab({ key, stack?, node?, screen?, prefix?, title?, icon?, selectedIcon?, ... })`
197
246
  - `onIndexChange(index)` — switch active tab
198
247
  - `setBadge(index, badge | null)`
199
248
  - `setTabBarConfig(partialConfig)`
200
249
  - `getState()` and `subscribe(cb)`
201
250
 
251
+ Notes:
252
+ - Exactly one of `stack`, `node`, `screen` must be provided.
253
+ - Use `prefix` to mount a tab's routes under a base path (e.g. `/mail`).
254
+
202
255
  Web behavior note:
203
256
  - The built-in **web** tab bar renderer resets Router history on tab switch (to keep URL and Router state consistent) using `router.reset(firstRoutePath)`.
204
257
 
@@ -207,22 +260,25 @@ Web behavior note:
207
260
  `SplitView` renders **two stacks**: `primary` and `secondary`.
208
261
 
209
262
  - On **native**, `secondary` overlays `primary` when it has at least one screen in its history.
210
- - On **web**, the layout becomes side-by-side at a fixed breakpoint (currently `>= 640px` in CSS).
263
+ - On **web**, the layout becomes side-by-side at a fixed breakpoint (`minWidth`, default `640px`).
211
264
 
212
265
  ```tsx
213
- import { NavigationStack, SplitView } from '@sigmela/router';
266
+ import { NavigationStack, SplitView, TabBar } from '@sigmela/router';
214
267
 
215
268
  const master = new NavigationStack().addScreen('/', ThreadsScreen);
216
269
  const detail = new NavigationStack().addScreen('/:threadId', ThreadScreen);
217
270
 
218
271
  const splitView = new SplitView({
219
- minWidth: 640, // currently not used by the web renderer; kept for API compatibility
272
+ minWidth: 640,
220
273
  primary: master,
221
274
  secondary: detail,
222
275
  primaryMaxWidth: 390,
223
276
  });
224
277
 
225
- const root = new NavigationStack().addScreen('/mail', splitView);
278
+ // Mount SplitView directly as a tab (no wrapper stack needed).
279
+ const tabBar = new TabBar()
280
+ .addTab({ key: 'mail', node: splitView, prefix: '/mail', title: 'Mail' })
281
+ .addTab({ key: 'settings', stack: settingsStack, title: 'Settings' });
226
282
  ```
227
283
 
228
284
  ## Controllers
@@ -5,13 +5,22 @@ import { RouterContext } from "./RouterContext.js";
5
5
  import { ScreenStack } from "./ScreenStack/index.js";
6
6
  import { StyleSheet } from 'react-native';
7
7
  import { useSyncExternalStore, memo, useCallback, useEffect, useState } from 'react';
8
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
+ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  const EMPTY_HISTORY = [];
10
10
  function useStackHistory(router, stackId) {
11
11
  const subscribe = useCallback(cb => stackId ? router.subscribeStack(stackId, cb) : () => {}, [router, stackId]);
12
12
  const get = useCallback(() => stackId ? router.getStackHistory(stackId) : EMPTY_HISTORY, [router, stackId]);
13
13
  return useSyncExternalStore(subscribe, get, get);
14
14
  }
15
+
16
+ /**
17
+ * Navigation component renders the root and global stacks.
18
+ *
19
+ * Modal stacks (NavigationStack added via addModal) are rendered as regular ScreenStackItems
20
+ * with their component being the StackRenderer that subscribes to its own stack history.
21
+ * This creates a clean recursive structure: stacks render their items, nested stacks
22
+ * (via childNode) render their own items through StackRenderer.
23
+ */
15
24
  export const Navigation = /*#__PURE__*/memo(({
16
25
  router,
17
26
  appearance
@@ -30,23 +39,17 @@ export const Navigation = /*#__PURE__*/memo(({
30
39
  rootId
31
40
  } = root;
32
41
  const rootTransition = router.getRootTransition();
33
- const globalId = router.getGlobalStackId();
34
42
  const rootItems = useStackHistory(router, rootId);
35
- const globalItems = useStackHistory(router, globalId);
36
43
  return /*#__PURE__*/_jsx(RouterContext.Provider, {
37
44
  value: router,
38
- children: /*#__PURE__*/_jsxs(ScreenStack, {
45
+ children: /*#__PURE__*/_jsx(ScreenStack, {
39
46
  style: styles.flex,
40
- children: [rootItems.map(item => /*#__PURE__*/_jsx(ScreenStackItem, {
47
+ children: rootItems.map(item => /*#__PURE__*/_jsx(ScreenStackItem, {
41
48
  stackId: rootId,
42
49
  item: item,
43
50
  stackAnimation: rootTransition,
44
51
  appearance: appearance
45
- }, `root-${item.key}`)), globalItems.map(item => /*#__PURE__*/_jsx(ScreenStackItem, {
46
- appearance: appearance,
47
- stackId: globalId,
48
- item: item
49
- }, `global-${item.key}`))]
52
+ }, `root-${item.key}`))
50
53
  }, rootId ?? 'root')
51
54
  });
52
55
  });
@@ -3,6 +3,8 @@
3
3
  import { nanoid } from 'nanoid/non-secure';
4
4
  import { match as pathMatchFactory } from 'path-to-regexp';
5
5
  import qs from 'query-string';
6
+ import React from 'react';
7
+ import { StackRenderer } from "./StackRenderer.js";
6
8
  export class NavigationStack {
7
9
  routes = [];
8
10
  children = [];
@@ -130,7 +132,15 @@ export class NavigationStack {
130
132
  return this.children.slice();
131
133
  }
132
134
  getRenderer() {
133
- return () => null;
135
+ // eslint-disable-next-line consistent-this
136
+ const stackInstance = this;
137
+ const stackId = stackInstance.getId();
138
+ return function NavigationStackRenderer(props) {
139
+ return /*#__PURE__*/React.createElement(StackRenderer, {
140
+ stackId: stackId,
141
+ appearance: props.appearance
142
+ });
143
+ };
134
144
  }
135
145
  seed() {
136
146
  const first = this.getFirstRoute();
@@ -3,6 +3,7 @@
3
3
  import { nanoid } from 'nanoid/non-secure';
4
4
  import { Platform } from 'react-native';
5
5
  import qs from 'query-string';
6
+ import { isModalLikePresentation } from "./types.js";
6
7
  function canSwitchToRoute(node) {
7
8
  return node !== undefined && typeof node.switchToRoute === 'function';
8
9
  }
@@ -56,6 +57,9 @@ export class Router {
56
57
  }
57
58
  this.recomputeActiveRoute();
58
59
  }
60
+ isDebugEnabled() {
61
+ return this.debugEnabled;
62
+ }
59
63
  log(message, data) {
60
64
  if (this.debugEnabled) {
61
65
  if (data !== undefined) {
@@ -136,6 +140,72 @@ export class Router {
136
140
  }
137
141
  this.popFromActiveStack();
138
142
  };
143
+
144
+ /**
145
+ * Closes the nearest modal or sheet, regardless of navigation depth inside it.
146
+ * Useful when a NavigationStack is rendered inside a modal and you want to
147
+ * close the entire modal from any screen within it.
148
+ */
149
+ dismiss = () => {
150
+ // Find the nearest modal/sheet item in history (searching from end)
151
+ let modalItem = null;
152
+ for (let i = this.state.history.length - 1; i >= 0; i--) {
153
+ const item = this.state.history[i];
154
+ if (item && isModalLikePresentation(item.options?.stackPresentation)) {
155
+ modalItem = item;
156
+ break;
157
+ }
158
+ }
159
+ if (!modalItem) {
160
+ this.log('dismiss: no modal found in history');
161
+ return;
162
+ }
163
+ this.log('dismiss: closing modal', {
164
+ key: modalItem.key,
165
+ routeId: modalItem.routeId,
166
+ stackId: modalItem.stackId,
167
+ presentation: modalItem.options?.stackPresentation
168
+ });
169
+
170
+ // Handle sheet dismisser if registered
171
+ if (modalItem.options?.stackPresentation === 'sheet') {
172
+ const dismisser = this.sheetDismissers.get(modalItem.key);
173
+ if (dismisser) {
174
+ this.unregisterSheetDismisser(modalItem.key);
175
+ dismisser();
176
+ return;
177
+ }
178
+ }
179
+
180
+ // Check if modal has a childNode (NavigationStack added via addModal)
181
+ const compiled = this.registry.find(r => r.routeId === modalItem.routeId);
182
+ const childNode = compiled?.childNode;
183
+ if (childNode) {
184
+ // Modal stack: remove all items from child stack AND the modal wrapper item
185
+ const childStackId = childNode.getId();
186
+ this.log('dismiss: closing modal stack', {
187
+ childStackId,
188
+ modalKey: modalItem.key
189
+ });
190
+ const newHistory = this.state.history.filter(item => item.stackId !== childStackId && item.key !== modalItem.key);
191
+ this.setState({
192
+ history: newHistory
193
+ });
194
+
195
+ // Clear child stack's history cache
196
+ this.stackHistories.delete(childStackId);
197
+ } else {
198
+ // Simple modal: just pop the modal item
199
+ this.applyHistoryChange('pop', modalItem);
200
+ }
201
+ this.recomputeActiveRoute();
202
+ this.emit(this.listeners);
203
+
204
+ // Sync URL on web
205
+ if (this.isWebEnv()) {
206
+ this.syncUrlAfterInternalPop(modalItem);
207
+ }
208
+ };
139
209
  getState = () => {
140
210
  return this.state;
141
211
  };
@@ -154,6 +224,14 @@ export class Router {
154
224
  }
155
225
  return this.stackHistories.get(stackId) ?? EMPTY_ARRAY;
156
226
  };
227
+
228
+ /**
229
+ * Returns all history items in navigation order.
230
+ * Useful for rendering all screens including modal stacks.
231
+ */
232
+ getFullHistory = () => {
233
+ return this.state.history;
234
+ };
157
235
  subscribeStack = (stackId, cb) => {
158
236
  if (!stackId) return () => {};
159
237
  let set = this.stackListeners.get(stackId);
@@ -172,9 +250,6 @@ export class Router {
172
250
  getRootStackId() {
173
251
  return this.root?.getId();
174
252
  }
175
- getGlobalStackId() {
176
- return undefined;
177
- }
178
253
  subscribeRoot(listener) {
179
254
  this.rootListeners.add(listener);
180
255
  return () => this.rootListeners.delete(listener);
@@ -508,12 +583,23 @@ export class Router {
508
583
  }
509
584
  const newItem = this.createHistoryItem(base, params, query, pathname, passProps);
510
585
  this.applyHistoryChange(action, newItem);
586
+
587
+ // Seed child node if present
588
+ if (base.childNode) {
589
+ this.addChildNodeSeedsToHistory(base.routeId);
590
+ }
511
591
  };
512
592
  base.controller(controllerInput, present);
513
593
  return;
514
594
  }
515
595
  const newItem = this.createHistoryItem(base, params, query, pathname);
516
596
  this.applyHistoryChange(action, newItem);
597
+
598
+ // If the matched route has a childNode (e.g., NavigationStack added via addModal),
599
+ // seed the child stack's history so StackRenderer has items to render.
600
+ if (base.childNode) {
601
+ this.addChildNodeSeedsToHistory(base.routeId);
602
+ }
517
603
  }
518
604
  createHistoryItem(matched, params, query, pathname, passProps) {
519
605
  const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
@@ -862,7 +948,7 @@ export class Router {
862
948
  }
863
949
  buildRegistry() {
864
950
  this.registry.length = 0;
865
- const addFromNode = (node, basePath) => {
951
+ const addFromNode = (node, basePath, inheritedOptions) => {
866
952
  const normalizedBasePath = this.normalizeBasePath(basePath);
867
953
  const baseSpecificity = this.computeBasePathSpecificity(normalizedBasePath);
868
954
  const routes = node.getNodeRoutes();
@@ -870,7 +956,16 @@ export class Router {
870
956
  if (stackId) {
871
957
  this.stackById.set(stackId, node);
872
958
  }
959
+ let isFirstRoute = true;
873
960
  for (const r of routes) {
961
+ // Merge options: first route inherits parent options (e.g., for nested stacks)
962
+ const mergedOptions = isFirstRoute && inheritedOptions ? {
963
+ ...inheritedOptions,
964
+ ...r.options
965
+ } : r.options;
966
+
967
+ // Always register the route.
968
+ // If it has a childNode, r.component is already childNode.getRenderer() (set by extractComponent).
874
969
  const compiled = {
875
970
  routeId: r.routeId,
876
971
  path: this.combinePathWithBase(r.path, normalizedBasePath),
@@ -891,7 +986,7 @@ export class Router {
891
986
  },
892
987
  component: r.component,
893
988
  controller: r.controller,
894
- options: r.options,
989
+ options: mergedOptions,
895
990
  stackId,
896
991
  childNode: r.childNode
897
992
  };
@@ -908,10 +1003,15 @@ export class Router {
908
1003
  pathnamePattern: compiled.pathnamePattern,
909
1004
  isWildcardPath: compiled.isWildcardPath,
910
1005
  baseSpecificity: compiled.baseSpecificity,
911
- stackId
1006
+ stackId,
1007
+ hasChildNode: !!compiled.childNode
912
1008
  });
1009
+ isFirstRoute = false;
1010
+
1011
+ // Also register routes from childNode (for navigation inside the nested stack)
913
1012
  if (r.childNode) {
914
1013
  const nextBaseForChild = r.isWildcardPath ? normalizedBasePath : this.joinPaths(normalizedBasePath, r.pathnamePattern);
1014
+ // Child routes don't inherit parent options - they use their own
915
1015
  addFromNode(r.childNode, nextBaseForChild);
916
1016
  }
917
1017
  }
@@ -1172,12 +1272,21 @@ export class Router {
1172
1272
  if (hasQueryPattern) {
1173
1273
  spec += 1000;
1174
1274
  }
1275
+
1276
+ // Routes with childNode AND modal/sheet presentation are "wrapper" routes
1277
+ // that should take priority over the child stack's own routes when both match.
1278
+ // This ensures addModal('/path', NavigationStack) renders the wrapper modal
1279
+ // and not the child stack's first screen directly.
1280
+ if (r.childNode && isModalLikePresentation(r.options?.stackPresentation)) {
1281
+ spec += 1;
1282
+ }
1175
1283
  this.log('matchBaseRoute candidate', {
1176
1284
  routeId: r.routeId,
1177
1285
  path: r.path,
1178
1286
  baseSpecificity: r.baseSpecificity,
1179
1287
  adjustedSpecificity: spec,
1180
1288
  hasQueryPattern,
1289
+ hasChildNode: !!r.childNode,
1181
1290
  stackId: r.stackId
1182
1291
  });
1183
1292
  if (!best || spec > best.specificity) {
@@ -1,11 +1,11 @@
1
1
  "use strict";
2
2
 
3
- import { memo, useRef, useLayoutEffect, useMemo, useEffect, Children, isValidElement, Fragment } from 'react';
3
+ import { memo, useRef, useLayoutEffect, useMemo, useEffect, Children, isValidElement, Fragment, useCallback, useContext } from 'react';
4
4
  import { useTransitionMap } from 'react-transition-state';
5
5
  import { ScreenStackItemsContext, ScreenStackAnimatingContext, useScreenStackConfig } from "./ScreenStackContext.js";
6
6
  import { getPresentationTypeClass, computeAnimationType } from "./animationHelpers.js";
7
+ import { RouterContext } from "../RouterContext.js";
7
8
  import { jsx as _jsx } from "react/jsx-runtime";
8
- const devLog = (_, __) => {};
9
9
  const isScreenStackItemElement = child => {
10
10
  if (! /*#__PURE__*/isValidElement(child)) return false;
11
11
  const anyProps = child.props;
@@ -54,6 +54,12 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
54
54
  transitionTime = 250,
55
55
  animated = true
56
56
  } = props;
57
+ const router = useContext(RouterContext);
58
+ const debugEnabled = router?.isDebugEnabled() ?? false;
59
+ const devLog = useCallback((msg, data) => {
60
+ if (!debugEnabled) return;
61
+ console.log(msg, data !== undefined ? JSON.stringify(data) : '');
62
+ }, [debugEnabled]);
57
63
  devLog('[ScreenStack] Render', {
58
64
  transitionTime,
59
65
  animated,
@@ -63,6 +69,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
63
69
  const isInitialMountRef = useRef(true);
64
70
  const suppressEnterAfterEmptyRef = useRef(false);
65
71
  const suppressedEnterKeyRef = useRef(null);
72
+ const isBulkRemovalRef = useRef(false);
66
73
  const prevKeysRef = useRef([]);
67
74
  const lastDirectionRef = useRef('forward');
68
75
  const childMapRef = useRef(new Map());
@@ -81,7 +88,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
81
88
  stackChildrenLength: stackItems.length
82
89
  });
83
90
  return stackItems;
84
- }, [children]);
91
+ }, [children, devLog]);
85
92
  const routeKeys = useMemo(() => {
86
93
  const keys = stackChildren.map(child => {
87
94
  const item = child.props.item;
@@ -89,7 +96,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
89
96
  });
90
97
  devLog('[ScreenStack] routeKeys', keys);
91
98
  return keys;
92
- }, [stackChildren]);
99
+ }, [devLog, stackChildren]);
93
100
  const childMap = useMemo(() => {
94
101
  const map = new Map(childMapRef.current);
95
102
  for (const child of stackChildren) {
@@ -103,7 +110,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
103
110
  keys: Array.from(map.keys())
104
111
  });
105
112
  return map;
106
- }, [stackChildren]);
113
+ }, [devLog, stackChildren]);
107
114
  const {
108
115
  stateMap,
109
116
  toggle,
@@ -137,12 +144,12 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
137
144
  isResolved: state.isResolved
138
145
  })));
139
146
  const stateMapEntries = Array.from(stateMap.entries());
147
+ const prevKeysForDirection = prevKeysRef.current;
140
148
  const direction = useMemo(() => {
141
- const prevKeys = prevKeysRef.current;
142
- const computed = computeDirection(prevKeys, routeKeys);
149
+ const computed = computeDirection(prevKeysForDirection, routeKeys);
143
150
  prevKeysRef.current = routeKeys;
144
151
  return computed;
145
- }, [routeKeys]);
152
+ }, [routeKeys, prevKeysForDirection]);
146
153
  devLog('[ScreenStack] Computed direction', {
147
154
  prevKeys: prevKeysRef.current,
148
155
  routeKeys,
@@ -166,10 +173,23 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
166
173
  exitingKeys
167
174
  });
168
175
  return result;
169
- }, [routeKeys, stateMapEntries]);
176
+ }, [devLog, routeKeys, stateMapEntries]);
170
177
  const containerClassName = useMemo(() => {
171
178
  return 'screen-stack';
172
179
  }, []);
180
+
181
+ // CRITICAL: Calculate bulk removal BEFORE useMemo for itemsContextValue
182
+ // so the flag is available when computing animation types
183
+ const removedKeysForBulkDetection = useMemo(() => {
184
+ const routeKeySet = new Set(routeKeys);
185
+ const existingKeySet = new Set();
186
+ for (const [key] of stateMapEntries) {
187
+ existingKeySet.add(key);
188
+ }
189
+ return [...existingKeySet].filter(key => !routeKeySet.has(key));
190
+ }, [routeKeys, stateMapEntries]);
191
+ const isBulkRemoval = removedKeysForBulkDetection.length > 1 || routeKeys.length === 0 && prevKeysForDirection.length > 1;
192
+ isBulkRemovalRef.current = isBulkRemoval;
173
193
  useLayoutEffect(() => {
174
194
  devLog('[ScreenStack] === LIFECYCLE EFFECT START ===', {
175
195
  prevKeys: prevKeysRef.current,
@@ -197,6 +217,14 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
197
217
  removedKeys
198
218
  });
199
219
 
220
+ // Bulk removal was already computed before useMemo (see above)
221
+ devLog('[ScreenStack] Bulk removal detected', {
222
+ isBulkRemoval: isBulkRemovalRef.current,
223
+ removedCount: removedKeys.length,
224
+ prevLength: prevKeysForDirection.length,
225
+ currentLength: routeKeys.length
226
+ });
227
+
200
228
  // If this is the first pushed key after the stack was empty, remember its key so we can
201
229
  // suppress only its enter animation (without affecting exit animations).
202
230
  if (!animateFirstScreenAfterEmpty && suppressEnterAfterEmptyRef.current && routeKeys.length > 0 && newKeys.length > 0) {
@@ -227,7 +255,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
227
255
  }
228
256
  lastDirectionRef.current = direction;
229
257
  devLog('[ScreenStack] === LIFECYCLE EFFECT END ===');
230
- }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty]);
258
+ }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty, devLog]);
231
259
  useLayoutEffect(() => {
232
260
  devLog('[ScreenStack] === CLEANUP EFFECT START ===');
233
261
  const routeKeySet = new Set(routeKeys);
@@ -253,7 +281,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
253
281
  }
254
282
  }
255
283
  devLog('[ScreenStack] === CLEANUP EFFECT END ===');
256
- }, [routeKeys, stateMapEntries, deleteItem]);
284
+ }, [routeKeys, stateMapEntries, deleteItem, devLog]);
257
285
  useEffect(() => {
258
286
  if (!isInitialMountRef.current) return;
259
287
  const hasMountedItem = stateMapEntries.some(([, st]) => st.isMounted);
@@ -271,7 +299,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
271
299
  isInitialMountRef.current = false;
272
300
  devLog('[ScreenStack] Initial mount completed');
273
301
  }
274
- }, [stateMapEntries, routeKeys.length, animateFirstScreenAfterEmpty]);
302
+ }, [stateMapEntries, routeKeys.length, animateFirstScreenAfterEmpty, devLog]);
275
303
 
276
304
  // Clear suppression key once it is no longer the top screen (so it can animate normally as
277
305
  // a background when new screens are pushed).
@@ -326,7 +354,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
326
354
  const routeIndex = routeKeys.indexOf(key);
327
355
  const zIndex = routeIndex >= 0 ? routeIndex + 1 : keysToRender.length + index + 1;
328
356
  const presentationType = getPresentationTypeClass(presentation);
329
- let animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
357
+ let animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated, isBulkRemovalRef.current);
330
358
 
331
359
  // SplitView-secondary-only: suppress enter animation for the first screen after empty.
332
360
  if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
@@ -358,7 +386,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
358
386
  phase = 'inactive';
359
387
  }
360
388
  const presentationType = getPresentationTypeClass(presentation);
361
- let animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
389
+ let animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated, isBulkRemovalRef.current);
362
390
  if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
363
391
  animationType = 'none';
364
392
  }
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
 
3
+ import { isModalLikePresentation } from "../types.js";
3
4
  export function getPresentationTypeClass(presentation) {
4
5
  switch (presentation) {
5
6
  case 'push':
6
7
  return 'push';
7
8
  case 'modal':
8
9
  return 'modal';
10
+ case 'modalRight':
11
+ return 'modal-right';
9
12
  case 'transparentModal':
10
13
  return 'transparent-modal';
11
14
  case 'containedModal':
@@ -32,15 +35,20 @@ export function getAnimationTypeForPresentation(presentation, isEntering, direct
32
35
  }
33
36
  return `${presentationClass}-${suffix}`;
34
37
  }
35
- export function computeAnimationType(_key, isInStack, isTop, direction, presentation, isInitialPhase, animated = true) {
38
+ export function computeAnimationType(_key, isInStack, isTop, direction, presentation, isInitialPhase, animated = true, isBulkRemoval = false) {
36
39
  if (!animated) {
37
40
  return 'no-animate';
38
41
  }
39
42
  if (isInitialPhase) {
40
43
  return 'none';
41
44
  }
45
+
46
+ // When multiple screens are removed at once (bulk removal), don't animate them
47
+ if (isBulkRemoval && !isInStack) {
48
+ return 'no-animate';
49
+ }
42
50
  const isEntering = isInStack && isTop;
43
- const isModalLike = ['modal', 'transparentModal', 'containedModal', 'containedTransparentModal', 'fullScreenModal', 'formSheet', 'pageSheet', 'sheet'].includes(presentation);
51
+ const isModalLike = isModalLikePresentation(presentation);
44
52
  if (isModalLike) {
45
53
  if (!isInStack) {
46
54
  return getAnimationTypeForPresentation(presentation, false, direction);
@@ -48,7 +56,14 @@ export function computeAnimationType(_key, isInStack, isTop, direction, presenta
48
56
  if (isEntering) {
49
57
  return getAnimationTypeForPresentation(presentation, true, direction);
50
58
  }
51
- return 'none';
59
+
60
+ // Modal-like screen that's NOT top (background) - animate like push
61
+ // This happens when navigating inside a modal stack
62
+ if (direction === 'forward') {
63
+ return 'push-background';
64
+ } else {
65
+ return 'pop-background';
66
+ }
52
67
  }
53
68
  if (!isInStack) {
54
69
  if (direction === 'forward') {
@@ -17,6 +17,9 @@ export const ScreenStackItem = /*#__PURE__*/memo(({
17
17
  stackPresentation,
18
18
  ...screenProps
19
19
  } = item.options || {};
20
+
21
+ // On native, modalRight behaves as regular modal
22
+ const nativePresentation = stackPresentation === 'modalRight' ? 'modal' : stackPresentation;
20
23
  const route = {
21
24
  presentation: stackPresentation ?? 'push',
22
25
  params: item.params,
@@ -54,7 +57,7 @@ export const ScreenStackItem = /*#__PURE__*/memo(({
54
57
  style: StyleSheet.absoluteFill,
55
58
  contentStyle: appearance?.screen,
56
59
  headerConfig: headerConfig,
57
- stackPresentation: stackPresentation,
60
+ stackPresentation: nativePresentation,
58
61
  stackAnimation: stackAnimation ?? item.options?.stackAnimation,
59
62
  children: /*#__PURE__*/_jsx(RouteLocalContext.Provider, {
60
63
  value: route,