one 1.21.7 → 1.21.9

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 (151) 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 +3 -4
  10. package/dist/cjs/layouts/Stack.native.js +3 -4
  11. package/dist/cjs/layouts/Stack.native.js.map +1 -1
  12. package/dist/cjs/layouts/withLayoutContext.cjs +39 -8
  13. package/dist/cjs/layouts/withLayoutContext.native.js +43 -8
  14. package/dist/cjs/layouts/withLayoutContext.native.js.map +1 -1
  15. package/dist/cjs/layouts/withLayoutContext.test.cjs +96 -0
  16. package/dist/cjs/layouts/withLayoutContext.test.native.js +100 -0
  17. package/dist/cjs/layouts/withLayoutContext.test.native.js.map +1 -0
  18. package/dist/cjs/router/Route.cjs +3 -0
  19. package/dist/cjs/router/Route.native.js +3 -0
  20. package/dist/cjs/router/Route.native.js.map +1 -1
  21. package/dist/cjs/router/protectedRoutes.test.cjs +22 -0
  22. package/dist/cjs/router/protectedRoutes.test.native.js +25 -0
  23. package/dist/cjs/router/protectedRoutes.test.native.js.map +1 -0
  24. package/dist/cjs/router/router.cjs +26 -8
  25. package/dist/cjs/router/router.native.js +26 -8
  26. package/dist/cjs/router/router.native.js.map +1 -1
  27. package/dist/cjs/router/useScreens.cjs +38 -11
  28. package/dist/cjs/router/useScreens.native.js +39 -11
  29. package/dist/cjs/router/useScreens.native.js.map +1 -1
  30. package/dist/cjs/router/useScreens.test.cjs +134 -0
  31. package/dist/cjs/router/useScreens.test.native.js +163 -0
  32. package/dist/cjs/router/useScreens.test.native.js.map +1 -0
  33. package/dist/cjs/views/Navigator.cjs +14 -6
  34. package/dist/cjs/views/Navigator.native.js +14 -6
  35. package/dist/cjs/views/Navigator.native.js.map +1 -1
  36. package/dist/cjs/views/Protected.native.js.map +1 -1
  37. package/dist/cjs/views/SuspenseFallback.cjs +18 -0
  38. package/dist/cjs/views/SuspenseFallback.native.js +21 -0
  39. package/dist/cjs/views/SuspenseFallback.native.js.map +1 -0
  40. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +47 -7
  41. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +47 -7
  42. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  43. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.cjs +15 -6
  44. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.native.js +15 -6
  45. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.native.js.map +1 -1
  46. package/dist/esm/cli/dev.mjs.map +1 -1
  47. package/dist/esm/cli/dev.native.js.map +1 -1
  48. package/dist/esm/cli.mjs +0 -3
  49. package/dist/esm/cli.mjs.map +1 -1
  50. package/dist/esm/cli.native.js +0 -3
  51. package/dist/esm/cli.native.js.map +1 -1
  52. package/dist/esm/image/getImageData.mjs +1 -1
  53. package/dist/esm/image/getImageData.mjs.map +1 -1
  54. package/dist/esm/image/getImageData.native.js +1 -1
  55. package/dist/esm/image/getImageData.native.js.map +1 -1
  56. package/dist/esm/index.js.map +1 -1
  57. package/dist/esm/index.mjs.map +1 -1
  58. package/dist/esm/index.native.js.map +1 -1
  59. package/dist/esm/layouts/Stack.mjs +3 -4
  60. package/dist/esm/layouts/Stack.mjs.map +1 -1
  61. package/dist/esm/layouts/Stack.native.js +3 -4
  62. package/dist/esm/layouts/Stack.native.js.map +1 -1
  63. package/dist/esm/layouts/withLayoutContext.mjs +41 -11
  64. package/dist/esm/layouts/withLayoutContext.mjs.map +1 -1
  65. package/dist/esm/layouts/withLayoutContext.native.js +45 -11
  66. package/dist/esm/layouts/withLayoutContext.native.js.map +1 -1
  67. package/dist/esm/layouts/withLayoutContext.test.mjs +73 -0
  68. package/dist/esm/layouts/withLayoutContext.test.mjs.map +1 -0
  69. package/dist/esm/layouts/withLayoutContext.test.native.js +74 -0
  70. package/dist/esm/layouts/withLayoutContext.test.native.js.map +1 -0
  71. package/dist/esm/router/Route.mjs +3 -1
  72. package/dist/esm/router/Route.mjs.map +1 -1
  73. package/dist/esm/router/Route.native.js +3 -1
  74. package/dist/esm/router/Route.native.js.map +1 -1
  75. package/dist/esm/router/protectedRoutes.test.mjs +23 -0
  76. package/dist/esm/router/protectedRoutes.test.mjs.map +1 -0
  77. package/dist/esm/router/protectedRoutes.test.native.js +23 -0
  78. package/dist/esm/router/protectedRoutes.test.native.js.map +1 -0
  79. package/dist/esm/router/router.mjs +26 -9
  80. package/dist/esm/router/router.mjs.map +1 -1
  81. package/dist/esm/router/router.native.js +26 -9
  82. package/dist/esm/router/router.native.js.map +1 -1
  83. package/dist/esm/router/useScreens.mjs +39 -11
  84. package/dist/esm/router/useScreens.mjs.map +1 -1
  85. package/dist/esm/router/useScreens.native.js +40 -11
  86. package/dist/esm/router/useScreens.native.js.map +1 -1
  87. package/dist/esm/router/useScreens.test.mjs +105 -0
  88. package/dist/esm/router/useScreens.test.mjs.map +1 -0
  89. package/dist/esm/router/useScreens.test.native.js +130 -0
  90. package/dist/esm/router/useScreens.test.native.js.map +1 -0
  91. package/dist/esm/views/Navigator.mjs +15 -7
  92. package/dist/esm/views/Navigator.mjs.map +1 -1
  93. package/dist/esm/views/Navigator.native.js +15 -7
  94. package/dist/esm/views/Navigator.native.js.map +1 -1
  95. package/dist/esm/views/Protected.mjs.map +1 -1
  96. package/dist/esm/views/Protected.native.js.map +1 -1
  97. package/dist/esm/views/SuspenseFallback.mjs +2 -0
  98. package/dist/esm/views/SuspenseFallback.mjs.map +1 -0
  99. package/dist/esm/views/SuspenseFallback.native.js +2 -0
  100. package/dist/esm/views/SuspenseFallback.native.js.map +1 -0
  101. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +47 -7
  102. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
  103. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +47 -7
  104. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  105. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.mjs +15 -6
  106. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.mjs.map +1 -1
  107. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.native.js +15 -6
  108. package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.native.js.map +1 -1
  109. package/package.json +11 -11
  110. package/src/cli/dev.ts +0 -1
  111. package/src/cli.ts +0 -3
  112. package/src/image/getImageData.ts +2 -2
  113. package/src/index.ts +1 -0
  114. package/src/layouts/Stack.tsx +4 -4
  115. package/src/layouts/withLayoutContext.test.tsx +74 -0
  116. package/src/layouts/withLayoutContext.tsx +71 -12
  117. package/src/router/Route.tsx +8 -0
  118. package/src/router/protectedRoutes.test.ts +35 -0
  119. package/src/router/router.ts +48 -20
  120. package/src/router/useScreens.test.tsx +139 -0
  121. package/src/router/useScreens.tsx +59 -13
  122. package/src/views/Navigator.tsx +25 -5
  123. package/src/views/Protected.tsx +3 -0
  124. package/src/views/SuspenseFallback.ts +7 -0
  125. package/src/vite/plugins/fileSystemRouterPlugin.test.ts +15 -6
  126. package/src/vite/plugins/fileSystemRouterPlugin.tsx +52 -7
  127. package/types/cli/dev.d.ts +0 -1
  128. package/types/cli/dev.d.ts.map +1 -1
  129. package/types/image/getImageData.d.ts +1 -1
  130. package/types/image/getImageData.d.ts.map +1 -1
  131. package/types/index.d.ts +1 -0
  132. package/types/index.d.ts.map +1 -1
  133. package/types/layouts/withLayoutContext.d.ts +7 -0
  134. package/types/layouts/withLayoutContext.d.ts.map +1 -1
  135. package/types/layouts/withLayoutContext.test.d.ts +2 -0
  136. package/types/layouts/withLayoutContext.test.d.ts.map +1 -0
  137. package/types/router/Route.d.ts +4 -0
  138. package/types/router/Route.d.ts.map +1 -1
  139. package/types/router/protectedRoutes.test.d.ts +2 -0
  140. package/types/router/protectedRoutes.test.d.ts.map +1 -0
  141. package/types/router/router.d.ts +4 -6
  142. package/types/router/router.d.ts.map +1 -1
  143. package/types/router/useScreens.d.ts.map +1 -1
  144. package/types/router/useScreens.test.d.ts +2 -0
  145. package/types/router/useScreens.test.d.ts.map +1 -0
  146. package/types/views/Navigator.d.ts.map +1 -1
  147. package/types/views/Protected.d.ts +3 -0
  148. package/types/views/Protected.d.ts.map +1 -1
  149. package/types/views/SuspenseFallback.d.ts +8 -0
  150. package/types/views/SuspenseFallback.d.ts.map +1 -0
  151. package/types/vite/plugins/fileSystemRouterPlugin.d.ts.map +1 -1
