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
@@ -75,9 +75,8 @@ export { getSafeWindowPathname } from './path'
75
75
  export let routeNode: RouteNode | null = null
76
76
  export let rootComponent: ComponentType
77
77
 
78
- // Global registry for protected routes
79
- // Key: contextKey (e.g., '/protected-test'), Value: Set of protected route names
80
- const protectedRouteRegistry = new Map<string, Set<string>>()
78
+ // key: navigator context, value: guarded route names and their optional redirect targets.
79
+ const protectedRouteRegistry = new Map<string, Map<string, OneRouter.Href | undefined>>()
81
80
 
82
81
  /**
83
82
  * Register protected routes for a navigator context.
@@ -85,12 +84,12 @@ const protectedRouteRegistry = new Map<string, Set<string>>()
85
84
  */
86
85
  export function registerProtectedRoutes(
87
86
  contextKey: string,
88
- protectedScreens: Set<string>
87
+ guardedRedirects: Map<string, OneRouter.Href | undefined>
89
88
  ) {
90
- if (protectedScreens.size === 0) {
89
+ if (guardedRedirects.size === 0) {
91
90
  protectedRouteRegistry.delete(contextKey)
92
91
  } else {
93
- protectedRouteRegistry.set(contextKey, protectedScreens)
92
+ protectedRouteRegistry.set(contextKey, guardedRedirects)
94
93
  }
95
94
  }
96
95
 
@@ -106,26 +105,28 @@ export function unregisterProtectedRoutes(contextKey: string) {
106
105
  * Check if a route path is protected and should be blocked.
107
106
  * Returns true if the route is protected.
108
107
  */
109
- export function isRouteProtected(href: string): boolean {
110
- // Normalize the href (remove leading/trailing slashes)
111
- const normalizedHref = href.replace(/^\/+|\/+$/g, '')
108
+ function getProtectedRedirect(href: string): OneRouter.Href | undefined | false {
109
+ const normalizedHref = extractPathnameFromHref(href).replace(/^\/+|\/+$/g, '')
112
110
 
113
- // Check each navigator context to see if this route is protected
114
- for (const [contextKey, protectedScreens] of protectedRouteRegistry) {
111
+ for (const [contextKey, guardedRedirects] of protectedRouteRegistry) {
115
112
  const normalizedContextKey = contextKey.replace(/^\/+|\/+$/g, '')
116
113
 
117
- // Check if this href is under this context
118
- if (normalizedHref.startsWith(normalizedContextKey)) {
119
- // Get the route name relative to this context
114
+ if (
115
+ !normalizedContextKey ||
116
+ normalizedHref === normalizedContextKey ||
117
+ normalizedHref.startsWith(`${normalizedContextKey}/`)
118
+ ) {
120
119
  const relativePath = normalizedHref
121
120
  .slice(normalizedContextKey.length)
122
121
  .replace(/^\//, '')
123
122
  const routeName = relativePath.split('/')[0] || 'index'
124
-
125
- // normalize /index suffix so e.g. "otp/[flow]" matches "otp/[flow]/index"
126
123
  const normalizedRouteName = routeName.replace(/\/index$/, '')
127
- if (protectedScreens.has(routeName) || protectedScreens.has(normalizedRouteName)) {
128
- return true
124
+
125
+ if (guardedRedirects.has(routeName)) {
126
+ return guardedRedirects.get(routeName)
127
+ }
128
+ if (guardedRedirects.has(normalizedRouteName)) {
129
+ return guardedRedirects.get(normalizedRouteName)
129
130
  }
130
131
  }
131
132
  }
@@ -133,6 +134,30 @@ export function isRouteProtected(href: string): boolean {
133
134
  return false
134
135
  }
135
136
 
137
+ /** resolve a protected href to its allowed redirect target. */
138
+ export function resolveProtectedHref(href: string): string | undefined {
139
+ let target = href
140
+ const visited = new Set<string>()
141
+
142
+ while (!visited.has(target)) {
143
+ visited.add(target)
144
+ const redirectTo = getProtectedRedirect(target)
145
+ if (redirectTo === false) {
146
+ return target
147
+ }
148
+ if (redirectTo === undefined) {
149
+ return undefined
150
+ }
151
+ target = resolveHref(redirectTo)
152
+ }
153
+
154
+ return undefined
155
+ }
156
+
157
+ export function isRouteProtected(href: string): boolean {
158
+ return getProtectedRedirect(href) !== false
159
+ }
160
+
136
161
  export let hasAttemptedToHideSplash = false
137
162
  export let initialState: OneRouter.ResultState | undefined
138
163
  export let rootState: OneRouter.ResultState | undefined
@@ -1077,10 +1102,13 @@ export async function linkTo(
1077
1102
  return
1078
1103
  }
1079
1104
 
1080
- // Check if the route is protected and should be blocked
1081
- if (isRouteProtected(href)) {
1105
+ const protectedHref = resolveProtectedHref(href)
1106
+ if (!protectedHref) {
1082
1107
  return
1083
1108
  }
1109
+ if (protectedHref !== href) {
1110
+ return linkTo(protectedHref, 'REPLACE', options)
1111
+ }
1084
1112
 
1085
1113
  // Check for intercepting routes (parallel routes with @slot)
1086
1114
  // This enables modal patterns where soft nav shows modal, hard nav shows full page
@@ -0,0 +1,139 @@
1
+ import React from 'react'
2
+ import TestRenderer, { act } from 'react-test-renderer'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import type { LoadedRoute, RouteNode } from './Route'
5
+ import { getQualifiedRouteComponent } from './useScreens'
6
+
7
+ type FallbackProps = {
8
+ route: string
9
+ params: Record<string, string | string[]>
10
+ }
11
+
12
+ const pendingRoute = new Promise<void>(() => {})
13
+ const previousTarget = process.env.TAMAGUI_TARGET
14
+ const previousSuspendRoutesNative = process.env.ONE_SUSPEND_ROUTES_NATIVE
15
+ const previousDisableSuspense = (globalThis as any).__ONE_DISABLE_SUSPENSE_ROUTES__
16
+
17
+ function createRouteNode(
18
+ type: RouteNode['type'],
19
+ route: string,
20
+ contextKey: string,
21
+ loadRoute: () => Partial<LoadedRoute>
22
+ ): RouteNode {
23
+ return {
24
+ type,
25
+ route,
26
+ contextKey,
27
+ loadRoute,
28
+ children: [],
29
+ dynamic: null,
30
+ }
31
+ }
32
+
33
+ function SuspendingRoute(): React.ReactNode {
34
+ throw pendingRoute
35
+ }
36
+
37
+ beforeEach(() => {
38
+ process.env.TAMAGUI_TARGET = 'native'
39
+ delete process.env.ONE_SUSPEND_ROUTES_NATIVE
40
+ delete (globalThis as any).__ONE_DISABLE_SUSPENSE_ROUTES__
41
+ })
42
+
43
+ afterEach(() => {
44
+ if (previousTarget === undefined) {
45
+ delete process.env.TAMAGUI_TARGET
46
+ } else {
47
+ process.env.TAMAGUI_TARGET = previousTarget
48
+ }
49
+ if (previousSuspendRoutesNative === undefined) {
50
+ delete process.env.ONE_SUSPEND_ROUTES_NATIVE
51
+ } else {
52
+ process.env.ONE_SUSPEND_ROUTES_NATIVE = previousSuspendRoutesNative
53
+ }
54
+ if (previousDisableSuspense === undefined) {
55
+ delete (globalThis as any).__ONE_DISABLE_SUSPENSE_ROUTES__
56
+ } else {
57
+ ;(globalThis as any).__ONE_DISABLE_SUSPENSE_ROUTES__ = previousDisableSuspense
58
+ }
59
+ })
60
+
61
+ describe('route SuspenseFallback', () => {
62
+ it('inherits the layout fallback and passes the suspended route details', async () => {
63
+ const childNode = createRouteNode(
64
+ 'spa',
65
+ 'profile/[id]',
66
+ './(app)/profile/[id].tsx',
67
+ () => {
68
+ throw pendingRoute
69
+ }
70
+ )
71
+ const ChildRoute = getQualifiedRouteComponent(childNode)
72
+ const layoutNode = createRouteNode('layout', '(app)', './(app)/_layout.tsx', () => ({
73
+ default: () => <ChildRoute route={{ params: { id: '123' } }} />,
74
+ SuspenseFallback: ({ route, params }: FallbackProps) => (
75
+ <div data-fallback="layout">
76
+ {route}:{params.id}
77
+ </div>
78
+ ),
79
+ }))
80
+ const LayoutRoute = getQualifiedRouteComponent(layoutNode)
81
+
82
+ let renderer: TestRenderer.ReactTestRenderer
83
+ await act(async () => {
84
+ renderer = TestRenderer.create(<LayoutRoute />)
85
+ })
86
+
87
+ expect(renderer!.root.findByProps({ 'data-fallback': 'layout' }).children).toEqual([
88
+ './(app)/profile/[id].tsx',
89
+ ':',
90
+ '123',
91
+ ])
92
+ })
93
+
94
+ it('uses the nearest layout fallback', async () => {
95
+ const childNode = createRouteNode('spa', 'profile', './(app)/profile.tsx', () => ({
96
+ default: SuspendingRoute,
97
+ }))
98
+ const ChildRoute = getQualifiedRouteComponent(childNode)
99
+ const nestedLayoutNode = createRouteNode(
100
+ 'layout',
101
+ '(app)',
102
+ './(app)/_layout.tsx',
103
+ () => ({
104
+ default: () => <ChildRoute />,
105
+ SuspenseFallback: () => <div data-fallback="nested" />,
106
+ })
107
+ )
108
+ const NestedLayoutRoute = getQualifiedRouteComponent(nestedLayoutNode)
109
+ const rootLayoutNode = createRouteNode('layout', '', './_layout.tsx', () => ({
110
+ default: () => <NestedLayoutRoute />,
111
+ SuspenseFallback: () => <div data-fallback="root" />,
112
+ }))
113
+ const RootLayoutRoute = getQualifiedRouteComponent(rootLayoutNode)
114
+
115
+ let renderer: TestRenderer.ReactTestRenderer
116
+ await act(async () => {
117
+ renderer = TestRenderer.create(<RootLayoutRoute />)
118
+ })
119
+
120
+ expect(renderer!.root.findAllByProps({ 'data-fallback': 'nested' })).toHaveLength(1)
121
+ expect(renderer!.root.findAllByProps({ 'data-fallback': 'root' })).toHaveLength(0)
122
+ })
123
+
124
+ it('ignores a SuspenseFallback exported from a leaf route', async () => {
125
+ const childNode = createRouteNode('spa', 'profile', './profile.tsx', () => ({
126
+ default: SuspendingRoute,
127
+ SuspenseFallback: () => <div data-fallback="leaf" />,
128
+ }))
129
+ const ChildRoute = getQualifiedRouteComponent(childNode)
130
+
131
+ let renderer: TestRenderer.ReactTestRenderer
132
+ await act(async () => {
133
+ renderer = TestRenderer.create(<ChildRoute />)
134
+ })
135
+
136
+ expect(renderer!.root.findAllByProps({ 'data-fallback': 'leaf' })).toHaveLength(0)
137
+ expect(renderer!.toJSON()).toBeNull()
138
+ })
139
+ })
@@ -22,11 +22,14 @@ import {
22
22
  type DynamicConvention,
23
23
  type LoadedRoute,
24
24
  Route,
25
+ RouteParamsContext,
25
26
  type RouteNode,
27
+ SuspenseFallbackContext,
26
28
  useRouteNode,
27
29
  } from './Route'
28
30
  import { SpaShellContext } from './SpaShellContext'
29
31
  import { NamedSlot } from '../views/Navigator'
32
+ import type { SuspenseFallbackProps } from '../views/SuspenseFallback'
30
33
  import { sortRoutesWithInitial } from './sortRoutes'
31
34
  import { getRouteHmrEpoch, subscribeRouteHmr } from './routeHmr'
32
35
  import { getClientMatchesSnapshot } from '../useMatches'
@@ -337,7 +340,7 @@ export function useSortedScreens(
337
340
  return sortedScreens
338
341
  }
339
342
 
340
- function fromImport({ ErrorBoundary, ...component }: LoadedRoute) {
343
+ function fromImport({ ErrorBoundary, SuspenseFallback, ...component }: LoadedRoute) {
341
344
  if (ErrorBoundary) {
342
345
  return {
343
346
  default: React.forwardRef((props: any, ref: any) => {
@@ -347,16 +350,30 @@ function fromImport({ ErrorBoundary, ...component }: LoadedRoute) {
347
350
  })
348
351
  return <Try catch={ErrorBoundary}>{children}</Try>
349
352
  }),
353
+ SuspenseFallback,
350
354
  }
351
355
  }
352
356
  if (process.env.NODE_ENV !== 'production') {
353
357
  const exported = getPageExport(component)
354
358
  if (exported && typeof exported === 'object' && Object.keys(exported).length === 0) {
355
- return { default: EmptyRoute }
359
+ return { default: EmptyRoute, SuspenseFallback }
356
360
  }
357
361
  }
358
362
 
359
- return { default: getPageExport(component) }
363
+ return { default: getPageExport(component), SuspenseFallback }
364
+ }
365
+
366
+ function RouteSuspenseFallback({
367
+ component: Component,
368
+ route,
369
+ }: {
370
+ component?: React.ComponentType<SuspenseFallbackProps>
371
+ route: string
372
+ }) {
373
+ const params = useContext(RouteParamsContext)
374
+ return Component ? (
375
+ <Component route={route} params={(params ?? {}) as SuspenseFallbackProps['params']} />
376
+ ) : null
360
377
  }
361
378
 
362
379
  // TODO: Maybe there's a more React-y way to do this?
@@ -412,6 +429,7 @@ export function getQualifiedRouteComponent(value: RouteNode) {
412
429
  // content after hydration.
413
430
  // eslint-disable-next-line react-hooks/rules-of-hooks
414
431
  const isSpaShell = useContext(SpaShellContext)
432
+ const InheritedSuspenseFallback = useContext(SuspenseFallbackContext)
415
433
  const matches = getRouteMatchesForProps()
416
434
 
417
435
  if (isSpaShell && props.segment !== '') {
@@ -423,8 +441,10 @@ export function getQualifiedRouteComponent(value: RouteNode) {
423
441
  }
424
442
  }
425
443
 
426
- const res = value.loadRoute()
427
- const Component = getPageExport(fromImport(res)) as React.ComponentType<any>
444
+ const res = fromImport(value.loadRoute())
445
+ const Component = getPageExport(res) as React.ComponentType<any>
446
+ const LayoutSuspenseFallback =
447
+ value.type === 'layout' ? res.SuspenseFallback : undefined
428
448
  const match =
429
449
  matches.find((match) => match.routeId === value.contextKey) ||
430
450
  (!value.children?.length ? matches[matches.length - 1] : undefined)
@@ -450,25 +470,48 @@ export function getQualifiedRouteComponent(value: RouteNode) {
450
470
  }
451
471
  }
452
472
 
473
+ let rendered: React.ReactNode
474
+
453
475
  // Root layout with HTML support - use RootLayoutRenderer for proper HMR tracking
454
476
  if (props.segment === '') {
455
- return (
477
+ rendered = (
456
478
  <RootLayoutRenderer
457
479
  LayoutComponent={Component}
458
480
  layoutProps={{ ...props, ...slotProps, ...loaderDataProps }}
459
481
  forwardedRef={ref}
460
482
  />
461
483
  )
484
+ } else {
485
+ rendered = (
486
+ <RouteErrorBoundary routeName={value.route}>
487
+ <Component {...props} {...slotProps} {...loaderDataProps} ref={ref} />
488
+ </RouteErrorBoundary>
489
+ )
462
490
  }
463
491
 
492
+ if (value.type !== 'layout') {
493
+ return rendered
494
+ }
495
+
496
+ const providedSuspenseFallback = LayoutSuspenseFallback ?? InheritedSuspenseFallback
497
+
464
498
  return (
465
- <RouteErrorBoundary routeName={value.route}>
466
- <Component {...props} {...slotProps} {...loaderDataProps} ref={ref} />
467
- </RouteErrorBoundary>
499
+ <SuspenseFallbackContext.Provider value={providedSuspenseFallback}>
500
+ {LayoutSuspenseFallback
501
+ ? wrapSuspense(rendered, LayoutSuspenseFallback)
502
+ : rendered}
503
+ </SuspenseFallbackContext.Provider>
468
504
  )
469
505
  })
470
506
 
471
- const wrapSuspense = (children: any) => {
507
+ const wrapSuspense = (
508
+ children: React.ReactNode,
509
+ SuspenseFallback?: React.ComponentType<SuspenseFallbackProps>
510
+ ) => {
511
+ const fallback = (
512
+ <RouteSuspenseFallback component={SuspenseFallback} route={value.contextKey} />
513
+ )
514
+
472
515
  if (process.env.TAMAGUI_TARGET === 'native') {
473
516
  // native opt-out: set native.suspendRoutes to false in your one() config
474
517
  // OR set globalThis.__ONE_DISABLE_SUSPENSE_ROUTES__ = true at runtime
@@ -487,7 +530,7 @@ export function getQualifiedRouteComponent(value: RouteNode) {
487
530
  ) {
488
531
  return children
489
532
  }
490
- return <Suspense fallback={null}>{children}</Suspense>
533
+ return <Suspense fallback={fallback}>{children}</Suspense>
491
534
  }
492
535
 
493
536
  // web opt-in: set web.suspendRoutes to true in your one() config
@@ -497,7 +540,7 @@ export function getQualifiedRouteComponent(value: RouteNode) {
497
540
  process.env.ONE_SUSPEND_ROUTES === '1' &&
498
541
  (globalThis as any).__ONE_DISABLE_SUSPENSE_ROUTES__ !== true
499
542
  ) {
500
- return <Suspense fallback={null}>{children}</Suspense>
543
+ return <Suspense fallback={fallback}>{children}</Suspense>
501
544
  }
502
545
  return children
503
546
  }
@@ -515,6 +558,8 @@ export function getQualifiedRouteComponent(value: RouteNode) {
515
558
  }: any,
516
559
  ref: any
517
560
  ) => {
561
+ const InheritedSuspenseFallback = useContext(SuspenseFallbackContext)
562
+
518
563
  return (
519
564
  <Route route={route} node={value}>
520
565
  <>
@@ -527,7 +572,8 @@ export function getQualifiedRouteComponent(value: RouteNode) {
527
572
  // the intention is to make it possible to deduce shared routes.
528
573
  segment: value.route,
529
574
  }}
530
- />
575
+ />,
576
+ InheritedSuspenseFallback
531
577
  )}
532
578
  </>
533
579
  </Route>
@@ -7,7 +7,10 @@ import {
7
7
  import { NavigationRouteContext } from '@react-navigation/core'
8
8
  import * as React from 'react'
9
9
  import { SafeAreaView } from 'react-native-safe-area-context'
10
- import { useFilterScreenChildren } from '../layouts/withLayoutContext'
10
+ import {
11
+ useFilterScreenChildren,
12
+ useResolvedGuardedRedirects,
13
+ } from '../layouts/withLayoutContext'
11
14
  import {
12
15
  findNearestNotFoundRoute,
13
16
  findRouteNodeByPath,
@@ -20,8 +23,12 @@ import {
20
23
  getSafeWindowPathname,
21
24
  initialPathname,
22
25
  lastIntendedPathname,
26
+ replace,
27
+ registerProtectedRoutes,
28
+ resolveProtectedHref,
29
+ routeInfo,
30
+ unregisterProtectedRoutes,
23
31
  } from '../router/router'
24
- import { registerProtectedRoutes, unregisterProtectedRoutes } from '../router/router'
25
32
  import { useSortedScreens, getQualifiedRouteComponent } from '../router/useScreens'
26
33
  import { Screen } from './Screen'
27
34
 
@@ -174,21 +181,34 @@ export function Navigator({
174
181
  screens,
175
182
  children: otherSlot,
176
183
  protectedScreens,
184
+ guardedRedirects,
177
185
  } = useFilterScreenChildren(children, {
178
186
  isCustomNavigator: true,
179
187
  contextKey,
180
188
  })
189
+ const resolvedGuardedRedirects = useResolvedGuardedRedirects(guardedRedirects)
181
190
 
182
191
  // Register protected routes globally so linkTo can block navigation to them
183
192
  // Register immediately (not just in effect) to catch navigation attempts during first render
184
- registerProtectedRoutes(contextKey, protectedScreens)
193
+ registerProtectedRoutes(contextKey, resolvedGuardedRedirects)
194
+
195
+ const currentPathname = routeInfo?.pathname
196
+ const protectedHref = currentPathname
197
+ ? resolveProtectedHref(currentPathname)
198
+ : currentPathname
185
199
 
186
200
  React.useEffect(() => {
187
- registerProtectedRoutes(contextKey, protectedScreens)
201
+ registerProtectedRoutes(contextKey, resolvedGuardedRedirects)
188
202
  return () => {
189
203
  unregisterProtectedRoutes(contextKey)
190
204
  }
191
- }, [contextKey, protectedScreens])
205
+ }, [contextKey, resolvedGuardedRedirects])
206
+
207
+ React.useEffect(() => {
208
+ if (currentPathname && protectedHref && protectedHref !== currentPathname) {
209
+ replace(protectedHref)
210
+ }
211
+ }, [currentPathname, protectedHref])
192
212
 
193
213
  const sorted = useSortedScreens(screens ?? [], { protectedScreens })
194
214
 
@@ -5,6 +5,7 @@ import {
5
5
  type ReactNode,
6
6
  isValidElement,
7
7
  } from 'react'
8
+ import type { OneRouter } from '../interfaces/router'
8
9
 
9
10
  // Get Group component directly from react-navigation core
10
11
  // (avoids pulling in @react-navigation/elements which has asset imports)
@@ -12,6 +13,8 @@ const { Group } = createNavigatorFactory({} as any)()
12
13
 
13
14
  export type ProtectedProps = {
14
15
  guard: boolean
16
+ /** where to redirect when `guard` is false. defaults to the navigator's first available route. */
17
+ redirectTo?: OneRouter.Href
15
18
  children?: ReactNode
16
19
  }
17
20
 
@@ -0,0 +1,7 @@
1
+ /** Props passed to a layout's `SuspenseFallback` export. */
2
+ export type SuspenseFallbackProps = {
3
+ /** The suspended route module's context key, such as `./profile/[id].tsx`. */
4
+ route: string
5
+ /** The suspended route's URL parameters. */
6
+ params: Record<string, string | string[]>
7
+ }
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { EventEmitter } from 'node:events'
2
3
  import { tmpdir } from 'node:os'
3
4
  import path from 'node:path'
4
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -192,23 +193,31 @@ describe('createFileSystemRouterPlugin', () => {
192
193
 
193
194
  async function request(pathname: string) {
194
195
  const chunks: string[] = []
195
- const req = {
196
+ const req = Object.assign(new EventEmitter(), {
197
+ aborted: false,
198
+ complete: true,
196
199
  originalUrl: pathname,
197
200
  url: pathname,
198
201
  headers: {
199
- host: 'localhost',
202
+ ':authority': 'localhost',
203
+ ':method': 'GET',
200
204
  },
201
205
  method: 'GET',
202
- }
203
- const res = {
206
+ })
207
+ const res = Object.assign(new EventEmitter(), {
208
+ destroyed: false,
209
+ writableFinished: false,
204
210
  setHeader: vi.fn(),
205
211
  appendHeader: vi.fn(),
206
212
  writeHead: vi.fn(),
207
213
  write: vi.fn((chunk: string) => {
208
214
  chunks.push(chunk)
209
215
  }),
210
- end: vi.fn(),
211
- }
216
+ end: vi.fn(() => {
217
+ res.writableFinished = true
218
+ res.emit('finish')
219
+ }),
220
+ })
212
221
  const next = vi.fn((error?: unknown) => {
213
222
  if (error) {
214
223
  throw error
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { Readable } from 'node:stream'
3
+ import { TLSSocket } from 'node:tls'
3
4
  import { debounce } from 'perfect-debounce'
4
5
  import colors from 'picocolors'
5
6
  import type { Connect, Plugin, ViteDevServer } from 'vite'
@@ -746,10 +747,36 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
746
747
  // prevent browsers (safari) from caching stale html/js in dev
747
748
  res.setHeader('Cache-Control', 'no-store')
748
749
 
750
+ const abortController = new AbortController()
751
+ const cleanupAbortListeners = () => {
752
+ req.removeListener('aborted', abortRequest)
753
+ req.removeListener('close', abortIncompleteRequest)
754
+ res.removeListener('close', abortIncompleteResponse)
755
+ res.removeListener('finish', cleanupAbortListeners)
756
+ }
757
+ const abortRequest = () => {
758
+ cleanupAbortListeners()
759
+ abortController.abort()
760
+ }
761
+ const abortIncompleteRequest = () => {
762
+ if (req.aborted || !req.complete) abortRequest()
763
+ }
764
+ const abortIncompleteResponse = () => {
765
+ if (!res.writableFinished) {
766
+ abortRequest()
767
+ return
768
+ }
769
+ cleanupAbortListeners()
770
+ }
771
+ req.on('aborted', abortRequest)
772
+ req.on('close', abortIncompleteRequest)
773
+ res.on('close', abortIncompleteResponse)
774
+ res.on('finish', cleanupAbortListeners)
775
+
749
776
  try {
750
777
  const redirects = options.web?.redirects
751
778
  if (redirects) {
752
- const url = new URL(req.url || '', `http://${req.headers.host}`)
779
+ const url = new URL(req.url || '', 'http://127.0.0.1')
753
780
  for (const redirect of redirects) {
754
781
  const regexStr = `^${redirect.source.replace(/:\w+/g, '([^/]+)')}$`
755
782
  const match = url.pathname.match(new RegExp(regexStr))
@@ -778,10 +805,11 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
778
805
  }
779
806
 
780
807
  const reply = await handleRequest.handler(
781
- convertIncomingMessageToRequest(req)
808
+ convertIncomingMessageToRequest(req, abortController.signal)
782
809
  )
783
810
 
784
811
  if (!reply) {
812
+ cleanupAbortListeners()
785
813
  return next()
786
814
  }
787
815
 
@@ -822,7 +850,6 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
822
850
  }
823
851
 
824
852
  res.statusCode = reply.status
825
- res.statusMessage = reply.statusText
826
853
 
827
854
  if (reply.body) {
828
855
  if (reply.body.locked) {
@@ -831,7 +858,14 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
831
858
  return
832
859
  }
833
860
  try {
834
- Readable.fromWeb(reply.body as any).pipe(res)
861
+ const body = Readable.fromWeb(reply.body as any)
862
+ body.on('error', (error) => {
863
+ if (!abortController.signal.aborted) {
864
+ console.warn('Error streaming reply body to response:', error)
865
+ }
866
+ if (!res.destroyed) res.destroy()
867
+ })
868
+ body.pipe(res)
835
869
  } catch (err) {
836
870
  console.warn('Error piping reply body to response:', err)
837
871
  res.end()
@@ -854,6 +888,7 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
854
888
  res.end()
855
889
  return
856
890
  } catch (error) {
891
+ cleanupAbortListeners()
857
892
  console.error(`[one] routing error ${req.url}: ${error}`)
858
893
  // Forward the error to Vite
859
894
  next(error)
@@ -870,21 +905,30 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
870
905
  } satisfies Plugin
871
906
  }
872
907
 
873
- const convertIncomingMessageToRequest = (req: Connect.IncomingMessage): Request => {
908
+ const convertIncomingMessageToRequest = (
909
+ req: Connect.IncomingMessage,
910
+ signal: AbortSignal
911
+ ): Request => {
874
912
  if (!req.originalUrl) {
875
913
  throw new Error(`Can't convert: originalUrl is missing`)
876
914
  }
877
915
 
878
- const urlBase = `http://${req.headers.host}`
916
+ const protocol =
917
+ req.socket instanceof TLSSocket && req.socket.encrypted ? 'https' : 'http'
918
+ const host = `${req.headers['x-forwarded-host'] || req.headers.host || req.headers[':authority'] || '127.0.0.1'}`
919
+ const urlBase = `${protocol}://${host}`
879
920
  const urlString = req.originalUrl
880
921
  const url = new URL(urlString, urlBase)
881
922
 
882
923
  const headers = new Headers()
883
924
  for (const key in req.headers) {
884
- if (req.headers[key]) {
925
+ if (!key.startsWith(':') && req.headers[key]) {
885
926
  headers.append(key, req.headers[key] as string)
886
927
  }
887
928
  }
929
+ if (!headers.has('host')) {
930
+ headers.set('host', host)
931
+ }
888
932
 
889
933
  const hasBody = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method || '')
890
934
  const body = hasBody ? Readable.toWeb(req) : null
@@ -893,6 +937,7 @@ const convertIncomingMessageToRequest = (req: Connect.IncomingMessage): Request
893
937
  method: req.method,
894
938
  headers,
895
939
  body,
940
+ signal,
896
941
  // Required for streaming bodies in Node's experimental fetch:
897
942
  duplex: 'half',
898
943
  } as RequestInit & { duplex: 'half' })