one 1.21.6 → 1.21.8

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.
Files changed (209) hide show
  1. package/dist/cjs/cli/dev.native.js.map +1 -1
  2. package/dist/cjs/cli.cjs +0 -3
  3. package/dist/cjs/cli.native.js +0 -3
  4. package/dist/cjs/cli.native.js.map +1 -1
  5. package/dist/cjs/image/getImageData.cjs +1 -1
  6. package/dist/cjs/image/getImageData.native.js +1 -1
  7. package/dist/cjs/image/getImageData.native.js.map +1 -1
  8. package/dist/cjs/index.native.js.map +1 -1
  9. package/dist/cjs/layouts/Stack.cjs +5 -5
  10. package/dist/cjs/layouts/Stack.native.js +5 -5
  11. package/dist/cjs/layouts/Stack.native.js.map +1 -1
  12. package/dist/cjs/layouts/stack-utils/StackScreen.cjs +3 -0
  13. package/dist/cjs/layouts/stack-utils/StackScreen.native.js +3 -0
  14. package/dist/cjs/layouts/stack-utils/StackScreen.native.js.map +1 -1
  15. package/dist/cjs/layouts/stack-utils/StackToolbar.cjs +419 -0
  16. package/dist/cjs/layouts/stack-utils/StackToolbar.native.js +453 -0
  17. package/dist/cjs/layouts/stack-utils/StackToolbar.native.js.map +1 -0
  18. package/dist/cjs/layouts/stack-utils/StackToolbar.types.cjs +18 -0
  19. package/dist/cjs/layouts/stack-utils/StackToolbar.types.native.js +21 -0
  20. package/dist/cjs/layouts/stack-utils/StackToolbar.types.native.js.map +1 -0
  21. package/dist/cjs/layouts/stack-utils/__tests__/StackToolbar.runtime.test.cjs +63 -0
  22. package/dist/cjs/layouts/stack-utils/__tests__/StackToolbar.runtime.test.native.js +73 -0
  23. package/dist/cjs/layouts/stack-utils/__tests__/StackToolbar.runtime.test.native.js.map +1 -0
  24. package/dist/cjs/layouts/stack-utils/__tests__/composition.test.cjs +205 -0
  25. package/dist/cjs/layouts/stack-utils/__tests__/composition.test.native.js +220 -0
  26. package/dist/cjs/layouts/stack-utils/__tests__/composition.test.native.js.map +1 -1
  27. package/dist/cjs/layouts/stack-utils/index.cjs +2 -0
  28. package/dist/cjs/layouts/stack-utils/index.native.js +2 -0
  29. package/dist/cjs/layouts/stack-utils/index.native.js.map +1 -1
  30. package/dist/cjs/layouts/withLayoutContext.cjs +39 -8
  31. package/dist/cjs/layouts/withLayoutContext.native.js +43 -8
  32. package/dist/cjs/layouts/withLayoutContext.native.js.map +1 -1
  33. package/dist/cjs/layouts/withLayoutContext.test.cjs +96 -0
  34. package/dist/cjs/layouts/withLayoutContext.test.native.js +100 -0
  35. package/dist/cjs/layouts/withLayoutContext.test.native.js.map +1 -0
  36. package/dist/cjs/router/Route.cjs +3 -0
  37. package/dist/cjs/router/Route.native.js +3 -0
  38. package/dist/cjs/router/Route.native.js.map +1 -1
  39. package/dist/cjs/router/protectedRoutes.test.cjs +22 -0
  40. package/dist/cjs/router/protectedRoutes.test.native.js +25 -0
  41. package/dist/cjs/router/protectedRoutes.test.native.js.map +1 -0
  42. package/dist/cjs/router/router.cjs +26 -8
  43. package/dist/cjs/router/router.native.js +26 -8
  44. package/dist/cjs/router/router.native.js.map +1 -1
  45. package/dist/cjs/router/useScreens.cjs +38 -11
  46. package/dist/cjs/router/useScreens.native.js +39 -11
  47. package/dist/cjs/router/useScreens.native.js.map +1 -1
  48. package/dist/cjs/router/useScreens.test.cjs +134 -0
  49. package/dist/cjs/router/useScreens.test.native.js +163 -0
  50. package/dist/cjs/router/useScreens.test.native.js.map +1 -0
  51. package/dist/cjs/views/Navigator.cjs +14 -6
  52. package/dist/cjs/views/Navigator.native.js +14 -6
  53. package/dist/cjs/views/Navigator.native.js.map +1 -1
  54. package/dist/cjs/views/Protected.native.js.map +1 -1
  55. package/dist/cjs/views/SuspenseFallback.cjs +18 -0
  56. package/dist/cjs/views/SuspenseFallback.native.js +21 -0
  57. package/dist/cjs/views/SuspenseFallback.native.js.map +1 -0
  58. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +47 -7
  59. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +47 -7
  60. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  61. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.cjs +15 -6
  62. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.native.js +15 -6
  63. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.native.js.map +1 -1
  64. package/dist/esm/cli/dev.mjs.map +1 -1
  65. package/dist/esm/cli/dev.native.js.map +1 -1
  66. package/dist/esm/cli.mjs +0 -3
  67. package/dist/esm/cli.mjs.map +1 -1
  68. package/dist/esm/cli.native.js +0 -3
  69. package/dist/esm/cli.native.js.map +1 -1
  70. package/dist/esm/image/getImageData.mjs +1 -1
  71. package/dist/esm/image/getImageData.mjs.map +1 -1
  72. package/dist/esm/image/getImageData.native.js +1 -1
  73. package/dist/esm/image/getImageData.native.js.map +1 -1
  74. package/dist/esm/index.js.map +1 -1
  75. package/dist/esm/index.mjs.map +1 -1
  76. package/dist/esm/index.native.js.map +1 -1
  77. package/dist/esm/layouts/Stack.mjs +6 -6
  78. package/dist/esm/layouts/Stack.mjs.map +1 -1
  79. package/dist/esm/layouts/Stack.native.js +6 -6
  80. package/dist/esm/layouts/Stack.native.js.map +1 -1
  81. package/dist/esm/layouts/stack-utils/StackScreen.mjs +3 -0
  82. package/dist/esm/layouts/stack-utils/StackScreen.mjs.map +1 -1
  83. package/dist/esm/layouts/stack-utils/StackScreen.native.js +3 -0
  84. package/dist/esm/layouts/stack-utils/StackScreen.native.js.map +1 -1
  85. package/dist/esm/layouts/stack-utils/StackToolbar.mjs +384 -0
  86. package/dist/esm/layouts/stack-utils/StackToolbar.mjs.map +1 -0
  87. package/dist/esm/layouts/stack-utils/StackToolbar.native.js +416 -0
  88. package/dist/esm/layouts/stack-utils/StackToolbar.native.js.map +1 -0
  89. package/dist/esm/layouts/stack-utils/StackToolbar.types.mjs +2 -0
  90. package/dist/esm/layouts/stack-utils/StackToolbar.types.mjs.map +1 -0
  91. package/dist/esm/layouts/stack-utils/StackToolbar.types.native.js +2 -0
  92. package/dist/esm/layouts/stack-utils/StackToolbar.types.native.js.map +1 -0
  93. package/dist/esm/layouts/stack-utils/__tests__/StackToolbar.runtime.test.mjs +40 -0
  94. package/dist/esm/layouts/stack-utils/__tests__/StackToolbar.runtime.test.mjs.map +1 -0
  95. package/dist/esm/layouts/stack-utils/__tests__/StackToolbar.runtime.test.native.js +47 -0
  96. package/dist/esm/layouts/stack-utils/__tests__/StackToolbar.runtime.test.native.js.map +1 -0
  97. package/dist/esm/layouts/stack-utils/__tests__/composition.test.mjs +205 -0
  98. package/dist/esm/layouts/stack-utils/__tests__/composition.test.mjs.map +1 -1
  99. package/dist/esm/layouts/stack-utils/__tests__/composition.test.native.js +220 -0
  100. package/dist/esm/layouts/stack-utils/__tests__/composition.test.native.js.map +1 -1
  101. package/dist/esm/layouts/stack-utils/index.mjs +2 -1
  102. package/dist/esm/layouts/stack-utils/index.mjs.map +1 -1
  103. package/dist/esm/layouts/stack-utils/index.native.js +2 -1
  104. package/dist/esm/layouts/stack-utils/index.native.js.map +1 -1
  105. package/dist/esm/layouts/withLayoutContext.mjs +41 -11
  106. package/dist/esm/layouts/withLayoutContext.mjs.map +1 -1
  107. package/dist/esm/layouts/withLayoutContext.native.js +45 -11
  108. package/dist/esm/layouts/withLayoutContext.native.js.map +1 -1
  109. package/dist/esm/layouts/withLayoutContext.test.mjs +73 -0
  110. package/dist/esm/layouts/withLayoutContext.test.mjs.map +1 -0
  111. package/dist/esm/layouts/withLayoutContext.test.native.js +74 -0
  112. package/dist/esm/layouts/withLayoutContext.test.native.js.map +1 -0
  113. package/dist/esm/router/Route.mjs +3 -1
  114. package/dist/esm/router/Route.mjs.map +1 -1
  115. package/dist/esm/router/Route.native.js +3 -1
  116. package/dist/esm/router/Route.native.js.map +1 -1
  117. package/dist/esm/router/protectedRoutes.test.mjs +23 -0
  118. package/dist/esm/router/protectedRoutes.test.mjs.map +1 -0
  119. package/dist/esm/router/protectedRoutes.test.native.js +23 -0
  120. package/dist/esm/router/protectedRoutes.test.native.js.map +1 -0
  121. package/dist/esm/router/router.mjs +26 -9
  122. package/dist/esm/router/router.mjs.map +1 -1
  123. package/dist/esm/router/router.native.js +26 -9
  124. package/dist/esm/router/router.native.js.map +1 -1
  125. package/dist/esm/router/useScreens.mjs +39 -11
  126. package/dist/esm/router/useScreens.mjs.map +1 -1
  127. package/dist/esm/router/useScreens.native.js +40 -11
  128. package/dist/esm/router/useScreens.native.js.map +1 -1
  129. package/dist/esm/router/useScreens.test.mjs +105 -0
  130. package/dist/esm/router/useScreens.test.mjs.map +1 -0
  131. package/dist/esm/router/useScreens.test.native.js +130 -0
  132. package/dist/esm/router/useScreens.test.native.js.map +1 -0
  133. package/dist/esm/views/Navigator.mjs +15 -7
  134. package/dist/esm/views/Navigator.mjs.map +1 -1
  135. package/dist/esm/views/Navigator.native.js +15 -7
  136. package/dist/esm/views/Navigator.native.js.map +1 -1
  137. package/dist/esm/views/Protected.mjs.map +1 -1
  138. package/dist/esm/views/Protected.native.js.map +1 -1
  139. package/dist/esm/views/SuspenseFallback.mjs +2 -0
  140. package/dist/esm/views/SuspenseFallback.mjs.map +1 -0
  141. package/dist/esm/views/SuspenseFallback.native.js +2 -0
  142. package/dist/esm/views/SuspenseFallback.native.js.map +1 -0
  143. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +47 -7
  144. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
  145. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +47 -7
  146. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  147. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.mjs +15 -6
  148. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.mjs.map +1 -1
  149. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.native.js +15 -6
  150. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.native.js.map +1 -1
  151. package/package.json +12 -11
  152. package/src/cli/dev.ts +0 -1
  153. package/src/cli.ts +0 -3
  154. package/src/image/getImageData.ts +2 -2
  155. package/src/index.ts +12 -0
  156. package/src/layouts/Stack.tsx +6 -4
  157. package/src/layouts/stack-utils/README.md +1 -0
  158. package/src/layouts/stack-utils/StackScreen.tsx +7 -0
  159. package/src/layouts/stack-utils/StackToolbar.tsx +550 -0
  160. package/src/layouts/stack-utils/StackToolbar.types.ts +98 -0
  161. package/src/layouts/stack-utils/__tests__/StackToolbar.runtime.test.tsx +46 -0
  162. package/src/layouts/stack-utils/__tests__/composition.test.tsx +176 -0
  163. package/src/layouts/stack-utils/index.tsx +16 -0
  164. package/src/layouts/withLayoutContext.test.tsx +74 -0
  165. package/src/layouts/withLayoutContext.tsx +71 -12
  166. package/src/router/Route.tsx +8 -0
  167. package/src/router/protectedRoutes.test.ts +35 -0
  168. package/src/router/router.ts +48 -20
  169. package/src/router/useScreens.test.tsx +139 -0
  170. package/src/router/useScreens.tsx +59 -13
  171. package/src/views/Navigator.tsx +25 -5
  172. package/src/views/Protected.tsx +3 -0
  173. package/src/views/SuspenseFallback.ts +7 -0
  174. package/src/vite/plugins/fileSystemRouterPlugin.test.ts +15 -6
  175. package/src/vite/plugins/fileSystemRouterPlugin.tsx +52 -7
  176. package/types/cli/dev.d.ts +0 -1
  177. package/types/cli/dev.d.ts.map +1 -1
  178. package/types/image/getImageData.d.ts +1 -1
  179. package/types/image/getImageData.d.ts.map +1 -1
  180. package/types/index.d.ts +2 -1
  181. package/types/index.d.ts.map +1 -1
  182. package/types/layouts/Stack.d.ts +11 -0
  183. package/types/layouts/Stack.d.ts.map +1 -1
  184. package/types/layouts/stack-utils/StackScreen.d.ts.map +1 -1
  185. package/types/layouts/stack-utils/StackToolbar.d.ts +26 -0
  186. package/types/layouts/stack-utils/StackToolbar.d.ts.map +1 -0
  187. package/types/layouts/stack-utils/StackToolbar.types.d.ts +82 -0
  188. package/types/layouts/stack-utils/StackToolbar.types.d.ts.map +1 -0
  189. package/types/layouts/stack-utils/index.d.ts +3 -1
  190. package/types/layouts/stack-utils/index.d.ts.map +1 -1
  191. package/types/layouts/withLayoutContext.d.ts +7 -0
  192. package/types/layouts/withLayoutContext.d.ts.map +1 -1
  193. package/types/layouts/withLayoutContext.test.d.ts +2 -0
  194. package/types/layouts/withLayoutContext.test.d.ts.map +1 -0
  195. package/types/router/Route.d.ts +4 -0
  196. package/types/router/Route.d.ts.map +1 -1
  197. package/types/router/protectedRoutes.test.d.ts +2 -0
  198. package/types/router/protectedRoutes.test.d.ts.map +1 -0
  199. package/types/router/router.d.ts +4 -6
  200. package/types/router/router.d.ts.map +1 -1
  201. package/types/router/useScreens.d.ts.map +1 -1
  202. package/types/router/useScreens.test.d.ts +2 -0
  203. package/types/router/useScreens.test.d.ts.map +1 -0
  204. package/types/views/Navigator.d.ts.map +1 -1
  205. package/types/views/Protected.d.ts +3 -0
  206. package/types/views/Protected.d.ts.map +1 -1
  207. package/types/views/SuspenseFallback.d.ts +8 -0
  208. package/types/views/SuspenseFallback.d.ts.map +1 -0
  209. package/types/vite/plugins/fileSystemRouterPlugin.d.ts.map +1 -1
