@sigmela/router 0.2.0 → 0.2.1

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.
@@ -351,6 +351,27 @@ export class Router {
351
351
  }
352
352
  const matchResult = base.matchPath(pathname);
353
353
  const params = matchResult ? matchResult.params : undefined;
354
+
355
+ // Smart navigate:
356
+ // If navigate(push) targets the currently active routeId within the same stack, treat it as
357
+ // "same screen, new data" and perform a replace (preserving the existing key) by default.
358
+ //
359
+ // Consumers can opt out per-route via ScreenOptions.allowMultipleInstances=true.
360
+ if (action === 'push' && base.stackId) {
361
+ const mergedOptions = this.mergeOptions(base.options, base.stackId);
362
+ const allowMultipleInstances = mergedOptions?.allowMultipleInstances === true;
363
+ const isActiveSameStack = this.activeRoute?.stackId === base.stackId;
364
+ const isActiveSameRoute = this.activeRoute?.routeId === base.routeId;
365
+
366
+ // Optional safety: only apply to push-presentation screens by default.
367
+ const presentation = mergedOptions?.stackPresentation ?? 'push';
368
+ const isPushPresentation = presentation === 'push';
369
+ if (!allowMultipleInstances && isPushPresentation && isActiveSameStack && isActiveSameRoute) {
370
+ const newItem = this.createHistoryItem(base, params, query, pathname);
371
+ this.applyHistoryChange('replace', newItem);
372
+ return;
373
+ }
374
+ }
354
375
  if (action === 'push') {
355
376
  if (base.stackId) {
356
377
  let existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { memo, useRef, useLayoutEffect, useMemo, useEffect, Children, isValidElement, Fragment } from 'react';
4
4
  import { useTransitionMap } from 'react-transition-state';
5
- import { ScreenStackItemsContext, ScreenStackAnimatingContext } from "./ScreenStackContext.js";
5
+ import { ScreenStackItemsContext, ScreenStackAnimatingContext, useScreenStackConfig } from "./ScreenStackContext.js";
6
6
  import { getPresentationTypeClass, computeAnimationType } from "./animationHelpers.js";
7
7
  import { jsx as _jsx } from "react/jsx-runtime";
8
8
  const devLog = (_, __) => {};
@@ -61,6 +61,8 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
61
61
  });
62
62
  const containerRef = useRef(null);
63
63
  const isInitialMountRef = useRef(true);
64
+ const suppressEnterAfterEmptyRef = useRef(false);
65
+ const suppressedEnterKeyRef = useRef(null);
64
66
  const prevKeysRef = useRef([]);
65
67
  const lastDirectionRef = useRef('forward');
66
68
  const childMapRef = useRef(new Map());
@@ -146,6 +148,8 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
146
148
  routeKeys,
147
149
  direction
148
150
  });
151
+ const screenStackConfig = useScreenStackConfig();
152
+ const animateFirstScreenAfterEmpty = screenStackConfig.animateFirstScreenAfterEmpty ?? true;
149
153
  const isInitialPhase = isInitialMountRef.current;
