@sigmela/router 0.3.1 → 0.3.2

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,33 +4,67 @@ import { ScreenStackItem } from "../ScreenStackItem/index.js";
4
4
  import { ScreenStack } from "../ScreenStack/index.js";
5
5
  import { SplitViewContext } from "./SplitViewContext.js";
6
6
  import { useRouter } from "../RouterContext.js";
7
- import { memo, useCallback, useSyncExternalStore, useMemo } from 'react';
7
+ import { memo, useMemo, useState, useEffect, startTransition } from 'react';
8
8
  import { StyleSheet } from 'react-native';
9
9
  import { jsx as _jsx } from "react/jsx-runtime";
10
10
  /**
11
- * On native (iPhone), SplitView renders primary and secondary screens
12
- * in a SINGLE ScreenStack to get native push/pop animations.
11
+ * On native, SplitView renders primary and secondary screens in a SINGLE
12
+ * ScreenStack to get native push/pop animations.
13
13
  *
14
14
  * The combined history is: [...primaryHistory, ...secondaryHistory]
15
15
  * This way, navigating from primary to secondary is a native push.
16
+ *
17
+ * On Android (Fabric) react-native-screens manages each ScreenStackItem via
18
+ * an Android Fragment. Fragment lifecycle (onCreateView) is asynchronous,
19
+ * but Fabric's layout pass is synchronous. If a new commit is dispatched
20
+ * while the previous Fragment transaction is still in flight, Fabric tries
21
+ * to insert child views into a Screen whose Fragment isn't attached yet:
22
+ *
23
+ * "addViewAt: Parent Screen does not have its Fragment attached"
24
+ *
25
+ * The fix follows the same pattern used in react-native-screens' own
26
+ * BottomTabsContainer example: subscribe via useEffect and apply updates
27
+ * inside `startTransition`. This marks the update as non-urgent, letting
28
+ * React defer the Fabric commit until pending Fragment transactions complete.
29
+ *
30
+ * See: https://github.com/software-mansion/react-native-screens/blob/ddd1a9e/
31
+ * apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx
16
32
  */
