@sigmela/router 0.2.4 → 0.2.6

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.
@@ -4,6 +4,7 @@ import { nanoid } from 'nanoid/non-secure';
4
4
  import { match as pathMatchFactory } from 'path-to-regexp';
5
5
  import qs from 'query-string';
6
6
  import React from 'react';
7
+ import { StackRenderer } from "./StackRenderer.js";
7
8
  export class NavigationStack {
8
9
  routes = [];
9
10
  children = [];
@@ -133,13 +134,10 @@ export class NavigationStack {
133
134
  getRenderer() {
134
135
  // eslint-disable-next-line consistent-this
135
136
  const stackInstance = this;
137
+ const stackId = stackInstance.getId();
136
138
  return function NavigationStackRenderer(props) {
137
- // Lazy require to avoid circular dependency (StackRenderer imports NavigationStack)
138
- const {
139
- StackRenderer
140
- } = require('./StackRenderer');
141
139
  return /*#__PURE__*/React.createElement(StackRenderer, {
142
- stack: stackInstance,
140
+ stackId: stackId,
143
141
  appearance: props.appearance
144
142
  });
145
143
  };
@@ -20,6 +20,7 @@ export class Router {
20
20
  };
21
21
  debugEnabled = false;
22
22
  sheetDismissers = new Map();
23
+ dismissedStackIds = new Set();
23
24
  stackListeners = new Map();
24
25
  stackById = new Map();
25
26
  routeById = new Map();
@@ -60,6 +61,17 @@ export class Router {
60
61
  isDebugEnabled() {
61
62
  return this.debugEnabled;
62
63
  }
64
+ isStackBeingDismissed(stackId) {
65
+ if (!stackId) return false;
66
+ return this.dismissedStackIds.has(stackId);
67
+ }
68
+ clearStackDismissed(stackId) {
69
+ if (!stackId) return;
70
+ this.dismissedStackIds.delete(stackId);
71
+ }
72
+ markStackDismissed(stackId) {
73
+ this.dismissedStackIds.add(stackId);
74
+ }
63
75
  log(message, data) {
64
76
  if (this.debugEnabled) {
65
77
  if (data !== undefined) {
@@ -187,6 +199,7 @@ export class Router {
187
199
  childStackId,
188
200
  modalKey: modalItem.key
189
201
  });
202
+ this.markStackDismissed(childStackId);
190
203
  const newHistory = this.state.history.filter(item => item.stackId !== childStackId && item.key !== modalItem.key);
191
204
  this.setState({
192
205
  history: newHistory
@@ -5,6 +5,7 @@ import { useTransitionMap } from 'react-transition-state';
5
5
  import { ScreenStackItemsContext, ScreenStackAnimatingContext, useScreenStackConfig } from "./ScreenStackContext.js";
6
6
  import { getPresentationTypeClass, computeAnimationType } from "./animationHelpers.js";
7
7
  import { RouterContext } from "../RouterContext.js";
8
+ import { isModalLikePresentation } from "../types.js";
8
9
  import { jsx as _jsx } from "react/jsx-runtime";
9
10
  const isScreenStackItemElement = child => {
10
11
  if (! /*#__PURE__*/isValidElement(child)) return false;
@@ -69,7 +70,10 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
69
70
  const isInitialMountRef = useRef(true);
70
71
  const suppressEnterAfterEmptyRef = useRef(false);
71
72
  const suppressedEnterKeyRef = useRef(null);
73
+ const isBulkRemovalRef = useRef(false);
72
74
  const prevKeysRef = useRef([]);
75
+ const lastStackIdRef = useRef(null);
76
+ const dismissInProgressRef = useRef(false);
73
77
  const lastDirectionRef = useRef('forward');
74
78
  const childMapRef = useRef(new Map());
75
79
  const stackChildren = useMemo(() => {
@@ -88,6 +92,20 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
88
92
  });
89
93
  return stackItems;
90
94
  }, [children, devLog]);
95
+ const currentStackId = useMemo(() => {
96
+ let found;
97
+ for (const child of stackChildren) {
98
+ const childStackId = child.props.stackId ?? child.props.item?.stackId ?? undefined;
99
+ if (childStackId) {
100
+ found = childStackId;
101
+ break;
102
+ }
103
+ }
104
+ if (found) {
105
+ lastStackIdRef.current = found;
106
+ }
107
+ return found ?? lastStackIdRef.current;
108
+ }, [stackChildren]);
91
109
  const routeKeys = useMemo(() => {
92
110
  const keys = stackChildren.map(child => {
93
111
  const item = child.props.item;
@@ -143,12 +161,11 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
143
161
  isResolved: state.isResolved
144
162
  })));
145
163
  const stateMapEntries = Array.from(stateMap.entries());
164
+ const prevKeysForDirection = prevKeysRef.current;
146
165
  const direction = useMemo(() => {
147
- const prevKeys = prevKeysRef.current;
148
- const computed = computeDirection(prevKeys, routeKeys);
149
- prevKeysRef.current = routeKeys;
166
+ const computed = computeDirection(prevKeysForDirection, routeKeys);
150
167
  return computed;
151
- }, [routeKeys]);
168
+ }, [routeKeys, prevKeysForDirection]);
152
169
  devLog('[ScreenStack] Computed direction', {
153
170
  prevKeys: prevKeysRef.current,
154
171
  routeKeys,
@@ -176,6 +193,13 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
176
193
  const containerClassName = useMemo(() => {
177
194
  return 'screen-stack';
178
195
  }, []);
196
+
197
+ // CRITICAL: Calculate bulk removal BEFORE useMemo for itemsContextValue
198
+ // so the flag is available when computing animation types
199
+ // Use prevKeysForDirection (captured before direction useMemo) to detect removals
200
+ const removedCount = prevKeysForDirection.filter(key => !routeKeys.includes(key)).length;
201
+ const isBulkRemoval = removedCount > 1 || routeKeys.length === 0 && prevKeysForDirection.length > 1;
202
+ isBulkRemovalRef.current = isBulkRemoval;
179
203
  useLayoutEffect(() => {
180
204
  devLog('[ScreenStack] === LIFECYCLE EFFECT START ===', {
181
205
  prevKeys: prevKeysRef.current,
@@ -203,6 +227,14 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
203
227
  removedKeys
204
228
  });
205
229
 
230
+ // Bulk removal was already computed before useMemo (see above)
231
+ devLog('[ScreenStack] Bulk removal detected', {
232
+ isBulkRemoval: isBulkRemovalRef.current,
233
+ removedCount: removedKeys.length,
234
+ prevLength: prevKeysForDirection.length,
235
+ currentLength: routeKeys.length
236
+ });
237
+
206
238
  // If this is the first pushed key after the stack was empty, remember its key so we can
207
239
  // suppress only its enter animation (without affecting exit animations).
208
240
  if (!animateFirstScreenAfterEmpty && suppressEnterAfterEmptyRef.current && routeKeys.length > 0 && newKeys.length > 0) {
@@ -233,7 +265,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
233
265
  }
234
266
  lastDirectionRef.current = direction;
235
267
  devLog('[ScreenStack] === LIFECYCLE EFFECT END ===');
236
- }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty, devLog]);
268
+ }, [routeKeys, direction, prevKeysForDirection.length, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty, devLog]);
237
269
  useLayoutEffect(() => {
238
270
  devLog('[ScreenStack] === CLEANUP EFFECT START ===');
239
271
  const routeKeySet = new Set(routeKeys);
@@ -260,6 +292,12 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
260
292
  }
261
293
  devLog('[ScreenStack] === CLEANUP EFFECT END ===');
262
294
  }, [routeKeys, stateMapEntries, deleteItem, devLog]);
295
+
296
+ // Update the previous keys after all layout effects so direction calculations
297
+ // always compare against the last committed stack.
298
+ useLayoutEffect(() => {
299
+ prevKeysRef.current = routeKeys;
300
+ }, [routeKeys]);
263
301
  useEffect(() => {
264
302
  if (!isInitialMountRef.current) return;
265
303
  const hasMountedItem = stateMapEntries.some(([, st]) => st.isMounted);
@@ -305,6 +343,32 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
305
343
  });
306
344
  const topKey = routeKeys[routeKeys.length - 1] ?? null;
307
345
  const routeKeySet = useMemo(() => new Set(routeKeys), [routeKeys]);
346
+ const hasExitingItems = useMemo(() => {
347
+ return stateMapEntries.some(([key, state]) => state.isMounted && !routeKeySet.has(key));
348
+ }, [stateMapEntries, routeKeySet]);
349
+ const hasExitingModalLike = useMemo(() => {
350
+ return stateMapEntries.some(([key, state]) => {
351
+ if (!state.isMounted || routeKeySet.has(key)) return false;
352
+ const item = childMap.get(key)?.props.item;
353
+ return isModalLikePresentation(item?.options?.stackPresentation);
354
+ });
355
+ }, [childMap, routeKeySet, stateMapEntries]);
356
+ const animationDirection = hasExitingItems ? lastDirectionRef.current : direction;
357
+ const stackDismissedByRouter = router?.isStackBeingDismissed?.(currentStackId ?? undefined) ?? false;
358
+ if (stackDismissedByRouter) {
359
+ dismissInProgressRef.current = true;
360
+ }
361
+ const isStackBeingDismissed = dismissInProgressRef.current;
362
+ devLog('[ScreenStack] Dismiss state', {
363
+ stackId: currentStackId,
364
+ isStackBeingDismissed
365
+ });
366
+ useLayoutEffect(() => {
367
+ if (!isStackBeingDismissed) return;
368
+ if (hasExitingItems) return;
369
+ dismissInProgressRef.current = false;
370
+ router?.clearStackDismissed?.(currentStackId ?? undefined);
371
+ }, [hasExitingItems, isStackBeingDismissed, currentStackId, router]);
308
372
  const itemsContextValue = useMemo(() => {
309
373
  const items = {};
310
374
  for (let index = 0; index < keysToRender.length; index++) {
@@ -332,7 +396,13 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
332
396
  const routeIndex = routeKeys.indexOf(key);
333
397
  const zIndex = routeIndex >= 0 ? routeIndex + 1 : keysToRender.length + index + 1;
334
398
  const presentationType = getPresentationTypeClass(presentation);
335
- let animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
399
+ let animationType = computeAnimationType(key, isInStack, isTop, animationDirection, presentation, isInitialPhase, animated, isBulkRemovalRef.current);
400
+ if (hasExitingModalLike && isTop && isInStack) {
401
+ animationType = 'none';
402
+ }
403
+ if (isStackBeingDismissed && !isInStack) {
404
+ animationType = 'no-animate';
405
+ }
336
406
 
337
407
  // SplitView-secondary-only: suppress enter animation for the first screen after empty.
338
408
  if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
@@ -364,7 +434,13 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
364
434
  phase = 'inactive';
365
435
  }
366
436
  const presentationType = getPresentationTypeClass(presentation);
367
- let animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
437
+ let animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, animationDirection, presentation, isInitialPhase, animated, isBulkRemovalRef.current);
438
+ if (hasExitingModalLike && isTop && isInStack) {
439
+ animationType = 'none';
440
+ }
441
+ if (isStackBeingDismissed && !isInStack) {
442
+ animationType = 'no-animate';
443
+ }
368
444
  if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
369
445
  animationType = 'none';
370
446
  }
@@ -379,7 +455,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
379
455
  return {
380
456
  items
381
457
  };
382
- }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, direction, animateFirstScreenAfterEmpty]);
458
+ }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, animationDirection, direction, animateFirstScreenAfterEmpty, isStackBeingDismissed, hasExitingModalLike]);
383
459
  const animating = useMemo(() => {
384
460
  return stateMapEntries.some(([, state]) => state.isMounted && (state.status === 'entering' || state.status === 'exiting' || state.status === 'preEnter' || state.status === 'preExit'));
385
461
  }, [stateMapEntries]);
