@furystack/shades 13.2.2 → 15.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 (146) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/esm/component-factory.spec.d.ts.map +1 -1
  3. package/esm/component-factory.spec.js +2 -2
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/define-nested-routes.d.ts +27 -0
  6. package/esm/components/define-nested-routes.d.ts.map +1 -0
  7. package/esm/components/define-nested-routes.js +26 -0
  8. package/esm/components/define-nested-routes.js.map +1 -0
  9. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  10. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  11. package/esm/components/define-nested-routes.spec.js +81 -0
  12. package/esm/components/define-nested-routes.spec.js.map +1 -0
  13. package/esm/components/index.d.ts +3 -3
  14. package/esm/components/index.d.ts.map +1 -1
  15. package/esm/components/index.js +3 -3
  16. package/esm/components/index.js.map +1 -1
  17. package/esm/components/nested-hooks.d.ts +62 -0
  18. package/esm/components/nested-hooks.d.ts.map +1 -0
  19. package/esm/components/nested-hooks.js +73 -0
  20. package/esm/components/nested-hooks.js.map +1 -0
  21. package/esm/components/nested-hooks.spec.d.ts +2 -0
  22. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  23. package/esm/components/nested-hooks.spec.js +211 -0
  24. package/esm/components/nested-hooks.spec.js.map +1 -0
  25. package/esm/components/nested-navigate.d.ts +52 -21
  26. package/esm/components/nested-navigate.d.ts.map +1 -1
  27. package/esm/components/nested-navigate.js +32 -17
  28. package/esm/components/nested-navigate.js.map +1 -1
  29. package/esm/components/nested-navigate.spec.js +109 -19
  30. package/esm/components/nested-navigate.spec.js.map +1 -1
  31. package/esm/components/nested-replace.d.ts +36 -0
  32. package/esm/components/nested-replace.d.ts.map +1 -0
  33. package/esm/components/nested-replace.js +38 -0
  34. package/esm/components/nested-replace.js.map +1 -0
  35. package/esm/components/nested-replace.spec.d.ts +2 -0
  36. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  37. package/esm/components/nested-replace.spec.js +59 -0
  38. package/esm/components/nested-replace.spec.js.map +1 -0
  39. package/esm/components/nested-route-link.d.ts +36 -26
  40. package/esm/components/nested-route-link.d.ts.map +1 -1
  41. package/esm/components/nested-route-link.js +18 -20
  42. package/esm/components/nested-route-link.js.map +1 -1
  43. package/esm/components/nested-route-link.spec.js +67 -17
  44. package/esm/components/nested-route-link.spec.js.map +1 -1
  45. package/esm/components/nested-route-types.d.ts +79 -2
  46. package/esm/components/nested-route-types.d.ts.map +1 -1
  47. package/esm/components/nested-router.d.ts +77 -7
  48. package/esm/components/nested-router.d.ts.map +1 -1
  49. package/esm/components/nested-router.js +139 -13
  50. package/esm/components/nested-router.js.map +1 -1
  51. package/esm/components/nested-router.spec.js +283 -52
  52. package/esm/components/nested-router.spec.js.map +1 -1
  53. package/esm/services/location-service.d.ts +39 -42
  54. package/esm/services/location-service.d.ts.map +1 -1
  55. package/esm/services/location-service.js +115 -134
  56. package/esm/services/location-service.js.map +1 -1
  57. package/esm/services/location-service.spec.js +59 -24
  58. package/esm/services/location-service.spec.js.map +1 -1
  59. package/esm/services/route-match-service.d.ts +3 -2
  60. package/esm/services/route-match-service.d.ts.map +1 -1
  61. package/esm/services/route-match-service.js +13 -22
  62. package/esm/services/route-match-service.js.map +1 -1
  63. package/esm/services/route-match-service.spec.js +42 -49
  64. package/esm/services/route-match-service.spec.js.map +1 -1
  65. package/esm/services/route-meta-utils.d.ts +28 -6
  66. package/esm/services/route-meta-utils.d.ts.map +1 -1
  67. package/esm/services/route-meta-utils.js +18 -4
  68. package/esm/services/route-meta-utils.js.map +1 -1
  69. package/esm/services/route-meta-utils.spec.js +60 -5
  70. package/esm/services/route-meta-utils.spec.js.map +1 -1
  71. package/esm/services/screen-service.d.ts +13 -72
  72. package/esm/services/screen-service.d.ts.map +1 -1
  73. package/esm/services/screen-service.js +45 -137
  74. package/esm/services/screen-service.js.map +1 -1
  75. package/esm/services/screen-service.spec.js +19 -18
  76. package/esm/services/screen-service.spec.js.map +1 -1
  77. package/esm/services/spatial-navigation-service.d.ts +17 -39
  78. package/esm/services/spatial-navigation-service.d.ts.map +1 -1
  79. package/esm/services/spatial-navigation-service.js +280 -313
  80. package/esm/services/spatial-navigation-service.js.map +1 -1
  81. package/esm/services/spatial-navigation-service.spec.js +163 -158
  82. package/esm/services/spatial-navigation-service.spec.js.map +1 -1
  83. package/esm/shade.d.ts +1 -1
  84. package/esm/shade.d.ts.map +1 -1
  85. package/esm/shade.js +10 -7
  86. package/esm/shade.js.map +1 -1
  87. package/esm/vnode.d.ts +1 -1
  88. package/esm/vnode.js +1 -1
  89. package/package.json +5 -4
  90. package/src/component-factory.spec.tsx +3 -2
  91. package/src/components/define-nested-routes.spec.ts +91 -0
  92. package/src/components/define-nested-routes.ts +27 -0
  93. package/src/components/index.ts +3 -3
  94. package/src/components/nested-hooks.spec.ts +254 -0
  95. package/src/components/nested-hooks.ts +109 -0
  96. package/src/components/nested-navigate.spec.ts +136 -32
  97. package/src/components/nested-navigate.ts +75 -26
  98. package/src/components/nested-replace.spec.ts +89 -0
  99. package/src/components/nested-replace.ts +46 -0
  100. package/src/components/nested-route-link.spec.tsx +90 -22
  101. package/src/components/nested-route-link.tsx +54 -29
  102. package/src/components/nested-route-types.ts +82 -2
  103. package/src/components/nested-router.spec.tsx +334 -51
  104. package/src/components/nested-router.tsx +184 -24
  105. package/src/services/location-service.spec.ts +68 -25
  106. package/src/services/location-service.tsx +156 -122
  107. package/src/services/route-match-service.spec.ts +41 -28
  108. package/src/services/route-match-service.ts +17 -8
  109. package/src/services/route-meta-utils.spec.ts +78 -10
  110. package/src/services/route-meta-utils.ts +44 -15
  111. package/src/services/screen-service.spec.ts +19 -18
  112. package/src/services/screen-service.ts +65 -110
  113. package/src/services/spatial-navigation-service.spec.ts +165 -160
  114. package/src/services/spatial-navigation-service.ts +299 -287
  115. package/src/shade.ts +11 -8
  116. package/src/vnode.ts +1 -1
  117. package/esm/components/link-to-route.d.ts +0 -10
  118. package/esm/components/link-to-route.d.ts.map +0 -1
  119. package/esm/components/link-to-route.js +0 -16
  120. package/esm/components/link-to-route.js.map +0 -1
  121. package/esm/components/link-to-route.spec.d.ts +0 -2
  122. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  123. package/esm/components/link-to-route.spec.js +0 -30
  124. package/esm/components/link-to-route.spec.js.map +0 -1
  125. package/esm/components/route-link.d.ts +0 -10
  126. package/esm/components/route-link.d.ts.map +0 -1
  127. package/esm/components/route-link.js +0 -25
  128. package/esm/components/route-link.js.map +0 -1
  129. package/esm/components/route-link.spec.d.ts +0 -2
  130. package/esm/components/route-link.spec.d.ts.map +0 -1
  131. package/esm/components/route-link.spec.js +0 -34
  132. package/esm/components/route-link.spec.js.map +0 -1
  133. package/esm/components/router.d.ts +0 -44
  134. package/esm/components/router.d.ts.map +0 -1
  135. package/esm/components/router.js +0 -66
  136. package/esm/components/router.js.map +0 -1
  137. package/esm/components/router.spec.d.ts +0 -2
  138. package/esm/components/router.spec.d.ts.map +0 -1
  139. package/esm/components/router.spec.js +0 -161
  140. package/esm/components/router.spec.js.map +0 -1
  141. package/src/components/link-to-route.spec.tsx +0 -45
  142. package/src/components/link-to-route.tsx +0 -26
  143. package/src/components/route-link.spec.tsx +0 -44
  144. package/src/components/route-link.tsx +0 -29
  145. package/src/components/router.spec.tsx +0 -217
  146. package/src/components/router.tsx +0 -103