150
154
  const keysToRender = useMemo(() => {
151
155
  const routeKeySet = new Set(routeKeys);
@@ -179,10 +183,27 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
179
183
  }
180
184
  const newKeys = routeKeys.filter(key => !existingKeySet.has(key));
181
185
  const removedKeys = [...existingKeySet].filter(key => !routeKeySet.has(key));
186
+
187
+ // Track "became empty" state to suppress the next enter animation when configured.
188
+ // This is SplitView-secondary-only (via ScreenStackConfigContext), not global behavior.
189
+ if (!animateFirstScreenAfterEmpty) {
190
+ if (routeKeys.length === 0) {
191
+ suppressEnterAfterEmptyRef.current = true;
192
+ suppressedEnterKeyRef.current = null;
193
+ }
194
+ }
182
195
  devLog('[ScreenStack] Lifecycle diff', {
183
196
  newKeys,
184
197
  removedKeys
185
198
  });
199
+
200
+ // If this is the first pushed key after the stack was empty, remember its key so we can
201
+ // suppress only its enter animation (without affecting exit animations).
202
+ if (!animateFirstScreenAfterEmpty && suppressEnterAfterEmptyRef.current && routeKeys.length > 0 && newKeys.length > 0) {
203
+ const candidate = newKeys[newKeys.length - 1] ?? null;
204
+ suppressedEnterKeyRef.current = candidate;
205
+ suppressEnterAfterEmptyRef.current = false;
206
+ }
186
207
  for (const key of newKeys) {
187
208
  devLog(`[ScreenStack] Adding item: ${key}`);
188
209
  setItem(key);
@@ -206,7 +227,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
206
227
  }
207
228
  lastDirectionRef.current = direction;
208
229
  devLog('[ScreenStack] === LIFECYCLE EFFECT END ===');
209
- }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap]);
230
+ }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty]);
210
231
  useLayoutEffect(() => {
211
232
  devLog('[ScreenStack] === CLEANUP EFFECT START ===');
212
233
  const routeKeySet = new Set(routeKeys);
@@ -240,15 +261,31 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
240
261
  // If the stack mounts empty, we still want the first pushed screen to animate.
241
262
  // Mark initial mount as completed immediately in that case.
242
263
  if (!hasMountedItem && routeKeys.length === 0) {
243
- isInitialMountRef.current = false;
244
- devLog('[ScreenStack] Initial mount completed (empty stack)');
264
+ if (animateFirstScreenAfterEmpty) {
265
+ isInitialMountRef.current = false;
266
+ devLog('[ScreenStack] Initial mount completed (empty stack)');
267
+ }
245
268
  return;
246
269
  }
247
270
  if (hasMountedItem) {
248
271
  isInitialMountRef.current = false;
249
272
  devLog('[ScreenStack] Initial mount completed');
250
273
  }
251
- }, [stateMapEntries, routeKeys.length]);
274
+ }, [stateMapEntries, routeKeys.length, animateFirstScreenAfterEmpty]);
275
+
276
+ // Clear suppression key once it is no longer the top screen (so it can animate normally as
277
+ // a background when new screens are pushed).
278
+ useLayoutEffect(() => {
279
+ if (animateFirstScreenAfterEmpty) return;
280
+ const topKey = routeKeys[routeKeys.length - 1] ?? null;
281
+ if (!topKey) {
282
+ suppressedEnterKeyRef.current = null;
283
+ return;
284
+ }
285
+ if (suppressedEnterKeyRef.current && suppressedEnterKeyRef.current !== topKey) {
286
+ suppressedEnterKeyRef.current = null;
287
+ }
288
+ }, [routeKeys, animateFirstScreenAfterEmpty]);
252
289
  useEffect(() => {
253
290
  if (!containerRef.current) return;
254
291
  const items = containerRef.current.querySelectorAll('.screen-stack-item');
@@ -289,7 +326,12 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
289
326
  const routeIndex = routeKeys.indexOf(key);
290
327
  const zIndex = routeIndex >= 0 ? routeIndex + 1 : keysToRender.length + index + 1;
291
328
  const presentationType = getPresentationTypeClass(presentation);
292
- const animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
329
+ let animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
330
+
331
+ // SplitView-secondary-only: suppress enter animation for the first screen after empty.
332
+ if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
333
+ animationType = 'none';
334
+ }
293
335
  items[key] = {
294
336
  presentationType,
295
337
  animationType,
@@ -316,7 +358,10 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
316
358
  phase = 'inactive';
317
359
  }
318
360
  const presentationType = getPresentationTypeClass(presentation);
319
- const animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
361
+ let animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
362
+ if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
363
+ animationType = 'none';
364
+ }
320
365
  items[key] = {
321
366
  presentationType,
322
367
  animationType,
@@ -328,7 +373,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
328
373
  return {
329
374
  items
330
375
  };
331
- }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, direction]);
376
+ }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, direction, animateFirstScreenAfterEmpty]);
332
377
  const animating = useMemo(() => {
333
378
  return stateMapEntries.some(([, state]) => state.isMounted && (state.status === 'entering' || state.status === 'exiting' || state.status === 'preEnter' || state.status === 'preExit'));
334
379
  }, [stateMapEntries]);
