@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
package/src/context.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Router and Route contexts for @fictjs/router
|
|
3
|
+
*
|
|
4
|
+
* This module provides the context system that allows components to access
|
|
5
|
+
* routing state without prop drilling. Uses Fict's context API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createContext, useContext } from '@fictjs/runtime'
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
RouterContextValue,
|
|
12
|
+
RouteContextValue,
|
|
13
|
+
Location,
|
|
14
|
+
Params,
|
|
15
|
+
RouteMatch,
|
|
16
|
+
NavigateFunction,
|
|
17
|
+
To,
|
|
18
|
+
BeforeLeaveHandler,
|
|
19
|
+
} from './types'
|
|
20
|
+
import { stripBasePath, prependBasePath } from './utils'
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Router Context
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default router context value (used when no router is present)
|
|
28
|
+
*/
|
|
29
|
+
const defaultRouterContext: RouterContextValue = {
|
|
30
|
+
location: () => ({
|
|
31
|
+
pathname: '/',
|
|
32
|
+
search: '',
|
|
33
|
+
hash: '',
|
|
34
|
+
state: null,
|
|
35
|
+
key: 'default',
|
|
36
|
+
}),
|
|
37
|
+
params: () => ({}),
|
|
38
|
+
matches: () => [],
|
|
39
|
+
navigate: () => {
|
|
40
|
+
console.warn('[fict-router] No router found. Wrap your app in a <Router>')
|
|
41
|
+
},
|
|
42
|
+
isRouting: () => false,
|
|
43
|
+
pendingLocation: () => null,
|
|
44
|
+
base: '',
|
|
45
|
+
resolvePath: (to: To) => (typeof to === 'string' ? to : to.pathname || '/'),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Router context - provides access to router state
|
|
50
|
+
*/
|
|
51
|
+
export const RouterContext = createContext<RouterContextValue>(defaultRouterContext)
|
|
52
|
+
RouterContext.displayName = 'RouterContext'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Use the router context
|
|
56
|
+
*/
|
|
57
|
+
export function useRouter(): RouterContextValue {
|
|
58
|
+
return useContext(RouterContext)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Route Context
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Default route context value (used when not inside a route)
|
|
67
|
+
*/
|
|
68
|
+
const defaultRouteContext: RouteContextValue = {
|
|
69
|
+
match: () => undefined,
|
|
70
|
+
data: () => undefined,
|
|
71
|
+
outlet: () => null,
|
|
72
|
+
resolvePath: (to: To) => (typeof to === 'string' ? to : to.pathname || '/'),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Route context - provides access to current route match and data
|
|
77
|
+
*/
|
|
78
|
+
export const RouteContext = createContext<RouteContextValue>(defaultRouteContext)
|
|
79
|
+
RouteContext.displayName = 'RouteContext'
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Use the route context
|
|
83
|
+
*/
|
|
84
|
+
export function useRoute(): RouteContextValue {
|
|
85
|
+
return useContext(RouteContext)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// BeforeLeave Context
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* BeforeLeave context for route guards
|
|
94
|
+
*/
|
|
95
|
+
export interface BeforeLeaveContextValue {
|
|
96
|
+
addHandler: (handler: BeforeLeaveHandler) => () => void
|
|
97
|
+
confirm: (to: Location, from: Location) => Promise<boolean>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const defaultBeforeLeaveContext: BeforeLeaveContextValue = {
|
|
101
|
+
addHandler: () => () => {},
|
|
102
|
+
confirm: async () => true,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const BeforeLeaveContext = createContext<BeforeLeaveContextValue>(defaultBeforeLeaveContext)
|
|
106
|
+
BeforeLeaveContext.displayName = 'BeforeLeaveContext'
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Use the beforeLeave context
|
|
110
|
+
*/
|
|
111
|
+
export function useBeforeLeaveContext(): BeforeLeaveContextValue {
|
|
112
|
+
return useContext(BeforeLeaveContext)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// Route Error Context (for ErrorBoundary-caught errors)
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Context for passing render errors caught by ErrorBoundary to errorElement components.
|
|
121
|
+
* This allows useRouteError() to access errors from both preload and render phases.
|
|
122
|
+
*/
|
|
123
|
+
export interface RouteErrorContextValue {
|
|
124
|
+
error: unknown
|
|
125
|
+
reset: (() => void) | undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const defaultRouteErrorContext: RouteErrorContextValue = {
|
|
129
|
+
error: undefined,
|
|
130
|
+
reset: undefined,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const RouteErrorContext = createContext<RouteErrorContextValue>(defaultRouteErrorContext)
|
|
134
|
+
RouteErrorContext.displayName = 'RouteErrorContext'
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// Navigation Hooks
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get the navigate function
|
|
142
|
+
*/
|
|
143
|
+
export function useNavigate(): NavigateFunction {
|
|
144
|
+
const router = useRouter()
|
|
145
|
+
return router.navigate
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get the current location
|
|
150
|
+
*/
|
|
151
|
+
export function useLocation(): () => Location {
|
|
152
|
+
const router = useRouter()
|
|
153
|
+
return router.location
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get the current route parameters
|
|
158
|
+
*/
|
|
159
|
+
export function useParams<P extends string = string>(): () => Params<P> {
|
|
160
|
+
const router = useRouter()
|
|
161
|
+
return router.params as () => Params<P>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get the current search parameters
|
|
166
|
+
*/
|
|
167
|
+
export function useSearchParams(): [
|
|
168
|
+
() => URLSearchParams,
|
|
169
|
+
(params: URLSearchParams | Record<string, string>, options?: { replace?: boolean }) => void,
|
|
170
|
+
] {
|
|
171
|
+
const router = useRouter()
|
|
172
|
+
|
|
173
|
+
const getSearchParams = () => {
|
|
174
|
+
const location = router.location()
|
|
175
|
+
return new URLSearchParams(location.search)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const setSearchParams = (
|
|
179
|
+
params: URLSearchParams | Record<string, string>,
|
|
180
|
+
options?: { replace?: boolean },
|
|
181
|
+
) => {
|
|
182
|
+
const searchParams = params instanceof URLSearchParams ? params : new URLSearchParams(params)
|
|
183
|
+
const search = searchParams.toString()
|
|
184
|
+
|
|
185
|
+
const location = router.location()
|
|
186
|
+
router.navigate(
|
|
187
|
+
{
|
|
188
|
+
pathname: location.pathname,
|
|
189
|
+
search: search ? '?' + search : '',
|
|
190
|
+
hash: location.hash,
|
|
191
|
+
},
|
|
192
|
+
{ replace: options?.replace },
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return [getSearchParams, setSearchParams]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get the current route matches
|
|
201
|
+
*/
|
|
202
|
+
export function useMatches(): () => RouteMatch[] {
|
|
203
|
+
const router = useRouter()
|
|
204
|
+
return router.matches
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Check if currently routing (loading new route)
|
|
209
|
+
*/
|
|
210
|
+
export function useIsRouting(): () => boolean {
|
|
211
|
+
const router = useRouter()
|
|
212
|
+
return router.isRouting
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get the pending navigation location (if any)
|
|
217
|
+
*/
|
|
218
|
+
export function usePendingLocation(): () => Location | null {
|
|
219
|
+
const router = useRouter()
|
|
220
|
+
return router.pendingLocation
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get the preloaded data for the current route
|
|
225
|
+
*/
|
|
226
|
+
export function useRouteData<T = unknown>(): () => T | undefined {
|
|
227
|
+
const route = useRoute()
|
|
228
|
+
return route.data as () => T | undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get route error (for use in errorElement components)
|
|
233
|
+
* This hook is used within an error boundary to access the caught error.
|
|
234
|
+
* It returns errors from both preload phase (via route context) and
|
|
235
|
+
* render phase (via ErrorBoundary context).
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```tsx
|
|
239
|
+
* function RouteErrorPage() {
|
|
240
|
+
* const error = useRouteError()
|
|
241
|
+
* return (
|
|
242
|
+
* <div>
|
|
243
|
+
* <h1>Error</h1>
|
|
244
|
+
* <p>{error?.message || 'Unknown error'}</p>
|
|
245
|
+
* </div>
|
|
246
|
+
* )
|
|
247
|
+
* }
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
export function useRouteError(): unknown {
|
|
251
|
+
// First check RouteErrorContext for render errors caught by ErrorBoundary
|
|
252
|
+
const errorContext = useContext(RouteErrorContext)
|
|
253
|
+
if (errorContext.error !== undefined) {
|
|
254
|
+
return errorContext.error
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Fall back to route context for preload errors
|
|
258
|
+
const route = useRoute()
|
|
259
|
+
return (route as any).error?.()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Resolve a path relative to the current route
|
|
264
|
+
*/
|
|
265
|
+
export function useResolvedPath(to: To | (() => To)): () => string {
|
|
266
|
+
const route = useRoute()
|
|
267
|
+
|
|
268
|
+
return () => {
|
|
269
|
+
const target = typeof to === 'function' ? to() : to
|
|
270
|
+
return route.resolvePath(target)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check if a path matches the current location
|
|
276
|
+
*/
|
|
277
|
+
export function useMatch(path: string | (() => string)): () => RouteMatch | null {
|
|
278
|
+
const router = useRouter()
|
|
279
|
+
|
|
280
|
+
return () => {
|
|
281
|
+
const targetPath = typeof path === 'function' ? path() : path
|
|
282
|
+
const matches = router.matches()
|
|
283
|
+
|
|
284
|
+
// Check if any match's pattern matches the target path
|
|
285
|
+
for (const match of matches) {
|
|
286
|
+
if (match.pattern === targetPath || match.pathname === targetPath) {
|
|
287
|
+
return match
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ============================================================================
|
|
296
|
+
// Helper Hooks
|
|
297
|
+
// ============================================================================
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get the href for a given path (useful for SSR)
|
|
301
|
+
*/
|
|
302
|
+
export function useHref(to: To | (() => To)): () => string {
|
|
303
|
+
const router = useRouter()
|
|
304
|
+
|
|
305
|
+
return () => {
|
|
306
|
+
const target = typeof to === 'function' ? to() : to
|
|
307
|
+
|
|
308
|
+
// Extract pathname, search, and hash from target
|
|
309
|
+
// For strings, we must extract WITHOUT normalizing to preserve relative paths
|
|
310
|
+
let pathname: string
|
|
311
|
+
let search = ''
|
|
312
|
+
let hash = ''
|
|
313
|
+
|
|
314
|
+
if (typeof target === 'string') {
|
|
315
|
+
// Extract hash first
|
|
316
|
+
let remaining = target
|
|
317
|
+
const hashIndex = remaining.indexOf('#')
|
|
318
|
+
if (hashIndex >= 0) {
|
|
319
|
+
hash = remaining.slice(hashIndex)
|
|
320
|
+
remaining = remaining.slice(0, hashIndex)
|
|
321
|
+
}
|
|
322
|
+
// Extract search
|
|
323
|
+
const searchIndex = remaining.indexOf('?')
|
|
324
|
+
if (searchIndex >= 0) {
|
|
325
|
+
search = remaining.slice(searchIndex)
|
|
326
|
+
remaining = remaining.slice(0, searchIndex)
|
|
327
|
+
}
|
|
328
|
+
// Keep empty string for search/hash-only targets
|
|
329
|
+
pathname = remaining
|
|
330
|
+
} else {
|
|
331
|
+
// Keep empty string for search/hash-only targets
|
|
332
|
+
pathname = target.pathname || ''
|
|
333
|
+
search = target.search || ''
|
|
334
|
+
hash = target.hash || ''
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// For empty pathname (search/hash-only), use current location's pathname
|
|
338
|
+
// Otherwise resolve the pathname (handles relative paths)
|
|
339
|
+
let resolved: string
|
|
340
|
+
if (pathname === '') {
|
|
341
|
+
// Use current path for search/hash-only hrefs
|
|
342
|
+
const currentPathname = router.location().pathname
|
|
343
|
+
const normalizedBase = router.base === '/' || router.base === '' ? '' : router.base
|
|
344
|
+
|
|
345
|
+
// Check if current location is within the router's base
|
|
346
|
+
if (normalizedBase && !currentPathname.startsWith(normalizedBase)) {
|
|
347
|
+
// Current location is outside the base - return raw pathname + search/hash
|
|
348
|
+
// without base manipulation to avoid generating incorrect hrefs
|
|
349
|
+
return currentPathname + search + hash
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
resolved = stripBasePath(currentPathname, router.base)
|
|
353
|
+
} else {
|
|
354
|
+
resolved = router.resolvePath(pathname)
|
|
355
|
+
}
|
|
356
|
+
// Prepend base to get the full href, then append search/hash
|
|
357
|
+
const baseHref = prependBasePath(resolved, router.base)
|
|
358
|
+
return baseHref + search + hash
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Check if a path is active (matches current location)
|
|
364
|
+
*/
|
|
365
|
+
export function useIsActive(
|
|
366
|
+
to: To | (() => To),
|
|
367
|
+
options?: { end?: boolean | undefined },
|
|
368
|
+
): () => boolean {
|
|
369
|
+
const router = useRouter()
|
|
370
|
+
|
|
371
|
+
return () => {
|
|
372
|
+
const target = typeof to === 'function' ? to() : to
|
|
373
|
+
|
|
374
|
+
// Resolve the target path relative to current location (handles relative paths)
|
|
375
|
+
const resolvedTargetPath = router.resolvePath(target)
|
|
376
|
+
|
|
377
|
+
// Strip base from current location pathname for comparison
|
|
378
|
+
const currentPath = router.location().pathname
|
|
379
|
+
if (router.base && currentPath !== router.base && !currentPath.startsWith(router.base + '/')) {
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
const currentPathWithoutBase = stripBasePath(currentPath, router.base)
|
|
383
|
+
|
|
384
|
+
if (options?.end) {
|
|
385
|
+
return currentPathWithoutBase === resolvedTargetPath
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
currentPathWithoutBase === resolvedTargetPath ||
|
|
390
|
+
currentPathWithoutBase.startsWith(resolvedTargetPath + '/')
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Register a beforeLeave handler for the current route
|
|
397
|
+
*/
|
|
398
|
+
export function useBeforeLeave(handler: BeforeLeaveHandler): void {
|
|
399
|
+
const context = useBeforeLeaveContext()
|
|
400
|
+
const _cleanup = context.addHandler(handler)
|
|
401
|
+
|
|
402
|
+
// Note: In Fict, cleanup happens automatically when the component unmounts
|
|
403
|
+
// via the RootContext cleanup system
|
|
404
|
+
}
|