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.
- package/dist/cjs/cli/dev.native.js.map +1 -1
- package/dist/cjs/cli.cjs +0 -3
- package/dist/cjs/cli.native.js +0 -3
- package/dist/cjs/cli.native.js.map +1 -1
- package/dist/cjs/image/getImageData.cjs +1 -1
- package/dist/cjs/image/getImageData.native.js +1 -1
- package/dist/cjs/image/getImageData.native.js.map +1 -1
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/layouts/Stack.cjs +3 -4
- package/dist/cjs/layouts/Stack.native.js +3 -4
- package/dist/cjs/layouts/Stack.native.js.map +1 -1
- package/dist/cjs/layouts/withLayoutContext.cjs +39 -8
- package/dist/cjs/layouts/withLayoutContext.native.js +43 -8
- package/dist/cjs/layouts/withLayoutContext.native.js.map +1 -1
- package/dist/cjs/layouts/withLayoutContext.test.cjs +96 -0
- package/dist/cjs/layouts/withLayoutContext.test.native.js +100 -0
- package/dist/cjs/layouts/withLayoutContext.test.native.js.map +1 -0
- package/dist/cjs/router/Route.cjs +3 -0
- package/dist/cjs/router/Route.native.js +3 -0
- package/dist/cjs/router/Route.native.js.map +1 -1
- package/dist/cjs/router/protectedRoutes.test.cjs +22 -0
- package/dist/cjs/router/protectedRoutes.test.native.js +25 -0
- package/dist/cjs/router/protectedRoutes.test.native.js.map +1 -0
- package/dist/cjs/router/router.cjs +26 -8
- package/dist/cjs/router/router.native.js +26 -8
- package/dist/cjs/router/router.native.js.map +1 -1
- package/dist/cjs/router/useScreens.cjs +38 -11
- package/dist/cjs/router/useScreens.native.js +39 -11
- package/dist/cjs/router/useScreens.native.js.map +1 -1
- package/dist/cjs/router/useScreens.test.cjs +134 -0
- package/dist/cjs/router/useScreens.test.native.js +163 -0
- package/dist/cjs/router/useScreens.test.native.js.map +1 -0
- package/dist/cjs/views/Navigator.cjs +14 -6
- package/dist/cjs/views/Navigator.native.js +14 -6
- package/dist/cjs/views/Navigator.native.js.map +1 -1
- package/dist/cjs/views/Protected.native.js.map +1 -1
- package/dist/cjs/views/SuspenseFallback.cjs +18 -0
- package/dist/cjs/views/SuspenseFallback.native.js +21 -0
- package/dist/cjs/views/SuspenseFallback.native.js.map +1 -0
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +47 -7
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +47 -7
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.cjs +15 -6
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.native.js +15 -6
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.test.native.js.map +1 -1
- package/dist/esm/cli/dev.mjs.map +1 -1
- package/dist/esm/cli/dev.native.js.map +1 -1
- package/dist/esm/cli.mjs +0 -3
- package/dist/esm/cli.mjs.map +1 -1
- package/dist/esm/cli.native.js +0 -3
- package/dist/esm/cli.native.js.map +1 -1
- package/dist/esm/image/getImageData.mjs +1 -1
- package/dist/esm/image/getImageData.mjs.map +1 -1
- package/dist/esm/image/getImageData.native.js +1 -1
- package/dist/esm/image/getImageData.native.js.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/layouts/Stack.mjs +3 -4
- package/dist/esm/layouts/Stack.mjs.map +1 -1
- package/dist/esm/layouts/Stack.native.js +3 -4
- package/dist/esm/layouts/Stack.native.js.map +1 -1
- package/dist/esm/layouts/withLayoutContext.mjs +41 -11
- package/dist/esm/layouts/withLayoutContext.mjs.map +1 -1
- package/dist/esm/layouts/withLayoutContext.native.js +45 -11
- package/dist/esm/layouts/withLayoutContext.native.js.map +1 -1
- package/dist/esm/layouts/withLayoutContext.test.mjs +73 -0
- package/dist/esm/layouts/withLayoutContext.test.mjs.map +1 -0
- package/dist/esm/layouts/withLayoutContext.test.native.js +74 -0
- package/dist/esm/layouts/withLayoutContext.test.native.js.map +1 -0
- package/dist/esm/router/Route.mjs +3 -1
- package/dist/esm/router/Route.mjs.map +1 -1
- package/dist/esm/router/Route.native.js +3 -1
- package/dist/esm/router/Route.native.js.map +1 -1
- package/dist/esm/router/protectedRoutes.test.mjs +23 -0
- package/dist/esm/router/protectedRoutes.test.mjs.map +1 -0
- package/dist/esm/router/protectedRoutes.test.native.js +23 -0
- package/dist/esm/router/protectedRoutes.test.native.js.map +1 -0
- package/dist/esm/router/router.mjs +26 -9
- package/dist/esm/router/router.mjs.map +1 -1
- package/dist/esm/router/router.native.js +26 -9
- package/dist/esm/router/router.native.js.map +1 -1
- package/dist/esm/router/useScreens.mjs +39 -11
- package/dist/esm/router/useScreens.mjs.map +1 -1
- package/dist/esm/router/useScreens.native.js +40 -11
- package/dist/esm/router/useScreens.native.js.map +1 -1
- package/dist/esm/router/useScreens.test.mjs +105 -0
- package/dist/esm/router/useScreens.test.mjs.map +1 -0
- package/dist/esm/router/useScreens.test.native.js +130 -0
- package/dist/esm/router/useScreens.test.native.js.map +1 -0
- package/dist/esm/views/Navigator.mjs +15 -7
- package/dist/esm/views/Navigator.mjs.map +1 -1
- package/dist/esm/views/Navigator.native.js +15 -7
- package/dist/esm/views/Navigator.native.js.map +1 -1
- package/dist/esm/views/Protected.mjs.map +1 -1
- package/dist/esm/views/Protected.native.js.map +1 -1
- package/dist/esm/views/SuspenseFallback.mjs +2 -0
- package/dist/esm/views/SuspenseFallback.mjs.map +1 -0
- package/dist/esm/views/SuspenseFallback.native.js +2 -0
- package/dist/esm/views/SuspenseFallback.native.js.map +1 -0
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +47 -7
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +47 -7
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.mjs +15 -6
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.mjs.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.native.js +15 -6
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.test.native.js.map +1 -1
- package/package.json +11 -11
- package/src/cli/dev.ts +0 -1
- package/src/cli.ts +0 -3
- package/src/image/getImageData.ts +2 -2
- package/src/index.ts +1 -0
- package/src/layouts/Stack.tsx +4 -4
- package/src/layouts/withLayoutContext.test.tsx +74 -0
- package/src/layouts/withLayoutContext.tsx +71 -12
- package/src/router/Route.tsx +8 -0
- package/src/router/protectedRoutes.test.ts +35 -0
- package/src/router/router.ts +48 -20
- package/src/router/useScreens.test.tsx +139 -0
- package/src/router/useScreens.tsx +59 -13
- package/src/views/Navigator.tsx +25 -5
- package/src/views/Protected.tsx +3 -0
- package/src/views/SuspenseFallback.ts +7 -0
- package/src/vite/plugins/fileSystemRouterPlugin.test.ts +15 -6
- package/src/vite/plugins/fileSystemRouterPlugin.tsx +52 -7
- package/types/cli/dev.d.ts +0 -1
- package/types/cli/dev.d.ts.map +1 -1
- package/types/image/getImageData.d.ts +1 -1
- package/types/image/getImageData.d.ts.map +1 -1
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
- package/types/layouts/withLayoutContext.d.ts +7 -0
- package/types/layouts/withLayoutContext.d.ts.map +1 -1
- package/types/layouts/withLayoutContext.test.d.ts +2 -0
- package/types/layouts/withLayoutContext.test.d.ts.map +1 -0
- package/types/router/Route.d.ts +4 -0
- package/types/router/Route.d.ts.map +1 -1
- package/types/router/protectedRoutes.test.d.ts +2 -0
- package/types/router/protectedRoutes.test.d.ts.map +1 -0
- package/types/router/router.d.ts +4 -6
- package/types/router/router.d.ts.map +1 -1
- package/types/router/useScreens.d.ts.map +1 -1
- package/types/router/useScreens.test.d.ts +2 -0
- package/types/router/useScreens.test.d.ts.map +1 -0
- package/types/views/Navigator.d.ts.map +1 -1
- package/types/views/Protected.d.ts +3 -0
- package/types/views/Protected.d.ts.map +1 -1
- package/types/views/SuspenseFallback.d.ts +8 -0
- package/types/views/SuspenseFallback.d.ts.map +1 -0
- package/types/vite/plugins/fileSystemRouterPlugin.d.ts.map +1 -1
package/src/cli.ts
CHANGED
|
@@ -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
|
|
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'
|
package/src/layouts/Stack.tsx
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
4
|
-
import {
|
|
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(
|
|
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
|
-
|
|
73
|
-
const excludeChildren = exclude ||
|
|
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(
|
|
128
|
-
|
|
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,
|
|
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,
|
|
189
|
+
registerProtectedRoutes(contextKey, resolvedGuardedRedirects)
|
|
137
190
|
return () => {
|
|
138
191
|
unregisterProtectedRoutes(contextKey)
|
|
139
192
|
}
|
|
140
|
-
}, [contextKey,
|
|
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, {
|
package/src/router/Route.tsx
CHANGED
|
@@ -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
|
+
})
|
package/src/router/router.ts
CHANGED
|
@@ -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
|
-
//
|
|
79
|
-
|
|
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
|
-
|
|
87
|
+
guardedRedirects: Map<string, OneRouter.Href | undefined>
|
|
89
88
|
) {
|
|
90
|
-
if (
|
|
89
|
+
if (guardedRedirects.size === 0) {
|
|
91
90
|
protectedRouteRegistry.delete(contextKey)
|
|
92
91
|
} else {
|
|
93
|
-
protectedRouteRegistry.set(contextKey,
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
for (const [contextKey, protectedScreens] of protectedRouteRegistry) {
|
|
111
|
+
for (const [contextKey, guardedRedirects] of protectedRouteRegistry) {
|
|
115
112
|
const normalizedContextKey = contextKey.replace(/^\/+|\/+$/g, '')
|
|
116
113
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
1081
|
-
if (
|
|
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
|
+
})
|