@furystack/shades 13.2.2 → 14.0.0
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/CHANGELOG.md +144 -0
- package/esm/components/define-nested-routes.d.ts +27 -0
- package/esm/components/define-nested-routes.d.ts.map +1 -0
- package/esm/components/define-nested-routes.js +26 -0
- package/esm/components/define-nested-routes.js.map +1 -0
- package/esm/components/define-nested-routes.spec.d.ts +2 -0
- package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
- package/esm/components/define-nested-routes.spec.js +81 -0
- package/esm/components/define-nested-routes.spec.js.map +1 -0
- package/esm/components/index.d.ts +3 -3
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +3 -3
- package/esm/components/index.js.map +1 -1
- package/esm/components/nested-hooks.d.ts +62 -0
- package/esm/components/nested-hooks.d.ts.map +1 -0
- package/esm/components/nested-hooks.js +73 -0
- package/esm/components/nested-hooks.js.map +1 -0
- package/esm/components/nested-hooks.spec.d.ts +2 -0
- package/esm/components/nested-hooks.spec.d.ts.map +1 -0
- package/esm/components/nested-hooks.spec.js +211 -0
- package/esm/components/nested-hooks.spec.js.map +1 -0
- package/esm/components/nested-navigate.d.ts +52 -21
- package/esm/components/nested-navigate.d.ts.map +1 -1
- package/esm/components/nested-navigate.js +32 -17
- package/esm/components/nested-navigate.js.map +1 -1
- package/esm/components/nested-navigate.spec.js +102 -12
- package/esm/components/nested-navigate.spec.js.map +1 -1
- package/esm/components/nested-replace.d.ts +36 -0
- package/esm/components/nested-replace.d.ts.map +1 -0
- package/esm/components/nested-replace.js +38 -0
- package/esm/components/nested-replace.js.map +1 -0
- package/esm/components/nested-replace.spec.d.ts +2 -0
- package/esm/components/nested-replace.spec.d.ts.map +1 -0
- package/esm/components/nested-replace.spec.js +59 -0
- package/esm/components/nested-replace.spec.js.map +1 -0
- package/esm/components/nested-route-link.d.ts +36 -26
- package/esm/components/nested-route-link.d.ts.map +1 -1
- package/esm/components/nested-route-link.js +17 -19
- package/esm/components/nested-route-link.js.map +1 -1
- package/esm/components/nested-route-link.spec.js +61 -11
- package/esm/components/nested-route-link.spec.js.map +1 -1
- package/esm/components/nested-route-types.d.ts +79 -2
- package/esm/components/nested-route-types.d.ts.map +1 -1
- package/esm/components/nested-router.d.ts +77 -7
- package/esm/components/nested-router.d.ts.map +1 -1
- package/esm/components/nested-router.js +137 -11
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +265 -34
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/services/location-service.spec.js +33 -0
- package/esm/services/location-service.spec.js.map +1 -1
- package/esm/services/route-match-service.spec.js +4 -0
- package/esm/services/route-match-service.spec.js.map +1 -1
- package/esm/services/route-meta-utils.d.ts +28 -6
- package/esm/services/route-meta-utils.d.ts.map +1 -1
- package/esm/services/route-meta-utils.js +18 -4
- package/esm/services/route-meta-utils.js.map +1 -1
- package/esm/services/route-meta-utils.spec.js +57 -2
- package/esm/services/route-meta-utils.spec.js.map +1 -1
- package/esm/vnode.d.ts +1 -1
- package/esm/vnode.js +1 -1
- package/package.json +2 -2
- package/src/components/define-nested-routes.spec.ts +91 -0
- package/src/components/define-nested-routes.ts +27 -0
- package/src/components/index.ts +3 -3
- package/src/components/nested-hooks.spec.ts +254 -0
- package/src/components/nested-hooks.ts +109 -0
- package/src/components/nested-navigate.spec.ts +128 -25
- package/src/components/nested-navigate.ts +75 -26
- package/src/components/nested-replace.spec.ts +88 -0
- package/src/components/nested-replace.ts +46 -0
- package/src/components/nested-route-link.spec.tsx +84 -16
- package/src/components/nested-route-link.tsx +53 -28
- package/src/components/nested-route-types.ts +82 -2
- package/src/components/nested-router.spec.tsx +316 -33
- package/src/components/nested-router.tsx +182 -22
- package/src/services/location-service.spec.ts +39 -0
- package/src/services/route-match-service.spec.ts +4 -0
- package/src/services/route-meta-utils.spec.ts +75 -7
- package/src/services/route-meta-utils.ts +44 -15
- package/src/vnode.ts +1 -1
- package/esm/components/link-to-route.d.ts +0 -10
- package/esm/components/link-to-route.d.ts.map +0 -1
- package/esm/components/link-to-route.js +0 -16
- package/esm/components/link-to-route.js.map +0 -1
- package/esm/components/link-to-route.spec.d.ts +0 -2
- package/esm/components/link-to-route.spec.d.ts.map +0 -1
- package/esm/components/link-to-route.spec.js +0 -30
- package/esm/components/link-to-route.spec.js.map +0 -1
- package/esm/components/route-link.d.ts +0 -10
- package/esm/components/route-link.d.ts.map +0 -1
- package/esm/components/route-link.js +0 -25
- package/esm/components/route-link.js.map +0 -1
- package/esm/components/route-link.spec.d.ts +0 -2
- package/esm/components/route-link.spec.d.ts.map +0 -1
- package/esm/components/route-link.spec.js +0 -34
- package/esm/components/route-link.spec.js.map +0 -1
- package/esm/components/router.d.ts +0 -44
- package/esm/components/router.d.ts.map +0 -1
- package/esm/components/router.js +0 -66
- package/esm/components/router.js.map +0 -1
- package/esm/components/router.spec.d.ts +0 -2
- package/esm/components/router.spec.d.ts.map +0 -1
- package/esm/components/router.spec.js +0 -161
- package/esm/components/router.spec.js.map +0 -1
- package/src/components/link-to-route.spec.tsx +0 -45
- package/src/components/link-to-route.tsx +0 -26
- package/src/components/route-link.spec.tsx +0 -44
- package/src/components/route-link.tsx +0 -29
- package/src/components/router.spec.tsx +0 -217
- package/src/components/router.tsx +0 -103
|
@@ -2,11 +2,11 @@ import { Injector } from '@furystack/inject'
|
|
|
2
2
|
import { usingAsync } from '@furystack/utils'
|
|
3
3
|
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
4
4
|
import { LocationService } from '../services/location-service.js'
|
|
5
|
-
import type {
|
|
6
|
-
import { createNestedNavigate, nestedNavigate } from './nested-navigate.js'
|
|
5
|
+
import type { TypedNestedNavigateArgs } from './nested-navigate.js'
|
|
6
|
+
import { buildNestedNavigateUrl, createNestedNavigate, nestedNavigate } from './nested-navigate.js'
|
|
7
7
|
import type { NestedRoute } from './nested-router.js'
|
|
8
8
|
|
|
9
|
-
type TestRoute = Pick<NestedRoute<unknown>, 'component'>
|
|
9
|
+
type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
|
|
10
10
|
|
|
11
11
|
describe('nestedNavigate', () => {
|
|
12
12
|
it('Should navigate to a simple path', async () => {
|
|
@@ -14,7 +14,7 @@ describe('nestedNavigate', () => {
|
|
|
14
14
|
const locationService = injector.getInstance(LocationService)
|
|
15
15
|
const spy = vi.spyOn(locationService, 'navigate')
|
|
16
16
|
|
|
17
|
-
nestedNavigate(injector, '/buttons')
|
|
17
|
+
nestedNavigate(injector, { path: '/buttons' })
|
|
18
18
|
|
|
19
19
|
expect(spy).toHaveBeenCalledWith('/buttons')
|
|
20
20
|
})
|
|
@@ -25,7 +25,7 @@ describe('nestedNavigate', () => {
|
|
|
25
25
|
const locationService = injector.getInstance(LocationService)
|
|
26
26
|
const spy = vi.spyOn(locationService, 'navigate')
|
|
27
27
|
|
|
28
|
-
nestedNavigate(injector, '/users/:id', { id: '42' })
|
|
28
|
+
nestedNavigate(injector, { path: '/users/:id', params: { id: '42' } })
|
|
29
29
|
|
|
30
30
|
expect(spy).toHaveBeenCalledWith('/users/42')
|
|
31
31
|
})
|
|
@@ -36,30 +36,105 @@ describe('nestedNavigate', () => {
|
|
|
36
36
|
const locationService = injector.getInstance(LocationService)
|
|
37
37
|
const spy = vi.spyOn(locationService, 'navigate')
|
|
38
38
|
|
|
39
|
-
nestedNavigate(injector,
|
|
39
|
+
nestedNavigate(injector, {
|
|
40
|
+
path: '/users/:userId/posts/:postId',
|
|
41
|
+
params: { userId: '1', postId: '99' },
|
|
42
|
+
})
|
|
40
43
|
|
|
41
44
|
expect(spy).toHaveBeenCalledWith('/users/1/posts/99')
|
|
42
45
|
})
|
|
43
46
|
})
|
|
47
|
+
|
|
48
|
+
it('Should append serialized query string when provided', async () => {
|
|
49
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
50
|
+
const locationService = injector.getInstance(LocationService)
|
|
51
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
52
|
+
|
|
53
|
+
nestedNavigate(injector, { path: '/buttons', query: { page: 2 } })
|
|
54
|
+
|
|
55
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
56
|
+
const navigatedUrl = spy.mock.calls[0][0]
|
|
57
|
+
expect(navigatedUrl.startsWith('/buttons?')).toBe(true)
|
|
58
|
+
const { deserializeQueryString } = locationService
|
|
59
|
+
const search = navigatedUrl.slice(navigatedUrl.indexOf('?') + 1)
|
|
60
|
+
expect(deserializeQueryString(search)).toEqual({ page: 2 })
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('Should omit the query string when no keys are provided', async () => {
|
|
65
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
66
|
+
const locationService = injector.getInstance(LocationService)
|
|
67
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
68
|
+
|
|
69
|
+
nestedNavigate(injector, { path: '/buttons', query: {} })
|
|
70
|
+
|
|
71
|
+
expect(spy).toHaveBeenCalledWith('/buttons')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('Should append the hash segment when provided', async () => {
|
|
76
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
77
|
+
const locationService = injector.getInstance(LocationService)
|
|
78
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
79
|
+
|
|
80
|
+
nestedNavigate(injector, { path: '/buttons', hash: 'overview' })
|
|
81
|
+
|
|
82
|
+
expect(spy).toHaveBeenCalledWith('/buttons#overview')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('Should combine params, query and hash in the correct order', async () => {
|
|
87
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
88
|
+
const locationService = injector.getInstance(LocationService)
|
|
89
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
90
|
+
|
|
91
|
+
nestedNavigate(injector, {
|
|
92
|
+
path: '/users/:id',
|
|
93
|
+
params: { id: '7' },
|
|
94
|
+
query: { tab: 'profile' },
|
|
95
|
+
hash: 'notes',
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
99
|
+
const navigatedUrl = spy.mock.calls[0][0]
|
|
100
|
+
expect(navigatedUrl.startsWith('/users/7?')).toBe(true)
|
|
101
|
+
expect(navigatedUrl.endsWith('#notes')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('buildNestedNavigateUrl', () => {
|
|
107
|
+
it('Should build a bare path URL', () => {
|
|
108
|
+
expect(buildNestedNavigateUrl({ path: '/buttons' })).toBe('/buttons')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('Should build a URL with compiled params', () => {
|
|
112
|
+
expect(buildNestedNavigateUrl({ path: '/users/:id', params: { id: '42' } })).toBe('/users/42')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('Should append a hash segment', () => {
|
|
116
|
+
expect(buildNestedNavigateUrl({ path: '/buttons', hash: 'top' })).toBe('/buttons#top')
|
|
117
|
+
})
|
|
44
118
|
})
|
|
45
119
|
|
|
46
120
|
describe('Type utilities', () => {
|
|
47
|
-
describe('
|
|
121
|
+
describe('TypedNestedNavigateArgs', () => {
|
|
48
122
|
it('Should make params optional for paths without parameters', () => {
|
|
49
|
-
type
|
|
50
|
-
expectTypeOf<
|
|
123
|
+
type Routes = { '/buttons': TestRoute }
|
|
124
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/buttons'>>().toExtend<{ path: '/buttons' }>()
|
|
125
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/buttons'>>().toExtend<{ params?: Record<string, string> }>()
|
|
51
126
|
})
|
|
52
127
|
|
|
53
128
|
it('Should require params for parameterized paths', () => {
|
|
54
|
-
type
|
|
55
|
-
expectTypeOf<
|
|
129
|
+
type Routes = { '/users/:id': TestRoute }
|
|
130
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/users/:id'>>().toExtend<{ params: { id: string } }>()
|
|
56
131
|
})
|
|
57
132
|
|
|
58
133
|
it('Should require all params for multi-param paths', () => {
|
|
59
|
-
type
|
|
60
|
-
expectTypeOf<
|
|
61
|
-
|
|
62
|
-
>()
|
|
134
|
+
type Routes = { '/users/:userId/posts/:postId': TestRoute }
|
|
135
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/users/:userId/posts/:postId'>>().toExtend<{
|
|
136
|
+
params: { userId: string; postId: string }
|
|
137
|
+
}>()
|
|
63
138
|
})
|
|
64
139
|
})
|
|
65
140
|
|
|
@@ -90,9 +165,9 @@ describe('Type utilities', () => {
|
|
|
90
165
|
}
|
|
91
166
|
|
|
92
167
|
const appNavigate = createNestedNavigate<Routes>()
|
|
93
|
-
expectTypeOf(appNavigate<'/users/:userId'>)
|
|
94
|
-
|
|
95
|
-
|
|
168
|
+
expectTypeOf(appNavigate<'/users/:userId'>)
|
|
169
|
+
.parameter(1)
|
|
170
|
+
.toExtend<{ params: { userId: string } }>()
|
|
96
171
|
})
|
|
97
172
|
|
|
98
173
|
it('Should make params optional for non-parameterized routes', () => {
|
|
@@ -105,9 +180,9 @@ describe('Type utilities', () => {
|
|
|
105
180
|
}
|
|
106
181
|
|
|
107
182
|
const appNavigate = createNestedNavigate<Routes>()
|
|
108
|
-
expectTypeOf(appNavigate<'/buttons'>)
|
|
109
|
-
|
|
110
|
-
|
|
183
|
+
expectTypeOf(appNavigate<'/buttons'>)
|
|
184
|
+
.parameter(1)
|
|
185
|
+
.toExtend<{ params?: Record<string, string> }>()
|
|
111
186
|
})
|
|
112
187
|
|
|
113
188
|
it('Should reject invalid paths', () => {
|
|
@@ -122,7 +197,7 @@ describe('Type utilities', () => {
|
|
|
122
197
|
|
|
123
198
|
const appNavigate = createNestedNavigate<Routes>()
|
|
124
199
|
// @ts-expect-error -- '/nonexistent' is not a valid route path
|
|
125
|
-
appNavigate(new Injector(), '/nonexistent')
|
|
200
|
+
appNavigate(new Injector(), { path: '/nonexistent' })
|
|
126
201
|
})
|
|
127
202
|
|
|
128
203
|
it('Should accept routes with typed match parameters (NestedRoute<T>)', () => {
|
|
@@ -135,9 +210,37 @@ describe('Type utilities', () => {
|
|
|
135
210
|
}
|
|
136
211
|
|
|
137
212
|
const appNavigate = createNestedNavigate<typeof routes>()
|
|
138
|
-
expectTypeOf(appNavigate<'/stacks/:stackName'>)
|
|
139
|
-
|
|
140
|
-
|
|
213
|
+
expectTypeOf(appNavigate<'/stacks/:stackName'>)
|
|
214
|
+
.parameter(1)
|
|
215
|
+
.toExtend<{ params: { stackName: string } }>()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('Should enforce a route-declared required query shape', () => {
|
|
219
|
+
const routes = {
|
|
220
|
+
'/list': {
|
|
221
|
+
component: () => null as unknown as JSX.Element,
|
|
222
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
223
|
+
},
|
|
224
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
225
|
+
|
|
226
|
+
const appNavigate = createNestedNavigate<typeof routes>()
|
|
227
|
+
expectTypeOf(appNavigate<'/list'>)
|
|
228
|
+
.parameter(1)
|
|
229
|
+
.toExtend<{ query: { page: number } }>()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('Should narrow hash to the declared literal tuple', () => {
|
|
233
|
+
const routes = {
|
|
234
|
+
'/tabs': {
|
|
235
|
+
component: () => null as unknown as JSX.Element,
|
|
236
|
+
hash: ['overview', 'details'] as const,
|
|
237
|
+
},
|
|
238
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
239
|
+
|
|
240
|
+
const appNavigate = createNestedNavigate<typeof routes>()
|
|
241
|
+
expectTypeOf(appNavigate<'/tabs'>)
|
|
242
|
+
.parameter(1)
|
|
243
|
+
.toExtend<{ hash?: 'overview' | 'details' }>()
|
|
141
244
|
})
|
|
142
245
|
})
|
|
143
246
|
})
|
|
@@ -1,36 +1,85 @@
|
|
|
1
1
|
import type { Injector } from '@furystack/inject'
|
|
2
|
+
import { serializeToQueryString } from '@furystack/rest'
|
|
2
3
|
import { compileRoute } from '../compile-route.js'
|
|
3
4
|
import { LocationService } from '../services/location-service.js'
|
|
4
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ExtractRouteHash,
|
|
7
|
+
ExtractRouteQuery,
|
|
8
|
+
ExtractRoutePaths,
|
|
9
|
+
RouteAt,
|
|
10
|
+
TypedHashArg,
|
|
11
|
+
TypedParamsArg,
|
|
12
|
+
TypedQueryArg,
|
|
13
|
+
} from './nested-route-types.js'
|
|
5
14
|
import type { NestedRoute } from './nested-router.js'
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* @
|
|
17
|
+
* Untyped runtime arguments for a nested navigate call.
|
|
18
|
+
* Used by the base {@link nestedNavigate} implementation; the typed variants
|
|
19
|
+
* returned by {@link createNestedNavigate} narrow each field against the route
|
|
20
|
+
* tree at compile time.
|
|
11
21
|
*/
|
|
12
|
-
export type
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
export type NestedNavigateArgs = {
|
|
23
|
+
path: string
|
|
24
|
+
params?: Record<string, string>
|
|
25
|
+
query?: Record<string, unknown>
|
|
26
|
+
hash?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Typed arguments for a nested navigate call, derived from the route tree and
|
|
31
|
+
* the specific target path.
|
|
32
|
+
*
|
|
33
|
+
* - `params` is required when the path contains `:param` segments.
|
|
34
|
+
* - `query` is required when the route's declared query shape has any required
|
|
35
|
+
* key, optional when all keys are optional, and forbidden when the route
|
|
36
|
+
* declares no `query` validator.
|
|
37
|
+
* - `hash` is always optional and restricted to the route's declared literal
|
|
38
|
+
* tuple; forbidden when the route declares no `hash`.
|
|
39
|
+
*
|
|
40
|
+
* @typeParam TRoutes - The route tree
|
|
41
|
+
* @typeParam TPath - A composed route path within `TRoutes`
|
|
42
|
+
*/
|
|
43
|
+
export type TypedNestedNavigateArgs<
|
|
44
|
+
TRoutes extends Record<string, NestedRoute<any, any, any>>,
|
|
45
|
+
TPath extends string,
|
|
46
|
+
> = { path: TPath } & TypedParamsArg<TPath> &
|
|
47
|
+
TypedQueryArg<ExtractRouteQuery<RouteAt<TRoutes, TPath>>> &
|
|
48
|
+
TypedHashArg<ExtractRouteHash<RouteAt<TRoutes, TPath>>>
|
|
15
49
|
|
|
16
50
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
51
|
+
* Builds the resolved URL for a nested navigate call, including path parameters,
|
|
52
|
+
* serialized query string and hash segment. Exported for callers that need the
|
|
53
|
+
* URL without side effects (e.g. link rendering helpers).
|
|
54
|
+
*/
|
|
55
|
+
export const buildNestedNavigateUrl = (args: NestedNavigateArgs): string => {
|
|
56
|
+
const { path, params, query, hash } = args
|
|
57
|
+
const resolvedPath = params && Object.keys(params).length > 0 ? compileRoute(path, params) : path
|
|
58
|
+
const hasQuery = query && Object.keys(query).length > 0
|
|
59
|
+
const search = hasQuery ? `?${serializeToQueryString(query)}` : ''
|
|
60
|
+
const hashPart = hash ? `#${hash}` : ''
|
|
61
|
+
return `${resolvedPath}${search}${hashPart}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Navigates to a route using the LocationService resolved from the given injector.
|
|
66
|
+
* Compiles parameterized routes (e.g. `/users/:id`), serializes the optional
|
|
67
|
+
* `query` record with the default `@furystack/rest` serializer, and appends the
|
|
68
|
+
* optional `hash` segment.
|
|
19
69
|
*
|
|
20
70
|
* @param injector - The injector instance to resolve LocationService from
|
|
21
|
-
* @param
|
|
22
|
-
* @param params - Optional route parameters to compile into the path
|
|
71
|
+
* @param args - The navigation arguments
|
|
23
72
|
*/
|
|
24
|
-
export const nestedNavigate = (injector: Injector,
|
|
25
|
-
|
|
26
|
-
injector.getInstance(LocationService).navigate(resolvedUrl)
|
|
73
|
+
export const nestedNavigate = (injector: Injector, args: NestedNavigateArgs): void => {
|
|
74
|
+
injector.getInstance(LocationService).navigate(buildNestedNavigateUrl(args))
|
|
27
75
|
}
|
|
28
76
|
|
|
29
77
|
/**
|
|
30
78
|
* Creates a type-safe navigate function constrained to a specific route tree.
|
|
31
79
|
* The returned function has the same runtime behavior as {@link nestedNavigate}
|
|
32
|
-
* but narrows `path` to only accept valid route paths,
|
|
33
|
-
*
|
|
80
|
+
* but narrows `path` to only accept valid route paths, requires `params` when
|
|
81
|
+
* the route has parameters, and enforces the route's declared `query` and
|
|
82
|
+
* `hash` schemas.
|
|
34
83
|
*
|
|
35
84
|
* Unlike {@link createNestedRouteLink}, the injector is passed at call time,
|
|
36
85
|
* not at creation time.
|
|
@@ -42,19 +91,19 @@ export const nestedNavigate = (injector: Injector, path: string, params?: Record
|
|
|
42
91
|
* ```typescript
|
|
43
92
|
* const appNavigate = createNestedNavigate<typeof appRoutes>()
|
|
44
93
|
*
|
|
45
|
-
*
|
|
46
|
-
* appNavigate(injector, '/
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
94
|
+
* appNavigate(injector, { path: '/buttons' })
|
|
95
|
+
* appNavigate(injector, { path: '/users/:id', params: { id: '123' } })
|
|
96
|
+
* appNavigate(injector, {
|
|
97
|
+
* path: '/users/:id',
|
|
98
|
+
* params: { id: '123' },
|
|
99
|
+
* query: { tab: 'profile' },
|
|
100
|
+
* hash: 'settings',
|
|
101
|
+
* })
|
|
53
102
|
* ```
|
|
54
103
|
*/
|
|
55
|
-
export const createNestedNavigate = <TRoutes extends Record<string, NestedRoute<any>>>() => {
|
|
104
|
+
export const createNestedNavigate = <TRoutes extends Record<string, NestedRoute<any, any, any>>>() => {
|
|
56
105
|
return nestedNavigate as <TPath extends ExtractRoutePaths<TRoutes>>(
|
|
57
106
|
injector: Injector,
|
|
58
|
-
|
|
107
|
+
args: TypedNestedNavigateArgs<TRoutes, TPath>,
|
|
59
108
|
) => void
|
|
60
109
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject'
|
|
2
|
+
import { usingAsync } from '@furystack/utils'
|
|
3
|
+
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
4
|
+
import { LocationService } from '../services/location-service.js'
|
|
5
|
+
import { createNestedReplace, nestedReplace } from './nested-replace.js'
|
|
6
|
+
import type { NestedRoute } from './nested-router.js'
|
|
7
|
+
|
|
8
|
+
type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
|
|
9
|
+
|
|
10
|
+
describe('nestedReplace', () => {
|
|
11
|
+
it('Should replace the current history entry with a simple path', async () => {
|
|
12
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
13
|
+
const locationService = injector.getInstance(LocationService)
|
|
14
|
+
const navigateSpy = vi.spyOn(locationService, 'navigate')
|
|
15
|
+
const replaceSpy = vi.spyOn(locationService, 'replace')
|
|
16
|
+
|
|
17
|
+
nestedReplace(injector, { path: '/buttons' })
|
|
18
|
+
|
|
19
|
+
expect(replaceSpy).toHaveBeenCalledWith('/buttons')
|
|
20
|
+
expect(navigateSpy).not.toHaveBeenCalled()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('Should compile params, query and hash for a replace call', async () => {
|
|
25
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
26
|
+
const locationService = injector.getInstance(LocationService)
|
|
27
|
+
const replaceSpy = vi.spyOn(locationService, 'replace')
|
|
28
|
+
|
|
29
|
+
nestedReplace(injector, {
|
|
30
|
+
path: '/users/:id',
|
|
31
|
+
params: { id: '7' },
|
|
32
|
+
query: { tab: 'profile' },
|
|
33
|
+
hash: 'notes',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1)
|
|
37
|
+
const replacedUrl = replaceSpy.mock.calls[0][0]
|
|
38
|
+
expect(replacedUrl.startsWith('/users/7?')).toBe(true)
|
|
39
|
+
expect(replacedUrl.endsWith('#notes')).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('createNestedReplace', () => {
|
|
45
|
+
it('Should constrain path to valid route paths', () => {
|
|
46
|
+
type Routes = {
|
|
47
|
+
'/': TestRoute & {
|
|
48
|
+
children: {
|
|
49
|
+
'/buttons': TestRoute
|
|
50
|
+
'/inputs': TestRoute
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const appReplace = createNestedReplace<Routes>()
|
|
56
|
+
expectTypeOf(appReplace<'/buttons'>)
|
|
57
|
+
.parameter(0)
|
|
58
|
+
.toEqualTypeOf<Injector>()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('Should reject invalid paths', () => {
|
|
62
|
+
type Routes = {
|
|
63
|
+
'/': TestRoute & {
|
|
64
|
+
children: {
|
|
65
|
+
'/buttons': TestRoute
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const appReplace = createNestedReplace<Routes>()
|
|
71
|
+
// @ts-expect-error -- '/nonexistent' is not a valid route path
|
|
72
|
+
appReplace(new Injector(), { path: '/nonexistent' })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('Should enforce a declared required query shape', () => {
|
|
76
|
+
const routes = {
|
|
77
|
+
'/list': {
|
|
78
|
+
component: () => null as unknown as JSX.Element,
|
|
79
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
80
|
+
},
|
|
81
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
82
|
+
|
|
83
|
+
const appReplace = createNestedReplace<typeof routes>()
|
|
84
|
+
expectTypeOf(appReplace<'/list'>)
|
|
85
|
+
.parameter(1)
|
|
86
|
+
.toExtend<{ query: { page: number } }>()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Injector } from '@furystack/inject'
|
|
2
|
+
import { LocationService } from '../services/location-service.js'
|
|
3
|
+
import type { NestedNavigateArgs, TypedNestedNavigateArgs } from './nested-navigate.js'
|
|
4
|
+
import { buildNestedNavigateUrl } from './nested-navigate.js'
|
|
5
|
+
import type { ExtractRoutePaths } from './nested-route-types.js'
|
|
6
|
+
import type { NestedRoute } from './nested-router.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Replaces the current history entry with a route using the LocationService
|
|
10
|
+
* resolved from the given injector. Has the same URL-composition semantics as
|
|
11
|
+
* `nestedNavigate`, but calls `history.replaceState` under the hood so the
|
|
12
|
+
* intermediate URL does not appear in the browser's back / forward stack.
|
|
13
|
+
*
|
|
14
|
+
* Use this for SPA redirects (e.g. canonicalization, auth guards) where the
|
|
15
|
+
* caller's URL should not be recoverable with the Back button.
|
|
16
|
+
*
|
|
17
|
+
* @param injector - The injector instance to resolve LocationService from
|
|
18
|
+
* @param args - The navigation arguments
|
|
19
|
+
*/
|
|
20
|
+
export const nestedReplace = (injector: Injector, args: NestedNavigateArgs): void => {
|
|
21
|
+
injector.getInstance(LocationService).replace(buildNestedNavigateUrl(args))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a type-safe replace function constrained to a specific route tree.
|
|
26
|
+
* The returned function has the same runtime behavior as {@link nestedReplace}
|
|
27
|
+
* but narrows `path` to valid route paths, requires `params` when the route has
|
|
28
|
+
* parameters, and enforces the route's declared `query` and `hash` schemas.
|
|
29
|
+
*
|
|
30
|
+
* @typeParam TRoutes - The route tree type (use `typeof yourRoutes`)
|
|
31
|
+
* @returns A type-safe replace function
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const appReplace = createNestedReplace<typeof appRoutes>()
|
|
36
|
+
*
|
|
37
|
+
* // Redirect an unauthenticated visitor without polluting history
|
|
38
|
+
* appReplace(injector, { path: '/login' })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export const createNestedReplace = <TRoutes extends Record<string, NestedRoute<any, any, any>>>() => {
|
|
42
|
+
return nestedReplace as <TPath extends ExtractRoutePaths<TRoutes>>(
|
|
43
|
+
injector: Injector,
|
|
44
|
+
args: TypedNestedNavigateArgs<TRoutes, TPath>,
|
|
45
|
+
) => void
|
|
46
|
+
}
|