@@ -0,0 +1,98 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { ColorValue, ImageSourcePropType, StyleProp, TextStyle } from 'react-native'
3
+
4
+ export type StackToolbarPlacement = 'left' | 'right' | 'bottom'
5
+
6
+ export interface StackToolbarProps {
7
+ children?: ReactNode
8
+ placement?: StackToolbarPlacement
9
+ asChild?: boolean
10
+ /** Android-only. One's Android toolbar host is not implemented yet. */
11
+ disableImePadding?: boolean
12
+ /** Android-only. One's Android toolbar host is not implemented yet. */
13
+ tintColor?: ColorValue
14
+ /** Android-only. One's Android toolbar host is not implemented yet. */
15
+ backgroundColor?: ColorValue
16
+ }
17
+
18
+ export interface StackToolbarIconProps {
19
+ src?: ImageSourcePropType
20
+ sf?: string
21
+ xcasset?: string
22
+ renderingMode?: 'template' | 'original'
23
+ }
24
+
25
+ export interface StackToolbarLabelProps {
26
+ children?: string
27
+ }
28
+
29
+ export interface StackToolbarBadgeProps {
30
+ children?: string
31
+ style?: StyleProp<
32
+ Pick<
33
+ TextStyle,
34
+ 'fontFamily' | 'fontSize' | 'fontWeight' | 'color' | 'backgroundColor'
35
+ >
36
+ >
37
+ }
38
+
39
+ export interface StackToolbarItemProps {
40
+ children?: ReactNode
41
+ icon?: string | ImageSourcePropType
42
+ iconRenderingMode?: 'template' | 'original'
43
+ tintColor?: ColorValue
44
+ hidden?: boolean
45
+ disabled?: boolean
46
+ accessibilityLabel?: string
47
+ accessibilityHint?: string
48
+ hidesSharedBackground?: boolean
49
+ separateBackground?: boolean
50
+ style?: StyleProp<TextStyle>
51
+ }
52
+
53
+ export interface StackToolbarButtonProps extends StackToolbarItemProps {
54
+ image?: ImageSourcePropType
55
+ onPress?: () => void
56
+ selected?: boolean
57
+ variant?: 'plain' | 'done' | 'prominent'
58
+ }
59
+
60
+ export interface StackToolbarMenuProps extends StackToolbarItemProps {
61
+ image?: ImageSourcePropType
62
+ destructive?: boolean
63
+ title?: string
64
+ inline?: boolean
65
+ palette?: boolean
66
+ singleSelection?: boolean
67
+ elementSize?: 'auto' | 'small' | 'medium' | 'large'
68
+ variant?: 'plain' | 'done' | 'prominent'
69
+ }
70
+
71
+ export interface StackToolbarMenuActionProps extends StackToolbarItemProps {
72
+ image?: ImageSourcePropType
73
+ destructive?: boolean
74
+ discoverabilityLabel?: string
75
+ subtitle?: string
76
+ isOn?: boolean
77
+ onPress?: () => void
78
+ unstable_keepPresented?: boolean
79
+ }
80
+
81
+ export interface StackToolbarSpacerProps {
82
+ width?: number
83
+ hidden?: boolean
84
+ sharesBackground?: boolean
85
+ }
86
+
87
+ export interface StackToolbarSearchBarSlotProps {
88
+ hidden?: boolean
89
+ hidesSharedBackground?: boolean
90
+ separateBackground?: boolean
91
+ }
92
+
93
+ export interface StackToolbarViewProps {
94
+ children?: ReactNode
95
+ hidden?: boolean
96
+ hidesSharedBackground?: boolean
97
+ separateBackground?: boolean
98
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react'
2
+ import TestRenderer, { act } from 'react-test-renderer'
3
+ import { Platform } from 'react-native'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ const setOptions = vi.fn()
7
+
8
+ vi.mock('../../../router/useNavigation', () => ({
9
+ useNavigation: () => ({ setOptions }),
10
+ }))
11
+
12
+ import { StackToolbar } from '../StackToolbar'
13
+
14
+ describe('StackToolbar runtime options', () => {
15
+ const originalOS = Platform.OS
16
+
17
+ afterEach(() => {
18
+ ;(Platform as any).OS = originalOS
19
+ setOptions.mockClear()
20
+ })
21
+
22
+ it('does not write undefined header overrides when the toolbar unmounts', () => {
23
+ ;(Platform as any).OS = 'ios'
24
+ let renderer: TestRenderer.ReactTestRenderer
25
+
26
+ act(() => {
27
+ renderer = TestRenderer.create(
28
+ <StackToolbar placement="right">
29
+ <StackToolbar.Button icon="bell" />
30
+ </StackToolbar>
31
+ )
32
+ })
33
+
34
+ expect(setOptions).toHaveBeenCalledTimes(1)
35
+ expect(setOptions).toHaveBeenLastCalledWith(
36
+ expect.objectContaining({
37
+ headerShown: true,
38
+ unstable_headerRightItems: expect.any(Function),
39
+ })
40
+ )
41
+
42
+ act(() => renderer!.unmount())
43
+
44
+ expect(setOptions).toHaveBeenCalledTimes(1)
45
+ })
46
+ })
@@ -25,6 +25,7 @@ import {
25
25
  } from '../StackHeaderComponent'
26
26
  import { appendScreenStackPropsToOptions } from '../StackScreen'
27
27
  import { StackHeader } from '../index'
28
+ import { StackToolbar, appendStackToolbarPropsToOptions } from '../StackToolbar'
28
29
 
29
30
  describe('Stack Header Composition', () => {
30
31
  describe('StackHeaderTitle', () => {
@@ -640,4 +641,179 @@ describe('Stack Header Composition', () => {
640
641
  })
641
642
  })
642
643
  })
644
+
645
+ describe('StackToolbar', () => {
646
+ const withIOS = <T,>(run: () => T): T => {
647
+ const originalOS = Platform.OS
648
+ ;(Platform as any).OS = 'ios'
649
+ try {
650
+ return run()
651
+ } finally {
652
+ ;(Platform as any).OS = originalOS
653
+ }
654
+ }
655
+
656
+ it('converts buttons, SF Symbols, badges, and iOS 26 background props to native header items', () =>
657
+ withIOS(() => {
658
+ const onPress = () => {}
659
+ const result = appendStackToolbarPropsToOptions(
660
+ { title: 'Inbox' },
661
+ {
662
+ placement: 'right',
663
+ children: (
664
+ <StackToolbar.Button
665
+ onPress={onPress}
666
+ selected
667
+ separateBackground
668
+ hidesSharedBackground
669
+ >
670
+ <StackToolbar.Icon sf="bell" />
671
+ <StackToolbar.Label>Notifications</StackToolbar.Label>
672
+ <StackToolbar.Badge style={{ color: 'white', backgroundColor: 'red' }}>
673
+ 5
674
+ </StackToolbar.Badge>
675
+ </StackToolbar.Button>
676
+ ),
677
+ }
678
+ )
679
+
680
+ expect(result.title).toBe('Inbox')
681
+ expect(result.headerShown).toBe(true)
682
+ const items = result.unstable_headerRightItems?.({} as any)
683
+ expect(items).toEqual([
684
+ expect.objectContaining({
685
+ type: 'button',
686
+ label: 'Notifications',
687
+ icon: { type: 'sfSymbol', name: 'bell' },
688
+ badge: {
689
+ value: '5',
690
+ style: expect.objectContaining({ color: 'white', backgroundColor: 'red' }),
691
+ },
692
+ selected: true,
693
+ sharesBackground: false,
694
+ hidesSharedBackground: true,
695
+ onPress,
696
+ }),
697
+ ])
698
+ }))
699
+
700
+ it('uses original image colors unless a tint or rendering mode requests a template', () =>
701
+ withIOS(() => {
702
+ const source = { uri: 'https://example.com/icon.png' }
703
+ const result = appendStackToolbarPropsToOptions(
704
+ {},
705
+ {
706
+ placement: 'right',
707
+ children: [
708
+ <StackToolbar.Button key="original" icon={source} />,
709
+ <StackToolbar.Button key="tinted" icon={source} tintColor="blue" />,
710
+ <StackToolbar.Button key="explicit" tintColor="blue">
711
+ <StackToolbar.Icon src={source} renderingMode="original" />
712
+ </StackToolbar.Button>,
713
+ ],
714
+ }
715
+ )
716
+
717
+ expect(result.unstable_headerRightItems?.({} as any)).toMatchObject([
718
+ { icon: { type: 'image', source, tinted: false } },
719
+ { icon: { type: 'image', source, tinted: true } },
720
+ { icon: { type: 'image', source, tinted: false } },
721
+ ])
722
+ }))
723
+
724
+ it('converts nested native menus and action state', () =>
725
+ withIOS(() => {
726
+ const archive = () => {}
727
+ const result = appendStackToolbarPropsToOptions(
728
+ {},
729
+ {
730
+ placement: 'left',
731
+ children: (
732
+ <StackToolbar.Menu title="Actions" icon="ellipsis.circle">
733
+ <StackToolbar.MenuAction icon="archivebox" isOn onPress={archive}>
734
+ Archive
735
+ </StackToolbar.MenuAction>
736
+ <StackToolbar.Menu destructive inline title="Move to">
737
+ <StackToolbar.MenuAction destructive>Trash</StackToolbar.MenuAction>
738
+ </StackToolbar.Menu>
739
+ </StackToolbar.Menu>
740
+ ),
741
+ }
742
+ )
743
+
744
+ const items = result.unstable_headerLeftItems?.({} as any)
745
+ expect(items).toHaveLength(1)
746
+ expect(items?.[0]).toMatchObject({
747
+ type: 'menu',
748
+ icon: { type: 'sfSymbol', name: 'ellipsis.circle' },
749
+ menu: {
750
+ title: 'Actions',
751
+ items: [
752
+ {
753
+ type: 'action',
754
+ label: 'Archive',
755
+ icon: { type: 'sfSymbol', name: 'archivebox' },
756
+ state: 'on',
757
+ onPress: archive,
758
+ },
759
+ {
760
+ type: 'submenu',
761
+ label: 'Move to',
762
+ inline: true,
763
+ destructive: true,
764
+ items: [{ type: 'action', label: 'Trash', destructive: true }],
765
+ },
766
+ ],
767
+ },
768
+ })
769
+ }))
770
+
771
+ it('merges left and right toolbars declared on Stack.Screen', () =>
772
+ withIOS(() => {
773
+ const result = appendScreenStackPropsToOptions(
774
+ { title: 'Document' },
775
+ {
776
+ children: [
777
+ <StackToolbar key="left" placement="left">
778
+ <StackToolbar.Button icon="sidebar.left" />
779
+ </StackToolbar>,
780
+ <StackToolbar key="right" placement="right">
781
+ <StackToolbar.Button icon="square.and.arrow.up" />
782
+ </StackToolbar>,
783
+ ],
784
+ }
785
+ )
786
+
787
+ expect(result.title).toBe('Document')
788
+ expect(result.unstable_headerLeftItems?.({} as any)).toHaveLength(1)
789
+ expect(result.unstable_headerRightItems?.({} as any)).toHaveLength(1)
790
+ }))
791
+
792
+ it('uses custom header content with asChild', () =>
793
+ withIOS(() => {
794
+ const custom = <button>Custom</button>
795
+ const result = appendStackToolbarPropsToOptions(
796
+ {},
797
+ { placement: 'right', asChild: true, children: custom }
798
+ )
799
+ expect(result.headerRight?.({} as any)).toBe(custom)
800
+ expect(result.unstable_headerRightItems).toBeUndefined()
801
+ }))
802
+
803
+ it('does not install native toolbar options on web', () => {
804
+ const originalOS = Platform.OS
805
+ ;(Platform as any).OS = 'web'
806
+ try {
807
+ const original = { title: 'Web' }
808
+ expect(
809
+ appendStackToolbarPropsToOptions(original, {
810
+ placement: 'right',
811
+ children: <StackToolbar.Button icon="star" />,
812
+ })
813
+ ).toBe(original)
814
+ } finally {
815
+ ;(Platform as any).OS = originalOS
816
+ }
817
+ })
818
+ })
643
819
  })