@@ -35,13 +35,18 @@ export function getAnimationTypeForPresentation(presentation, isEntering, direct
35
35
  }
36
36
  return `${presentationClass}-${suffix}`;
37
37
  }
38
- 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) {
39
39
  if (!animated) {
40
40
  return 'no-animate';
41
41
  }
42
42
  if (isInitialPhase) {
43
43
  return 'none';
44
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
+ }
45
50
  const isEntering = isInStack && isTop;
46
51
  const isModalLike = isModalLikePresentation(presentation);
47
52
  if (isModalLike) {
@@ -38,7 +38,7 @@ const StackSliceRenderer = /*#__PURE__*/memo(({
38
38
  }
39
39
  return /*#__PURE__*/_jsx(StackRenderer, {
40
40
  appearance: appearance,
41
- stack: stack,
41
+ stackId: stackId,
42
42
  history: historyToRender
43
43
  });
44
44
  });
@@ -7,12 +7,11 @@ import { useRouter } from "./RouterContext.js";
7
7
  import { StyleSheet } from 'react-native';
8
8
  import { jsx as _jsx } from "react/jsx-runtime";
9
9
  export const StackRenderer = /*#__PURE__*/memo(({
10
- stack,
10
+ stackId,
11
11
  appearance,
12
12
  history
13
13
  }) => {
14
14
  const router = useRouter();
15
- const stackId = stack.getId();
16
15
  const subscribe = useCallback(cb => router.subscribeStack(stackId, cb), [router, stackId]);
17
16
  const get = useCallback(() => router.getStackHistory(stackId), [router, stackId]);
18
17
  const historyFromStore = useSyncExternalStore(subscribe, get, get);
@@ -141,7 +141,7 @@ const TabStackRenderer = /*#__PURE__*/memo(({
141
141
  const history = useSyncExternalStore(subscribe, get, get);
142
142
  return /*#__PURE__*/_jsx(StackRenderer, {
143
143
  appearance: appearance,
144
- stack: stack,
144
+ stackId: stackId,
145
145
  history: history
146
146
  });
147
147
  });
@@ -29,7 +29,7 @@ const TabStackRenderer = /*#__PURE__*/memo(({
29
29
  const history = useSyncExternalStore(subscribe, get, get);
30
30
  return /*#__PURE__*/_jsx(StackRenderer, {
31
31
  appearance: appearance,
32
- stack: stack,
32
+ stackId: stackId,
33
33
  history: history
34
34
  });
35
35
  });
@@ -21,6 +21,7 @@ export declare class Router {
21
21
  private readonly routerScreenOptions;
22
22
  private readonly debugEnabled;
23
23
  private sheetDismissers;
24
+ private dismissedStackIds;
24
25
  private stackListeners;
25
26
  private stackById;
26
27
  private routeById;
@@ -35,6 +36,9 @@ export declare class Router {
35
36
  private navigationToken;
36
37
  constructor(config: RouterConfig);
37
38
  isDebugEnabled(): boolean;
39
+ isStackBeingDismissed(stackId?: string): boolean;
40
+ clearStackDismissed(stackId?: string): void;
41
+ private markStackDismissed;
38
42
  private log;
39
43
  navigate: (path: string) => void;
40
44
  replace: (path: string, dedupe?: boolean) => void;
@@ -2,5 +2,5 @@ import type { StackPresentationTypes } from '../types';
2
2
  import type { PresentationTypeClass, AnimationType } from './ScreenStackContext';
3
3
  export declare function getPresentationTypeClass(presentation: StackPresentationTypes): PresentationTypeClass;
4
4
  export declare function getAnimationTypeForPresentation(presentation: StackPresentationTypes, isEntering: boolean, direction: 'forward' | 'back'): string;
5
- export declare function computeAnimationType(_key: string, isInStack: boolean, isTop: boolean, direction: 'forward' | 'back', presentation: StackPresentationTypes, isInitialPhase: boolean, animated?: boolean): AnimationType;
5
+ export declare function computeAnimationType(_key: string, isInStack: boolean, isTop: boolean, direction: 'forward' | 'back', presentation: StackPresentationTypes, isInitialPhase: boolean, animated?: boolean, isBulkRemoval?: boolean): AnimationType;
6
6
  //# sourceMappingURL=animationHelpers.d.ts.map
@@ -1,7 +1,6 @@
1
1
  import type { HistoryItem, NavigationAppearance } from './types';
2
- import { NavigationStack } from './NavigationStack';
3
2
  export interface StackRendererProps {
4
- stack: NavigationStack;
3
+ stackId: string;
5
4
  appearance?: NavigationAppearance;
6
5
  history?: HistoryItem[];
7
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigmela/router",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "React Native Router",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",