@@ -1,174 +1,208 @@
1
- import { Injectable, type Injector } from '@furystack/inject'
1
+ import type { Injector, Token } from '@furystack/inject'
2
+ import { defineService } from '@furystack/inject'
2
3
  import {
3
4
  deserializeQueryString as defaultDeserializeQueryString,
4
5
  serializeToQueryString as defaultSerializeToQueryString,
5
6
  } from '@furystack/rest'
6
7
  import { ObservableValue } from '@furystack/utils'
8
+
7
9
  /**
8
- * Singleton service that tracks browser location changes (pathname, search, hash)
9
- * and exposes them as observable values for reactive routing and URL-driven state.
10
+ * Overridable settings for {@link LocationService}. Bind a replacement factory
11
+ * via {@link useCustomSearchStateSerializer} to swap the URL query-string
12
+ * (de)serializers used by the service.
10
13
  */
11
- @Injectable({ lifetime: 'singleton' })
12
- export class LocationService implements Disposable {
13
- constructor(
14
- private readonly serializeToQueryString = defaultSerializeToQueryString,
15
-
16
- public readonly deserializeQueryString = defaultDeserializeQueryString,
17
- ) {
18
- window.addEventListener('popstate', this.popStateListener)
19
- window.addEventListener('hashchange', this.hashChangeListener)
20
-
21
- this.onDeserializedLocationSearchChanged = new ObservableValue(this.deserializeQueryString(location.search))
22
-
23
- this.originalPushState = window.history.pushState.bind(window.history)
24
- window.history.pushState = ((...args: Parameters<typeof window.history.pushState>) => {
25
- this.originalPushState(...args)
26
- this.updateState()
27
- }).bind(this)
28
-
29
- this.originalReplaceState = window.history.replaceState.bind(window.history)
30
- window.history.replaceState = ((...args: Parameters<typeof window.history.replaceState>) => {
31
- this.originalReplaceState(...args)
32
- this.updateState()
33
- }).bind(this)
34
- }
35
-
36
- private originalPushState: typeof window.history.pushState
37
- private originalReplaceState: typeof window.history.replaceState
14
+ export interface LocationServiceSettings {
15
+ serialize: typeof defaultSerializeToQueryString
16
+ deserialize: typeof defaultDeserializeQueryString
17
+ }
38
18
 
39
- public [Symbol.dispose]() {
40
- window.removeEventListener('popstate', this.popStateListener)
41
- window.removeEventListener('hashchange', this.hashChangeListener)
42
- this.onLocationSearchChanged[Symbol.dispose]()
43
- this.onDeserializedLocationSearchChanged[Symbol.dispose]()
44
- this.locationDeserializerObserver[Symbol.dispose]()
45
- this.onLocationPathChanged[Symbol.dispose]()
46
- this.onLocationHashChanged[Symbol.dispose]()
47
-
48
- window.history.pushState = this.originalPushState
49
- window.history.replaceState = this.originalReplaceState
50
- }
19
+ export const LocationServiceSettings: Token<LocationServiceSettings, 'singleton'> = defineService({
20
+ name: '@furystack/shades/LocationServiceSettings',
21
+ lifetime: 'singleton',
22
+ factory: () => ({
23
+ serialize: defaultSerializeToQueryString,
24
+ deserialize: defaultDeserializeQueryString,
25
+ }),
26
+ })
51
27
 
28
+ /**
29
+ * Singleton service that tracks browser location changes (pathname, search, hash)
30
+ * and exposes them as observable values for reactive routing and URL-driven state.
31
+ */
32
+ export interface LocationService {
33
+ readonly deserializeQueryString: typeof defaultDeserializeQueryString
34
+ readonly onLocationPathChanged: ObservableValue<string>
35
+ readonly onLocationHashChanged: ObservableValue<string>
36
+ readonly onLocationSearchChanged: ObservableValue<string>
37
+ readonly onDeserializedLocationSearchChanged: ObservableValue<Record<string, unknown>>
38
+ readonly searchParamObservables: Map<string, ObservableValue<any>>
52
39
  /**
53
- * Observable value that will be updated when the location pathname (e.g. /page/1) changes
40
+ * Synchronizes the observable state with the current browser location.
41
+ * Called internally after navigation events and history state changes.
54
42
  */
55
- public onLocationPathChanged = new ObservableValue(new URL(location.href).pathname)
56
-
43
+ updateState(): void
44
+ /**
45
+ * Navigate to a path. Use this instead of raw history.pushState for SPA routing.
46
+ */
47
+ navigate(path: string): void
57
48
  /**
58
- * Observable value that will be updated when the location hash (e.g. #hash) changes
49
+ * Replace the current history entry with a new path. Use this instead of raw
50
+ * history.replaceState for SPA redirects where the intermediate URL should
51
+ * not appear in the browser's back/forward stack.
59
52
  */
60
- public onLocationHashChanged = new ObservableValue(location.hash.replace('#', ''))
61
-
53
+ replace(path: string): void
62
54
  /**
63
- * Observable value that will be updated when the location search (e.g. ?search=1) changes
55
+ * Gets or creates an observable for a search parameter.
64
56
  */
65
- public onLocationSearchChanged = new ObservableValue<string>(location.search)
57
+ useSearchParam<T>(key: string, defaultValue: T): ObservableValue<T>
58
+ }
66
59
 
67
- public onDeserializedLocationSearchChanged: ObservableValue<Record<string, unknown>>
60
+ export const LocationService: Token<LocationService, 'singleton'> = defineService({
61
+ name: '@furystack/shades/LocationService',
62
+ lifetime: 'singleton',
63
+ factory: ({ inject, onDispose }) => {
64
+ const { serialize, deserialize } = inject(LocationServiceSettings)
65
+
66
+ const onLocationPathChanged = new ObservableValue(new URL(location.href).pathname)
67
+ const onLocationHashChanged = new ObservableValue(location.hash.replace('#', ''))
68
+ const onLocationSearchChanged = new ObservableValue<string>(location.search)
69
+ const onDeserializedLocationSearchChanged = new ObservableValue<Record<string, unknown>>(
70
+ deserialize(location.search),
71
+ )
72
+ const searchParamObservables = new Map<string, ObservableValue<any>>()
73
+
74
+ const updateState = (): void => {
75
+ onLocationPathChanged.setValue(location.pathname)
76
+ onLocationHashChanged.setValue(location.hash.replace('#', ''))
77
+ onLocationSearchChanged.setValue(location.search)
78
+ }
68
79
 
69
- public locationDeserializerObserver = this.onLocationSearchChanged.subscribe((search) => {
70
- this.onDeserializedLocationSearchChanged.setValue(this.deserializeQueryString(search))
71
- })
80
+ const locationDeserializerObserver = onLocationSearchChanged.subscribe((search) => {
81
+ onDeserializedLocationSearchChanged.setValue(deserialize(search))
82
+ })
72
83
 
73
- /**
74
- * Synchronizes the observable state with the current browser location.
75
- * Called internally after navigation events and history state changes.
76
- */
77
- public updateState = (() => {
78
- this.onLocationPathChanged.setValue(location.pathname)
79
- this.onLocationHashChanged.setValue(location.hash.replace('#', ''))
80
- this.onLocationSearchChanged.setValue(location.search)
81
- }).bind(this)
84
+ const popStateListener = (_ev: PopStateEvent): void => {
85
+ updateState()
86
+ }
87
+ const hashChangeListener = (_ev: HashChangeEvent): void => {
88
+ updateState()
89
+ }
90
+ window.addEventListener('popstate', popStateListener)
91
+ window.addEventListener('hashchange', hashChangeListener)
82
92
 
83
- /**
84
- * Navigate to a path. Use this instead of raw history.pushState for SPA routing.
85
- * The LocationService interceptor ensures routing state is updated correctly.
86
- */
87
- public navigate(path: string): void {
88
- // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.navigate() implementation.
89
- history.pushState(null, '', path)
90
- this.updateState()
91
- }
93
+ const originalPushState = window.history.pushState.bind(window.history)
94
+ window.history.pushState = (...args: Parameters<typeof window.history.pushState>) => {
95
+ originalPushState(...args)
96
+ updateState()
97
+ }
92
98
 
93
- /**
94
- * Replace the current history entry with a new path. Use this instead of
95
- * raw history.replaceState for SPA redirects (e.g. the intermediate URL
96
- * should not appear in the browser's back/forward stack).
97
- */
98
- public replace(path: string): void {
99
- // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.replace() implementation.
100
- history.replaceState(null, '', path)
101
- this.updateState()
102
- }
99
+ const originalReplaceState = window.history.replaceState.bind(window.history)
100
+ window.history.replaceState = (...args: Parameters<typeof window.history.replaceState>) => {
101
+ originalReplaceState(...args)
102
+ updateState()
103
+ }
103
104
 
104
- /**
105
- * Internal cache of per-key search parameter observables created by {@link useSearchParam}.
106
- */
107
- public readonly searchParamObservables = new Map<string, ObservableValue<any>>()
105
+ const navigate = (path: string): void => {
106
+ // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.navigate() implementation.
107
+ history.pushState(null, '', path)
108
+ updateState()
109
+ }
108
110
 
109
- /**
110
- * Gets or creates an observable for a search parameter
111
- * @param key The search param key (e.g. ?search=1 -> search)
112
- * @param defaultValue The default value if not provided
113
- * @returns An observable with the current value (or default value) of the search param
114
- */
115
- public useSearchParam<T>(key: string, defaultValue: T) {
116
- const existing = this.searchParamObservables.get(key)
111
+ const replace = (path: string): void => {
112
+ // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.replace() implementation.
113
+ history.replaceState(null, '', path)
114
+ updateState()
115
+ }
117
116
 
118
- if (!existing) {
119
- const currentDeserialized = this.onDeserializedLocationSearchChanged.getValue()
117
+ const useSearchParam = <T,>(key: string, defaultValue: T): ObservableValue<T> => {
118
+ const existing = searchParamObservables.get(key)
119
+ if (existing) return existing as ObservableValue<T>
120
120
 
121
+ const currentDeserialized = onDeserializedLocationSearchChanged.getValue()
121
122
  const actualValue = Object.prototype.hasOwnProperty.call(currentDeserialized, key)
122
123
  ? (currentDeserialized[key] as T)
123
124
  : defaultValue
124
125
 
125
126
  const newObservable = new ObservableValue(actualValue)
126
- this.searchParamObservables.set(key, newObservable)
127
+ searchParamObservables.set(key, newObservable)
127
128
 
128
129
  newObservable.subscribe((value) => {
129
- const currentQueryStringObject = this.onDeserializedLocationSearchChanged.getValue()
130
+ const currentQueryStringObject = onDeserializedLocationSearchChanged.getValue()
130
131
  if (currentQueryStringObject[key] !== value) {
131
- const params = this.serializeToQueryString({ ...currentQueryStringObject, [key]: value })
132
+ const params = serialize({ ...currentQueryStringObject, [key]: value })
132
133
  const newUrl = `${location.pathname}?${params}`
133
134
  // eslint-disable-next-line furystack/prefer-location-service -- Internal LocationService plumbing for search param sync.
134
135
  history.pushState({}, '', newUrl)
135
136
  }
136
137
  })
137
138
 
138
- this.onDeserializedLocationSearchChanged.subscribe((search) => {
139
+ onDeserializedLocationSearchChanged.subscribe((search) => {
139
140
  const value = (search[key] as T) ?? defaultValue
140
- this.searchParamObservables.get(key)?.setValue(value)
141
+ searchParamObservables.get(key)?.setValue(value)
141
142
  })
143
+
142
144
  return newObservable
143
145
  }
144
- return existing as ObservableValue<T>
145
- }
146
-
147
- private popStateListener = (_ev: PopStateEvent) => {
148
- this.updateState()
149
- }
150
146
 
151
- private hashChangeListener = ((_ev: HashChangeEvent) => {
152
- this.updateState()
153
- }).bind(this)
154
- }
147
+ onDispose(() => {
148
+ window.removeEventListener('popstate', popStateListener)
149
+ window.removeEventListener('hashchange', hashChangeListener)
150
+ window.history.pushState = originalPushState
151
+ window.history.replaceState = originalReplaceState
152
+ locationDeserializerObserver[Symbol.dispose]()
153
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
154
+ onLocationSearchChanged[Symbol.dispose]()
155
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
156
+ onDeserializedLocationSearchChanged[Symbol.dispose]()
157
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
158
+ onLocationPathChanged[Symbol.dispose]()
159
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
160
+ onLocationHashChanged[Symbol.dispose]()
161
+ })
162
+
163
+ return {
164
+ deserializeQueryString: deserialize,
165
+ onLocationPathChanged,
166
+ onLocationHashChanged,
167
+ onLocationSearchChanged,
168
+ onDeserializedLocationSearchChanged,
169
+ searchParamObservables,
170
+ updateState,
171
+ navigate,
172
+ replace,
173
+ useSearchParam,
174
+ }
175
+ },
176
+ })
155
177
 
156
178
  /**
157
- * Configures custom serialization for URL search state.
158
- * Must be called **before** `LocationService` is first instantiated by the injector.
159
- * @param injector The root injector
160
- * @param serialize Function to serialize state to a query string
161
- * @param deserialize Function to deserialize a query string to state
179
+ * Configures custom (de)serialization for URL search state by binding
180
+ * {@link LocationServiceSettings} on the given injector.
181
+ *
182
+ * Must be called **before** any consumer resolves {@link LocationService}:
183
+ * the service patches `history.pushState` / `history.replaceState` and
184
+ * subscribes to `popstate` / `hashchange` on construction, and those
185
+ * listeners are only torn down when the owning injector is disposed.
186
+ * Rebinding after the first resolution would leak the previous instance,
187
+ * so this helper throws loudly if that happens.
188
+ *
189
+ * @param injector The root injector.
190
+ * @param serialize Function to serialize state to a query string.
191
+ * @param deserialize Function to deserialize a query string to state.
192
+ * @throws If {@link LocationService} has already been resolved on
193
+ * `injector` (or any reachable ancestor).
162
194
  */
163
195
  export const useCustomSearchStateSerializer = (
164
196
  injector: Injector,
165
197
  serialize: typeof defaultSerializeToQueryString,
166
198
  deserialize: typeof defaultDeserializeQueryString,
167
- ) => {
168
- if (injector.cachedSingletons.has(LocationService)) {
169
- throw new Error('useCustomSearchStateSerializer must be called before the LocationService is instantiated')
199
+ ): void => {
200
+ if (injector.isResolved(LocationService)) {
201
+ throw new Error(
202
+ 'useCustomSearchStateSerializer must be called before LocationService is resolved for the first time. ' +
203
+ 'Configure serializers during injector bootstrap (e.g. before the first render).',
204
+ )
170
205
  }
171
-
172
- const locationService = new LocationService(serialize, deserialize)
173
- injector.setExplicitInstance(locationService, LocationService)
206
+ injector.bind(LocationServiceSettings, () => ({ serialize, deserialize }))
207
+ injector.invalidate(LocationServiceSettings)
174
208
  }
@@ -1,44 +1,57 @@
1
+ import { createInjector } from '@furystack/inject'
2
+ import { usingAsync } from '@furystack/utils'
1
3
  import { describe, expect, it, vi } from 'vitest'
2
4
  import type { MatchChainEntry } from '../components/nested-router.js'
3
5
  import { RouteMatchService } from './route-match-service.js'
4
6
 
5
7
  describe('RouteMatchService', () => {
6
- it('should start with an empty match chain', () => {
7
- using service = new RouteMatchService()
8
- expect(service.currentMatchChain.getValue()).toEqual([])
8
+ it('should start with an empty match chain', async () => {
9
+ await usingAsync(createInjector(), async (i) => {
10
+ const service = i.get(RouteMatchService)
11
+ expect(service.currentMatchChain.getValue()).toEqual([])
12
+ })
9
13
  })
10
14
 
11
- it('should update the match chain', () => {
12
- using service = new RouteMatchService()
13
- const chain: MatchChainEntry[] = [
14
- {
15
- route: { component: () => ({}) as JSX.Element },
16
- match: { path: '/about', params: {} },
17
- },
18
- ]
19
- service.currentMatchChain.setValue(chain)
20
- expect(service.currentMatchChain.getValue()).toBe(chain)
15
+ it('should update the match chain', async () => {
16
+ await usingAsync(createInjector(), async (i) => {
17
+ const service = i.get(RouteMatchService)
18
+ const chain: MatchChainEntry[] = [
19
+ {
20
+ route: { component: () => ({}) as JSX.Element },
21
+ match: { path: '/about', params: {} },
22
+ query: null,
23
+ hash: undefined,
24
+ },
25
+ ]
26
+ service.currentMatchChain.setValue(chain)
27
+ expect(service.currentMatchChain.getValue()).toBe(chain)
28
+ })
21
29
  })
22
30
 
23
- it('should notify subscribers on match chain change', () => {
24
- using service = new RouteMatchService()
25
- const listener = vi.fn()
26
- using _subscription = service.currentMatchChain.subscribe(listener)
31
+ it('should notify subscribers on match chain change', async () => {
32
+ await usingAsync(createInjector(), async (i) => {
33
+ const service = i.get(RouteMatchService)
34
+ const listener = vi.fn()
35
+ using _subscription = service.currentMatchChain.subscribe(listener)
27
36
 
28
- const chain: MatchChainEntry[] = [
29
- {
30
- route: { meta: { title: 'Home' }, component: () => ({}) as JSX.Element },
31
- match: { path: '/', params: {} },
32
- },
33
- ]
34
- service.currentMatchChain.setValue(chain)
37
+ const chain: MatchChainEntry[] = [
38
+ {
39
+ route: { meta: { title: 'Home' }, component: () => ({}) as JSX.Element },
40
+ match: { path: '/', params: {} },
41
+ query: null,
42
+ hash: undefined,
43
+ },
44
+ ]
45
+ service.currentMatchChain.setValue(chain)
35
46
 
36
- expect(listener).toHaveBeenCalledWith(chain)
47
+ expect(listener).toHaveBeenCalledWith(chain)
48
+ })
37
49
  })
38
50
 
39
- it('should dispose the observable on service disposal', () => {
40
- const service = new RouteMatchService()
41
- service[Symbol.dispose]()
51
+ it('should dispose the observable on injector disposal', async () => {
52
+ const injector = createInjector()
53
+ const service = injector.get(RouteMatchService)
54
+ await injector[Symbol.asyncDispose]()
42
55
 
43
56
  expect(() => service.currentMatchChain.setValue([])).toThrow()
44
57
  })
@@ -1,4 +1,5 @@
1
- import { Injectable } from '@furystack/inject'
1
+ import type { Token } from '@furystack/inject'
2
+ import { defineService } from '@furystack/inject'
2
3
  import { ObservableValue } from '@furystack/utils'
3
4
  import type { MatchChainEntry } from '../components/nested-router.js'
4
5
 
@@ -7,11 +8,19 @@ import type { MatchChainEntry } from '../components/nested-router.js'
7
8
  * NestedRouter as an observable. Consumers (breadcrumbs, document title,
8
9
  * navigation trees) subscribe to this instead of re-running route matching.
9
10
  */
10
- @Injectable({ lifetime: 'singleton' })
11
- export class RouteMatchService implements Disposable {
12
- public readonly currentMatchChain = new ObservableValue<MatchChainEntry[]>([])
13
-
14
- public [Symbol.dispose]() {
15
- this.currentMatchChain[Symbol.dispose]()
16
- }
11
+ export interface RouteMatchService {
12
+ readonly currentMatchChain: ObservableValue<MatchChainEntry[]>
17
13
  }
14
+
15
+ export const RouteMatchService: Token<RouteMatchService, 'singleton'> = defineService({
16
+ name: '@furystack/shades/RouteMatchService',
17
+ lifetime: 'singleton',
18
+ factory: ({ onDispose }) => {
19
+ const currentMatchChain = new ObservableValue<MatchChainEntry[]>([])
20
+ onDispose(() => {
21
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
22
+ currentMatchChain[Symbol.dispose]()
23
+ })
24
+ return { currentMatchChain }
25
+ },
26
+ })
@@ -1,15 +1,25 @@
1
- import { Injector } from '@furystack/inject'
2
- import { describe, expect, it } from 'vitest'
1
+ import { createInjector, Injector } from '@furystack/inject'
2
+ import { describe, expect, expectTypeOf, it } from 'vitest'
3
3
  import type { MatchChainEntry, NestedRoute } from '../components/nested-router.js'
4
- import { buildDocumentTitle, extractNavTree, resolveRouteTitle, resolveRouteTitles } from './route-meta-utils.js'
4
+ import {
5
+ buildDocumentTitle,
6
+ extractNavTree,
7
+ type NavTreeNode,
8
+ resolveRouteTitle,
9
+ resolveRouteTitles,
10
+ } from './route-meta-utils.js'
11
+
12
+ type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
5
13
 
6
14
  describe('resolveRouteTitle', () => {
7
- const injector = new Injector()
15
+ const injector = createInjector()
8
16
 
9
17
  it('should return undefined when no meta is configured', async () => {
10
18
  const entry: MatchChainEntry = {
11
19
  route: { component: () => ({}) as JSX.Element },
12
20
  match: { path: '/about', params: {} },
21
+ query: null,
22
+ hash: undefined,
13
23
  }
14
24
  expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
15
25
  })
@@ -18,6 +28,8 @@ describe('resolveRouteTitle', () => {
18
28
  const entry: MatchChainEntry = {
19
29
  route: { meta: {}, component: () => ({}) as JSX.Element },
20
30
  match: { path: '/about', params: {} },
31
+ query: null,
32
+ hash: undefined,
21
33
  }
22
34
  expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
23
35
  })
@@ -26,6 +38,8 @@ describe('resolveRouteTitle', () => {
26
38
  const entry: MatchChainEntry = {
27
39
  route: { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
28
40
  match: { path: '/about', params: {} },
41
+ query: null,
42
+ hash: undefined,
29
43
  }
30
44
  expect(await resolveRouteTitle(entry, injector)).toBe('About')
31
45
  })
@@ -37,6 +51,8 @@ describe('resolveRouteTitle', () => {
37
51
  component: () => ({}) as JSX.Element,
38
52
  },
39
53
  match: { path: '/users/42', params: { id: '42' } },
54
+ query: null,
55
+ hash: undefined,
40
56
  }
41
57
  expect(await resolveRouteTitle(entry, injector)).toBe('User 42')
42
58
  })
@@ -53,6 +69,8 @@ describe('resolveRouteTitle', () => {
53
69
  component: () => ({}) as JSX.Element,
54
70
  },
55
71
  match: { path: '/movies/7', params: { id: '7' } },
72
+ query: null,
73
+ hash: undefined,
56
74
  }
57
75
  expect(await resolveRouteTitle(entry, injector)).toBe('Movie 7')
58
76
  })
@@ -66,23 +84,29 @@ describe('resolveRouteTitle', () => {
66
84
  component: () => ({}) as JSX.Element,
67
85
  },
68
86
  match: { path: '/test', params: {} },
87
+ query: null,
88
+ hash: undefined,
69
89
  }
70
90
  expect(await resolveRouteTitle(entry, injector)).toBe('has-injector')
71
91
  })
72
92
  })
73
93
 
74
94
  describe('resolveRouteTitles', () => {
75
- const injector = new Injector()
95
+ const injector = createInjector()
76
96
 
77
97
  it('should resolve all titles in a mixed chain', async () => {
78
98
  const chain: MatchChainEntry[] = [
79
99
  {
80
100
  route: { meta: { title: 'Media' }, component: () => ({}) as JSX.Element },
81
101
  match: { path: '/media', params: {} },
102
+ query: null,
103
+ hash: undefined,
82
104
  },
83
105
  {
84
106
  route: { meta: { title: 'Movies' }, component: () => ({}) as JSX.Element },
85
107
  match: { path: '/movies', params: {} },
108
+ query: null,
109
+ hash: undefined,
86
110
  },
87
111
  {
88
112
  route: {
@@ -90,6 +114,8 @@ describe('resolveRouteTitles', () => {
90
114
  component: () => ({}) as JSX.Element,
91
115
  },
92
116
  match: { path: '/7', params: { id: '7' } },
117
+ query: null,
118
+ hash: undefined,
93
119
  },
94
120
  ]
95
121
  const titles = await resolveRouteTitles(chain, injector)
@@ -106,10 +132,14 @@ describe('resolveRouteTitles', () => {
106
132
  {
107
133
  route: { meta: { title: 'Root' }, component: () => ({}) as JSX.Element },
108
134
  match: { path: '/', params: {} },
135
+ query: null,
136
+ hash: undefined,
109
137
  },
110
138
  {
111
139
  route: { component: () => ({}) as JSX.Element },
112
140
  match: { path: '/child', params: {} },
141
+ query: null,
142
+ hash: undefined,
113
143
  },
114
144
  ]
115
145
  const titles = await resolveRouteTitles(chain, injector)
@@ -155,7 +185,7 @@ describe('buildDocumentTitle', () => {
155
185
 
156
186
  describe('extractNavTree', () => {
157
187
  it('should extract a flat route tree', () => {
158
- const routes: Record<string, NestedRoute<unknown>> = {
188
+ const routes: Record<string, NestedRoute<any, any, any>> = {
159
189
  '/about': { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
160
190
  '/contact': { meta: { title: 'Contact' }, component: () => ({}) as JSX.Element },
161
191
  }
@@ -167,7 +197,7 @@ describe('extractNavTree', () => {
167
197
  })
168
198
 
169
199
  it('should extract nested routes recursively', () => {
170
- const routes: Record<string, NestedRoute<unknown>> = {
200
+ const routes: Record<string, NestedRoute<any, any, any>> = {
171
201
  '/media': {
172
202
  meta: { title: 'Media' },
173
203
  component: () => ({}) as JSX.Element,
@@ -197,7 +227,7 @@ describe('extractNavTree', () => {
197
227
  })
198
228
 
199
229
  it('should handle root "/" parent path correctly', () => {
200
- const routes: Record<string, NestedRoute<unknown>> = {
230
+ const routes: Record<string, NestedRoute<any, any, any>> = {
201
231
  '/': {
202
232
  meta: { title: 'Home' },
203
233
  component: () => ({}) as JSX.Element,
@@ -212,7 +242,7 @@ describe('extractNavTree', () => {
212
242
  })
213
243
 
214
244
  it('should compute correct fullPath for deeply nested routes (3+ levels)', () => {
215
- const routes: Record<string, NestedRoute<unknown>> = {
245
+ const routes: Record<string, NestedRoute<any, any, any>> = {
216
246
  '/a': {
217
247
  meta: { title: 'A' },
218
248
  component: () => ({}) as JSX.Element,
@@ -234,10 +264,48 @@ describe('extractNavTree', () => {
234
264
  })
235
265
 
236
266
  it('should include routes without meta', () => {
237
- const routes: Record<string, NestedRoute<unknown>> = {
267
+ const routes: Record<string, NestedRoute<any, any, any>> = {
238
268
  '/hidden': { component: () => ({}) as JSX.Element },
239
269
  }
240
270
  const tree = extractNavTree(routes)
241
271
  expect(tree[0].meta).toBeUndefined()
242
272
  })
273
+
274
+ describe('typed output', () => {
275
+ it('should narrow pattern and fullPath for a flat typed tree', () => {
276
+ const routes = {
277
+ '/about': { component: () => ({}) as JSX.Element } satisfies TestRoute,
278
+ '/contact': { component: () => ({}) as JSX.Element } satisfies TestRoute,
279
+ } satisfies Record<string, NestedRoute<any, any, any>>
280
+
281
+ const tree = extractNavTree(routes)
282
+ expectTypeOf(tree[0].pattern).toEqualTypeOf<'/about' | '/contact'>()
283
+ expectTypeOf(tree[0].fullPath).toEqualTypeOf<'/about' | '/contact'>()
284
+ })
285
+
286
+ it('should include nested composed paths in the fullPath union', () => {
287
+ const routes = {
288
+ '/media': {
289
+ component: () => ({}) as JSX.Element,
290
+ children: {
291
+ '/movies': { component: () => ({}) as JSX.Element } satisfies TestRoute,
292
+ '/music': { component: () => ({}) as JSX.Element } satisfies TestRoute,
293
+ },
294
+ },
295
+ } satisfies Record<string, NestedRoute<any, any, any>>
296
+
297
+ const tree = extractNavTree(routes)
298
+ expectTypeOf(tree[0].fullPath).toEqualTypeOf<'/media' | '/media/movies' | '/media/music'>()
299
+ expectTypeOf(tree[0].children).toEqualTypeOf<Array<NavTreeNode<typeof routes>> | undefined>()
300
+ })
301
+
302
+ it('should preserve backward compatibility with the widened default', () => {
303
+ const node: NavTreeNode = {
304
+ pattern: '/anything',
305
+ fullPath: '/anything',
306
+ }
307
+ expectTypeOf(node.pattern).toEqualTypeOf<string>()
308
+ expectTypeOf(node.fullPath).toEqualTypeOf<string>()
309
+ })
310
+ })
243
311
  })