@@ -10,6 +10,7 @@ import {
10
10
  type StackHeaderSearchBarProps,
11
11
  } from './StackHeaderSearchBar'
12
12
  import { StackHeaderTitle, type StackHeaderTitleProps } from './StackHeaderTitle'
13
+ import { StackToolbar } from './StackToolbar'
13
14
 
14
15
  /**
15
16
  * Compound component for configuring stack headers.
@@ -36,8 +37,23 @@ export {
36
37
  type StackHeaderSearchBarProps,
37
38
  StackHeaderTitle,
38
39
  type StackHeaderTitleProps,
40
+ StackToolbar,
39
41
  }
40
42
 
43
+ export type {
44
+ StackToolbarBadgeProps,
45
+ StackToolbarButtonProps,
46
+ StackToolbarIconProps,
47
+ StackToolbarLabelProps,
48
+ StackToolbarMenuActionProps,
49
+ StackToolbarMenuProps,
50
+ StackToolbarPlacement,
51
+ StackToolbarProps,
52
+ StackToolbarSearchBarSlotProps,
53
+ StackToolbarSpacerProps,
54
+ StackToolbarViewProps,
55
+ } from './StackToolbar'
56
+
41
57
  export {
42
58
  StackScreen,
43
59
  appendScreenStackPropsToOptions,
@@ -0,0 +1,74 @@
1
+ import React from 'react'
2
+ import TestRenderer, { act } from 'react-test-renderer'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { Protected } from '../views/Protected'
5
+ import { Screen } from '../views/Screen'
6
+ import { useFilterScreenChildren } from './withLayoutContext'
7
+
8
+ function readGuardedRedirects(children: React.ReactNode) {
9
+ let result: ReturnType<typeof useFilterScreenChildren> | undefined
10
+
11
+ function Capture() {
12
+ result = useFilterScreenChildren(children)
13
+ return null
14
+ }
15
+
16
+ act(() => {
17
+ TestRenderer.create(<Capture />)
18
+ })
19
+
20
+ return result!.guardedRedirects
21
+ }
22
+
23
+ describe('Protected redirectTo', () => {
24
+ it('associates a failed guard redirect with each protected screen', () => {
25
+ const redirects = readGuardedRedirects(
26
+ <Protected guard={false} redirectTo="/login">
27
+ <Screen name="dashboard" />
28
+ <Screen name="settings" />
29
+ </Protected>
30
+ )
31
+
32
+ expect(Array.from(redirects)).toEqual([
33
+ ['dashboard', '/login'],
34
+ ['settings', '/login'],
35
+ ])
36
+ })
37
+
38
+ it('uses the innermost failing guard redirect', () => {
39
+ const redirects = readGuardedRedirects(
40
+ <Protected guard={false} redirectTo="/login">
41
+ <Protected guard={false} redirectTo="/subscribe">
42
+ <Screen name="premium" />
43
+ </Protected>
44
+ </Protected>
45
+ )
46
+
47
+ expect(redirects.get('premium')).toBe('/subscribe')
48
+ })
49
+
50
+ it('inherits a failed parent redirect through a passing child guard', () => {
51
+ const redirects = readGuardedRedirects(
52
+ <Protected guard={false} redirectTo="/login">
53
+ <Protected guard>
54
+ <Screen name="account" />
55
+ </Protected>
56
+ </Protected>
57
+ )
58
+
59
+ expect(redirects.get('account')).toBe('/login')
60
+ })
61
+
62
+ it('ignores redirectTo on a passing parent guard', () => {
63
+ const redirects = readGuardedRedirects(
64
+ <Protected guard redirectTo="/login">
65
+ <Protected guard={false}>
66
+ <Screen name="account" />
67
+ </Protected>
68
+ </Protected>
69
+ )
70
+
71
+ expect(redirects.has('account')).toBe(true)
72
+ expect(redirects.get('account')).toBeUndefined()
73
+ })
74
+ })
@@ -1,8 +1,17 @@
1
1
  import type { EventMapBase, NavigationState } from '@react-navigation/native'
2
2
  import React from 'react'
3
- import { useContextKey } from '../router/Route'
4
- import { registerProtectedRoutes, unregisterProtectedRoutes } from '../router/router'
3
+ import type { OneRouter } from '../interfaces/router'
4
+ import { getContextKey, stripInvisibleSegmentsFromPath } from '../router/matchers'
5
+ import { useContextKey, useRouteNode } from '../router/Route'
6
+ import {
7
+ registerProtectedRoutes,
8
+ replace,
9
+ resolveProtectedHref,
10
+ routeInfo,
11
+ unregisterProtectedRoutes,
12
+ } from '../router/router'
5
13
  import { type ScreenProps, useSortedScreens } from '../router/useScreens'
14
+ import { sortRoutesWithInitial } from '../router/sortRoutes'
6
15
  import type { PickPartial } from '../types'
7
16
  import { withStaticProperties } from '../utils/withStaticProperties'
8
17
  import { isProtectedElement } from '../views/Protected'
@@ -24,12 +33,17 @@ export function useFilterScreenChildren(
24
33
  const customChildren: any[] = []
25
34
  const screens: any[] = []
26
35
  const protectedScreens = new Set<string>()
36
+ const guardedRedirects = new Map<string, OneRouter.Href | undefined>()
27
37
 
28
38
  /**
29
39
  * Recursively process children, handling Protected elements.
30
40
  * When exclude is true, all Screen children are added to protectedScreens instead of screens.
31
41
  */
