@furystack/shades 11.1.0 → 12.0.1

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 (166) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/README.md +13 -13
  3. package/esm/component-factory.spec.js +13 -5
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/index.d.ts +4 -1
  6. package/esm/components/index.d.ts.map +1 -1
  7. package/esm/components/index.js +4 -1
  8. package/esm/components/index.js.map +1 -1
  9. package/esm/components/lazy-load.d.ts +2 -4
  10. package/esm/components/lazy-load.d.ts.map +1 -1
  11. package/esm/components/lazy-load.js +40 -24
  12. package/esm/components/lazy-load.js.map +1 -1
  13. package/esm/components/lazy-load.spec.js +57 -50
  14. package/esm/components/lazy-load.spec.js.map +1 -1
  15. package/esm/components/link-to-route.d.ts +2 -0
  16. package/esm/components/link-to-route.d.ts.map +1 -1
  17. package/esm/components/link-to-route.js +3 -2
  18. package/esm/components/link-to-route.js.map +1 -1
  19. package/esm/components/link-to-route.spec.js +13 -9
  20. package/esm/components/link-to-route.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.d.ts +62 -0
  22. package/esm/components/nested-route-link.d.ts.map +1 -0
  23. package/esm/components/nested-route-link.js +66 -0
  24. package/esm/components/nested-route-link.js.map +1 -0
  25. package/esm/components/nested-route-link.spec.d.ts +2 -0
  26. package/esm/components/nested-route-link.spec.d.ts.map +1 -0
  27. package/esm/components/nested-route-link.spec.js +179 -0
  28. package/esm/components/nested-route-link.spec.js.map +1 -0
  29. package/esm/components/nested-route-types.d.ts +37 -0
  30. package/esm/components/nested-route-types.d.ts.map +1 -0
  31. package/esm/components/nested-route-types.js +2 -0
  32. package/esm/components/nested-route-types.js.map +1 -0
  33. package/esm/components/nested-router.d.ts +103 -0
  34. package/esm/components/nested-router.d.ts.map +1 -0
  35. package/esm/components/nested-router.js +183 -0
  36. package/esm/components/nested-router.js.map +1 -0
  37. package/esm/components/nested-router.spec.d.ts +2 -0
  38. package/esm/components/nested-router.spec.d.ts.map +1 -0
  39. package/esm/components/nested-router.spec.js +737 -0
  40. package/esm/components/nested-router.spec.js.map +1 -0
  41. package/esm/components/route-link.d.ts +4 -0
  42. package/esm/components/route-link.d.ts.map +1 -1
  43. package/esm/components/route-link.js +5 -5
  44. package/esm/components/route-link.js.map +1 -1
  45. package/esm/components/route-link.spec.js +16 -12
  46. package/esm/components/route-link.spec.js.map +1 -1
  47. package/esm/components/router.d.ts +20 -2
  48. package/esm/components/router.d.ts.map +1 -1
  49. package/esm/components/router.js +12 -7
  50. package/esm/components/router.js.map +1 -1
  51. package/esm/components/router.spec.js +141 -74
  52. package/esm/components/router.spec.js.map +1 -1
  53. package/esm/initialize.d.ts +11 -0
  54. package/esm/initialize.d.ts.map +1 -1
  55. package/esm/initialize.js +5 -0
  56. package/esm/initialize.js.map +1 -1
  57. package/esm/jsx.d.ts +83 -2
  58. package/esm/jsx.d.ts.map +1 -1
  59. package/esm/models/children-list.d.ts +5 -1
  60. package/esm/models/children-list.d.ts.map +1 -1
  61. package/esm/models/partial-element.d.ts +12 -2
  62. package/esm/models/partial-element.d.ts.map +1 -1
  63. package/esm/models/render-options.d.ts +89 -3
  64. package/esm/models/render-options.d.ts.map +1 -1
  65. package/esm/models/selection-state.d.ts +4 -0
  66. package/esm/models/selection-state.d.ts.map +1 -1
  67. package/esm/services/location-service.d.ts +11 -0
  68. package/esm/services/location-service.d.ts.map +1 -1
  69. package/esm/services/location-service.js +11 -0
  70. package/esm/services/location-service.js.map +1 -1
  71. package/esm/services/resource-manager.d.ts +24 -0
  72. package/esm/services/resource-manager.d.ts.map +1 -1
  73. package/esm/services/resource-manager.js +36 -1
  74. package/esm/services/resource-manager.js.map +1 -1
  75. package/esm/services/resource-manager.spec.js +102 -0
  76. package/esm/services/resource-manager.spec.js.map +1 -1
  77. package/esm/services/screen-service.d.ts +81 -4
  78. package/esm/services/screen-service.d.ts.map +1 -1
  79. package/esm/services/screen-service.js +75 -4
  80. package/esm/services/screen-service.js.map +1 -1
  81. package/esm/services/screen-service.spec.js +91 -7
  82. package/esm/services/screen-service.spec.js.map +1 -1
  83. package/esm/shade-component.d.ts +17 -4
  84. package/esm/shade-component.d.ts.map +1 -1
  85. package/esm/shade-component.js +67 -5
  86. package/esm/shade-component.js.map +1 -1
  87. package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
  88. package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
  89. package/esm/shade-host-props-ref.integration.spec.js +381 -0
  90. package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
  91. package/esm/shade-resources.integration.spec.js +208 -39
  92. package/esm/shade-resources.integration.spec.js.map +1 -1
  93. package/esm/shade.d.ts +20 -17
  94. package/esm/shade.d.ts.map +1 -1
  95. package/esm/shade.js +172 -33
  96. package/esm/shade.js.map +1 -1
  97. package/esm/shade.spec.js +31 -30
  98. package/esm/shade.spec.js.map +1 -1
  99. package/esm/shades.integration.spec.js +135 -72
  100. package/esm/shades.integration.spec.js.map +1 -1
  101. package/esm/style-manager.d.ts +2 -2
  102. package/esm/style-manager.js +2 -2
  103. package/esm/svg-types.d.ts +389 -0
  104. package/esm/svg-types.d.ts.map +1 -0
  105. package/esm/svg-types.js +9 -0
  106. package/esm/svg-types.js.map +1 -0
  107. package/esm/svg.d.ts +15 -0
  108. package/esm/svg.d.ts.map +1 -0
  109. package/esm/svg.js +76 -0
  110. package/esm/svg.js.map +1 -0
  111. package/esm/svg.spec.d.ts +2 -0
  112. package/esm/svg.spec.d.ts.map +1 -0
  113. package/esm/svg.spec.js +80 -0
  114. package/esm/svg.spec.js.map +1 -0
  115. package/esm/vnode.d.ts +103 -0
  116. package/esm/vnode.d.ts.map +1 -0
  117. package/esm/vnode.integration.spec.d.ts +2 -0
  118. package/esm/vnode.integration.spec.d.ts.map +1 -0
  119. package/esm/vnode.integration.spec.js +494 -0
  120. package/esm/vnode.integration.spec.js.map +1 -0
  121. package/esm/vnode.js +453 -0
  122. package/esm/vnode.js.map +1 -0
  123. package/esm/vnode.spec.d.ts +2 -0
  124. package/esm/vnode.spec.d.ts.map +1 -0
  125. package/esm/vnode.spec.js +473 -0
  126. package/esm/vnode.spec.js.map +1 -0
  127. package/package.json +8 -9
  128. package/src/component-factory.spec.tsx +18 -5
  129. package/src/components/index.ts +4 -1
  130. package/src/components/lazy-load.spec.tsx +82 -75
  131. package/src/components/lazy-load.tsx +49 -27
  132. package/src/components/link-to-route.spec.tsx +25 -21
  133. package/src/components/link-to-route.tsx +4 -2
  134. package/src/components/nested-route-link.spec.tsx +303 -0
  135. package/src/components/nested-route-link.tsx +100 -0
  136. package/src/components/nested-route-types.ts +42 -0
  137. package/src/components/nested-router.spec.tsx +918 -0
  138. package/src/components/nested-router.tsx +260 -0
  139. package/src/components/route-link.spec.tsx +22 -18
  140. package/src/components/route-link.tsx +6 -5
  141. package/src/components/router.spec.tsx +196 -108
  142. package/src/components/router.tsx +21 -8
  143. package/src/initialize.ts +12 -0
  144. package/src/jsx.ts +129 -2
  145. package/src/models/children-list.ts +7 -1
  146. package/src/models/partial-element.ts +13 -2
  147. package/src/models/render-options.ts +90 -3
  148. package/src/models/selection-state.ts +4 -0
  149. package/src/services/location-service.tsx +11 -0
  150. package/src/services/resource-manager.spec.ts +128 -0
  151. package/src/services/resource-manager.ts +36 -1
  152. package/src/services/screen-service.spec.ts +109 -7
  153. package/src/services/screen-service.ts +81 -4
  154. package/src/shade-component.ts +72 -6
  155. package/src/shade-host-props-ref.integration.spec.tsx +460 -0
  156. package/src/shade-resources.integration.spec.tsx +276 -52
  157. package/src/shade.spec.tsx +40 -39
  158. package/src/shade.ts +186 -58
  159. package/src/shades.integration.spec.tsx +154 -80
  160. package/src/style-manager.ts +2 -2
  161. package/src/svg-types.ts +437 -0
  162. package/src/svg.spec.ts +89 -0
  163. package/src/svg.ts +78 -0
  164. package/src/vnode.integration.spec.tsx +657 -0
  165. package/src/vnode.spec.ts +579 -0
  166. package/src/vnode.ts +508 -0
