@fictjs/router 0.2.2 → 0.3.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 +2373 -3
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1136 -2
- package/dist/index.d.ts +1136 -2
- package/dist/index.js +2305 -3
- package/dist/index.js.map +1 -0
- package/package.json +33 -7
- package/src/components.tsx +926 -0
- package/src/context.ts +404 -0
- package/src/data.ts +545 -0
- package/src/history.ts +659 -0
- package/src/index.ts +217 -0
- package/src/lazy.tsx +242 -0
- package/src/link.tsx +601 -0
- package/src/scroll.ts +245 -0
- package/src/types.ts +447 -0
- package/src/utils.ts +570 -0
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Router components for @fictjs/router
|
|
3
|
+
*
|
|
4
|
+
* This module provides the main Router, Routes, Route, and Outlet components.
|
|
5
|
+
* These integrate with Fict's reactive system for fine-grained updates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createEffect,
|
|
10
|
+
onCleanup,
|
|
11
|
+
createMemo,
|
|
12
|
+
batch,
|
|
13
|
+
untrack,
|
|
14
|
+
startTransition,
|
|
15
|
+
Fragment,
|
|
16
|
+
Suspense,
|
|
17
|
+
ErrorBoundary,
|
|
18
|
+
type FictNode,
|
|
19
|
+
type Component,
|
|
20
|
+
} from '@fictjs/runtime'
|
|
21
|
+
import { createSignal } from '@fictjs/runtime/advanced'
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
RouterContext,
|
|
25
|
+
RouteContext,
|
|
26
|
+
BeforeLeaveContext,
|
|
27
|
+
RouteErrorContext,
|
|
28
|
+
useRouter,
|
|
29
|
+
useRoute,
|
|
30
|
+
type BeforeLeaveContextValue,
|
|
31
|
+
} from './context'
|
|
32
|
+
import {
|
|
33
|
+
createBrowserHistory,
|
|
34
|
+
createHashHistory,
|
|
35
|
+
createMemoryHistory,
|
|
36
|
+
createStaticHistory,
|
|
37
|
+
} from './history'
|
|
38
|
+
import type {
|
|
39
|
+
History,
|
|
40
|
+
Location,
|
|
41
|
+
RouteDefinition,
|
|
42
|
+
RouteMatch,
|
|
43
|
+
RouterContextValue,
|
|
44
|
+
RouteContextValue,
|
|
45
|
+
NavigateFunction,
|
|
46
|
+
NavigateOptions,
|
|
47
|
+
To,
|
|
48
|
+
Params,
|
|
49
|
+
BeforeLeaveHandler,
|
|
50
|
+
BeforeLeaveEventArgs,
|
|
51
|
+
MemoryRouterOptions,
|
|
52
|
+
HashRouterOptions,
|
|
53
|
+
RouterOptions,
|
|
54
|
+
} 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'
|
|
68
|
+
|
|
69
|
+
// Use Fict's signal for reactive state
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Internal State Types
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
interface RouterState {
|
|
76
|
+
location: Location
|
|
77
|
+
matches: RouteMatch[]
|
|
78
|
+
isRouting: boolean
|
|
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
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// Router Component
|
|
390
|
+
// ============================================================================
|
|
391
|
+
|
|
392
|
+
interface BaseRouterProps {
|
|
393
|
+
children?: FictNode
|
|
394
|
+
base?: string
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface BrowserRouterProps extends BaseRouterProps, RouterOptions {}
|
|
398
|
+
interface HashRouterProps extends BaseRouterProps, HashRouterOptions {}
|
|
399
|
+
interface MemoryRouterProps extends BaseRouterProps, MemoryRouterOptions {}
|
|
400
|
+
interface StaticRouterProps extends BaseRouterProps {
|
|
401
|
+
url: string
|
|
402
|
+
}
|
|
403
|
+
|
|
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
|
+
/**
|
|
459
|
+
* Browser Router - uses the History API
|
|
460
|
+
*/
|
|
461
|
+
export function Router(props: BrowserRouterProps & { children?: FictNode }) {
|
|
462
|
+
const history = props.history || createBrowserHistory()
|
|
463
|
+
const routes = extractRoutes(props.children)
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<RouterProvider history={history} routes={routes} base={props.base}>
|
|
467
|
+
<Routes>{props.children}</Routes>
|
|
468
|
+
</RouterProvider>
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Hash Router - uses the URL hash
|
|
474
|
+
*/
|
|
475
|
+
export function HashRouter(props: HashRouterProps & { children?: FictNode }) {
|
|
476
|
+
const hashOptions = props.hashType ? { hashType: props.hashType } : undefined
|
|
477
|
+
const history = createHashHistory(hashOptions)
|
|
478
|
+
const routes = extractRoutes(props.children)
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<RouterProvider history={history} routes={routes} base={props.base}>
|
|
482
|
+
<Routes>{props.children}</Routes>
|
|
483
|
+
</RouterProvider>
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Memory Router - keeps history in memory (for testing/SSR)
|
|
489
|
+
*/
|
|
490
|
+
export function MemoryRouter(props: MemoryRouterProps & { children?: FictNode }) {
|
|
491
|
+
const memoryOptions: { initialEntries?: string[]; initialIndex?: number } = {}
|
|
492
|
+
if (props.initialEntries !== undefined) {
|
|
493
|
+
memoryOptions.initialEntries = props.initialEntries
|
|
494
|
+
}
|
|
495
|
+
if (props.initialIndex !== undefined) {
|
|
496
|
+
memoryOptions.initialIndex = props.initialIndex
|
|
497
|
+
}
|
|
498
|
+
const history = createMemoryHistory(
|
|
499
|
+
Object.keys(memoryOptions).length > 0 ? memoryOptions : undefined,
|
|
500
|
+
)
|
|
501
|
+
const routes = extractRoutes(props.children)
|
|
502
|
+
|
|
503
|
+
return (
|
|
504
|
+
<RouterProvider history={history} routes={routes} base={props.base}>
|
|
505
|
+
<Routes>{props.children}</Routes>
|
|
506
|
+
</RouterProvider>
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Static Router - for server-side rendering
|
|
512
|
+
*/
|
|
513
|
+
export function StaticRouter(props: StaticRouterProps & { children?: FictNode }) {
|
|
514
|
+
const history = createStaticHistory(props.url)
|
|
515
|
+
const routes = extractRoutes(props.children)
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<RouterProvider history={history} routes={routes} base={props.base}>
|
|
519
|
+
<Routes>{props.children}</Routes>
|
|
520
|
+
</RouterProvider>
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ============================================================================
|
|
525
|
+
// Routes Component
|
|
526
|
+
// ============================================================================
|
|
527
|
+
|
|
528
|
+
interface RoutesProps {
|
|
529
|
+
children?: FictNode
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Routes component - renders the matched route
|
|
534
|
+
*/
|
|
535
|
+
export function Routes(props: RoutesProps) {
|
|
536
|
+
const router = useRouter()
|
|
537
|
+
const parentRoute = useRoute()
|
|
538
|
+
|
|
539
|
+
// Get routes from children
|
|
540
|
+
const routes = extractRoutes(props.children)
|
|
541
|
+
|
|
542
|
+
// Compile routes for matching
|
|
543
|
+
const compiledRoutes = routes.map(r => compileRoute(r))
|
|
544
|
+
const branches = createBranches(compiledRoutes)
|
|
545
|
+
|
|
546
|
+
// Create reactive memo for current matches
|
|
547
|
+
const currentMatches = createMemo(() => {
|
|
548
|
+
const location = router.location()
|
|
549
|
+
const parentMatch = parentRoute.match()
|
|
550
|
+
const locationPath = stripBaseOrWarn(location.pathname, router.base)
|
|
551
|
+
if (locationPath == null) return []
|
|
552
|
+
|
|
553
|
+
// Calculate the remaining path after parent route
|
|
554
|
+
let basePath = '/'
|
|
555
|
+
if (parentMatch) {
|
|
556
|
+
basePath = parentMatch.pathname
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Get path relative to parent
|
|
560
|
+
const relativePath = locationPath.startsWith(basePath)
|
|
561
|
+
? locationPath.slice(basePath.length) || '/'
|
|
562
|
+
: locationPath
|
|
563
|
+
|
|
564
|
+
return matchRoutes(branches, relativePath) || []
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// Render the matched routes
|
|
568
|
+
return <>{renderMatches(currentMatches(), 0)}</>
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Route data state for preloading
|
|
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
|
+
}
|
|
587
|
+
|
|
588
|
+
const match = matches[index]!
|
|
589
|
+
const route = match.route
|
|
590
|
+
const router = useRouter()
|
|
591
|
+
|
|
592
|
+
// Create signals for route data
|
|
593
|
+
const dataState = createSignal<RouteDataState>({
|
|
594
|
+
data: undefined,
|
|
595
|
+
error: undefined,
|
|
596
|
+
loading: !!route.preload,
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
// Token to prevent stale preload results from overwriting newer ones
|
|
600
|
+
let preloadToken = 0
|
|
601
|
+
|
|
602
|
+
// Load data if preload is defined
|
|
603
|
+
if (route.preload) {
|
|
604
|
+
// Trigger preload on initial render and when location changes
|
|
605
|
+
createEffect(() => {
|
|
606
|
+
const location = router.location()
|
|
607
|
+
const preloadArgs = {
|
|
608
|
+
params: match.params,
|
|
609
|
+
location,
|
|
610
|
+
intent: 'navigate' as const,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Increment token to invalidate any pending preloads
|
|
614
|
+
const currentToken = ++preloadToken
|
|
615
|
+
|
|
616
|
+
dataState({ data: undefined, error: undefined, loading: true })
|
|
617
|
+
|
|
618
|
+
Promise.resolve(route.preload!(preloadArgs))
|
|
619
|
+
.then(result => {
|
|
620
|
+
// Only apply result if this preload is still current
|
|
621
|
+
if (currentToken === preloadToken) {
|
|
622
|
+
dataState({ data: result, error: undefined, loading: false })
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
.catch(error => {
|
|
626
|
+
// Only apply error if this preload is still current
|
|
627
|
+
if (currentToken === preloadToken) {
|
|
628
|
+
dataState({ data: undefined, error, loading: false })
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Create route context for this level
|
|
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
|
+
}
|
|
646
|
+
|
|
647
|
+
// Determine what to render
|
|
648
|
+
const renderContent = (): FictNode => {
|
|
649
|
+
const state = dataState()
|
|
650
|
+
|
|
651
|
+
// If there's an error and an errorElement, render it
|
|
652
|
+
if (state.error !== undefined && route.errorElement) {
|
|
653
|
+
return route.errorElement
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// If loading and there's a loadingElement, render it
|
|
657
|
+
if (state.loading && route.loadingElement) {
|
|
658
|
+
return route.loadingElement
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Render the normal content
|
|
662
|
+
if (route.component) {
|
|
663
|
+
const Component = route.component
|
|
664
|
+
return (
|
|
665
|
+
<Component params={match.params} location={router.location()} data={state.data}>
|
|
666
|
+
<Outlet />
|
|
667
|
+
</Component>
|
|
668
|
+
)
|
|
669
|
+
} else if (route.element) {
|
|
670
|
+
return route.element
|
|
671
|
+
} else if (route.children) {
|
|
672
|
+
// Layout route without component - just render outlet
|
|
673
|
+
return <Outlet />
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return null
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Build the route content with context provider
|
|
680
|
+
let content: FictNode = (
|
|
681
|
+
<RouteContext.Provider value={routeContext}>{renderContent()}</RouteContext.Provider>
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
// 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
|
+
if (route.errorElement) {
|
|
688
|
+
content = (
|
|
689
|
+
<ErrorBoundary
|
|
690
|
+
fallback={(err: unknown, reset?: () => void) => (
|
|
691
|
+
<RouteErrorContext.Provider value={{ error: err, reset }}>
|
|
692
|
+
{route.errorElement}
|
|
693
|
+
</RouteErrorContext.Provider>
|
|
694
|
+
)}
|
|
695
|
+
>
|
|
696
|
+
{content}
|
|
697
|
+
</ErrorBoundary>
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// If route has loadingElement and component uses Suspense internally
|
|
702
|
+
if (route.loadingElement) {
|
|
703
|
+
content = <Suspense fallback={route.loadingElement}>{content}</Suspense>
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return content
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ============================================================================
|
|
710
|
+
// Route Component
|
|
711
|
+
// ============================================================================
|
|
712
|
+
|
|
713
|
+
interface RouteJSXProps {
|
|
714
|
+
path?: string | undefined
|
|
715
|
+
component?: Component<any> | undefined
|
|
716
|
+
element?: FictNode
|
|
717
|
+
children?: FictNode
|
|
718
|
+
index?: boolean | undefined
|
|
719
|
+
key?: string | undefined
|
|
720
|
+
preload?:
|
|
721
|
+
| ((args: {
|
|
722
|
+
params: Params
|
|
723
|
+
location: Location
|
|
724
|
+
intent: 'initial' | 'navigate' | 'native' | 'preload'
|
|
725
|
+
}) => unknown | Promise<unknown>)
|
|
726
|
+
| undefined
|
|
727
|
+
errorElement?: FictNode
|
|
728
|
+
loadingElement?: FictNode
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Route component - defines a route
|
|
733
|
+
* This is a configuration component, it doesn't render anything directly.
|
|
734
|
+
*/
|
|
735
|
+
export function Route(_props: RouteJSXProps): FictNode {
|
|
736
|
+
// Route components are declarative - they're processed by Routes/extractRoutes
|
|
737
|
+
// They don't render anything themselves
|
|
738
|
+
return null
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ============================================================================
|
|
742
|
+
// Outlet Component
|
|
743
|
+
// ============================================================================
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Outlet component - renders the child route
|
|
747
|
+
*/
|
|
748
|
+
export function Outlet(): FictNode {
|
|
749
|
+
const route = useRoute()
|
|
750
|
+
return <>{route.outlet()}</>
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ============================================================================
|
|
754
|
+
// Navigate Component
|
|
755
|
+
// ============================================================================
|
|
756
|
+
|
|
757
|
+
interface NavigateComponentProps {
|
|
758
|
+
to: To
|
|
759
|
+
replace?: boolean
|
|
760
|
+
state?: unknown
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Navigate component - declarative navigation
|
|
765
|
+
* Navigates immediately when rendered.
|
|
766
|
+
*/
|
|
767
|
+
export function Navigate(props: NavigateComponentProps): FictNode {
|
|
768
|
+
const router = useRouter()
|
|
769
|
+
|
|
770
|
+
// Navigate on mount
|
|
771
|
+
createEffect(() => {
|
|
772
|
+
router.navigate(props.to, {
|
|
773
|
+
replace: props.replace ?? true,
|
|
774
|
+
state: props.state,
|
|
775
|
+
})
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
return null
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ============================================================================
|
|
782
|
+
// Redirect Component
|
|
783
|
+
// ============================================================================
|
|
784
|
+
|
|
785
|
+
interface RedirectProps {
|
|
786
|
+
/** Target path to redirect to */
|
|
787
|
+
to: To
|
|
788
|
+
/** Path pattern that triggers this redirect (optional, for declarative redirects) */
|
|
789
|
+
from?: string
|
|
790
|
+
/** State to pass with the redirect */
|
|
791
|
+
state?: unknown
|
|
792
|
+
/** Whether to replace or push to history (default: true) */
|
|
793
|
+
push?: boolean
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Redirect component - declarative redirect
|
|
798
|
+
*
|
|
799
|
+
* Unlike Navigate, Redirect is specifically for redirect scenarios:
|
|
800
|
+
* - Always replaces by default (unless push=true)
|
|
801
|
+
* - Can be used in route definitions with a `from` pattern
|
|
802
|
+
* - Semantically indicates a redirect rather than navigation
|
|
803
|
+
*
|
|
804
|
+
* @example
|
|
805
|
+
* ```tsx
|
|
806
|
+
* // Basic redirect (replaces current entry)
|
|
807
|
+
* <Redirect to="/login" />
|
|
808
|
+
*
|
|
809
|
+
* // Redirect with state
|
|
810
|
+
* <Redirect to="/login" state={{ from: location.pathname }} />
|
|
811
|
+
*
|
|
812
|
+
* // Push instead of replace
|
|
813
|
+
* <Redirect to="/new-page" push />
|
|
814
|
+
*
|
|
815
|
+
* // In route definitions (redirect old paths)
|
|
816
|
+
* <Route path="/old-path" element={<Redirect to="/new-path" />} />
|
|
817
|
+
* ```
|
|
818
|
+
*/
|
|
819
|
+
export function Redirect(props: RedirectProps): FictNode {
|
|
820
|
+
const router = useRouter()
|
|
821
|
+
|
|
822
|
+
// Redirect on mount
|
|
823
|
+
createEffect(() => {
|
|
824
|
+
router.navigate(props.to, {
|
|
825
|
+
replace: props.push !== true, // Replace by default, push only if explicitly requested
|
|
826
|
+
state: props.state,
|
|
827
|
+
})
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
return null
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ============================================================================
|
|
834
|
+
// Utility Functions
|
|
835
|
+
// ============================================================================
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Extract route definitions from JSX children
|
|
839
|
+
*/
|
|
840
|
+
function extractRoutes(children: FictNode): RouteDefinition[] {
|
|
841
|
+
const routes: RouteDefinition[] = []
|
|
842
|
+
|
|
843
|
+
if (children == null) return routes
|
|
844
|
+
|
|
845
|
+
const childArray = Array.isArray(children) ? children : [children]
|
|
846
|
+
|
|
847
|
+
for (const child of childArray) {
|
|
848
|
+
if (child == null || typeof child !== 'object') continue
|
|
849
|
+
|
|
850
|
+
// Check if it's a Route element
|
|
851
|
+
const vnode = child as { type?: unknown; props?: Record<string, unknown> }
|
|
852
|
+
|
|
853
|
+
if (vnode.type === Route) {
|
|
854
|
+
const props = vnode.props || {}
|
|
855
|
+
const routeDef: RouteDefinition = {}
|
|
856
|
+
if (props.path !== undefined) routeDef.path = props.path as string
|
|
857
|
+
if (props.component !== undefined) routeDef.component = props.component as Component<any>
|
|
858
|
+
if (props.element !== undefined) routeDef.element = props.element as FictNode
|
|
859
|
+
if (props.index !== undefined) routeDef.index = props.index as boolean
|
|
860
|
+
if (props.preload !== undefined)
|
|
861
|
+
routeDef.preload = props.preload as NonNullable<RouteDefinition['preload']>
|
|
862
|
+
if (props.errorElement !== undefined) routeDef.errorElement = props.errorElement as FictNode
|
|
863
|
+
if (props.loadingElement !== undefined)
|
|
864
|
+
routeDef.loadingElement = props.loadingElement as FictNode
|
|
865
|
+
if (props.children) routeDef.children = extractRoutes(props.children as FictNode)
|
|
866
|
+
routes.push(routeDef)
|
|
867
|
+
} else if (vnode.type === Fragment && vnode.props?.children) {
|
|
868
|
+
// Handle fragments
|
|
869
|
+
routes.push(...extractRoutes(vnode.props.children as FictNode))
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return routes
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ============================================================================
|
|
877
|
+
// Programmatic Route Definition
|
|
878
|
+
// ============================================================================
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Create routes from a configuration array (alternative to JSX)
|
|
882
|
+
*/
|
|
883
|
+
export function createRoutes(routes: RouteDefinition[]): RouteDefinition[] {
|
|
884
|
+
return routes
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Create a router with programmatic routes
|
|
889
|
+
*/
|
|
890
|
+
export function createRouter(
|
|
891
|
+
routes: RouteDefinition[],
|
|
892
|
+
options?: RouterOptions,
|
|
893
|
+
): {
|
|
894
|
+
Router: Component<{ children?: FictNode }>
|
|
895
|
+
} {
|
|
896
|
+
return {
|
|
897
|
+
Router: (props: { children?: FictNode }) => {
|
|
898
|
+
const history = options?.history || createBrowserHistory()
|
|
899
|
+
|
|
900
|
+
return (
|
|
901
|
+
<RouterProvider history={history} routes={routes} base={options?.base}>
|
|
902
|
+
{props.children || <Routes>{routesToElements(routes)}</Routes>}
|
|
903
|
+
</RouterProvider>
|
|
904
|
+
)
|
|
905
|
+
},
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Convert route definitions to Route elements
|
|
911
|
+
*/
|
|
912
|
+
function routesToElements(routes: RouteDefinition[]): FictNode {
|
|
913
|
+
return (
|
|
914
|
+
<>
|
|
915
|
+
{routes.map((route, i) => {
|
|
916
|
+
const routeProps: RouteJSXProps = { key: route.key || `route-${i}` }
|
|
917
|
+
if (route.path !== undefined) routeProps.path = route.path
|
|
918
|
+
if (route.component !== undefined) routeProps.component = route.component
|
|
919
|
+
if (route.element !== undefined) routeProps.element = route.element
|
|
920
|
+
if (route.index !== undefined) routeProps.index = route.index
|
|
921
|
+
if (route.children) routeProps.children = routesToElements(route.children)
|
|
922
|
+
return <Route {...routeProps} />
|
|
923
|
+
})}
|
|
924
|
+
</>
|
|
925
|
+
)
|
|
926
|
+
}
|