package/src/cli.ts CHANGED
@@ -79,9 +79,6 @@ const dev = defineCommand({
79
79
  port: {
80
80
  type: 'string',
81
81
  },
82
- https: {
83
- type: 'boolean',
84
- },
85
82
  mode: {
86
83
  type: 'string',
87
84
  description:
@@ -16,10 +16,10 @@ export type ImageMeta = {
16
16
 
17
17
  let sharpWarned = false
18
18
 
19
- export async function getSharp(): Promise<typeof import('sharp') | null> {
19
+ export async function getSharp(): Promise<(typeof import('sharp'))['default'] | null> {
20
20
  try {
21
21
  const sharpModule = await import('sharp')
22
- return (sharpModule as any).default || sharpModule
22
+ return sharpModule.default
23
23
  } catch (e) {
24
24
  if (!sharpWarned) {
25
25
  sharpWarned = true
package/src/index.ts CHANGED
@@ -187,6 +187,7 @@ export { LoadProgressBar } from './views/LoadProgressBar'
187
187
  export { Navigator, Slot } from './views/Navigator'
188
188
  export { ScrollBehavior } from './views/ScrollBehavior'
189
189
  export { SourceInspector, type SourceInspectorProps } from './views/SourceInspector'
190
+ export type { SuspenseFallbackProps } from './views/SuspenseFallback'
190
191
  export type { ErrorBoundaryProps, ErrorRouteInfo } from './views/Try'
191
192
  // scroll position groups
192
193
  export { useScrollGroup } from './useScrollGroup'
@@ -46,10 +46,10 @@ function mapChildren(children: React.ReactNode): React.ReactNode {
46
46
 
47
47
  if (isChildOfType(child, Protected)) {
48
48
  // recursively process Protected children
49
- return (
50
- <Protected key={`protected-${index}`} guard={child.props.guard}>
51
- {mapChildren(child.props.children)}
52
- </Protected>
49
+ return React.cloneElement(
50
+ child,
51
+ { key: `protected-${index}` },
52
+ mapChildren(child.props.children)
53
53
  )
54
54
  }
55
55
 
@@ -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
+ })
@@ -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
+ })