@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/history.ts ADDED
@@ -0,0 +1,659 @@
1
+ /**
2
+ * @fileoverview History implementations for @fictjs/router
3
+ *
4
+ * Provides browser history, hash history, and memory history implementations.
5
+ * These handle the low-level navigation state management.
6
+ */
7
+
8
+ import type { History, HistoryAction, HistoryListener, Location, To, Blocker } from './types'
9
+ import { createLocation, createURL, parseURL, createKey, normalizePath } from './utils'
10
+
11
+ // ============================================================================
12
+ // Shared Utilities
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Create a history state object
17
+ */
18
+ function createHistoryState(
19
+ location: Location,
20
+ index: number,
21
+ ): { usr: unknown; key: string; idx: number } {
22
+ return {
23
+ usr: location.state,
24
+ key: location.key,
25
+ idx: index,
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Read a location from window.history.state
31
+ */
32
+ function readLocation(
33
+ state: { usr?: unknown; key?: string; idx?: number } | null,
34
+ url: string,
35
+ ): Location {
36
+ const { pathname, search, hash } = parseURL(url)
37
+ return {
38
+ pathname,
39
+ search,
40
+ hash,
41
+ state: state?.usr ?? null,
42
+ key: state?.key ?? createKey(),
43
+ }
44
+ }
45
+
46
+ // ============================================================================
47
+ // Browser History
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Create a browser history instance that uses the History API.
52
+ * This is the standard history for most web applications.
53
+ *
54
+ * @throws Error if called in a non-browser environment (SSR)
55
+ */
56
+ export function createBrowserHistory(): History {
57
+ // SSR guard: throw clear error if window is not available
58
+ if (typeof window === 'undefined') {
59
+ throw new Error(
60
+ '[fict-router] createBrowserHistory cannot be used in a server environment. ' +
61
+ 'Use createMemoryHistory or createStaticHistory for SSR.',
62
+ )
63
+ }
64
+
65
+ const listeners = new Set<HistoryListener>()
66
+ const blockers = new Set<Blocker>()
67
+
68
+ let action: HistoryAction = 'POP'
69
+ let location = readLocation(
70
+ window.history.state,
71
+ window.location.pathname + window.location.search + window.location.hash,
72
+ )
73
+ let index = (window.history.state?.idx as number) ?? 0
74
+
75
+ // Handle popstate (back/forward navigation)
76
+ function handlePopState(event: PopStateEvent) {
77
+ const nextLocation = readLocation(
78
+ event.state,
79
+ window.location.pathname + window.location.search + window.location.hash,
80
+ )
81
+ const nextAction: HistoryAction = 'POP'
82
+ const nextIndex = (event.state?.idx as number) ?? 0
83
+
84
+ // Check blockers
85
+ if (blockers.size > 0) {
86
+ let blocked = false
87
+ const retry = () => {
88
+ // Re-trigger the navigation
89
+ window.history.go(nextIndex - index)
90
+ }
91
+
92
+ for (const blocker of blockers) {
93
+ blocker({
94
+ action: nextAction,
95
+ location: nextLocation,
96
+ retry,
97
+ })
98
+ blocked = true
99
+ break
100
+ }
101
+
102
+ if (blocked) {
103
+ // Restore the previous state by going back
104
+ window.history.go(index - nextIndex)
105
+ return
106
+ }
107
+ }
108
+
109
+ action = nextAction
110
+ location = nextLocation
111
+ index = nextIndex
112
+
113
+ notifyListeners()
114
+ }
115
+
116
+ window.addEventListener('popstate', handlePopState)
117
+
118
+ function notifyListeners() {
119
+ for (const listener of listeners) {
120
+ listener({ action, location })
121
+ }
122
+ }
123
+
124
+ function push(to: To, state?: unknown) {
125
+ const nextLocation = createLocation(to, state)
126
+ const nextAction: HistoryAction = 'PUSH'
127
+
128
+ // Check blockers
129
+ if (blockers.size > 0) {
130
+ let blocked = false
131
+ const retry = () => push(to, state)
132
+
133
+ for (const blocker of blockers) {
134
+ blocker({
135
+ action: nextAction,
136
+ location: nextLocation,
137
+ retry,
138
+ })
139
+ blocked = true
140
+ break
141
+ }
142
+
143
+ if (blocked) return
144
+ }
145
+
146
+ action = nextAction
147
+ location = nextLocation
148
+ index++
149
+
150
+ const historyState = createHistoryState(location, index)
151
+ window.history.pushState(historyState, '', createURL(location))
152
+
153
+ notifyListeners()
154
+ }
155
+
156
+ function replace(to: To, state?: unknown) {
157
+ const nextLocation = createLocation(to, state)
158
+ const nextAction: HistoryAction = 'REPLACE'
159
+
160
+ // Check blockers
161
+ if (blockers.size > 0) {
162
+ let blocked = false
163
+ const retry = () => replace(to, state)
164
+
165
+ for (const blocker of blockers) {
166
+ blocker({
167
+ action: nextAction,
168
+ location: nextLocation,
169
+ retry,
170
+ })
171
+ blocked = true
172
+ break
173
+ }
174
+
175
+ if (blocked) return
176
+ }
177
+
178
+ action = nextAction
179
+ location = nextLocation
180
+
181
+ const historyState = createHistoryState(location, index)
182
+ window.history.replaceState(historyState, '', createURL(location))
183
+
184
+ notifyListeners()
185
+ }
186
+
187
+ function go(delta: number) {
188
+ window.history.go(delta)
189
+ }
190
+
191
+ // Initialize history state if not set
192
+ if (window.history.state === null) {
193
+ const historyState = createHistoryState(location, index)
194
+ window.history.replaceState(historyState, '', createURL(location))
195
+ }
196
+
197
+ return {
198
+ get action() {
199
+ return action
200
+ },
201
+ get location() {
202
+ return location
203
+ },
204
+ push,
205
+ replace,
206
+ go,
207
+ back() {
208
+ go(-1)
209
+ },
210
+ forward() {
211
+ go(1)
212
+ },
213
+ listen(listener: HistoryListener) {
214
+ listeners.add(listener)
215
+ return () => listeners.delete(listener)
216
+ },
217
+ createHref(to: To) {
218
+ const loc = typeof to === 'string' ? parseURL(to) : to
219
+ return createURL(loc as Location)
220
+ },
221
+ block(blocker: Blocker) {
222
+ blockers.add(blocker)
223
+
224
+ // Set up beforeunload handler if this is the first blocker
225
+ if (blockers.size === 1) {
226
+ window.addEventListener('beforeunload', handleBeforeUnload)
227
+ }
228
+
229
+ return () => {
230
+ blockers.delete(blocker)
231
+ if (blockers.size === 0) {
232
+ window.removeEventListener('beforeunload', handleBeforeUnload)
233
+ }
234
+ }
235
+ },
236
+ }
237
+ }
238
+
239
+ function handleBeforeUnload(event: BeforeUnloadEvent) {
240
+ event.preventDefault()
241
+ // Modern browsers ignore the return value, but we set it anyway
242
+ event.returnValue = ''
243
+ }
244
+
245
+ // ============================================================================
246
+ // Hash History
247
+ // ============================================================================
248
+
249
+ /**
250
+ * Create a hash history instance that uses the URL hash.
251
+ * Useful for static file hosting or when you can't configure server-side routing.
252
+ *
253
+ * @throws Error if called in a non-browser environment (SSR)
254
+ */
255
+ export function createHashHistory(options: { hashType?: 'slash' | 'noslash' } = {}): History {
256
+ // SSR guard: throw clear error if window is not available
257
+ if (typeof window === 'undefined') {
258
+ throw new Error(
259
+ '[fict-router] createHashHistory cannot be used in a server environment. ' +
260
+ 'Use createMemoryHistory or createStaticHistory for SSR.',
261
+ )
262
+ }
263
+
264
+ const { hashType = 'slash' } = options
265
+ const listeners = new Set<HistoryListener>()
266
+ const blockers = new Set<Blocker>()
267
+
268
+ let action: HistoryAction = 'POP'
269
+ let location = readHashLocation()
270
+ let index = 0
271
+
272
+ function readHashLocation(): Location {
273
+ let hash = window.location.hash.slice(1) // Remove the #
274
+
275
+ // Handle hash type
276
+ if (hashType === 'slash' && !hash.startsWith('/')) {
277
+ hash = '/' + hash
278
+ } else if (hashType === 'noslash' && hash.startsWith('/')) {
279
+ hash = hash.slice(1)
280
+ }
281
+
282
+ const { pathname, search, hash: innerHash } = parseURL(hash || '/')
283
+
284
+ return {
285
+ pathname: normalizePath(pathname),
286
+ search,
287
+ hash: innerHash,
288
+ state: window.history.state?.usr ?? null,
289
+ key: window.history.state?.key ?? createKey(),
290
+ }
291
+ }
292
+
293
+ function createHashHref(location: Location): string {
294
+ const url = createURL(location)
295
+ if (hashType === 'noslash') {
296
+ return '#' + url.slice(1) // Remove leading /
297
+ }
298
+ return '#' + url
299
+ }
300
+
301
+ function handleHashChange() {
302
+ const nextLocation = readHashLocation()
303
+ const nextAction: HistoryAction = 'POP'
304
+
305
+ // Check blockers
306
+ if (blockers.size > 0) {
307
+ let blocked = false
308
+ const retry = () => {
309
+ window.location.hash = createHashHref(nextLocation)
310
+ }
311
+
312
+ for (const blocker of blockers) {
313
+ blocker({
314
+ action: nextAction,
315
+ location: nextLocation,
316
+ retry,
317
+ })
318
+ blocked = true
319
+ break
320
+ }
321
+
322
+ if (blocked) {
323
+ // Restore the previous hash
324
+ window.location.hash = createHashHref(location)
325
+ return
326
+ }
327
+ }
328
+
329
+ action = nextAction
330
+ location = nextLocation
331
+
332
+ notifyListeners()
333
+ }
334
+
335
+ window.addEventListener('hashchange', handleHashChange)
336
+
337
+ function notifyListeners() {
338
+ for (const listener of listeners) {
339
+ listener({ action, location })
340
+ }
341
+ }
342
+
343
+ function push(to: To, state?: unknown) {
344
+ const nextLocation = createLocation(to, state)
345
+ const nextAction: HistoryAction = 'PUSH'
346
+
347
+ // Check blockers
348
+ if (blockers.size > 0) {
349
+ let blocked = false
350
+ const retry = () => push(to, state)
351
+
352
+ for (const blocker of blockers) {
353
+ blocker({
354
+ action: nextAction,
355
+ location: nextLocation,
356
+ retry,
357
+ })
358
+ blocked = true
359
+ break
360
+ }
361
+
362
+ if (blocked) return
363
+ }
364
+
365
+ action = nextAction
366
+ location = nextLocation
367
+ index++
368
+
369
+ const historyState = createHistoryState(location, index)
370
+ window.history.pushState(historyState, '', createHashHref(location))
371
+
372
+ notifyListeners()
373
+ }
374
+
375
+ function replace(to: To, state?: unknown) {
376
+ const nextLocation = createLocation(to, state)
377
+ const nextAction: HistoryAction = 'REPLACE'
378
+
379
+ // Check blockers
380
+ if (blockers.size > 0) {
381
+ let blocked = false
382
+ const retry = () => replace(to, state)
383
+
384
+ for (const blocker of blockers) {
385
+ blocker({
386
+ action: nextAction,
387
+ location: nextLocation,
388
+ retry,
389
+ })
390
+ blocked = true
391
+ break
392
+ }
393
+
394
+ if (blocked) return
395
+ }
396
+
397
+ action = nextAction
398
+ location = nextLocation
399
+
400
+ const historyState = createHistoryState(location, index)
401
+ window.history.replaceState(historyState, '', createHashHref(location))
402
+
403
+ notifyListeners()
404
+ }
405
+
406
+ function go(delta: number) {
407
+ window.history.go(delta)
408
+ }
409
+
410
+ return {
411
+ get action() {
412
+ return action
413
+ },
414
+ get location() {
415
+ return location
416
+ },
417
+ push,
418
+ replace,
419
+ go,
420
+ back() {
421
+ go(-1)
422
+ },
423
+ forward() {
424
+ go(1)
425
+ },
426
+ listen(listener: HistoryListener) {
427
+ listeners.add(listener)
428
+ return () => listeners.delete(listener)
429
+ },
430
+ createHref(to: To) {
431
+ const loc = createLocation(to)
432
+ return createHashHref(loc)
433
+ },
434
+ block(blocker: Blocker) {
435
+ blockers.add(blocker)
436
+
437
+ if (blockers.size === 1) {
438
+ window.addEventListener('beforeunload', handleBeforeUnload)
439
+ }
440
+
441
+ return () => {
442
+ blockers.delete(blocker)
443
+ if (blockers.size === 0) {
444
+ window.removeEventListener('beforeunload', handleBeforeUnload)
445
+ }
446
+ }
447
+ },
448
+ }
449
+ }
450
+
451
+ // ============================================================================
452
+ // Memory History
453
+ // ============================================================================
454
+
455
+ /**
456
+ * Create a memory history instance that keeps history in memory.
457
+ * Useful for testing and server-side rendering.
458
+ */
459
+ export function createMemoryHistory(
460
+ options: {
461
+ initialEntries?: string[]
462
+ initialIndex?: number
463
+ } = {},
464
+ ): History {
465
+ const { initialEntries = ['/'], initialIndex } = options
466
+ const listeners = new Set<HistoryListener>()
467
+ const blockers = new Set<Blocker>()
468
+
469
+ // Initialize entries
470
+ const entries: Location[] = initialEntries.map((entry, i) => createLocation(entry, null, `${i}`))
471
+
472
+ let index = initialIndex ?? entries.length - 1
473
+ let action: HistoryAction = 'POP'
474
+
475
+ // Clamp index to valid range
476
+ index = Math.max(0, Math.min(index, entries.length - 1))
477
+
478
+ function notifyListeners() {
479
+ const location = entries[index]!
480
+ for (const listener of listeners) {
481
+ listener({ action, location })
482
+ }
483
+ }
484
+
485
+ function push(to: To, state?: unknown) {
486
+ const nextLocation = createLocation(to, state)
487
+ const nextAction: HistoryAction = 'PUSH'
488
+
489
+ // Check blockers
490
+ if (blockers.size > 0) {
491
+ let blocked = false
492
+ const retry = () => push(to, state)
493
+
494
+ for (const blocker of blockers) {
495
+ blocker({
496
+ action: nextAction,
497
+ location: nextLocation,
498
+ retry,
499
+ })
500
+ blocked = true
501
+ break
502
+ }
503
+
504
+ if (blocked) return
505
+ }
506
+
507
+ action = nextAction
508
+
509
+ // Remove any entries after the current index
510
+ entries.splice(index + 1)
511
+
512
+ // Add the new entry
513
+ entries.push(nextLocation)
514
+ index = entries.length - 1
515
+
516
+ notifyListeners()
517
+ }
518
+
519
+ function replace(to: To, state?: unknown) {
520
+ const nextLocation = createLocation(to, state)
521
+ const nextAction: HistoryAction = 'REPLACE'
522
+
523
+ // Check blockers
524
+ if (blockers.size > 0) {
525
+ let blocked = false
526
+ const retry = () => replace(to, state)
527
+
528
+ for (const blocker of blockers) {
529
+ blocker({
530
+ action: nextAction,
531
+ location: nextLocation,
532
+ retry,
533
+ })
534
+ blocked = true
535
+ break
536
+ }
537
+
538
+ if (blocked) return
539
+ }
540
+
541
+ action = nextAction
542
+ entries[index] = nextLocation
543
+
544
+ notifyListeners()
545
+ }
546
+
547
+ function go(delta: number) {
548
+ const nextIndex = Math.max(0, Math.min(index + delta, entries.length - 1))
549
+
550
+ if (nextIndex === index) return
551
+
552
+ const nextLocation = entries[nextIndex]!
553
+ const nextAction: HistoryAction = 'POP'
554
+
555
+ // Check blockers
556
+ if (blockers.size > 0) {
557
+ let blocked = false
558
+ const retry = () => go(delta)
559
+
560
+ for (const blocker of blockers) {
561
+ blocker({
562
+ action: nextAction,
563
+ location: nextLocation,
564
+ retry,
565
+ })
566
+ blocked = true
567
+ break
568
+ }
569
+
570
+ if (blocked) return
571
+ }
572
+
573
+ action = nextAction
574
+ index = nextIndex
575
+
576
+ notifyListeners()
577
+ }
578
+
579
+ return {
580
+ get action() {
581
+ return action
582
+ },
583
+ get location() {
584
+ return entries[index]!
585
+ },
586
+ push,
587
+ replace,
588
+ go,
589
+ back() {
590
+ go(-1)
591
+ },
592
+ forward() {
593
+ go(1)
594
+ },
595
+ listen(listener: HistoryListener) {
596
+ listeners.add(listener)
597
+ return () => listeners.delete(listener)
598
+ },
599
+ createHref(to: To) {
600
+ const loc = typeof to === 'string' ? parseURL(to) : to
601
+ return createURL(loc as Location)
602
+ },
603
+ block(blocker: Blocker) {
604
+ blockers.add(blocker)
605
+ return () => blockers.delete(blocker)
606
+ },
607
+ }
608
+ }
609
+
610
+ // ============================================================================
611
+ // Static History (for SSR)
612
+ // ============================================================================
613
+
614
+ /**
615
+ * Create a static history for server-side rendering.
616
+ * This history doesn't support navigation and always returns the initial location.
617
+ */
618
+ export function createStaticHistory(url: string): History {
619
+ const location = createLocation(url)
620
+
621
+ return {
622
+ get action(): HistoryAction {
623
+ return 'POP'
624
+ },
625
+ get location() {
626
+ return location
627
+ },
628
+ push() {
629
+ // No-op on server
630
+ console.warn('[fict-router] Cannot push on static history (SSR)')
631
+ },
632
+ replace() {
633
+ // No-op on server
634
+ console.warn('[fict-router] Cannot replace on static history (SSR)')
635
+ },
636
+ go() {
637
+ // No-op on server
638
+ console.warn('[fict-router] Cannot go on static history (SSR)')
639
+ },
640
+ back() {
641
+ // No-op on server
642
+ },
643
+ forward() {
644
+ // No-op on server
645
+ },
646
+ listen() {
647
+ // No-op on server
648
+ return () => {}
649
+ },
650
+ createHref(to: To) {
651
+ const loc = typeof to === 'string' ? parseURL(to) : to
652
+ return createURL(loc as Location)
653
+ },
654
+ block() {
655
+ // No-op on server
656
+ return () => {}
657
+ },
658
+ }
659
+ }