@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.
@@ -0,0 +1,388 @@
1
+ import { batch, onCleanup, startTransition, untrack, type FictNode } from '@fictjs/runtime'
2
+ import { createSignal } from '@fictjs/runtime/advanced'
3
+ import { jsx } from '@fictjs/runtime/jsx-runtime'
4
+
5
+ import { wrapAccessor, wrapValue } from './accessor-utils'
6
+ import {
7
+ BeforeLeaveContext,
8
+ type BeforeLeaveContextValue,
9
+ RouterContext,
10
+ pushActiveBeforeLeave,
11
+ pushActiveRouter,
12
+ popActiveBeforeLeave,
13
+ popActiveRouter,
14
+ } from './context'
15
+ import { stripBaseIfPresent, stripBaseOrWarn } from './router-internals'
16
+ import { getScrollRestoration } from './scroll'
17
+ import type {
18
+ BeforeLeaveEventArgs,
19
+ BeforeLeaveHandler,
20
+ History,
21
+ Location,
22
+ NavigateFunction,
23
+ NavigateOptions,
24
+ Params,
25
+ RouteDefinition,
26
+ RouteMatch,
27
+ RouterContextValue,
28
+ To,
29
+ } from './types'
30
+ import {
31
+ createLocation,
32
+ createBranches,
33
+ compileRoute,
34
+ isBrowser,
35
+ locationsAreEqual,
36
+ matchRoutes,
37
+ normalizePath,
38
+ prependBasePath,
39
+ resolvePath,
40
+ } from './utils'
41
+
42
+ interface RouterState {
43
+ location: Location
44
+ matches: RouteMatch[]
45
+ isRouting: boolean
46
+ pendingLocation: Location | null
47
+ }
48
+
49
+ /**
50
+ * Create a router instance with the given history and routes
51
+ */
52
+ function createRouterState(
53
+ history: History,
54
+ routes: RouteDefinition[],
55
+ base = '',
56
+ ): {
57
+ state: () => RouterState
58
+ navigate: NavigateFunction
59
+ beforeLeave: BeforeLeaveContextValue
60
+ cleanup: () => void
61
+ normalizedBase: string
62
+ } {
63
+ // Normalize the base path
64
+ const normalizedBase = normalizePath(base)
65
+ const baseForStrip = normalizedBase === '/' ? '' : normalizedBase
66
+
67
+ // Compile routes into branches for efficient matching
68
+ const compiledRoutes = routes.map(r => compileRoute(r))
69
+ const branches = createBranches(compiledRoutes)
70
+
71
+ // Helper to match with base path stripped
72
+ const matchWithBase = (pathname: string): RouteMatch[] => {
73
+ const strippedPath = stripBaseOrWarn(pathname, baseForStrip)
74
+ if (strippedPath == null) return []
75
+ return matchRoutes(branches, strippedPath) || []
76
+ }
77
+
78
+ // Initial state
79
+ const initialLocation = history.location
80
+ const initialMatches = matchWithBase(initialLocation.pathname)
81
+
82
+ // Reactive state using signals
83
+ const locationSignal = createSignal<Location>(initialLocation)
84
+ const matchesSignal = createSignal<RouteMatch[]>(initialMatches)
85
+ const isRoutingSignal = createSignal<boolean>(false)
86
+ const pendingLocationSignal = createSignal<Location | null>(null)
87
+
88
+ // BeforeLeave handlers and navigation token for async ordering
89
+ const beforeLeaveHandlers = new Set<BeforeLeaveHandler>()
90
+ let navigationToken = 0
91
+
92
+ const beforeLeave: BeforeLeaveContextValue = {
93
+ addHandler(handler: BeforeLeaveHandler) {
94
+ beforeLeaveHandlers.add(handler)
95
+ return () => beforeLeaveHandlers.delete(handler)
96
+ },
97
+ async confirm(to: Location, from: Location): Promise<boolean> {
98
+ if (beforeLeaveHandlers.size === 0) return true
99
+
100
+ // Capture current token for this navigation
101
+ const currentToken = ++navigationToken
102
+
103
+ // Block by default when any beforeLeave handlers are registered.
104
+ let defaultPrevented = true
105
+ let retryRequested = false
106
+ let forceRetry = false
107
+
108
+ const event: BeforeLeaveEventArgs = {
109
+ to,
110
+ from,
111
+ get defaultPrevented() {
112
+ return defaultPrevented
113
+ },
114
+ preventDefault() {
115
+ defaultPrevented = true
116
+ },
117
+ retry(force?: boolean) {
118
+ retryRequested = true
119
+ forceRetry = force ?? false
120
+ },
121
+ }
122
+
123
+ for (const handler of beforeLeaveHandlers) {
124
+ await handler(event)
125
+
126
+ // Check if this navigation is still current (not superseded by newer navigation)
127
+ if (currentToken !== navigationToken) {
128
+ // This navigation was superseded, ignore its result
129
+ return false
130
+ }
131
+
132
+ if (defaultPrevented && !retryRequested) {
133
+ return false
134
+ }
135
+ if (retryRequested && forceRetry) {
136
+ return true
137
+ }
138
+ }
139
+
140
+ // Final check that this navigation is still current
141
+ if (currentToken !== navigationToken) {
142
+ return false
143
+ }
144
+
145
+ return !defaultPrevented || retryRequested
146
+ },
147
+ }
148
+
149
+ // Navigation function
150
+ const navigate: NavigateFunction = (toOrDelta: To | number, options?: NavigateOptions) => {
151
+ if (typeof toOrDelta === 'number') {
152
+ history.go(toOrDelta)
153
+ return
154
+ }
155
+
156
+ const currentLocation = locationSignal()
157
+ const to = toOrDelta
158
+
159
+ // Extract pathname, search, and hash from string without normalizing pathname
160
+ // This preserves relative paths like 'settings' vs '/settings'
161
+ let toPathname: string
162
+ let toSearch = ''
163
+ let toHash = ''
164
+
165
+ if (typeof to === 'string') {
166
+ // Extract hash first
167
+ let remaining = to
168
+ const hashIndex = remaining.indexOf('#')
169
+ if (hashIndex >= 0) {
170
+ toHash = remaining.slice(hashIndex)
171
+ remaining = remaining.slice(0, hashIndex)
172
+ }
173
+ // Extract search
174
+ const searchIndex = remaining.indexOf('?')
175
+ if (searchIndex >= 0) {
176
+ toSearch = remaining.slice(searchIndex)
177
+ remaining = remaining.slice(0, searchIndex)
178
+ }
179
+ // Remaining is the pathname (keep empty string for search/hash-only navigation)
180
+ toPathname = remaining
181
+ } else {
182
+ toPathname = to.pathname || ''
183
+ toSearch = to.search || ''
184
+ toHash = to.hash || ''
185
+ }
186
+
187
+ // Resolve the target path (relative to current path, without base)
188
+ let targetPath: string
189
+ const currentPathWithoutBase = stripBaseOrWarn(currentLocation.pathname, baseForStrip) || '/'
190
+
191
+ if (typeof to === 'string') {
192
+ // Empty pathname means search/hash-only navigation - keep current path
193
+ if (toPathname === '') {
194
+ targetPath = currentPathWithoutBase
195
+ } else if (options?.relative === 'route') {
196
+ // Resolve relative to current route
197
+ const matches = matchesSignal()
198
+ const currentMatch = matches[matches.length - 1]
199
+ const currentRoutePath = currentMatch?.pathname || currentPathWithoutBase
200
+ targetPath = resolvePath(currentRoutePath, toPathname)
201
+ } else {
202
+ // Resolve relative to current pathname
203
+ // Only strip base if it's an absolute path
204
+ targetPath = toPathname.startsWith('/')
205
+ ? stripBaseIfPresent(toPathname, baseForStrip)
206
+ : resolvePath(currentPathWithoutBase, toPathname)
207
+ }
208
+ } else {
209
+ const rawTargetPath = toPathname || currentPathWithoutBase
210
+ targetPath = stripBaseIfPresent(rawTargetPath, baseForStrip)
211
+ }
212
+
213
+ // Create the full target location, preserving to.state and to.key
214
+ // options.state overrides to.state if provided
215
+ const toState = typeof to === 'object' ? to.state : undefined
216
+ const toKey = typeof to === 'object' ? to.key : undefined
217
+ const finalState = options?.state !== undefined ? options.state : toState
218
+
219
+ // Build location object, only including key if defined
220
+ const targetPathWithBase = prependBasePath(targetPath, baseForStrip)
221
+ const locationSpec: Partial<Location> = {
222
+ pathname: targetPathWithBase,
223
+ search: toSearch,
224
+ hash: toHash,
225
+ }
226
+ if (finalState !== undefined) {
227
+ locationSpec.state = finalState
228
+ }
229
+ if (toKey !== undefined) {
230
+ locationSpec.key = toKey
231
+ }
232
+
233
+ const targetLocation = createLocation(locationSpec, finalState, toKey)
234
+
235
+ // Check beforeLeave handlers
236
+ untrack(async () => {
237
+ if (beforeLeaveHandlers.size > 0) {
238
+ pendingLocationSignal(targetLocation)
239
+ }
240
+ const canNavigate = await beforeLeave.confirm(targetLocation, currentLocation)
241
+ if (!canNavigate) {
242
+ return
243
+ }
244
+
245
+ // Start routing indicator and set pending location
246
+ batch(() => {
247
+ isRoutingSignal(true)
248
+ pendingLocationSignal(targetLocation)
249
+ })
250
+
251
+ // Use transition for smooth updates
252
+ // Note: We only push/replace to history here.
253
+ // The actual signal updates happen in history.listen to avoid duplicates.
254
+ startTransition(() => {
255
+ const prevLocation = history.location
256
+ if (options?.replace) {
257
+ history.replace(targetLocation, finalState)
258
+ } else {
259
+ history.push(targetLocation, finalState)
260
+ }
261
+
262
+ // Scroll handling for programmatic navigation
263
+ if (options?.scroll !== false && isBrowser()) {
264
+ const scrollRestoration = getScrollRestoration()
265
+ scrollRestoration.handleNavigation(
266
+ prevLocation,
267
+ history.location,
268
+ options?.replace ? 'REPLACE' : 'PUSH',
269
+ )
270
+ }
271
+
272
+ // If navigation was blocked or no-op, reset routing state
273
+ if (locationsAreEqual(prevLocation, history.location)) {
274
+ batch(() => {
275
+ isRoutingSignal(false)
276
+ pendingLocationSignal(null)
277
+ })
278
+ }
279
+ })
280
+ })
281
+ }
282
+
283
+ // Listen for history changes (browser back/forward AND navigate calls)
284
+ // This is the single source of truth for location/matches updates
285
+ const unlisten = history.listen(({ action, location: newLocation }) => {
286
+ const prevLocation = locationSignal()
287
+
288
+ batch(() => {
289
+ locationSignal(newLocation)
290
+ const newMatches = matchWithBase(newLocation.pathname)
291
+ matchesSignal(newMatches)
292
+ isRoutingSignal(false)
293
+ pendingLocationSignal(null)
294
+ })
295
+
296
+ // Handle scroll restoration for POP navigation (back/forward)
297
+ if (action === 'POP' && isBrowser()) {
298
+ const scrollRestoration = getScrollRestoration()
299
+ scrollRestoration.handleNavigation(prevLocation, newLocation, 'POP')
300
+ }
301
+ })
302
+
303
+ // State accessor
304
+ const state = () => ({
305
+ location: locationSignal(),
306
+ matches: matchesSignal(),
307
+ isRouting: isRoutingSignal(),
308
+ pendingLocation: pendingLocationSignal(),
309
+ })
310
+
311
+ return {
312
+ state,
313
+ navigate,
314
+ beforeLeave,
315
+ cleanup: unlisten,
316
+ normalizedBase: baseForStrip,
317
+ }
318
+ }
319
+
320
+ export function RouterProvider(props: {
321
+ history: History
322
+ routes: RouteDefinition[]
323
+ base?: string | undefined
324
+ children?: FictNode
325
+ }) {
326
+ const { state, navigate, beforeLeave, cleanup, normalizedBase } = createRouterState(
327
+ props.history,
328
+ props.routes,
329
+ props.base,
330
+ )
331
+
332
+ onCleanup(cleanup)
333
+
334
+ const beforeLeaveContext: BeforeLeaveContextValue = {
335
+ addHandler: wrapAccessor(beforeLeave.addHandler),
336
+ confirm: wrapAccessor(beforeLeave.confirm),
337
+ }
338
+
339
+ const resolvePathFn = (to: To) => {
340
+ const location = state().location
341
+ const currentPathWithoutBase = stripBaseOrWarn(location.pathname, normalizedBase) || '/'
342
+ const rawTargetPath = typeof to === 'string' ? to : to.pathname || '/'
343
+ const targetPath = rawTargetPath.startsWith('/')
344
+ ? stripBaseIfPresent(rawTargetPath, normalizedBase)
345
+ : rawTargetPath
346
+ return resolvePath(currentPathWithoutBase, targetPath)
347
+ }
348
+
349
+ const routerContext: RouterContextValue = {
350
+ location: () => state().location,
351
+ params: () => {
352
+ const matches = state().matches
353
+ const allParams: Record<string, string | undefined> = {}
354
+ for (const match of matches) {
355
+ Object.assign(allParams, match.params)
356
+ }
357
+ return allParams as Params
358
+ },
359
+ matches: () => state().matches,
360
+ navigate: wrapAccessor(navigate),
361
+ isRouting: () => state().isRouting,
362
+ pendingLocation: () => state().pendingLocation,
363
+ base: wrapValue(normalizedBase),
364
+ resolvePath: wrapAccessor(resolvePathFn),
365
+ }
366
+
367
+ pushActiveRouter(routerContext)
368
+ pushActiveBeforeLeave(beforeLeaveContext)
369
+ onCleanup(() => {
370
+ popActiveBeforeLeave(beforeLeaveContext)
371
+ popActiveRouter(routerContext)
372
+ })
373
+
374
+ const RouterContextProvider = RouterContext.Provider as unknown as (
375
+ props: Record<string, unknown>,
376
+ ) => FictNode
377
+ const BeforeLeaveProvider = BeforeLeaveContext.Provider as unknown as (
378
+ props: Record<string, unknown>,
379
+ ) => FictNode
380
+
381
+ return jsx(RouterContextProvider, {
382
+ value: routerContext,
383
+ children: jsx(BeforeLeaveProvider, {
384
+ value: beforeLeaveContext,
385
+ children: props.children,
386
+ }),
387
+ })
388
+ }