17
33
  export const RenderSplitView = /*#__PURE__*/memo(({
18
34
  splitView,
19
35
  appearance
20
36
  }) => {
21
37
  const router = useRouter();
22
-
23
- // Subscribe to primary stack
24
38
  const primaryId = splitView.primary.getId();
25
- const subscribePrimary = useCallback(cb => router.subscribeStack(primaryId, cb), [router, primaryId]);
26
- const getPrimary = useCallback(() => router.getStackHistory(primaryId), [router, primaryId]);
27
- const primaryHistory = useSyncExternalStore(subscribePrimary, getPrimary, getPrimary);
28
-
29
- // Subscribe to secondary stack
30
39
  const secondaryId = splitView.secondary.getId();
31
- const subscribeSecondary = useCallback(cb => router.subscribeStack(secondaryId, cb), [router, secondaryId]);
32
- const getSecondary = useCallback(() => router.getStackHistory(secondaryId), [router, secondaryId]);
33
- const secondaryHistory = useSyncExternalStore(subscribeSecondary, getSecondary, getSecondary);
40
+
41
+ // Subscribe to both stacks via useEffect + startTransition.
42
+ // startTransition defers the Fabric commit so the FragmentManager has
43
+ // time to finish pending transactions between commits.
44
+ const [primaryHistory, setPrimaryHistory] = useState(() => router.getStackHistory(primaryId));
45
+ const [secondaryHistory, setSecondaryHistory] = useState(() => router.getStackHistory(secondaryId));
46
+ useEffect(() => {
47
+ const updatePrimary = () => {
48
+ startTransition(() => {
49
+ setPrimaryHistory(router.getStackHistory(primaryId));
50
+ });
51
+ };
52
+ const updateSecondary = () => {
53
+ startTransition(() => {
54
+ setSecondaryHistory(router.getStackHistory(secondaryId));
55
+ });
56
+ };
57
+ const unsub1 = router.subscribeStack(primaryId, updatePrimary);
58
+ const unsub2 = router.subscribeStack(secondaryId, updateSecondary);
59
+
60
+ // Sync in case the store changed between useState init and useEffect
61
+ updatePrimary();
62
+ updateSecondary();
63
+ return () => {
64
+ unsub1();
65
+ unsub2();
66
+ };
67
+ }, [router, primaryId, secondaryId]);
34
68
 
35
69
  // Fallback: if primary is empty, seed with first route
36
70
  const primaryHistoryToRender = useMemo(() => {
@@ -56,9 +90,6 @@ export const RenderSplitView = /*#__PURE__*/memo(({
56
90
  const combinedHistory = useMemo(() => {
57
91
  return [...primaryHistoryToRender, ...secondaryHistory];
58
92
  }, [primaryHistoryToRender, secondaryHistory]);
59
-
60
- // Use primary stack ID for the combined ScreenStack
61
- // (secondary items will animate as if pushed onto this stack)
62
93
  return /*#__PURE__*/_jsx(SplitViewContext.Provider, {
63
94
  value: splitView,
64
95
  children: /*#__PURE__*/_jsx(ScreenStack, {
@@ -3,7 +3,7 @@
3
3
  import { StackRenderer } from "../StackRenderer.js";
4
4
  import { TabBarContext } from "./TabBarContext.js";
5
5
  import { useRouter } from "../RouterContext.js";
6
- import { Tabs, ScreenStackItem } from 'react-native-screens';
6
+ import { Tabs } from 'react-native-screens';
7
7
  import { Platform, StyleSheet, View } from 'react-native';
8
8
  import { useCallback, useSyncExternalStore, memo, useEffect, useState, useMemo } from 'react';
9
9
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -190,81 +190,54 @@ export const RenderTabBar = /*#__PURE__*/memo(({
190
190
  const tabKey = event.nativeEvent.tabKey;
191
191
  const tabIndex = tabs.findIndex(route => route.tabKey === tabKey);
192
192
  if (tabIndex === -1) return;
193
- const targetTab = tabs[tabIndex];
194
- if (!targetTab) return;
195
- const targetStack = tabBar.stacks[targetTab.tabKey];
196
- const targetNode = tabBar.nodes[targetTab.tabKey];
197
193
 
198
- // Update TabBar UI state
194
+ // Keep native Tabs.Host focus state as the source of truth.
199
195
  if (tabIndex !== index) {
200
196
  tabBar.onIndexChange(tabIndex);
201
197
  }
202
-
203
- // Navigate to the target stack's first route if needed
198
+ }, [tabs, tabBar, index]);
199
+ const seedTabIfNeeded = useCallback(targetTab => {
200
+ if (!targetTab) return;
201
+ const targetStack = tabBar.stacks[targetTab.tabKey];
202
+ const targetNode = tabBar.nodes[targetTab.tabKey];
204
203
  if (targetStack) {
205
204
  const stackId = targetStack.getId();
206
205
  const stackHistory = router.getStackHistory(stackId);
207
- // Only navigate if stack is empty (first visit)
208
206
  if (stackHistory.length === 0) {
209
207
  const firstRoute = targetStack.getFirstRoute();
210
208
  if (firstRoute?.path) {
211
209
  router.navigate(firstRoute.path);
212
210
  }
213
211
  }
214
- } else if (targetNode) {
215
- // For nodes like SplitView, check if we need to seed it
212
+ return;
213
+ }
214
+ if (targetNode) {
216
215
  const nodeId = targetNode.getId?.();
217
- if (nodeId) {
218
- const nodeHistory = router.getStackHistory(nodeId);
219
- if (nodeHistory.length === 0) {
220
- const seed = targetNode.seed?.();
221
- if (seed?.path) {
222
- const prefix = targetTab.tabPrefix ?? '';
223
- const fullPath = prefix && !seed.path.startsWith(prefix) ? `${prefix}${seed.path.startsWith('/') ? '' : '/'}${seed.path}` : seed.path;
224
- router.navigate(fullPath);
225
- }
216
+ if (!nodeId) return;
217
+ const nodeHistory = router.getStackHistory(nodeId);
218
+ if (nodeHistory.length === 0) {
219
+ const seed = targetNode.seed?.();
220
+ if (seed?.path) {
221
+ const prefix = targetTab.tabPrefix ?? '';
222
+ const fullPath = prefix && !seed.path.startsWith(prefix) ? `${prefix}${seed.path.startsWith('/') ? '' : '/'}${seed.path}` : seed.path;
223
+ router.navigate(fullPath);
226
224
  }
227
225
  }
228
226
  }
229
- }, [tabs, tabBar, index, router]);
227
+ }, [tabBar.nodes, tabBar.stacks, router]);
228
+ useEffect(() => {
229
+ seedTabIfNeeded(tabs[index]);
230
+ }, [tabs, index, seedTabIfNeeded]);
230
231
  const onTabPress = useCallback(nextIndex => {
231
232
  const targetTab = tabs[nextIndex];
232
233
  if (!targetTab) return;
233
- const targetStack = tabBar.stacks[targetTab.tabKey];
234
- const targetNode = tabBar.nodes[targetTab.tabKey];
235
234
 
236
235
  // Update TabBar UI state
237
236
  if (nextIndex !== index) {
238
237
  tabBar.onIndexChange(nextIndex);
239
238
  }
240
-
241
- // Navigate to the target stack's first route if needed
242
- if (targetStack) {
243
- const stackId = targetStack.getId();
244
- const stackHistory = router.getStackHistory(stackId);
245
- // Only navigate if stack is empty (first visit)
246
- if (stackHistory.length === 0) {
247
- const firstRoute = targetStack.getFirstRoute();
248
- if (firstRoute?.path) {
249
- router.navigate(firstRoute.path);
250
- }
251
- }
252
- } else if (targetNode) {
253
- // For nodes like SplitView, check if we need to seed it
254
- const nodeId = targetNode.getId?.();
255
- if (nodeId) {
256
- const nodeHistory = router.getStackHistory(nodeId);
257
- if (nodeHistory.length === 0) {
258
- const seed = targetNode.seed?.();
259
- if (seed?.path) {
260
- const prefix = targetTab.tabPrefix ?? '';
261
- const fullPath = prefix && !seed.path.startsWith(prefix) ? `${prefix}${seed.path.startsWith('/') ? '' : '/'}${seed.path}` : seed.path;
262
- router.navigate(fullPath);
263
- }
264
- }
265
- }
266
- }
267
- }, [tabs, tabBar, index, router]);
239
+ seedTabIfNeeded(targetTab);
240
+ }, [tabs, tabBar, index, seedTabIfNeeded]);
268
241
  const containerProps = useMemo(() => ({
269
242
  tabBarBackgroundColor: backgroundColor,
270
243
  tabBarItemTitleFontFamily: title?.fontFamily,
@@ -339,83 +312,67 @@ export const RenderTabBar = /*#__PURE__*/memo(({
339
312
  }
340
313
  }, [tabs, index]);
341
314
  if (CustomTabBar) {
342
- return /*#__PURE__*/_jsx(ScreenStackItem, {
343
- screenId: "root-tabbar",
344
- headerConfig: {
345
- hidden: true
346
- },
347
- style: StyleSheet.absoluteFill,
348
- stackAnimation: "slide_from_right",
349
- children: /*#__PURE__*/_jsxs(TabBarContext.Provider, {
350
- value: tabBar,
351
- children: [/*#__PURE__*/_jsx(View, {
352
- style: styles.flex,
353
- children: tabs.filter(t => visited[t.tabKey]).map(tab => {
354
- const isActive = tab.tabKey === tabs[index]?.tabKey;
355
- const stackForTab = tabBar.stacks[tab.tabKey];
356
- const nodeForTab = tabBar.nodes[tab.tabKey];
357
- const ScreenForTab = tabBar.screens[tab.tabKey];
358
- return /*#__PURE__*/_jsx(View, {
359
- style: [styles.flex, !isActive && styles.hidden],
360
- children: stackForTab ? /*#__PURE__*/_jsx(TabStackRenderer, {
361
- appearance: appearance,
362
- stack: stackForTab
363
- }) : nodeForTab ? /*#__PURE__*/_jsx(TabNodeRenderer, {
364
- appearance: appearance,
365
- node: nodeForTab
366
- }) : ScreenForTab ? /*#__PURE__*/_jsx(ScreenForTab, {}) : null
367
- }, `tab-content-${tab.tabKey}`);
368
- })
369
- }), /*#__PURE__*/_jsx(CustomTabBar, {
370
- onTabPress: onTabPress,
371
- activeIndex: index,
372
- tabs: tabs
373
- })]
374
- })
375
- });
376
- }
377
- return /*#__PURE__*/_jsx(ScreenStackItem, {
378
- screenId: "root-tabbar",
379
- headerConfig: {
380
- hidden: true
381
- },
382
- style: StyleSheet.absoluteFill,
383
- stackAnimation: "slide_from_right",
384
- children: /*#__PURE__*/_jsx(TabBarContext.Provider, {
315
+ return /*#__PURE__*/_jsxs(TabBarContext.Provider, {
385
316
  value: tabBar,
386
- children: /*#__PURE__*/_jsx(Tabs.Host, {
387
- onNativeFocusChange: onNativeFocusChange,
388
- bottomAccessory: config.bottomAccessory,
389
- experimentalControlNavigationStateInJS: config.experimentalControlNavigationStateInJS,
390
- ...containerProps,
391
- children: tabs.map((tab, i) => {
392
- const isFocused = tab.tabKey === tabs[index]?.tabKey;
393
- const stack = tabBar.stacks[tab.tabKey];
394
- const node = tabBar.nodes[tab.tabKey];
395
- const Screen = tabBar.screens[tab.tabKey];
396
- const convertedIcon = tabIcons[i];
397
- const {
398
- icon: _icon,
399
- selectedIcon: _selectedIcon,
400
- tabPrefix: _prefix,
401
- ...tabScreenProps
402
- } = tab;
403
- return /*#__PURE__*/_jsx(Tabs.Screen, {
404
- ...tabScreenProps,
405
- scrollEdgeAppearance: iosAppearance,
406
- standardAppearance: iosAppearance,
407
- isFocused: isFocused,
408
- icon: convertedIcon?.icon,
409
- selectedIcon: convertedIcon?.selectedIcon,
410
- children: stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
317
+ children: [/*#__PURE__*/_jsx(View, {
318
+ style: styles.flex,
319
+ children: tabs.filter(t => visited[t.tabKey]).map(tab => {
320
+ const isActive = tab.tabKey === tabs[index]?.tabKey;
321
+ const stackForTab = tabBar.stacks[tab.tabKey];
322
+ const nodeForTab = tabBar.nodes[tab.tabKey];
323
+ const ScreenForTab = tabBar.screens[tab.tabKey];
324
+ return /*#__PURE__*/_jsx(View, {
325
+ style: [styles.flex, !isActive && styles.hidden],
326
+ children: stackForTab ? /*#__PURE__*/_jsx(TabStackRenderer, {
411
327
  appearance: appearance,
412
- stack: stack
413
- }) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
328
+ stack: stackForTab
329
+ }) : nodeForTab ? /*#__PURE__*/_jsx(TabNodeRenderer, {
414
330
  appearance: appearance,
415
- node: node
416
- }) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null
417
- }, tab.tabKey);
331
+ node: nodeForTab
332
+ }) : ScreenForTab ? /*#__PURE__*/_jsx(ScreenForTab, {}) : null
333
+ }, `tab-content-${tab.tabKey}`);
418
334
  })
335
+ }), /*#__PURE__*/_jsx(CustomTabBar, {
336
+ onTabPress: onTabPress,
337
+ activeIndex: index,
338
+ tabs: tabs
339
+ })]
340
+ });
341
+ }
342
+ return /*#__PURE__*/_jsx(TabBarContext.Provider, {
343
+ value: tabBar,
344
+ children: /*#__PURE__*/_jsx(Tabs.Host, {
345
+ onNativeFocusChange: onNativeFocusChange,
346
+ bottomAccessory: config.bottomAccessory,
347
+ experimentalControlNavigationStateInJS: config.experimentalControlNavigationStateInJS,
348
+ ...containerProps,
349
+ children: tabs.map((tab, i) => {
350
+ const isFocused = tab.tabKey === tabs[index]?.tabKey;
351
+ const stack = tabBar.stacks[tab.tabKey];
352
+ const node = tabBar.nodes[tab.tabKey];
353
+ const Screen = tabBar.screens[tab.tabKey];
354
+ const convertedIcon = tabIcons[i];
355
+ const {
356
+ icon: _icon,
357
+ selectedIcon: _selectedIcon,
358
+ tabPrefix: _prefix,
359
+ ...tabScreenProps
360
+ } = tab;
361
+ return /*#__PURE__*/_jsx(Tabs.Screen, {
362
+ ...tabScreenProps,
363
+ scrollEdgeAppearance: iosAppearance,
364
+ standardAppearance: iosAppearance,
365
+ isFocused: isFocused,
366
+ icon: convertedIcon?.icon,
367
+ selectedIcon: convertedIcon?.selectedIcon,
368
+ children: stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
369
+ appearance: appearance,
370
+ stack: stack
371
+ }) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
372
+ appearance: appearance,
373
+ node: node
374
+ }) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null
375
+ }, tab.tabKey);
419
376
  })
420
377
  })
421
378
  });
@@ -5,11 +5,27 @@ export interface RenderSplitViewProps {
5
5
  appearance?: NavigationAppearance;
6
6
  }
7
7
  /**
8
- * On native (iPhone), SplitView renders primary and secondary screens
9
- * in a SINGLE ScreenStack to get native push/pop animations.
8
+ * On native, SplitView renders primary and secondary screens in a SINGLE
9
+ * ScreenStack to get native push/pop animations.
10
10
  *
11
11
  * The combined history is: [...primaryHistory, ...secondaryHistory]
12
12
  * This way, navigating from primary to secondary is a native push.
13
+ *
14
+ * On Android (Fabric) react-native-screens manages each ScreenStackItem via
15
+ * an Android Fragment. Fragment lifecycle (onCreateView) is asynchronous,
16
+ * but Fabric's layout pass is synchronous. If a new commit is dispatched
17
+ * while the previous Fragment transaction is still in flight, Fabric tries
18
+ * to insert child views into a Screen whose Fragment isn't attached yet:
19
+ *
20
+ * "addViewAt: Parent Screen does not have its Fragment attached"
21
+ *
22
+ * The fix follows the same pattern used in react-native-screens' own
23
+ * BottomTabsContainer example: subscribe via useEffect and apply updates
24
+ * inside `startTransition`. This marks the update as non-urgent, letting
25
+ * React defer the Fabric commit until pending Fragment transactions complete.
26
+ *
27
+ * See: https://github.com/software-mansion/react-native-screens/blob/ddd1a9e/
28
+ * apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx
13
29
  */
14
30
  export declare const RenderSplitView: import("react").NamedExoticComponent<RenderSplitViewProps>;
15
31
  //# sourceMappingURL=RenderSplitView.native.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigmela/router",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "React Native Router",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",