@fictjs/router 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +351 -174
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +340 -167
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/accessor-utils.ts +22 -0
- package/src/components.tsx +50 -451
- package/src/context.ts +161 -42
- package/src/link.tsx +54 -15
- package/src/router-internals.ts +33 -0
- package/src/router-provider.ts +388 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fictjs/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Reactive router for Fict applications",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -13,8 +13,6 @@
|
|
|
13
13
|
"spa",
|
|
14
14
|
"reactive"
|
|
15
15
|
],
|
|
16
|
-
"author": "Michael Lin",
|
|
17
|
-
"license": "MIT",
|
|
18
16
|
"repository": {
|
|
19
17
|
"type": "git",
|
|
20
18
|
"url": "https://github.com/fictjs/fict.git",
|
|
@@ -37,22 +35,25 @@
|
|
|
37
35
|
"src"
|
|
38
36
|
],
|
|
39
37
|
"dependencies": {
|
|
40
|
-
"@fictjs/runtime": "0.
|
|
38
|
+
"@fictjs/runtime": "0.5.0"
|
|
41
39
|
},
|
|
42
40
|
"devDependencies": {
|
|
43
41
|
"jsdom": "^27.4.0",
|
|
44
42
|
"tsup": "^8.5.1",
|
|
45
|
-
"@fictjs/
|
|
46
|
-
"
|
|
43
|
+
"@fictjs/testing-library": "0.5.0",
|
|
44
|
+
"@fictjs/vite-plugin": "0.5.0",
|
|
45
|
+
"fict": "0.5.0"
|
|
47
46
|
},
|
|
48
47
|
"peerDependencies": {
|
|
49
|
-
"fict": "0.3.0"
|
|
48
|
+
"fict": ">=0.3.0"
|
|
50
49
|
},
|
|
51
50
|
"peerDependenciesMeta": {
|
|
52
51
|
"fict": {
|
|
53
52
|
"optional": true
|
|
54
53
|
}
|
|
55
54
|
},
|
|
55
|
+
"author": "unadlib",
|
|
56
|
+
"license": "MIT",
|
|
56
57
|
"scripts": {
|
|
57
58
|
"build": "tsup",
|
|
58
59
|
"dev": "tsup --watch",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function wrapAccessor<T extends (...args: any[]) => any>(fn: T): T {
|
|
2
|
+
const wrapped = ((...args: any[]) => {
|
|
3
|
+
if (args.length === 0) return wrapped
|
|
4
|
+
return fn(...(args as Parameters<T>))
|
|
5
|
+
}) as unknown as T
|
|
6
|
+
return wrapped
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function wrapValue<T>(value: T): T {
|
|
10
|
+
const wrapped = (() => value) as unknown as T & {
|
|
11
|
+
toString?: () => string
|
|
12
|
+
valueOf?: () => T
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
16
|
+
const primitive = value
|
|
17
|
+
wrapped.toString = () => String(primitive)
|
|
18
|
+
wrapped.valueOf = () => primitive
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return wrapped as unknown as T
|
|
22
|
+
}
|
package/src/components.tsx
CHANGED
|
@@ -7,11 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
createEffect,
|
|
10
|
-
onCleanup,
|
|
11
10
|
createMemo,
|
|
12
|
-
batch,
|
|
13
|
-
untrack,
|
|
14
|
-
startTransition,
|
|
15
11
|
Fragment,
|
|
16
12
|
Suspense,
|
|
17
13
|
ErrorBoundary,
|
|
@@ -20,51 +16,28 @@ import {
|
|
|
20
16
|
} from '@fictjs/runtime'
|
|
21
17
|
import { createSignal } from '@fictjs/runtime/advanced'
|
|
22
18
|
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
RouteContext,
|
|
26
|
-
BeforeLeaveContext,
|
|
27
|
-
RouteErrorContext,
|
|
28
|
-
useRouter,
|
|
29
|
-
useRoute,
|
|
30
|
-
type BeforeLeaveContextValue,
|
|
31
|
-
} from './context'
|
|
19
|
+
import { wrapAccessor } from './accessor-utils'
|
|
20
|
+
import { RouteContext, RouteErrorContext, useRouter, useRoute, readAccessor } from './context'
|
|
32
21
|
import {
|
|
33
22
|
createBrowserHistory,
|
|
34
23
|
createHashHistory,
|
|
35
24
|
createMemoryHistory,
|
|
36
25
|
createStaticHistory,
|
|
37
26
|
} from './history'
|
|
27
|
+
import { stripBaseOrWarn } from './router-internals'
|
|
28
|
+
import { RouterProvider } from './router-provider'
|
|
38
29
|
import type {
|
|
39
|
-
History,
|
|
40
|
-
Location,
|
|
41
30
|
RouteDefinition,
|
|
31
|
+
Location,
|
|
42
32
|
RouteMatch,
|
|
43
|
-
RouterContextValue,
|
|
44
33
|
RouteContextValue,
|
|
45
|
-
NavigateFunction,
|
|
46
|
-
NavigateOptions,
|
|
47
34
|
To,
|
|
48
35
|
Params,
|
|
49
|
-
BeforeLeaveHandler,
|
|
50
|
-
BeforeLeaveEventArgs,
|
|
51
36
|
MemoryRouterOptions,
|
|
52
37
|
HashRouterOptions,
|
|
53
38
|
RouterOptions,
|
|
54
39
|
} from './types'
|
|
55
|
-
import {
|
|
56
|
-
compileRoute,
|
|
57
|
-
createBranches,
|
|
58
|
-
matchRoutes,
|
|
59
|
-
resolvePath,
|
|
60
|
-
createLocation,
|
|
61
|
-
normalizePath,
|
|
62
|
-
isBrowser,
|
|
63
|
-
stripBasePath,
|
|
64
|
-
prependBasePath,
|
|
65
|
-
locationsAreEqual,
|
|
66
|
-
} from './utils'
|
|
67
|
-
import { getScrollRestoration } from './scroll'
|
|
40
|
+
import { compileRoute, createBranches, matchRoutes, resolvePath } from './utils'
|
|
68
41
|
|
|
69
42
|
// Use Fict's signal for reactive state
|
|
70
43
|
|
|
@@ -72,317 +45,10 @@ import { getScrollRestoration } from './scroll'
|
|
|
72
45
|
// Internal State Types
|
|
73
46
|
// ============================================================================
|
|
74
47
|
|
|
75
|
-
interface
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
pendingLocation: Location | null
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const isDevEnv =
|
|
83
|
-
(typeof import.meta !== 'undefined' &&
|
|
84
|
-
(import.meta as { env?: { DEV?: boolean } }).env?.DEV === true) ||
|
|
85
|
-
(typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production')
|
|
86
|
-
|
|
87
|
-
let didWarnBaseMismatch = false
|
|
88
|
-
|
|
89
|
-
function hasBasePrefix(pathname: string, base: string): boolean {
|
|
90
|
-
if (!base) return true
|
|
91
|
-
return pathname === base || pathname.startsWith(base + '/')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function stripBaseOrWarn(pathname: string, base: string): string | null {
|
|
95
|
-
if (!base) return pathname
|
|
96
|
-
if (!hasBasePrefix(pathname, base)) {
|
|
97
|
-
if (isDevEnv && !didWarnBaseMismatch) {
|
|
98
|
-
didWarnBaseMismatch = true
|
|
99
|
-
console.warn(
|
|
100
|
-
`[fict-router] Location "${pathname}" does not start with base "${base}". No routes matched.`,
|
|
101
|
-
)
|
|
102
|
-
}
|
|
103
|
-
return null
|
|
104
|
-
}
|
|
105
|
-
return stripBasePath(pathname, base)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function stripBaseIfPresent(pathname: string, base: string): string {
|
|
109
|
-
if (!base) return pathname
|
|
110
|
-
if (hasBasePrefix(pathname, base)) {
|
|
111
|
-
return stripBasePath(pathname, base)
|
|
112
|
-
}
|
|
113
|
-
return pathname
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ============================================================================
|
|
117
|
-
// createRouter - Core router factory
|
|
118
|
-
// ============================================================================
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Create a router instance with the given history and routes
|
|
122
|
-
*/
|
|
123
|
-
function createRouterState(
|
|
124
|
-
history: History,
|
|
125
|
-
routes: RouteDefinition[],
|
|
126
|
-
base = '',
|
|
127
|
-
): {
|
|
128
|
-
state: () => RouterState
|
|
129
|
-
navigate: NavigateFunction
|
|
130
|
-
beforeLeave: BeforeLeaveContextValue
|
|
131
|
-
cleanup: () => void
|
|
132
|
-
normalizedBase: string
|
|
133
|
-
} {
|
|
134
|
-
// Normalize the base path
|
|
135
|
-
const normalizedBase = normalizePath(base)
|
|
136
|
-
const baseForStrip = normalizedBase === '/' ? '' : normalizedBase
|
|
137
|
-
|
|
138
|
-
// Compile routes into branches for efficient matching
|
|
139
|
-
const compiledRoutes = routes.map(r => compileRoute(r))
|
|
140
|
-
const branches = createBranches(compiledRoutes)
|
|
141
|
-
|
|
142
|
-
// Helper to match with base path stripped
|
|
143
|
-
const matchWithBase = (pathname: string): RouteMatch[] => {
|
|
144
|
-
const strippedPath = stripBaseOrWarn(pathname, baseForStrip)
|
|
145
|
-
if (strippedPath == null) return []
|
|
146
|
-
return matchRoutes(branches, strippedPath) || []
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Initial state
|
|
150
|
-
const initialLocation = history.location
|
|
151
|
-
const initialMatches = matchWithBase(initialLocation.pathname)
|
|
152
|
-
|
|
153
|
-
// Reactive state using signals
|
|
154
|
-
const locationSignal = createSignal<Location>(initialLocation)
|
|
155
|
-
const matchesSignal = createSignal<RouteMatch[]>(initialMatches)
|
|
156
|
-
const isRoutingSignal = createSignal<boolean>(false)
|
|
157
|
-
const pendingLocationSignal = createSignal<Location | null>(null)
|
|
158
|
-
|
|
159
|
-
// BeforeLeave handlers and navigation token for async ordering
|
|
160
|
-
const beforeLeaveHandlers = new Set<BeforeLeaveHandler>()
|
|
161
|
-
let navigationToken = 0
|
|
162
|
-
|
|
163
|
-
const beforeLeave: BeforeLeaveContextValue = {
|
|
164
|
-
addHandler(handler: BeforeLeaveHandler) {
|
|
165
|
-
beforeLeaveHandlers.add(handler)
|
|
166
|
-
return () => beforeLeaveHandlers.delete(handler)
|
|
167
|
-
},
|
|
168
|
-
async confirm(to: Location, from: Location): Promise<boolean> {
|
|
169
|
-
if (beforeLeaveHandlers.size === 0) return true
|
|
170
|
-
|
|
171
|
-
// Capture current token for this navigation
|
|
172
|
-
const currentToken = ++navigationToken
|
|
173
|
-
|
|
174
|
-
let defaultPrevented = false
|
|
175
|
-
let retryRequested = false
|
|
176
|
-
let forceRetry = false
|
|
177
|
-
|
|
178
|
-
const event: BeforeLeaveEventArgs = {
|
|
179
|
-
to,
|
|
180
|
-
from,
|
|
181
|
-
get defaultPrevented() {
|
|
182
|
-
return defaultPrevented
|
|
183
|
-
},
|
|
184
|
-
preventDefault() {
|
|
185
|
-
defaultPrevented = true
|
|
186
|
-
},
|
|
187
|
-
retry(force?: boolean) {
|
|
188
|
-
retryRequested = true
|
|
189
|
-
forceRetry = force ?? false
|
|
190
|
-
},
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
for (const handler of beforeLeaveHandlers) {
|
|
194
|
-
await handler(event)
|
|
195
|
-
|
|
196
|
-
// Check if this navigation is still current (not superseded by newer navigation)
|
|
197
|
-
if (currentToken !== navigationToken) {
|
|
198
|
-
// This navigation was superseded, ignore its result
|
|
199
|
-
return false
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (defaultPrevented && !retryRequested) {
|
|
203
|
-
return false
|
|
204
|
-
}
|
|
205
|
-
if (retryRequested && forceRetry) {
|
|
206
|
-
return true
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Final check that this navigation is still current
|
|
211
|
-
if (currentToken !== navigationToken) {
|
|
212
|
-
return false
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return !defaultPrevented || retryRequested
|
|
216
|
-
},
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Navigation function
|
|
220
|
-
const navigate: NavigateFunction = (toOrDelta: To | number, options?: NavigateOptions) => {
|
|
221
|
-
if (typeof toOrDelta === 'number') {
|
|
222
|
-
history.go(toOrDelta)
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const currentLocation = locationSignal()
|
|
227
|
-
const to = toOrDelta
|
|
228
|
-
|
|
229
|
-
// Extract pathname, search, and hash from string without normalizing pathname
|
|
230
|
-
// This preserves relative paths like 'settings' vs '/settings'
|
|
231
|
-
let toPathname: string
|
|
232
|
-
let toSearch = ''
|
|
233
|
-
let toHash = ''
|
|
234
|
-
|
|
235
|
-
if (typeof to === 'string') {
|
|
236
|
-
// Extract hash first
|
|
237
|
-
let remaining = to
|
|
238
|
-
const hashIndex = remaining.indexOf('#')
|
|
239
|
-
if (hashIndex >= 0) {
|
|
240
|
-
toHash = remaining.slice(hashIndex)
|
|
241
|
-
remaining = remaining.slice(0, hashIndex)
|
|
242
|
-
}
|
|
243
|
-
// Extract search
|
|
244
|
-
const searchIndex = remaining.indexOf('?')
|
|
245
|
-
if (searchIndex >= 0) {
|
|
246
|
-
toSearch = remaining.slice(searchIndex)
|
|
247
|
-
remaining = remaining.slice(0, searchIndex)
|
|
248
|
-
}
|
|
249
|
-
// Remaining is the pathname (keep empty string for search/hash-only navigation)
|
|
250
|
-
toPathname = remaining
|
|
251
|
-
} else {
|
|
252
|
-
toPathname = to.pathname || ''
|
|
253
|
-
toSearch = to.search || ''
|
|
254
|
-
toHash = to.hash || ''
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Resolve the target path (relative to current path, without base)
|
|
258
|
-
let targetPath: string
|
|
259
|
-
const currentPathWithoutBase = stripBaseOrWarn(currentLocation.pathname, baseForStrip) || '/'
|
|
260
|
-
|
|
261
|
-
if (typeof to === 'string') {
|
|
262
|
-
// Empty pathname means search/hash-only navigation - keep current path
|
|
263
|
-
if (toPathname === '') {
|
|
264
|
-
targetPath = currentPathWithoutBase
|
|
265
|
-
} else if (options?.relative === 'route') {
|
|
266
|
-
// Resolve relative to current route
|
|
267
|
-
const matches = matchesSignal()
|
|
268
|
-
const currentMatch = matches[matches.length - 1]
|
|
269
|
-
const currentRoutePath = currentMatch?.pathname || currentPathWithoutBase
|
|
270
|
-
targetPath = resolvePath(currentRoutePath, toPathname)
|
|
271
|
-
} else {
|
|
272
|
-
// Resolve relative to current pathname
|
|
273
|
-
// Only strip base if it's an absolute path
|
|
274
|
-
targetPath = toPathname.startsWith('/')
|
|
275
|
-
? stripBaseIfPresent(toPathname, baseForStrip)
|
|
276
|
-
: resolvePath(currentPathWithoutBase, toPathname)
|
|
277
|
-
}
|
|
278
|
-
} else {
|
|
279
|
-
const rawTargetPath = toPathname || currentPathWithoutBase
|
|
280
|
-
targetPath = stripBaseIfPresent(rawTargetPath, baseForStrip)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Create the full target location, preserving to.state and to.key
|
|
284
|
-
// options.state overrides to.state if provided
|
|
285
|
-
const toState = typeof to === 'object' ? to.state : undefined
|
|
286
|
-
const toKey = typeof to === 'object' ? to.key : undefined
|
|
287
|
-
const finalState = options?.state !== undefined ? options.state : toState
|
|
288
|
-
|
|
289
|
-
// Build location object, only including key if defined
|
|
290
|
-
const targetPathWithBase = prependBasePath(targetPath, baseForStrip)
|
|
291
|
-
const locationSpec: Partial<Location> = {
|
|
292
|
-
pathname: targetPathWithBase,
|
|
293
|
-
search: toSearch,
|
|
294
|
-
hash: toHash,
|
|
295
|
-
}
|
|
296
|
-
if (finalState !== undefined) {
|
|
297
|
-
locationSpec.state = finalState
|
|
298
|
-
}
|
|
299
|
-
if (toKey !== undefined) {
|
|
300
|
-
locationSpec.key = toKey
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const targetLocation = createLocation(locationSpec, finalState, toKey)
|
|
304
|
-
|
|
305
|
-
// Check beforeLeave handlers
|
|
306
|
-
untrack(async () => {
|
|
307
|
-
const canNavigate = await beforeLeave.confirm(targetLocation, currentLocation)
|
|
308
|
-
if (!canNavigate) {
|
|
309
|
-
pendingLocationSignal(null)
|
|
310
|
-
return
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Start routing indicator and set pending location
|
|
314
|
-
batch(() => {
|
|
315
|
-
isRoutingSignal(true)
|
|
316
|
-
pendingLocationSignal(targetLocation)
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
// Use transition for smooth updates
|
|
320
|
-
// Note: We only push/replace to history here.
|
|
321
|
-
// The actual signal updates happen in history.listen to avoid duplicates.
|
|
322
|
-
startTransition(() => {
|
|
323
|
-
const prevLocation = history.location
|
|
324
|
-
if (options?.replace) {
|
|
325
|
-
history.replace(targetLocation, finalState)
|
|
326
|
-
} else {
|
|
327
|
-
history.push(targetLocation, finalState)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Scroll handling for programmatic navigation
|
|
331
|
-
if (options?.scroll !== false && isBrowser()) {
|
|
332
|
-
const scrollRestoration = getScrollRestoration()
|
|
333
|
-
scrollRestoration.handleNavigation(
|
|
334
|
-
prevLocation,
|
|
335
|
-
history.location,
|
|
336
|
-
options?.replace ? 'REPLACE' : 'PUSH',
|
|
337
|
-
)
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// If navigation was blocked or no-op, reset routing state
|
|
341
|
-
if (locationsAreEqual(prevLocation, history.location)) {
|
|
342
|
-
batch(() => {
|
|
343
|
-
isRoutingSignal(false)
|
|
344
|
-
pendingLocationSignal(null)
|
|
345
|
-
})
|
|
346
|
-
}
|
|
347
|
-
})
|
|
348
|
-
})
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Listen for history changes (browser back/forward AND navigate calls)
|
|
352
|
-
// This is the single source of truth for location/matches updates
|
|
353
|
-
const unlisten = history.listen(({ action, location: newLocation }) => {
|
|
354
|
-
const prevLocation = locationSignal()
|
|
355
|
-
|
|
356
|
-
batch(() => {
|
|
357
|
-
locationSignal(newLocation)
|
|
358
|
-
const newMatches = matchWithBase(newLocation.pathname)
|
|
359
|
-
matchesSignal(newMatches)
|
|
360
|
-
isRoutingSignal(false)
|
|
361
|
-
pendingLocationSignal(null)
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
// Handle scroll restoration for POP navigation (back/forward)
|
|
365
|
-
if (action === 'POP' && isBrowser()) {
|
|
366
|
-
const scrollRestoration = getScrollRestoration()
|
|
367
|
-
scrollRestoration.handleNavigation(prevLocation, newLocation, 'POP')
|
|
368
|
-
}
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
// State accessor
|
|
372
|
-
const state = () => ({
|
|
373
|
-
location: locationSignal(),
|
|
374
|
-
matches: matchesSignal(),
|
|
375
|
-
isRouting: isRoutingSignal(),
|
|
376
|
-
pendingLocation: pendingLocationSignal(),
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
return {
|
|
380
|
-
state,
|
|
381
|
-
navigate,
|
|
382
|
-
beforeLeave,
|
|
383
|
-
cleanup: unlisten,
|
|
384
|
-
normalizedBase: baseForStrip,
|
|
385
|
-
}
|
|
48
|
+
interface RouteDataState<T = unknown> {
|
|
49
|
+
data: T | undefined
|
|
50
|
+
error: unknown
|
|
51
|
+
loading: boolean
|
|
386
52
|
}
|
|
387
53
|
|
|
388
54
|
// ============================================================================
|
|
@@ -401,60 +67,6 @@ interface StaticRouterProps extends BaseRouterProps {
|
|
|
401
67
|
url: string
|
|
402
68
|
}
|
|
403
69
|
|
|
404
|
-
/**
|
|
405
|
-
* Internal router component that sets up the context
|
|
406
|
-
*/
|
|
407
|
-
function RouterProvider(props: {
|
|
408
|
-
history: History
|
|
409
|
-
routes: RouteDefinition[]
|
|
410
|
-
base?: string | undefined
|
|
411
|
-
children?: FictNode
|
|
412
|
-
}) {
|
|
413
|
-
const { state, navigate, beforeLeave, cleanup, normalizedBase } = createRouterState(
|
|
414
|
-
props.history,
|
|
415
|
-
props.routes,
|
|
416
|
-
props.base,
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
onCleanup(cleanup)
|
|
420
|
-
|
|
421
|
-
const routerContext: RouterContextValue = {
|
|
422
|
-
location: () => state().location,
|
|
423
|
-
params: () => {
|
|
424
|
-
const matches = state().matches
|
|
425
|
-
// Use Record<string, string | undefined> for type precision
|
|
426
|
-
const allParams: Record<string, string | undefined> = {}
|
|
427
|
-
for (const match of matches) {
|
|
428
|
-
Object.assign(allParams, match.params)
|
|
429
|
-
}
|
|
430
|
-
return allParams as Params
|
|
431
|
-
},
|
|
432
|
-
matches: () => state().matches,
|
|
433
|
-
navigate,
|
|
434
|
-
isRouting: () => state().isRouting,
|
|
435
|
-
pendingLocation: () => state().pendingLocation,
|
|
436
|
-
base: normalizedBase,
|
|
437
|
-
resolvePath: (to: To) => {
|
|
438
|
-
// Resolve path relative to current location (without base)
|
|
439
|
-
const location = state().location
|
|
440
|
-
const currentPathWithoutBase = stripBaseOrWarn(location.pathname, normalizedBase) || '/'
|
|
441
|
-
const rawTargetPath = typeof to === 'string' ? to : to.pathname || '/'
|
|
442
|
-
const targetPath = rawTargetPath.startsWith('/')
|
|
443
|
-
? stripBaseIfPresent(rawTargetPath, normalizedBase)
|
|
444
|
-
: rawTargetPath
|
|
445
|
-
return resolvePath(currentPathWithoutBase, targetPath)
|
|
446
|
-
},
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return (
|
|
450
|
-
<RouterContext.Provider value={routerContext}>
|
|
451
|
-
<BeforeLeaveContext.Provider value={beforeLeave}>
|
|
452
|
-
{props.children}
|
|
453
|
-
</BeforeLeaveContext.Provider>
|
|
454
|
-
</RouterContext.Provider>
|
|
455
|
-
)
|
|
456
|
-
}
|
|
457
|
-
|
|
458
70
|
/**
|
|
459
71
|
* Browser Router - uses the History API
|
|
460
72
|
*/
|
|
@@ -545,9 +157,11 @@ export function Routes(props: RoutesProps) {
|
|
|
545
157
|
|
|
546
158
|
// Create reactive memo for current matches
|
|
547
159
|
const currentMatches = createMemo(() => {
|
|
548
|
-
const
|
|
549
|
-
const
|
|
550
|
-
const
|
|
160
|
+
const pendingLocation = readAccessor(router.pendingLocation)
|
|
161
|
+
const location = pendingLocation ?? readAccessor(router.location)
|
|
162
|
+
const parentMatch = readAccessor(parentRoute.match)
|
|
163
|
+
const base = readAccessor(router.base)
|
|
164
|
+
const locationPath = stripBaseOrWarn(location.pathname, base)
|
|
551
165
|
if (locationPath == null) return []
|
|
552
166
|
|
|
553
167
|
// Calculate the remaining path after parent route
|
|
@@ -565,26 +179,15 @@ export function Routes(props: RoutesProps) {
|
|
|
565
179
|
})
|
|
566
180
|
|
|
567
181
|
// Render the matched routes
|
|
568
|
-
|
|
182
|
+
const matches = currentMatches()
|
|
183
|
+
return <>{matches.length > 0 ? renderMatches(matches, 0) : null}</>
|
|
569
184
|
}
|
|
570
185
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
interface RouteDataState<T = unknown> {
|
|
575
|
-
data: T | undefined
|
|
576
|
-
error: unknown
|
|
577
|
-
loading: boolean
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* Render route matches recursively with data loading support
|
|
582
|
-
*/
|
|
583
|
-
function renderMatches(matches: RouteMatch[], index: number): FictNode {
|
|
584
|
-
if (index >= matches.length) {
|
|
585
|
-
return null
|
|
586
|
-
}
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// Route Component
|
|
188
|
+
// ============================================================================
|
|
587
189
|
|
|
190
|
+
export function renderMatches(matches: RouteMatch[], index: number): FictNode {
|
|
588
191
|
const match = matches[index]!
|
|
589
192
|
const route = match.route
|
|
590
193
|
const router = useRouter()
|
|
@@ -603,7 +206,7 @@ function renderMatches(matches: RouteMatch[], index: number): FictNode {
|
|
|
603
206
|
if (route.preload) {
|
|
604
207
|
// Trigger preload on initial render and when location changes
|
|
605
208
|
createEffect(() => {
|
|
606
|
-
const location = router.location
|
|
209
|
+
const location = readAccessor(router.location)
|
|
607
210
|
const preloadArgs = {
|
|
608
211
|
params: match.params,
|
|
609
212
|
location,
|
|
@@ -631,18 +234,7 @@ function renderMatches(matches: RouteMatch[], index: number): FictNode {
|
|
|
631
234
|
})
|
|
632
235
|
}
|
|
633
236
|
|
|
634
|
-
|
|
635
|
-
const routeContext: RouteContextValue = {
|
|
636
|
-
match: () => match,
|
|
637
|
-
data: () => dataState().data,
|
|
638
|
-
error: () => dataState().error,
|
|
639
|
-
outlet: () => renderMatches(matches, index + 1),
|
|
640
|
-
resolvePath: (to: To) => {
|
|
641
|
-
const basePath = match.pathname
|
|
642
|
-
const targetPath = typeof to === 'string' ? to : to.pathname || '/'
|
|
643
|
-
return resolvePath(basePath, targetPath)
|
|
644
|
-
},
|
|
645
|
-
}
|
|
237
|
+
const outletNode = <Outlet />
|
|
646
238
|
|
|
647
239
|
// Determine what to render
|
|
648
240
|
const renderContent = (): FictNode => {
|
|
@@ -660,30 +252,48 @@ function renderMatches(matches: RouteMatch[], index: number): FictNode {
|
|
|
660
252
|
|
|
661
253
|
// Render the normal content
|
|
662
254
|
if (route.component) {
|
|
663
|
-
const Component = route.component
|
|
255
|
+
const Component = route.component as Component<{
|
|
256
|
+
params: Params
|
|
257
|
+
location: Location
|
|
258
|
+
data: unknown
|
|
259
|
+
children?: FictNode
|
|
260
|
+
}>
|
|
664
261
|
return (
|
|
665
|
-
<Component params={match.params} location={router.location
|
|
666
|
-
|
|
262
|
+
<Component params={match.params} location={readAccessor(router.location)} data={state.data}>
|
|
263
|
+
{outletNode}
|
|
667
264
|
</Component>
|
|
668
265
|
)
|
|
669
|
-
}
|
|
266
|
+
}
|
|
267
|
+
if (route.element) {
|
|
670
268
|
return route.element
|
|
671
|
-
}
|
|
269
|
+
}
|
|
270
|
+
if (route.children) {
|
|
672
271
|
// Layout route without component - just render outlet
|
|
673
|
-
return
|
|
272
|
+
return outletNode
|
|
674
273
|
}
|
|
675
274
|
|
|
676
275
|
return null
|
|
677
276
|
}
|
|
678
277
|
|
|
278
|
+
// Create route context for this level
|
|
279
|
+
const routeContext: RouteContextValue = {
|
|
280
|
+
match: () => match,
|
|
281
|
+
data: () => dataState().data,
|
|
282
|
+
error: () => dataState().error,
|
|
283
|
+
outlet: () => (index + 1 < matches.length ? renderMatches(matches, index + 1) : null),
|
|
284
|
+
resolvePath: wrapAccessor((to: To) => {
|
|
285
|
+
const basePath = match.pathname
|
|
286
|
+
const targetPath = typeof to === 'string' ? to : to.pathname || '/'
|
|
287
|
+
return resolvePath(basePath, targetPath)
|
|
288
|
+
}),
|
|
289
|
+
}
|
|
290
|
+
|
|
679
291
|
// Build the route content with context provider
|
|
680
292
|
let content: FictNode = (
|
|
681
293
|
<RouteContext.Provider value={routeContext}>{renderContent()}</RouteContext.Provider>
|
|
682
294
|
)
|
|
683
295
|
|
|
684
296
|
// Always wrap with ErrorBoundary if errorElement is defined
|
|
685
|
-
// This catches both preload errors (handled in renderContent) AND render errors from components
|
|
686
|
-
// Use a function fallback to pass the error via RouteErrorContext for useRouteError()
|
|
687
297
|
if (route.errorElement) {
|
|
688
298
|
content = (
|
|
689
299
|
<ErrorBoundary
|
|
@@ -706,10 +316,6 @@ function renderMatches(matches: RouteMatch[], index: number): FictNode {
|
|
|
706
316
|
return content
|
|
707
317
|
}
|
|
708
318
|
|
|
709
|
-
// ============================================================================
|
|
710
|
-
// Route Component
|
|
711
|
-
// ============================================================================
|
|
712
|
-
|
|
713
319
|
interface RouteJSXProps {
|
|
714
320
|
path?: string | undefined
|
|
715
321
|
component?: Component<any> | undefined
|
|
@@ -738,16 +344,9 @@ export function Route(_props: RouteJSXProps): FictNode {
|
|
|
738
344
|
return null
|
|
739
345
|
}
|
|
740
346
|
|
|
741
|
-
// ============================================================================
|
|
742
|
-
// Outlet Component
|
|
743
|
-
// ============================================================================
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* Outlet component - renders the child route
|
|
747
|
-
*/
|
|
748
347
|
export function Outlet(): FictNode {
|
|
749
348
|
const route = useRoute()
|
|
750
|
-
return
|
|
349
|
+
return readAccessor(route.outlet)
|
|
751
350
|
}
|
|
752
351
|
|
|
753
352
|
// ============================================================================
|