32
- function flattenChild(child: React.ReactNode, exclude = false) {
42
+ function flattenChild(
43
+ child: React.ReactNode,
44
+ exclude = false,
45
+ redirectTo?: OneRouter.Href
46
+ ) {
33
47
  // Handle Screen or StackScreen elements
34
48
  if (
35
49
  React.isValidElement(child) &&
@@ -61,6 +75,7 @@ export function useFilterScreenChildren(
61
75
  const screenProps = child.props as ScreenProps
62
76
  if (exclude && screenProps.name) {
63
77
  protectedScreens.add(screenProps.name)
78
+ guardedRedirects.set(screenProps.name, redirectTo)
64
79
  } else {
65
80
  screens.push(screenProps)
66
81
  }
@@ -69,10 +84,11 @@ export function useFilterScreenChildren(
69
84
 
70
85
  // Handle Protected elements - recursively process children with guard logic
71
86
  if (isProtectedElement(child)) {
72
- // Key logic: exclude if parent excluded OR guard is false
73
- const excludeChildren = exclude || !child.props.guard
87
+ const guardFails = !child.props.guard
88
+ const excludeChildren = exclude || guardFails
89
+ const childRedirectTo = guardFails ? child.props.redirectTo : redirectTo
74
90
  React.Children.forEach(child.props.children, (nested) => {
75
- flattenChild(nested, excludeChildren)
91
+ flattenChild(nested, excludeChildren, childRedirectTo)
76
92
  })
77
93
  return
78
94
  }
@@ -102,10 +118,40 @@ export function useFilterScreenChildren(
102
118
  screens,
103
119
  children: customChildren,
104
120
  protectedScreens,
121
+ guardedRedirects,
105
122
  }
106
123
  }, [children, contextKey, isCustomNavigator])
107
124
  }
108
125
 
126
+ export function useResolvedGuardedRedirects(
127
+ guardedRedirects: Map<string, OneRouter.Href | undefined>
128
+ ) {
129
+ const node = useRouteNode()
130
+
131
+ return React.useMemo(() => {
132
+ if (!node) {
133
+ return guardedRedirects
134
+ }
135
+
136
+ const defaultRoute = [...node.children]
137
+ .sort(sortRoutesWithInitial(node.initialRouteName))
138
+ .find((child) => {
139
+ const normalized = child.route.replace(/\/index$/, '')
140
+ return !(guardedRedirects.has(child.route) || guardedRedirects.has(normalized))
141
+ })
142
+ const defaultHref = defaultRoute
143
+ ? stripInvisibleSegmentsFromPath(getContextKey(defaultRoute.contextKey)) || '/'
144
+ : undefined
145
+
146
+ return new Map(
147
+ Array.from(guardedRedirects, ([name, redirectTo]) => [
148
+ name,
149
+ redirectTo ?? defaultHref,
150
+ ])
151
+ )
152
+ }, [guardedRedirects, node])
153
+ }
154
+
109
155
  /** Return a navigator that automatically injects matched routes and renders nothing when there are no children. Return type with children prop optional */
110
156
  export function withLayoutContext<
111
157
  TOptions extends object,
@@ -124,20 +170,33 @@ export function withLayoutContext<
124
170
  (propsIn, ref) => {
125
171
  const { children, ...props } = propsIn as React.ComponentProps<T>
126
172
  const contextKey = useContextKey()
127
- const { screens, protectedScreens } = useFilterScreenChildren(children, {
128
- contextKey,
129
- })
173
+ const { screens, protectedScreens, guardedRedirects } = useFilterScreenChildren(
174
+ children,
175
+ { contextKey }
176
+ )
177
+ const resolvedGuardedRedirects = useResolvedGuardedRedirects(guardedRedirects)
130
178
 
131
179
  // Register protected routes globally so linkTo can block navigation to them
132
180
  // Register immediately (not just in effect) to catch navigation attempts during first render
133
- registerProtectedRoutes(contextKey, protectedScreens)
181
+ registerProtectedRoutes(contextKey, resolvedGuardedRedirects)
182
+
183
+ const currentPathname = routeInfo?.pathname
184
+ const protectedHref = currentPathname
185
+ ? resolveProtectedHref(currentPathname)
186
+ : currentPathname
134
187
 
135
188
  React.useEffect(() => {
136
- registerProtectedRoutes(contextKey, protectedScreens)
189
+ registerProtectedRoutes(contextKey, resolvedGuardedRedirects)
137
190
  return () => {
138
191
  unregisterProtectedRoutes(contextKey)
139
192
  }
140
- }, [contextKey, protectedScreens])
193
+ }, [contextKey, resolvedGuardedRedirects])
194
+
195
+ React.useEffect(() => {
196
+ if (currentPathname && protectedHref && protectedHref !== currentPathname) {
197
+ replace(protectedHref)
198
+ }
199
+ }, [currentPathname, protectedHref])
141
200
 
142
201
  const processed = processor ? processor(screens ?? ([] as any)) : screens
143
202
  const sorted = useSortedScreens((processed ?? []) as any, {
@@ -1,6 +1,7 @@
1
1
  import React, { createContext, type ReactNode, useContext } from 'react'
2
2
  import { findFocusedRoute } from '../fork/findFocusedRoute'
3
3
  import type { ErrorBoundaryProps } from '../views/Try'
4
+ import type { SuspenseFallbackProps } from '../views/SuspenseFallback'
4
5
  import type { LoaderProps } from '../types'
5
6
  import type { One } from '../vite/types'
6
7
  import type { ParamValidator, RouteValidationFn } from '../validateParams'
@@ -37,6 +38,7 @@ export type SlotConfig = {
37
38
 
38
39
  export type LoadedRoute = {
39
40
  ErrorBoundary?: React.ComponentType<ErrorBoundaryProps>
41
+ SuspenseFallback?: React.ComponentType<SuspenseFallbackProps>
40
42
  default?: React.ComponentType<any>
41
43
  unstable_settings?: Record<string, any>
42
44
  getNavOptions?: (args: any) => any
@@ -124,8 +126,14 @@ export const RouteParamsContext = createContext<
124
126
 
125
127
  const CurrentRouteContext = React.createContext<RouteNode | null>(null)
126
128
 
129
+ /** Allows a layout to provide a Suspense fallback for its child routes. */
130
+ export const SuspenseFallbackContext = createContext<
131
+ React.ComponentType<SuspenseFallbackProps> | undefined
132
+ >(undefined)
133
+
127
134
  if (process.env.NODE_ENV !== 'production') {
128
135
  CurrentRouteContext.displayName = 'RouteNode'
136
+ SuspenseFallbackContext.displayName = 'SuspenseFallback'
129
137
  }
130
138
 
131
139
  /** Return the RouteNode at the current contextual boundary. */
@@ -0,0 +1,35 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ isRouteProtected,
4
+ registerProtectedRoutes,
5
+ resolveProtectedHref,
6
+ unregisterProtectedRoutes,
7
+ } from './router'
8
+
9
+ const contextKey = '/account'
10
+
11
+ afterEach(() => {
12
+ unregisterProtectedRoutes(contextKey)
13
+ })
14
+
15
+ describe('protected route redirects', () => {
16
+ it('resolves a protected href to its redirect target', () => {
17
+ registerProtectedRoutes(contextKey, new Map([['settings', '/login']]))
18
+
19
+ expect(isRouteProtected('/account/settings?tab=billing')).toBe(true)
20
+ expect(resolveProtectedHref('/account/settings?tab=billing')).toBe('/login')
21
+ })
22
+
23
+ it('blocks a protected href without an explicit target', () => {
24
+ registerProtectedRoutes(contextKey, new Map([['settings', undefined]]))
25
+
26
+ expect(resolveProtectedHref('/account/settings')).toBeUndefined()
27
+ })
28
+
29
+ it('leaves allowed and similarly prefixed routes unchanged', () => {
30
+ registerProtectedRoutes(contextKey, new Map([['settings', '/login']]))
31
+
32
+ expect(resolveProtectedHref('/account/profile')).toBe('/account/profile')
33
+ expect(resolveProtectedHref('/accounting/settings')).toBe('/accounting/settings')
34
+ })
35
+ })