@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.
- package/lib/module/NavigationStack.js +3 -5
- package/lib/module/Router.js +13 -0
- package/lib/module/ScreenStack/ScreenStack.web.js +84 -8
- package/lib/module/ScreenStack/animationHelpers.js +6 -1
- package/lib/module/SplitView/RenderSplitView.web.js +1 -1
- package/lib/module/StackRenderer.js +1 -2
- package/lib/module/TabBar/RenderTabBar.native.js +1 -1
- package/lib/module/TabBar/RenderTabBar.web.js +1 -1
- package/lib/typescript/src/Router.d.ts +4 -0
- package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +1 -1
- package/lib/typescript/src/StackRenderer.d.ts +1 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
140
|
+
stackId: stackId,
|
|
143
141
|
appearance: props.appearance
|
|
144
142
|
});
|
|
145
143
|
};
|
package/lib/module/Router.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
144
|
+
stackId: stackId,
|
|
145
145
|
history: history
|
|
146
146
|
});
|
|
147
147
|
});
|
|
@@ -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
|
-
|
|
3
|
+
stackId: string;
|
|
5
4
|
appearance?: NavigationAppearance;
|
|
6
5
|
history?: HistoryItem[];
|
|
7
6
|
}
|