@sigmela/router 0.2.5 → 0.2.7

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.
@@ -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;
@@ -71,6 +72,8 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
71
72
  const suppressedEnterKeyRef = useRef(null);
72
73
  const isBulkRemovalRef = useRef(false);
73
74
  const prevKeysRef = useRef([]);
75
+ const lastStackIdRef = useRef(null);
76
+ const dismissInProgressRef = useRef(false);
74
77
  const lastDirectionRef = useRef('forward');
75
78
  const childMapRef = useRef(new Map());
76
79
  const stackChildren = useMemo(() => {
@@ -89,6 +92,20 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
89
92
  });
90
93
  return stackItems;
91
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]);
92
109
  const routeKeys = useMemo(() => {
93
110
  const keys = stackChildren.map(child => {
94
111
  const item = child.props.item;
@@ -147,7 +164,6 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
147
164
  const prevKeysForDirection = prevKeysRef.current;
148
165
  const direction = useMemo(() => {
149
166
  const computed = computeDirection(prevKeysForDirection, routeKeys);
150
- prevKeysRef.current = routeKeys;
151
167
  return computed;
152
168
  }, [routeKeys, prevKeysForDirection]);
153
169
  devLog('[ScreenStack] Computed direction', {
@@ -180,15 +196,9 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
180
196
 
181
197
  // CRITICAL: Calculate bulk removal BEFORE useMemo for itemsContextValue
182
198
  // 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;
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;
192
202
  isBulkRemovalRef.current = isBulkRemoval;
193
203
  useLayoutEffect(() => {
194
204
  devLog('[ScreenStack] === LIFECYCLE EFFECT START ===', {
@@ -255,7 +265,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
255
265
  }
256
266
  lastDirectionRef.current = direction;
257
267
  devLog('[ScreenStack] === LIFECYCLE EFFECT END ===');
258
- }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty, devLog]);
268
+ }, [routeKeys, direction, prevKeysForDirection.length, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty, devLog]);
259
269
  useLayoutEffect(() => {
260
270
  devLog('[ScreenStack] === CLEANUP EFFECT START ===');
261
271
  const routeKeySet = new Set(routeKeys);
@@ -282,6 +292,12 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
282
292
  }
283
293
  devLog('[ScreenStack] === CLEANUP EFFECT END ===');
284
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]);
285
301
  useEffect(() => {
286
302
  if (!isInitialMountRef.current) return;
287
303
  const hasMountedItem = stateMapEntries.some(([, st]) => st.isMounted);
@@ -327,6 +343,32 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
327
343
  });
328
344
  const topKey = routeKeys[routeKeys.length - 1] ?? null;
329
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]);
330
372
  const itemsContextValue = useMemo(() => {
331
373
  const items = {};
332
374
  for (let index = 0; index < keysToRender.length; index++) {
@@ -354,7 +396,13 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
354
396
  const routeIndex = routeKeys.indexOf(key);
355
397
  const zIndex = routeIndex >= 0 ? routeIndex + 1 : keysToRender.length + index + 1;
356
398
  const presentationType = getPresentationTypeClass(presentation);
357
- let animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated, isBulkRemovalRef.current);
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
+ }
358
406
 
359
407
  // SplitView-secondary-only: suppress enter animation for the first screen after empty.
360
408
  if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
@@ -386,7 +434,13 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
386
434
  phase = 'inactive';
387
435
  }
388
436
  const presentationType = getPresentationTypeClass(presentation);
389
- let animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated, isBulkRemovalRef.current);
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
+ }
390
444
  if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
391
445
  animationType = 'none';
392
446
  }
@@ -401,7 +455,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
401
455
  return {
402
456
  items
403
457
  };
404
- }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, direction, animateFirstScreenAfterEmpty]);
458
+ }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, animationDirection, direction, animateFirstScreenAfterEmpty, isStackBeingDismissed, hasExitingModalLike]);
405
459
  const animating = useMemo(() => {
406
460
  return stateMapEntries.some(([, state]) => state.isMounted && (state.status === 'entering' || state.status === 'exiting' || state.status === 'preEnter' || state.status === 'preExit'));
407
461
  }, [stateMapEntries]);
