@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/esm/components/define-nested-routes.d.ts +27 -0
  3. package/esm/components/define-nested-routes.d.ts.map +1 -0
  4. package/esm/components/define-nested-routes.js +26 -0
  5. package/esm/components/define-nested-routes.js.map +1 -0
  6. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  7. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  8. package/esm/components/define-nested-routes.spec.js +81 -0
  9. package/esm/components/define-nested-routes.spec.js.map +1 -0
  10. package/esm/components/index.d.ts +3 -3
  11. package/esm/components/index.d.ts.map +1 -1
  12. package/esm/components/index.js +3 -3
  13. package/esm/components/index.js.map +1 -1
  14. package/esm/components/nested-hooks.d.ts +62 -0
  15. package/esm/components/nested-hooks.d.ts.map +1 -0
  16. package/esm/components/nested-hooks.js +73 -0
  17. package/esm/components/nested-hooks.js.map +1 -0
  18. package/esm/components/nested-hooks.spec.d.ts +2 -0
  19. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  20. package/esm/components/nested-hooks.spec.js +211 -0
  21. package/esm/components/nested-hooks.spec.js.map +1 -0
  22. package/esm/components/nested-navigate.d.ts +52 -21
  23. package/esm/components/nested-navigate.d.ts.map +1 -1
  24. package/esm/components/nested-navigate.js +32 -17
  25. package/esm/components/nested-navigate.js.map +1 -1
  26. package/esm/components/nested-navigate.spec.js +102 -12
  27. package/esm/components/nested-navigate.spec.js.map +1 -1
  28. package/esm/components/nested-replace.d.ts +36 -0
  29. package/esm/components/nested-replace.d.ts.map +1 -0
  30. package/esm/components/nested-replace.js +38 -0
  31. package/esm/components/nested-replace.js.map +1 -0
  32. package/esm/components/nested-replace.spec.d.ts +2 -0
  33. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  34. package/esm/components/nested-replace.spec.js +59 -0
  35. package/esm/components/nested-replace.spec.js.map +1 -0
  36. package/esm/components/nested-route-link.d.ts +36 -26
  37. package/esm/components/nested-route-link.d.ts.map +1 -1
  38. package/esm/components/nested-route-link.js +17 -19
  39. package/esm/components/nested-route-link.js.map +1 -1
  40. package/esm/components/nested-route-link.spec.js +61 -11
  41. package/esm/components/nested-route-link.spec.js.map +1 -1
  42. package/esm/components/nested-route-types.d.ts +79 -2
  43. package/esm/components/nested-route-types.d.ts.map +1 -1
  44. package/esm/components/nested-router.d.ts +77 -7
  45. package/esm/components/nested-router.d.ts.map +1 -1
  46. package/esm/components/nested-router.js +137 -11
  47. package/esm/components/nested-router.js.map +1 -1
  48. package/esm/components/nested-router.spec.js +265 -34
  49. package/esm/components/nested-router.spec.js.map +1 -1
  50. package/esm/services/location-service.spec.js +33 -0
  51. package/esm/services/location-service.spec.js.map +1 -1
  52. package/esm/services/route-match-service.spec.js +4 -0
  53. package/esm/services/route-match-service.spec.js.map +1 -1
  54. package/esm/services/route-meta-utils.d.ts +28 -6
  55. package/esm/services/route-meta-utils.d.ts.map +1 -1
  56. package/esm/services/route-meta-utils.js +18 -4
  57. package/esm/services/route-meta-utils.js.map +1 -1
  58. package/esm/services/route-meta-utils.spec.js +57 -2
  59. package/esm/services/route-meta-utils.spec.js.map +1 -1
  60. package/esm/vnode.d.ts +1 -1
  61. package/esm/vnode.js +1 -1
  62. package/package.json +2 -2
  63. package/src/components/define-nested-routes.spec.ts +91 -0
  64. package/src/components/define-nested-routes.ts +27 -0
  65. package/src/components/index.ts +3 -3
  66. package/src/components/nested-hooks.spec.ts +254 -0
  67. package/src/components/nested-hooks.ts +109 -0
  68. package/src/components/nested-navigate.spec.ts +128 -25
  69. package/src/components/nested-navigate.ts +75 -26
  70. package/src/components/nested-replace.spec.ts +88 -0
  71. package/src/components/nested-replace.ts +46 -0
  72. package/src/components/nested-route-link.spec.tsx +84 -16
  73. package/src/components/nested-route-link.tsx +53 -28
  74. package/src/components/nested-route-types.ts +82 -2
  75. package/src/components/nested-router.spec.tsx +316 -33
  76. package/src/components/nested-router.tsx +182 -22
  77. package/src/services/location-service.spec.ts +39 -0
  78. package/src/services/route-match-service.spec.ts +4 -0
  79. package/src/services/route-meta-utils.spec.ts +75 -7
  80. package/src/services/route-meta-utils.ts +44 -15
  81. package/src/vnode.ts +1 -1
  82. package/esm/components/link-to-route.d.ts +0 -10
  83. package/esm/components/link-to-route.d.ts.map +0 -1
  84. package/esm/components/link-to-route.js +0 -16
  85. package/esm/components/link-to-route.js.map +0 -1
  86. package/esm/components/link-to-route.spec.d.ts +0 -2
  87. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  88. package/esm/components/link-to-route.spec.js +0 -30
  89. package/esm/components/link-to-route.spec.js.map +0 -1
  90. package/esm/components/route-link.d.ts +0 -10
  91. package/esm/components/route-link.d.ts.map +0 -1
  92. package/esm/components/route-link.js +0 -25
  93. package/esm/components/route-link.js.map +0 -1
  94. package/esm/components/route-link.spec.d.ts +0 -2
  95. package/esm/components/route-link.spec.d.ts.map +0 -1
  96. package/esm/components/route-link.spec.js +0 -34
  97. package/esm/components/route-link.spec.js.map +0 -1
  98. package/esm/components/router.d.ts +0 -44
  99. package/esm/components/router.d.ts.map +0 -1
  100. package/esm/components/router.js +0 -66
  101. package/esm/components/router.js.map +0 -1
  102. package/esm/components/router.spec.d.ts +0 -2
  103. package/esm/components/router.spec.d.ts.map +0 -1
  104. package/esm/components/router.spec.js +0 -161
  105. package/esm/components/router.spec.js.map +0 -1
  106. package/src/components/link-to-route.spec.tsx +0 -45
  107. package/src/components/link-to-route.tsx +0 -26
  108. package/src/components/route-link.spec.tsx +0 -44
  109. package/src/components/route-link.tsx +0 -29
  110. package/src/components/router.spec.tsx +0 -217
  111. 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 { TypedNavigateArgs } from './nested-navigate.js'
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, '/users/:userId/posts/:postId', { userId: '1', postId: '99' })
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('TypedNavigateArgs', () => {
121
+ describe('TypedNestedNavigateArgs', () => {
48
122
  it('Should make params optional for paths without parameters', () => {
49
- type Args = TypedNavigateArgs<'/buttons'>
50
- expectTypeOf<Args>().toEqualTypeOf<[path: '/buttons', params?: Record<string, string>]>()
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 Args = TypedNavigateArgs<'/users/:id'>
55
- expectTypeOf<Args>().toEqualTypeOf<[path: '/users/:id', params: { id: string }]>()
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 Args = TypedNavigateArgs<'/users/:userId/posts/:postId'>
60
- expectTypeOf<Args>().toEqualTypeOf<
61
- [path: '/users/:userId/posts/:postId', params: { userId: string; postId: string }]
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'>).parameters.toEqualTypeOf<
94
- [injector: Injector, path: '/users/:userId', params: { userId: string }]
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'>).parameters.toEqualTypeOf<
109
- [injector: Injector, path: '/buttons', params?: Record<string, string>]
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'>).parameters.toEqualTypeOf<
139
- [injector: Injector, path: '/stacks/:stackName', params: { stackName: string }]
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 { ExtractRouteParams, ExtractRoutePaths } from './nested-route-types.js'
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
- * Arguments for a type-safe nested navigate call.
9
- * When the path contains parameters (e.g. `:id`), the `params` argument becomes required.
10
- * @typeParam TPath - A specific route path string
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 TypedNavigateArgs<TPath extends string> = string extends keyof ExtractRouteParams<TPath>
13
- ? [path: TPath, params?: Record<string, string>]
14
- : [path: TPath, params: ExtractRouteParams<TPath>]
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
- * Navigates to a route path using the LocationService from the given injector.
18
- * Compiles parameterized routes (e.g. `/users/:id`) when `params` is provided.
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 path - The route path to navigate to
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, path: string, params?: Record<string, string>): void => {
25
- const resolvedUrl = params ? compileRoute(path, params) : path
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, and requires `params`
33
- * when the route has parameters.
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
- * // Type-safe: only valid paths accepted
46
- * appNavigate(injector, '/buttons')
47
- *
48
- * // TypeScript error: invalid path
49
- * appNavigate(injector, '/nonexistent')
50
- *
51
- * // Params required for parameterized routes
52
- * appNavigate(injector, '/users/:id', { id: '123' })
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
- ...args: TypedNavigateArgs<TPath>
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
+ }