@@ -0,0 +1,260 @@
1
+ import { ObservableAlreadyDisposedError } from '@furystack/utils'
2
+ import type { MatchOptions, MatchResult } from 'path-to-regexp'
3
+ import { match } from 'path-to-regexp'
4
+ import type { RenderOptions } from '../models/render-options.js'
5
+ import { LocationService } from '../services/location-service.js'
6
+ import { createComponent, setRenderMode } from '../shade-component.js'
7
+ import { Shade } from '../shade.js'
8
+
9
+ /**
10
+ * A single route entry in a NestedRouter configuration.
11
+ * Unlike flat `Route`, the URL is the Record key (not a field), and the
12
+ * `component` receives an `outlet` for rendering matched child content.
13
+ * @typeParam TMatchResult - The type of matched URL parameters
14
+ */
15
+ export type NestedRoute<TMatchResult = unknown> = {
16
+ component: (options: {
17
+ currentUrl: string
18
+ match: MatchResult<TMatchResult extends object ? TMatchResult : object>
19
+ outlet?: JSX.Element
20
+ }) => JSX.Element
21
+ routingOptions?: MatchOptions
22
+ onVisit?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
23
+ onLeave?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
24
+ children?: Record<string, NestedRoute<any>>
25
+ }
26
+
27
+ /**
28
+ * Props for the NestedRouter component.
29
+ * Routes are defined as a Record where keys are URL patterns.
30
+ */
31
+ export type NestedRouterProps = {
32
+ routes: Record<string, NestedRoute<any>>
33
+ notFound?: JSX.Element
34
+ }
35
+
36
+ /**
37
+ * A single entry in a match chain, pairing a matched route with its match result.
38
+ */
39
+ export type MatchChainEntry = {
40
+ route: NestedRoute<unknown>
41
+ match: MatchResult<object>
42
+ }
43
+
44
+ /**
45
+ * Internal state for the NestedRouter component.
46
+ * `matchChain` is `null` when a notFound fallback has been rendered,
47
+ * distinguishing it from the initial empty array (not yet processed).
48
+ */
49
+ export type NestedRouterState = {
50
+ matchChain: MatchChainEntry[] | null
51
+ jsx: JSX.Element
52
+ chainElements: JSX.Element[]
53
+ }
54
+
55
+ /**
56
+ * Recursively builds a match chain from outermost to innermost matched route.
57
+ *
58
+ * For routes with children, a prefix match (`end: false`) is attempted first.
59
+ * If a child matches the remaining URL, the parent and child chain are combined.
60
+ * If no child matches, an exact match on the parent alone is attempted.
61
+ *
62
+ * For leaf routes (no children), only exact matching is used.
63
+ *
64
+ * @param routes - The route definitions to match against
65
+ * @param currentUrl - The URL path to match
66
+ * @returns An array of matched chain entries from outermost to innermost, or null if no match
67
+ */
68
+ export const buildMatchChain = (
69
+ routes: Record<string, NestedRoute<any>>,
70
+ currentUrl: string,
71
+ ): MatchChainEntry[] | null => {
72
+ for (const [pattern, route] of Object.entries(routes)) {
73
+ if (route.children) {
74
+ const prefixMatchFn = match(pattern, { ...route.routingOptions, end: false })
75
+ let prefixResult = prefixMatchFn(currentUrl)
76
+
77
+ // In path-to-regexp v8, match('/', { end: false }) only matches exact '/'.
78
+ // For the root pattern, any URL is logically under '/', so force a prefix match.
79
+ if (!prefixResult && pattern === '/') {
80
+ prefixResult = { path: '/', params: {} }
81
+ }
82
+
83
+ if (prefixResult) {
84
+ let remainingUrl = currentUrl.slice(prefixResult.path.length)
85
+ if (!remainingUrl.startsWith('/')) {
86
+ remainingUrl = `/${remainingUrl}`
87
+ }
88
+
89
+ const childChain = buildMatchChain(route.children, remainingUrl)
90
+ if (childChain) {
91
+ return [{ route, match: prefixResult }, ...childChain]
92
+ }
93
+ }
94
+
95
+ const exactMatchFn = match(pattern, route.routingOptions)
96
+ const exactResult = exactMatchFn(currentUrl)
97
+ if (exactResult) {
98
+ return [{ route, match: exactResult }]
99
+ }
100
+ } else {
101
+ const matchFn = match(pattern, route.routingOptions)
102
+ const matchResult = matchFn(currentUrl)
103
+ if (matchResult) {
104
+ return [{ route, match: matchResult }]
105
+ }
106
+ }
107
+ }
108
+
109
+ return null
110
+ }
111
+
112
+ /**
113
+ * Finds the first index where two match chains diverge.
114
+ * Returns the length of the shorter chain if one is a prefix of the other.
115
+ */
116
+ export const findDivergenceIndex = (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]): number => {
117
+ const minLength = Math.min(oldChain.length, newChain.length)
118
+ for (let i = 0; i < minLength; i++) {
119
+ if (
120
+ oldChain[i].route !== newChain[i].route ||
121
+ JSON.stringify(oldChain[i].match.params) !== JSON.stringify(newChain[i].match.params)
122
+ ) {
123
+ return i
124
+ }
125
+ }
126
+ return minLength
127
+ }
128
+
129
+ /**
130
+ * The result of rendering a match chain, containing both the fully composed
131
+ * JSX tree and per-entry elements for scoped lifecycle animations.
132
+ */
133
+ export type RenderMatchChainResult = {
134
+ jsx: JSX.Element
135
+ chainElements: JSX.Element[]
136
+ }
137
+
138
+ /**
139
+ * Renders a match chain inside-out: starts with the innermost (leaf) route
140
+ * rendered with `outlet: undefined`, then passes its JSX as `outlet` to
141
+ * each successive parent up the chain.
142
+ *
143
+ * Returns per-entry elements so that lifecycle hooks (`onLeave`/`onVisit`)
144
+ * receive only the element for their own route level, not the full tree.
145
+ *
146
+ * @param chain - The match chain from outermost to innermost
147
+ * @param currentUrl - The current URL path
148
+ * @returns The fully composed JSX element and per-entry rendered elements
149
+ */
150
+ export const renderMatchChain = (chain: MatchChainEntry[], currentUrl: string): RenderMatchChainResult => {
151
+ let outlet: JSX.Element | undefined
152
+ const chainElements: JSX.Element[] = new Array<JSX.Element>(chain.length)
153
+
154
+ for (let i = chain.length - 1; i >= 0; i--) {
155
+ const entry = chain[i]
156
+ outlet = entry.route.component({
157
+ currentUrl,
158
+ match: entry.match,
159
+ outlet,
160
+ })
161
+ chainElements[i] = outlet
162
+ }
163
+
164
+ return { jsx: outlet as JSX.Element, chainElements }
165
+ }
166
+
167
+ /**
168
+ * A nested router component that supports hierarchical route definitions
169
+ * with parent/child relationships. Parent routes receive an `outlet` prop
170
+ * containing the rendered child route, enabling layout composition.
171
+ *
172
+ * Routes are defined as a Record where keys are URL patterns (following the
173
+ * RestApi pattern). The matching algorithm builds a chain from outermost to
174
+ * innermost route, then renders inside-out so each parent wraps its child.
175
+ */
176
+ export const NestedRouter = Shade<NestedRouterProps>({
177
+ shadowDomName: 'shade-nested-router',
178
+ render: (options) => {
179
+ const { useState, useObservable, injector } = options
180
+ const [versionRef] = useState('navVersion', { current: 0 })
181
+ const [state, setState] = useState<NestedRouterState>('routerState', {
182
+ matchChain: [],
183
+ jsx: <div />,
184
+ chainElements: [],
185
+ })
186
+
187
+ const updateUrl = async (currentUrl: string) => {
188
+ const [lastState] = useState<NestedRouterState>('routerState', state)
189
+ const { matchChain: lastChain, chainElements: lastChainElements } = lastState
190
+ try {
191
+ const newChain = buildMatchChain(options.props.routes, currentUrl)
192
+
193
+ if (newChain) {
194
+ const lastChainEntries = lastChain ?? []
195
+ const divergeIndex = findDivergenceIndex(lastChainEntries, newChain)
196
+ const hasChanged =
197
+ divergeIndex < lastChainEntries.length ||
198
+ divergeIndex < newChain.length ||
199
+ lastChainEntries.length !== newChain.length
200
+
201
+ if (hasChanged) {
202
+ const version = ++versionRef.current
203
+
204
+ // Call onLeave for routes that are being left (from divergence point to end of old chain)
205
+ for (let i = lastChainEntries.length - 1; i >= divergeIndex; i--) {
206
+ await lastChainEntries[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
207
+ if (version !== versionRef.current) return
208
+ }
209
+
210
+ let newResult: RenderMatchChainResult
211
+ setRenderMode(true)
212
+ try {
213
+ newResult = renderMatchChain(newChain, currentUrl)
214
+ } finally {
215
+ setRenderMode(false)
216
+ }
217
+ if (version !== versionRef.current) return
218
+ setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements })
219
+
220
+ // Call onVisit for routes that are being entered (from divergence point to end of new chain)
221
+ for (let i = divergeIndex; i < newChain.length; i++) {
222
+ await newChain[i].route.onVisit?.({ ...options, element: newResult.chainElements[i] })
223
+ if (version !== versionRef.current) return
224
+ }
225
+ }
226
+ } else if (lastChain !== null) {
227
+ const version = ++versionRef.current
228
+
229
+ // No match found — call onLeave for all active routes and show notFound.
230
+ // The null sentinel prevents re-entering this block on re-render.
231
+ for (let i = (lastChain?.length ?? 0) - 1; i >= 0; i--) {
232
+ await lastChain[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
233
+ if (version !== versionRef.current) return
234
+ }
235
+ setState({
236
+ matchChain: null,
237
+ jsx: options.props.notFound || <div />,
238
+ chainElements: [],
239
+ })
240
+ }
241
+ } catch (e) {
242
+ if (!(e instanceof ObservableAlreadyDisposedError)) {
243
+ throw e
244
+ }
245
+ }
246
+ }
247
+
248
+ const [locationPath] = useObservable(
249
+ 'locationPathChanged',
250
+ injector.getInstance(LocationService).onLocationPathChanged,
251
+ {
252
+ onChange: (newValue) => {
253
+ void updateUrl(newValue)
254
+ },
255
+ },
256
+ )
257
+ void updateUrl(locationPath)
258
+ return state.jsx
259
+ },
260
+ })
@@ -1,8 +1,10 @@
1
1
  import { Injector } from '@furystack/inject'
