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