@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/dist/index.cjs +351 -174
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +340 -167
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/accessor-utils.ts +22 -0
- package/src/components.tsx +50 -451
- package/src/context.ts +161 -42
- package/src/link.tsx +54 -15
- package/src/router-internals.ts +33 -0
- package/src/router-provider.ts +388 -0
|
@@ -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
|
+
}
|