@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.
@@ -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
+ }