@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.
- package/CHANGELOG.md +312 -0
- package/README.md +13 -13
- package/esm/component-factory.spec.js +13 -5
- package/esm/component-factory.spec.js.map +1 -1
- package/esm/components/index.d.ts +4 -1
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +4 -1
- package/esm/components/index.js.map +1 -1
- package/esm/components/lazy-load.d.ts +2 -4
- package/esm/components/lazy-load.d.ts.map +1 -1
- package/esm/components/lazy-load.js +40 -24
- package/esm/components/lazy-load.js.map +1 -1
- package/esm/components/lazy-load.spec.js +57 -50
- package/esm/components/lazy-load.spec.js.map +1 -1
- package/esm/components/link-to-route.d.ts +2 -0
- package/esm/components/link-to-route.d.ts.map +1 -1
- package/esm/components/link-to-route.js +3 -2
- package/esm/components/link-to-route.js.map +1 -1
- package/esm/components/link-to-route.spec.js +13 -9
- package/esm/components/link-to-route.spec.js.map +1 -1
- package/esm/components/nested-route-link.d.ts +62 -0
- package/esm/components/nested-route-link.d.ts.map +1 -0
- package/esm/components/nested-route-link.js +66 -0
- package/esm/components/nested-route-link.js.map +1 -0
- package/esm/components/nested-route-link.spec.d.ts +2 -0
- package/esm/components/nested-route-link.spec.d.ts.map +1 -0
- package/esm/components/nested-route-link.spec.js +179 -0
- package/esm/components/nested-route-link.spec.js.map +1 -0
- package/esm/components/nested-route-types.d.ts +37 -0
- package/esm/components/nested-route-types.d.ts.map +1 -0
- package/esm/components/nested-route-types.js +2 -0
- package/esm/components/nested-route-types.js.map +1 -0
- package/esm/components/nested-router.d.ts +103 -0
- package/esm/components/nested-router.d.ts.map +1 -0
- package/esm/components/nested-router.js +183 -0
- package/esm/components/nested-router.js.map +1 -0
- package/esm/components/nested-router.spec.d.ts +2 -0
- package/esm/components/nested-router.spec.d.ts.map +1 -0
- package/esm/components/nested-router.spec.js +737 -0
- package/esm/components/nested-router.spec.js.map +1 -0
- package/esm/components/route-link.d.ts +4 -0
- package/esm/components/route-link.d.ts.map +1 -1
- package/esm/components/route-link.js +5 -5
- package/esm/components/route-link.js.map +1 -1
- package/esm/components/route-link.spec.js +16 -12
- package/esm/components/route-link.spec.js.map +1 -1
- package/esm/components/router.d.ts +20 -2
- package/esm/components/router.d.ts.map +1 -1
- package/esm/components/router.js +12 -7
- package/esm/components/router.js.map +1 -1
- package/esm/components/router.spec.js +141 -74
- package/esm/components/router.spec.js.map +1 -1
- package/esm/initialize.d.ts +11 -0
- package/esm/initialize.d.ts.map +1 -1
- package/esm/initialize.js +5 -0
- package/esm/initialize.js.map +1 -1
- package/esm/jsx.d.ts +83 -2
- package/esm/jsx.d.ts.map +1 -1
- package/esm/models/children-list.d.ts +5 -1
- package/esm/models/children-list.d.ts.map +1 -1
- package/esm/models/partial-element.d.ts +12 -2
- package/esm/models/partial-element.d.ts.map +1 -1
- package/esm/models/render-options.d.ts +89 -3
- package/esm/models/render-options.d.ts.map +1 -1
- package/esm/models/selection-state.d.ts +4 -0
- package/esm/models/selection-state.d.ts.map +1 -1
- package/esm/services/location-service.d.ts +11 -0
- package/esm/services/location-service.d.ts.map +1 -1
- package/esm/services/location-service.js +11 -0
- package/esm/services/location-service.js.map +1 -1
- package/esm/services/resource-manager.d.ts +24 -0
- package/esm/services/resource-manager.d.ts.map +1 -1
- package/esm/services/resource-manager.js +36 -1
- package/esm/services/resource-manager.js.map +1 -1
- package/esm/services/resource-manager.spec.js +102 -0
- package/esm/services/resource-manager.spec.js.map +1 -1
- package/esm/services/screen-service.d.ts +81 -4
- package/esm/services/screen-service.d.ts.map +1 -1
- package/esm/services/screen-service.js +75 -4
- package/esm/services/screen-service.js.map +1 -1
- package/esm/services/screen-service.spec.js +91 -7
- package/esm/services/screen-service.spec.js.map +1 -1
- package/esm/shade-component.d.ts +17 -4
- package/esm/shade-component.d.ts.map +1 -1
- package/esm/shade-component.js +67 -5
- package/esm/shade-component.js.map +1 -1
- package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
- package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
- package/esm/shade-host-props-ref.integration.spec.js +381 -0
- package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
- package/esm/shade-resources.integration.spec.js +208 -39
- package/esm/shade-resources.integration.spec.js.map +1 -1
- package/esm/shade.d.ts +20 -17
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +172 -33
- package/esm/shade.js.map +1 -1
- package/esm/shade.spec.js +31 -30
- package/esm/shade.spec.js.map +1 -1
- package/esm/shades.integration.spec.js +135 -72
- package/esm/shades.integration.spec.js.map +1 -1
- package/esm/style-manager.d.ts +2 -2
- package/esm/style-manager.js +2 -2
- package/esm/svg-types.d.ts +389 -0
- package/esm/svg-types.d.ts.map +1 -0
- package/esm/svg-types.js +9 -0
- package/esm/svg-types.js.map +1 -0
- package/esm/svg.d.ts +15 -0
- package/esm/svg.d.ts.map +1 -0
- package/esm/svg.js +76 -0
- package/esm/svg.js.map +1 -0
- package/esm/svg.spec.d.ts +2 -0
- package/esm/svg.spec.d.ts.map +1 -0
- package/esm/svg.spec.js +80 -0
- package/esm/svg.spec.js.map +1 -0
- package/esm/vnode.d.ts +103 -0
- package/esm/vnode.d.ts.map +1 -0
- package/esm/vnode.integration.spec.d.ts +2 -0
- package/esm/vnode.integration.spec.d.ts.map +1 -0
- package/esm/vnode.integration.spec.js +494 -0
- package/esm/vnode.integration.spec.js.map +1 -0
- package/esm/vnode.js +453 -0
- package/esm/vnode.js.map +1 -0
- package/esm/vnode.spec.d.ts +2 -0
- package/esm/vnode.spec.d.ts.map +1 -0
- package/esm/vnode.spec.js +473 -0
- package/esm/vnode.spec.js.map +1 -0
- package/package.json +8 -9
- package/src/component-factory.spec.tsx +18 -5
- package/src/components/index.ts +4 -1
- package/src/components/lazy-load.spec.tsx +82 -75
- package/src/components/lazy-load.tsx +49 -27
- package/src/components/link-to-route.spec.tsx +25 -21
- package/src/components/link-to-route.tsx +4 -2
- package/src/components/nested-route-link.spec.tsx +303 -0
- package/src/components/nested-route-link.tsx +100 -0
- package/src/components/nested-route-types.ts +42 -0
- package/src/components/nested-router.spec.tsx +918 -0
- package/src/components/nested-router.tsx +260 -0
- package/src/components/route-link.spec.tsx +22 -18
- package/src/components/route-link.tsx +6 -5
- package/src/components/router.spec.tsx +196 -108
- package/src/components/router.tsx +21 -8
- package/src/initialize.ts +12 -0
- package/src/jsx.ts +129 -2
- package/src/models/children-list.ts +7 -1
- package/src/models/partial-element.ts +13 -2
- package/src/models/render-options.ts +90 -3
- package/src/models/selection-state.ts +4 -0
- package/src/services/location-service.tsx +11 -0
- package/src/services/resource-manager.spec.ts +128 -0
- package/src/services/resource-manager.ts +36 -1
- package/src/services/screen-service.spec.ts +109 -7
- package/src/services/screen-service.ts +81 -4
- package/src/shade-component.ts +72 -6
- package/src/shade-host-props-ref.integration.spec.tsx +460 -0
- package/src/shade-resources.integration.spec.tsx +276 -52
- package/src/shade.spec.tsx +40 -39
- package/src/shade.ts +186 -58
- package/src/shades.integration.spec.tsx +154 -80
- package/src/style-manager.ts +2 -2
- package/src/svg-types.ts +437 -0
- package/src/svg.spec.ts +89 -0
- package/src/svg.ts +78 -0
- package/src/vnode.integration.spec.tsx +657 -0
- package/src/vnode.spec.ts +579 -0
- 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
|
-
|
|
18
|
-
|
|
19
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
20
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
const onRouteChange = vi.fn()
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
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,
|
|
17
|
-
|
|
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('
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
})
|