@@ -3,6 +3,12 @@
3
3
  import { createContext, useContext } from 'react';
4
4
  export const ScreenStackItemsContext = /*#__PURE__*/createContext(null);
5
5
  export const ScreenStackAnimatingContext = /*#__PURE__*/createContext(false);
6
+ export const ScreenStackConfigContext = /*#__PURE__*/createContext({
7
+ animateFirstScreenAfterEmpty: true
8
+ });
9
+ export const useScreenStackConfig = () => {
10
+ return useContext(ScreenStackConfigContext);
11
+ };
6
12
  export const useScreenStackItemsContext = () => {
7
13
  const ctx = useContext(ScreenStackItemsContext);
8
14
  if (!ctx) {
@@ -3,7 +3,8 @@
3
3
  import { StackRenderer } from "../StackRenderer.js";
4
4
  import { SplitViewContext } from "./SplitViewContext.js";
5
5
  import { useRouter } from "../RouterContext.js";
6
- import { memo, useCallback, useMemo, useSyncExternalStore } from 'react';
6
+ import { ScreenStackConfigContext } from "../ScreenStack/ScreenStackContext.js";
7
+ import { memo, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
7
8
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
9
  const StackSliceRenderer = /*#__PURE__*/memo(({
9
10
  stack,
@@ -46,7 +47,31 @@ export const RenderSplitView = /*#__PURE__*/memo(({
46
47
  splitView,
47
48
  appearance
48
49
  }) => {
49
- const instanceClass = useMemo(() => `split-view-instance-${splitView.getId()}`, [splitView]);
50
+ const [isWide, setIsWide] = useState(() => {
51
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
52
+ return false;
53
+ }
54
+ return window.matchMedia(`(min-width: ${splitView.minWidth}px)`).matches;
55
+ });
56
+ useEffect(() => {
57
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
58
+ return;
59
+ }
60
+ const mq = window.matchMedia(`(min-width: ${splitView.minWidth}px)`);
61
+ const onChange = ev => setIsWide(ev.matches);
62
+
63
+ // Sync immediately.
64
+ setIsWide(mq.matches);
65
+ if (typeof mq.addEventListener === 'function') {
66
+ mq.addEventListener('change', onChange);
67
+ return () => mq.removeEventListener('change', onChange);
68
+ }
69
+
70
+ // Safari < 14 fallback (deprecated, but still needed).
71
+ const mqAny = mq;
72
+ mqAny.addListener?.(onChange);
73
+ return () => mqAny.removeListener?.(onChange);
74
+ }, [splitView.minWidth]);
50
75
  const containerStyle = useMemo(() => {
51
76
  return {
52
77
  '--split-view-primary-max-width': `${splitView.primaryMaxWidth}px`
@@ -54,26 +79,31 @@ export const RenderSplitView = /*#__PURE__*/memo(({
54
79
  }, [splitView.primaryMaxWidth]);
55
80
  return /*#__PURE__*/_jsx(SplitViewContext.Provider, {
56
81
  value: splitView,
57
- children: /*#__PURE__*/_jsx("div", {
58
- className: instanceClass,
59
- children: /*#__PURE__*/_jsxs("div", {
60
- className: "split-view-container",
61
- style: containerStyle,
62
- children: [/*#__PURE__*/_jsx("div", {
63
- className: "split-view-primary",
64
- children: /*#__PURE__*/_jsx(StackSliceRenderer, {
65
- appearance: appearance,
66
- stack: splitView.primary,
67
- fallbackToFirstRoute: true
68
- })
69
- }), /*#__PURE__*/_jsx("div", {
70
- className: "split-view-secondary",
82
+ children: /*#__PURE__*/_jsxs("div", {
83
+ className: "split-view-container",
84
+ style: containerStyle,
85
+ children: [/*#__PURE__*/_jsx("div", {
86
+ className: "split-view-primary",
87
+ children: /*#__PURE__*/_jsx(StackSliceRenderer, {
88
+ appearance: appearance,
89
+ stack: splitView.primary,
90
+ fallbackToFirstRoute: true
91
+ })
92
+ }), /*#__PURE__*/_jsx("div", {
93
+ className: "split-view-secondary",
94
+ children: isWide ? /*#__PURE__*/_jsx(ScreenStackConfigContext.Provider, {
95
+ value: {
96
+ animateFirstScreenAfterEmpty: false
97
+ },
71
98
  children: /*#__PURE__*/_jsx(StackSliceRenderer, {
72
99
  appearance: appearance,
73
100
  stack: splitView.secondary
74
101
  })
75
- })]
76
- })
102
+ }) : /*#__PURE__*/_jsx(StackSliceRenderer, {
103
+ appearance: appearance,
104
+ stack: splitView.secondary
105
+ })
106
+ })]
77
107
  })
78
108
  });
79
109
  });
@@ -82,7 +82,6 @@ export class TabBar {
82
82
  } catch (e) {
83
83
  // TabBar has no debug flag; keep behavior quiet in production.
84
84
  if (__DEV__) {
85
- // eslint-disable-next-line no-console
86
85
  console.error('[TabBar] listener error', e);
87
86
  }
88
87
  }
@@ -8,7 +8,7 @@ export const TAB_BAR_HEIGHT = 57;
8
8
  /**
9
9
  * Hook that returns the height of the TabBar
10
10
  * @returns {number} The height of the TabBar in pixels
11
- *
11
+ *
12
12
  * @example
13
13
  * ```tsx
14
14
  * const TabBarExample = () => {
@@ -17,6 +17,15 @@ export type ScreenStackItemsContextValue = {
17
17
  export type ScreenStackAnimatingContextValue = boolean;
18
18
  export declare const ScreenStackItemsContext: import("react").Context<ScreenStackItemsContextValue | null>;
19
19
  export declare const ScreenStackAnimatingContext: import("react").Context<boolean>;
20
+ export type ScreenStackConfig = {
21
+ /**
22
+ * When false, the first screen pushed after the stack becomes empty will NOT animate
23
+ * (treated as an initial render). Subsequent pushes/pops still animate normally.
24
+ */
25
+ animateFirstScreenAfterEmpty?: boolean;
26
+ };
27
+ export declare const ScreenStackConfigContext: import("react").Context<ScreenStackConfig>;
28
+ export declare const useScreenStackConfig: () => ScreenStackConfig;
20
29
  export declare const useScreenStackItemsContext: () => ScreenStackItemsContextValue;
21
30
  export declare const useScreenStackAnimatingContext: () => boolean;
22
31
  //# sourceMappingURL=ScreenStackContext.d.ts.map
@@ -16,6 +16,14 @@ export type ScreenOptions = Partial<Omit<RNSScreenProps, 'stackPresentation'>> &
16
16
  syncWithUrl?: boolean;
17
17
  tabBarIcon?: TabBarIcon;
18
18
  animated?: boolean;
19
+ /**
20
+ * Allows pushing multiple instances of the same screen (same routeId) onto a stack.
21
+ *
22
+ * By default, Router.navigate() will behave like "replace" when targeting the currently
23
+ * active routeId in the same stack (i.e. treat it as "same screen, new data").
24
+ * Set this to true to force "push" even when navigating to the active routeId.
25
+ */
26
+ allowMultipleInstances?: boolean;
19
27
  /**
20
28
  * Allows Router.goBack() to pop the last (root) screen of a stack.
21
29
  * Useful for secondary stacks in split-view / overlays.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigmela/router",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "React Native Router",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",