@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/router",
3
- "version": "0.3.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.3.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/vite-plugin": "0.3.0",
46
- "fict": "0.3.0"
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
+ }
@@ -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
- RouterContext,
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 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
- }
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 location = router.location()
549
- const parentMatch = parentRoute.match()
550
- const locationPath = stripBaseOrWarn(location.pathname, router.base)
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
- return <>{renderMatches(currentMatches(), 0)}</>
182
+ const matches = currentMatches()
183
+ return <>{matches.length > 0 ? renderMatches(matches, 0) : null}</>
569
184
  }
570
185
 
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
- }
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
- // 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
- }
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()} data={state.data}>
666
- <Outlet />
262
+ <Component params={match.params} location={readAccessor(router.location)} data={state.data}>
263
+ {outletNode}
667
264
  </Component>
668
265
  )
669
- } else if (route.element) {
266
+ }
267
+ if (route.element) {
670
268
  return route.element
671
- } else if (route.children) {
269
+ }
270
+ if (route.children) {
672
271
  // Layout route without component - just render outlet
673
- return <Outlet />
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 <>{route.outlet()}</>
349
+ return readAccessor(route.outlet)
751
350
  }
752
351
 
753
352
  // ============================================================================