@@ -412,6 +466,9 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
412
466
  children: /*#__PURE__*/_jsx("div", {
413
467
  ref: containerRef,
414
468
  className: containerClassName + (animating ? ' animating' : ''),
469
+ style: {
470
+ '--stack-transition-time': `${transitionTime}ms`
471
+ },
415
472
  children: keysToRender.map(key => {
416
473
  const transitionState = stateMap.get(key);
417
474
  if (!transitionState || !transitionState.isMounted) {
@@ -17,8 +17,10 @@
17
17
 
18
18
  /* Base transition for opening screen */
19
19
  transition:
20
- transform 300ms cubic-bezier(0.22, 0.61, 0.36, 1),
21
- filter 300ms cubic-bezier(0.22, 0.61, 0.36, 1);
20
+ transform var(--stack-transition-time, 300ms)
21
+ cubic-bezier(0.22, 0.61, 0.36, 1),
22
+ filter var(--stack-transition-time, 300ms)
23
+ cubic-bezier(0.22, 0.61, 0.36, 1);
22
24
  }
23
25
 
24
26
  /* Inner container for regular screen */
@@ -43,6 +45,8 @@
43
45
  pointer-events: none;
44
46
  z-index: 1;
45
47
  will-change: opacity;
48
+ transition: opacity var(--stack-transition-time, 300ms)
49
+ cubic-bezier(0.22, 0.61, 0.36, 1);
46
50
  }
47
51
 
48
52
  /* Show overlay only for modal-like presentations.
@@ -53,26 +57,6 @@
53
57
  display: none;
54
58
  }
55
59
 
56
- /* Keyframes for overlay appearance */
57
- @keyframes modal-overlay-enter {
58
- from {
59
- opacity: 0;
60
- }
61
- to {
62
- opacity: 0.5;
63
- }
64
- }
65
-
66
- /* Keyframes for overlay disappearance */
67
- @keyframes modal-overlay-exit {
68
- from {
69
- opacity: 0.5;
70
- }
71
- to {
72
- opacity: 0;
73
- }
74
- }
75
-
76
60
  /* Overlay in initial state — transparent */
77
61
  .screen-stack-item.modal.transition-preEnter > .stack-modal-overlay,
78
62
  .screen-stack-item.modal-right.transition-preEnter > .stack-modal-overlay,
@@ -86,7 +70,7 @@
86
70
  pointer-events: none;
87
71
  }
88
72
 
89
- /* Overlay on enter — start animation */
73
+ /* Overlay on enter — fade in via transition */
90
74
  .screen-stack-item.modal.transition-entering > .stack-modal-overlay,
91
75
  .screen-stack-item.modal.phase-active.transition-preEnter > .stack-modal-overlay,
92
76
  .screen-stack-item.modal-right.transition-entering > .stack-modal-overlay,
@@ -103,7 +87,7 @@
103
87
  .screen-stack-item.sheet.phase-active.transition-preEnter > .stack-modal-overlay {
104
88
  background: rgba(0, 0, 0, 0.5);
105
89
  pointer-events: none;
106
- animation: modal-overlay-enter 300ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
90
+ opacity: 0.5;
107
91
  }
108
92
 
109
93
  /* For modal-like in active / entered states — fully visible */
@@ -126,7 +110,7 @@
126
110
  pointer-events: auto;
127
111
  }
128
112
 
129
- /* Overlay on modal-like close — disappearance animation */
113
+ /* Overlay on modal-like close — fade out via transition */
130
114
  .screen-stack-item.modal.phase-exiting > .stack-modal-overlay,
131
115
  .screen-stack-item.modal.transition-exiting > .stack-modal-overlay,
132
116
  .screen-stack-item.modal-right.phase-exiting > .stack-modal-overlay,
@@ -143,7 +127,7 @@
143
127
  .screen-stack-item.sheet.transition-exiting > .stack-modal-overlay {
144
128
  background: rgba(0, 0, 0, 0.5);
145
129
  pointer-events: none;
146
- animation: modal-overlay-exit 300ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
130
+ opacity: 0;
147
131
  }
148
132
 
149
133
  /* Overlay for transparent-modal — transparent */
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigmela/router",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "React Native Router",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",