2
+ import { usingAsync } from '@furystack/utils'
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
4
  import { initializeShadeRoot } from '../initialize.js'
4
5
  import { LocationService } from '../services/location-service.js'
5
6
  import { createComponent } from '../shade-component.js'
7
+ import { flushUpdates } from '../shade.js'
6
8
  import { RouteLink } from './route-link.js'
7
9
 
8
10
  describe('RouteLink', () => {
@@ -14,27 +16,29 @@ describe('RouteLink', () => {
14
16
  })
15
17
 
16
18
  it('Shuld display the loader and completed state', async () => {
17
- const injector = new Injector()
18
- const rootElement = document.getElementById('root') as HTMLDivElement
19
+ await usingAsync(new Injector(), async (injector) => {
20
+ const rootElement = document.getElementById('root') as HTMLDivElement
19
21
 
20
- const onRouteChange = vi.fn()
22
+ const onRouteChange = vi.fn()
21
23
 
22
- injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
24
+ injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
23
25
 
24
- initializeShadeRoot({
25
- injector,
26
- rootElement,
27
- jsxElement: (
28
- <RouteLink id="route" href="/subroute">
29
- Link
30
- </RouteLink>
31
- ),
26
+ initializeShadeRoot({
27
+ injector,
28
+ rootElement,
29
+ jsxElement: (
30
+ <RouteLink id="route" href="/subroute">
31
+ Link
32
+ </RouteLink>
33
+ ),
34
+ })
35
+ await flushUpdates()
36
+ expect(document.body.innerHTML).toMatchInlineSnapshot(
37
+ `"<div id="root"><a is="route-link" id="route" href="/subroute">Link</a></div>"`,
38
+ )
39
+ expect(onRouteChange).not.toBeCalled()
40
+ document.getElementById('route')?.click()
41
+ expect(onRouteChange).toBeCalledTimes(1)
32
42
  })
33
- expect(document.body.innerHTML).toMatchInlineSnapshot(
34
- `"<div id="root"><a is="route-link" id="route" href="/subroute">Link</a></div>"`,
35
- )
36
- expect(onRouteChange).not.toBeCalled()
37
- document.getElementById('route')?.click()
38
- expect(onRouteChange).toBeCalledTimes(1)
39
43
  })
40
44
  })
@@ -1,10 +1,12 @@
1
- import { Shade } from '../shade.js'
2
1
  import type { PartialElement } from '../models/partial-element.js'
3
2
  import { LocationService } from '../services/location-service.js'
4
- import { attachProps, createComponent } from '../shade-component.js'
3
+ import { createComponent } from '../shade-component.js'
4
+ import { Shade } from '../shade.js'
5
5
 
6
+ /** @deprecated Use `NestedRouteLinkProps` from `nested-route-link` instead */
6
7
  export type RouteLinkProps = PartialElement<Omit<HTMLAnchorElement, 'onclick'>>
7
8
 
9
+ /** @deprecated Use `NestedRouteLink` from `nested-route-link` instead */
8
10
  export const RouteLink = Shade<RouteLinkProps>({
9
11
  shadowDomName: 'route-link',
10
12
  elementBase: HTMLAnchorElement,
@@ -13,9 +15,8 @@ export const RouteLink = Shade<RouteLinkProps>({
13
15
  color: 'inherit',
14
16
  textDecoration: 'inherit',
15
17
  },
16
- render: ({ children, props, injector, element }) => {
17
- attachProps(element, {
18
- ...props,
18
+ render: ({ children, props, injector, useHostProps }) => {
19
+ useHostProps({
19
20
  onclick: (ev: MouseEvent) => {
20
21
  ev.preventDefault()
21
22
  history.pushState('', props.title || '', props.href)
@@ -1,5 +1,5 @@
1
1
  import { Injector } from '@furystack/inject'
2
- import { sleepAsync } from '@furystack/utils'
2
+ import { sleepAsync, usingAsync } from '@furystack/utils'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import { initializeShadeRoot } from '../initialize.js'
5
5
  import { LocationService } from '../services/location-service.js'
@@ -15,114 +15,202 @@ describe('Router', () => {
15
15
  document.body.innerHTML = ''
16
16
  })
17
17
 
18
- it('Shuld display the loader and completed state', async () => {
19
- history.pushState(null, '', '/')
20
-
21
- const onVisit = vi.fn()
22
- const onLeave = vi.fn()
23
- const onLastLeave = vi.fn()
24
-
25
- const injector = new Injector()
26
- const rootElement = document.getElementById('root') as HTMLDivElement
27
-
28
- const onRouteChange = vi.fn()
29
-
30
- injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
31
-
32
- const route1: Route = {
33
- url: '/route-a',
34
- component: () => <div id="content">route-a</div>,
35
- onVisit,
36
- onLeave,
37
- }
38
-
39
- const route2: Route<{ id: string }> = {
40
- url: '/route-b{/:id}',
41
- component: ({ match }) => <div id="content">route-b{match.params.id}</div>,
42
- }
43
-
44
- const route3: Route = {
45
- url: '/route-c',
46
- component: () => <div id="content">route-c</div>,
47
- onLeave: onLastLeave,
48
- }
49
-
50
- const route4 = { url: '/', component: () => <div id="content">home</div> }
51
-
52
- initializeShadeRoot({
53
- injector,
54
- rootElement,
55
- jsxElement: (
56
- <div>
57
- <RouteLink id="home" href="/">
58
- home
59
- </RouteLink>
60
- <RouteLink id="a" href="/route-a">
61
- a
62
- </RouteLink>
63
- <RouteLink id="b" href="/route-b">
64
- b
65
- </RouteLink>
66
- <RouteLink id="b-with-id" href="/route-b/123">
67
- b-with-id
68
- </RouteLink>
69
- <RouteLink id="c" href="/route-c">
70
- c
71
- </RouteLink>
72
- <RouteLink id="x" href="/route-x">
73
- x
74
- </RouteLink>
75
- <Router routes={[route1, route2, route3, route4]} notFound={<div id="content">not found</div>} />
76
- </div>
77
- ),
18
+ it('should skip intermediate route when navigating rapidly (latest wins)', async () => {
19
+ await usingAsync(new Injector(), async (injector) => {
20
+ history.pushState(null, '', '/route-a')
21
+
22
+ const callOrder: string[] = []
23
+
24
+ const onVisitA = vi.fn(async () => {
25
+ callOrder.push('visit-a')
26
+ })
27
+ const onLeaveA = vi.fn(async () => {
28
+ callOrder.push('leave-a')
29
+ })
30
+ const onVisitB = vi.fn(async () => {
31
+ await sleepAsync(200)
32
+ callOrder.push('visit-b')
33
+ })
34
+ const onLeaveB = vi.fn(async () => {
35
+ callOrder.push('leave-b')
36
+ })
37
+ const onVisitC = vi.fn(async () => {
38
+ callOrder.push('visit-c')
39
+ })
40
+
41
+ const routeA: Route = {
42
+ url: '/route-a',
43
+ component: () => <div id="content">route-a</div>,
44
+ onVisit: onVisitA,
45
+ onLeave: onLeaveA,
46
+ }
47
+ const routeB: Route = {
48
+ url: '/route-b',
49
+ component: () => <div id="content">route-b</div>,
50
+ onVisit: onVisitB,
51
+ onLeave: onLeaveB,
52
+ }
53
+ const routeC: Route = {
54
+ url: '/route-c',
55
+ component: () => <div id="content">route-c</div>,
56
+ onVisit: onVisitC,
57
+ }
58
+
59
+ const rootElement = document.getElementById('root') as HTMLDivElement
60
+
61
+ initializeShadeRoot({
62
+ injector,
63
+ rootElement,
64
+ jsxElement: (
65
+ <div>
66
+ <RouteLink id="go-a" href="/route-a">
67
+ a
68
+ </RouteLink>
69
+ <RouteLink id="go-b" href="/route-b">
70
+ b
71
+ </RouteLink>
72
+ <RouteLink id="go-c" href="/route-c">
73
+ c
74
+ </RouteLink>
75
+ <Router routes={[routeA, routeB, routeC]} />
76
+ </div>
77
+ ),
78
+ })
79
+
80
+ const getContent = () => document.getElementById('content')?.innerHTML
81
+ const clickOn = (name: string) => document.getElementById(name)?.click()
82
+
83
+ // --- Initial load at /route-a ---
84
+ await sleepAsync(100)
85
+ expect(getContent()).toBe('route-a')
86
+ expect(onVisitA).toHaveBeenCalledTimes(1)
87
+
88
+ // --- Rapid navigation: click B then immediately C ---
89
+ callOrder.length = 0
90
+ clickOn('go-b')
91
+ clickOn('go-c')
92
+
93
+ // Wait long enough for both transitions to settle
94
+ await sleepAsync(500)
95
+
96
+ // The final destination should be route-c
97
+ expect(getContent()).toBe('route-c')
98
+ expect(onVisitC).toHaveBeenCalledTimes(1)
99
+
100
+ // route-b's onVisit should have been abandoned
101
+ expect(callOrder).not.toContain('visit-b')
78
102
  })
103
+ })
79
104
 
80
- const getContent = () => document.getElementById('content')?.innerHTML
81
- const getLocation = () => location.pathname
82
-
83
- const clickOn = (name: string) => document.getElementById(name)?.click()
84
-
85
- await sleepAsync(100)
86
-
87
- expect(getLocation()).toBe('/')
88
- expect(getContent()).toBe('home')
89
-
90
- expect(onVisit).not.toBeCalled()
91
-
92
- clickOn('a')
93
- await sleepAsync(100)
94
- expect(getContent()).toBe('route-a')
95
- expect(getLocation()).toBe('/route-a')
96
- expect(onRouteChange).toBeCalledTimes(1)
97
- expect(onVisit).toBeCalledTimes(1)
98
-
99
- clickOn('a')
100
- await sleepAsync(100)
101
- expect(onVisit).toBeCalledTimes(1)
102
- expect(onLeave).not.toBeCalled()
103
-
104
- clickOn('b')
105
- await sleepAsync(100)
106
- expect(onLeave).toBeCalledTimes(1)
107
-
108
- expect(getContent()).toBe('route-b')
109
- expect(getLocation()).toBe('/route-b')
110
-
111
- clickOn('b-with-id')
112
- await sleepAsync(100)
113
- expect(getContent()).toBe('route-b123')
114
- expect(getLocation()).toBe('/route-b/123')
115
-
116
- clickOn('c')
117
- await sleepAsync(100)
118
- expect(getContent()).toBe('route-c')
119
- expect(getLocation()).toBe('/route-c')
120
-
121
- expect(onLastLeave).not.toBeCalled()
122
- clickOn('x')
123
- await sleepAsync(100)
124
- expect(getContent()).toBe('not found')
125
- expect(getLocation()).toBe('/route-x')
126
- expect(onLastLeave).toBeCalledTimes(1)
105
+ it('Shuld display the loader and completed state', async () => {
106
+ await usingAsync(new Injector(), async (injector) => {
107
+ history.pushState(null, '', '/')
108
+
109
+ const onVisit = vi.fn()
110
+ const onLeave = vi.fn()
111
+ const onLastLeave = vi.fn()
112
+
113
+ const rootElement = document.getElementById('root') as HTMLDivElement
114
+
115
+ const onRouteChange = vi.fn()
116
+
117
+ injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
118
+
119
+ const route1: Route = {
120
+ url: '/route-a',
121
+ component: () => <div id="content">route-a</div>,
122
+ onVisit,
123
+ onLeave,
124
+ }
125
+
126
+ const route2: Route<{ id: string }> = {
127
+ url: '/route-b{/:id}',
128
+ component: ({ match }) => <div id="content">route-b{match.params.id}</div>,
129
+ }
130
+
131
+ const route3: Route = {
132
+ url: '/route-c',
133
+ component: () => <div id="content">route-c</div>,
134
+ onLeave: onLastLeave,
135
+ }
136
+
137
+ const route4 = { url: '/', component: () => <div id="content">home</div> }
138
+
139
+ initializeShadeRoot({
140
+ injector,
141
+ rootElement,
142
+ jsxElement: (
143
+ <div>
144
+ <RouteLink id="home" href="/">
145
+ home
146
+ </RouteLink>
147
+ <RouteLink id="a" href="/route-a">
148
+ a
149
+ </RouteLink>
150
+ <RouteLink id="b" href="/route-b">
151
+ b
152
+ </RouteLink>
153
+ <RouteLink id="b-with-id" href="/route-b/123">
154
+ b-with-id
155
+ </RouteLink>
156
+ <RouteLink id="c" href="/route-c">
157
+ c
158
+ </RouteLink>
159
+ <RouteLink id="x" href="/route-x">
160
+ x
161
+ </RouteLink>
162
+ <Router routes={[route1, route2, route3, route4]} notFound={<div id="content">not found</div>} />
163
+ </div>
164
+ ),
165
+ })
166
+
167
+ const getContent = () => document.getElementById('content')?.innerHTML
168
+ const getLocation = () => location.pathname
169
+
170
+ const clickOn = (name: string) => document.getElementById(name)?.click()
171
+
172
+ await sleepAsync(100)
173
+
174
+ expect(getLocation()).toBe('/')
175
+ expect(getContent()).toBe('home')
176
+
177
+ expect(onVisit).not.toBeCalled()
178
+
179
+ clickOn('a')
180
+ await sleepAsync(100)
181
+ expect(getContent()).toBe('route-a')
182
+ expect(getLocation()).toBe('/route-a')
183
+ expect(onRouteChange).toBeCalledTimes(1)
184
+ expect(onVisit).toBeCalledTimes(1)
185
+
186
+ clickOn('a')
187
+ await sleepAsync(100)
188
+ expect(onVisit).toBeCalledTimes(1)
189
+ expect(onLeave).not.toBeCalled()
190
+
191
+ clickOn('b')
192
+ await sleepAsync(100)
193
+ expect(onLeave).toBeCalledTimes(1)
194
+
195
+ expect(getContent()).toBe('route-b')
196
+ expect(getLocation()).toBe('/route-b')
197
+
198
+ clickOn('b-with-id')
199
+ await sleepAsync(100)
200
+ expect(getContent()).toBe('route-b123')
201
+ expect(getLocation()).toBe('/route-b/123')
202
+
203
+ clickOn('c')
204
+ await sleepAsync(100)
205
+ expect(getContent()).toBe('route-c')
206
+ expect(getLocation()).toBe('/route-c')
207
+
208
+ expect(onLastLeave).not.toBeCalled()
209
+ clickOn('x')
210
+ await sleepAsync(100)
211
+ expect(getContent()).toBe('not found')
212
+ expect(getLocation()).toBe('/route-x')
213
+ expect(onLastLeave).toBeCalledTimes(1)
214
+ })
127
215
  })
128
216
  })