@tanstack/history 1.87.6 → 1.95.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/index.ts CHANGED
@@ -5,21 +5,37 @@
5
5
  export interface NavigateOptions {
6
6
  ignoreBlocker?: boolean
7
7
  }
8
+
9
+ type SubscriberHistoryAction =
10
+ | {
11
+ type: HistoryAction
12
+ }
13
+ | {
14
+ type: 'GO'
15
+ index: number
16
+ }
17
+
18
+ type SubscriberArgs = {
19
+ location: HistoryLocation
20
+ action: SubscriberHistoryAction
21
+ }
22
+
8
23
  export interface RouterHistory {
9
24
  location: HistoryLocation
10
25
  length: number
11
- subscribers: Set<(opts: { location: HistoryLocation }) => void>
12
- subscribe: (cb: (opts: { location: HistoryLocation }) => void) => () => void
26
+ subscribers: Set<(opts: SubscriberArgs) => void>
27
+ subscribe: (cb: (opts: SubscriberArgs) => void) => () => void
13
28
  push: (path: string, state?: any, navigateOpts?: NavigateOptions) => void
14
29
  replace: (path: string, state?: any, navigateOpts?: NavigateOptions) => void
15
30
  go: (index: number, navigateOpts?: NavigateOptions) => void
16
31
  back: (navigateOpts?: NavigateOptions) => void
17
32
  forward: (navigateOpts?: NavigateOptions) => void
33
+ canGoBack: () => boolean
18
34
  createHref: (href: string) => string
19
- block: (blocker: BlockerFn) => () => void
35
+ block: (blocker: NavigationBlocker) => () => void
20
36
  flush: () => void
21
37
  destroy: () => void
22
- notify: () => void
38
+ notify: (action: SubscriberHistoryAction) => void
23
39
  _ignoreSubscribers?: boolean
24
40
  }
25
41
 
@@ -36,62 +52,101 @@ export interface ParsedPath {
36
52
 
37
53
  export interface HistoryState {
38
54
  key?: string
55
+ __TSR_index: number
39
56
  }
40
57
 
41
58
  type ShouldAllowNavigation = any
42
59
 
43
- export type BlockerFn = () =>
44
- | Promise<ShouldAllowNavigation>
45
- | ShouldAllowNavigation
46
-
47
- const pushStateEvent = 'pushstate'
48
- const popStateEvent = 'popstate'
49
- const beforeUnloadEvent = 'beforeunload'
60
+ export type HistoryAction = 'PUSH' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'
50
61
 
51
- const beforeUnloadListener = (event: Event) => {
52
- event.preventDefault()
53
- // @ts-expect-error
54
- return (event.returnValue = '')
62
+ export type BlockerFnArgs = {
63
+ currentLocation: HistoryLocation
64
+ nextLocation: HistoryLocation
65
+ action: HistoryAction
55
66
  }
56
67
 
57
- const stopBlocking = () => {
58
- removeEventListener(beforeUnloadEvent, beforeUnloadListener, {
59
- capture: true,
60
- })
68
+ export type BlockerFn = (
69
+ args: BlockerFnArgs,
70
+ ) => Promise<ShouldAllowNavigation> | ShouldAllowNavigation
71
+
72
+ export type NavigationBlocker = {
73
+ blockerFn: BlockerFn
74
+ enableBeforeUnload?: (() => boolean) | boolean
61
75
  }
62
76
 
77
+ type TryNavigateArgs = {
78
+ task: () => void
79
+ type: 'PUSH' | 'REPLACE' | 'BACK' | 'FORWARD' | 'GO'
80
+ navigateOpts?: NavigateOptions
81
+ } & (
82
+ | {
83
+ type: 'PUSH' | 'REPLACE'
84
+ path: string
85
+ state: any
86
+ }
87
+ | {
88
+ type: 'BACK' | 'FORWARD' | 'GO'
89
+ }
90
+ )
91
+
92
+ const stateIndexKey = '__TSR_index'
93
+ const popStateEvent = 'popstate'
94
+ const beforeUnloadEvent = 'beforeunload'
95
+
63
96
  export function createHistory(opts: {
64
97
  getLocation: () => HistoryLocation
65
98
  getLength: () => number
66
99
  pushState: (path: string, state: any) => void
67
100
  replaceState: (path: string, state: any) => void
68
101
  go: (n: number) => void
69
- back: () => void
70
- forward: () => void
102
+ back: (ignoreBlocker: boolean) => void
103
+ forward: (ignoreBlocker: boolean) => void
71
104
  createHref: (path: string) => string
72
105
  flush?: () => void
73
106
  destroy?: () => void
74
- onBlocked?: (onUpdate: () => void) => void
107
+ onBlocked?: () => void
108
+ getBlockers?: () => Array<NavigationBlocker>
109
+ setBlockers?: (blockers: Array<NavigationBlocker>) => void
110
+ // Avoid notifying on forward/back/go, used for browser history as we already get notified by the popstate event
111
+ notifyOnIndexChange?: boolean
75
112
  }): RouterHistory {
76
113
  let location = opts.getLocation()
77
- const subscribers = new Set<(opts: { location: HistoryLocation }) => void>()
78
- let blockers: Array<BlockerFn> = []
114
+ const subscribers = new Set<(opts: SubscriberArgs) => void>()
79
115
 
80
- const notify = () => {
116
+ const notify = (action: SubscriberHistoryAction) => {
81
117
  location = opts.getLocation()
82
- subscribers.forEach((subscriber) => subscriber({ location }))
118
+ subscribers.forEach((subscriber) => subscriber({ location, action }))
83
119
  }
84
120
 
85
- const tryNavigation = async (
86
- task: () => void,
87
- navigateOpts?: NavigateOptions,
88
- ) => {
121
+ const handleIndexChange = (action: SubscriberHistoryAction) => {
122
+ if (opts.notifyOnIndexChange ?? true) notify(action)
123
+ else location = opts.getLocation()
124
+ }
125
+
126
+ const tryNavigation = async ({
127
+ task,
128
+ navigateOpts,
129
+ ...actionInfo
130
+ }: TryNavigateArgs) => {
89
131
  const ignoreBlocker = navigateOpts?.ignoreBlocker ?? false
90
- if (!ignoreBlocker && typeof document !== 'undefined' && blockers.length) {
132
+ if (ignoreBlocker) {
133
+ task()
134
+ return
135
+ }
136
+
137
+ const blockers = opts.getBlockers?.() ?? []
138
+ const isPushOrReplace =
139
+ actionInfo.type === 'PUSH' || actionInfo.type === 'REPLACE'
140
+ if (typeof document !== 'undefined' && blockers.length && isPushOrReplace) {
91
141
  for (const blocker of blockers) {
92
- const allowed = await blocker()
93
- if (!allowed) {
94
- opts.onBlocked?.(notify)
142
+ const nextLocation = parseHref(actionInfo.path, actionInfo.state)
143
+ const isBlocked = await blocker.blockerFn({
144
+ currentLocation: location,
145
+ nextLocation,
146
+ action: actionInfo.type,
147
+ })
148
+ if (isBlocked) {
149
+ opts.onBlocked?.()
95
150
  return
96
151
  }
97
152
  }
@@ -108,7 +163,7 @@ export function createHistory(opts: {
108
163
  return opts.getLength()
109
164
  },
110
165
  subscribers,
111
- subscribe: (cb: (opts: { location: HistoryLocation }) => void) => {
166
+ subscribe: (cb: (opts: SubscriberArgs) => void) => {
112
167
  subscribers.add(cb)
113
168
 
114
169
  return () => {
@@ -116,53 +171,73 @@ export function createHistory(opts: {
116
171
  }
117
172
  },
118
173
  push: (path, state, navigateOpts) => {
119
- state = assignKey(state)
120
- tryNavigation(() => {
121
- opts.pushState(path, state)
122
- notify()
123
- }, navigateOpts)
174
+ const currentIndex = location.state[stateIndexKey]
175
+ state = assignKeyAndIndex(currentIndex + 1, state)
176
+ tryNavigation({
177
+ task: () => {
178
+ opts.pushState(path, state)
179
+ notify({ type: 'PUSH' })
180
+ },
181
+ navigateOpts,
182
+ type: 'PUSH',
183
+ path,
184
+ state,
185
+ })
124
186
  },
125
187
  replace: (path, state, navigateOpts) => {
126
- state = assignKey(state)
127
- tryNavigation(() => {
128
- opts.replaceState(path, state)
129
- notify()
130
- }, navigateOpts)
188
+ const currentIndex = location.state[stateIndexKey]
189
+ state = assignKeyAndIndex(currentIndex, state)
190
+ tryNavigation({
191
+ task: () => {
192
+ opts.replaceState(path, state)
193
+ notify({ type: 'REPLACE' })
194
+ },
195
+ navigateOpts,
196
+ type: 'REPLACE',
197
+ path,
198
+ state,
199
+ })
131
200
  },
132
201
  go: (index, navigateOpts) => {
133
- tryNavigation(() => {
134
- opts.go(index)
135
- notify()
136
- }, navigateOpts)
202
+ tryNavigation({
203
+ task: () => {
204
+ opts.go(index)
205
+ handleIndexChange({ type: 'GO', index })
206
+ },
207
+ navigateOpts,
208
+ type: 'GO',
209
+ })
137
210
  },
138
211
  back: (navigateOpts) => {
139
- tryNavigation(() => {
140
- opts.back()
141
- notify()
142
- }, navigateOpts)
212
+ tryNavigation({
213
+ task: () => {
214
+ opts.back(navigateOpts?.ignoreBlocker ?? false)
215
+ handleIndexChange({ type: 'BACK' })
216
+ },
217
+ navigateOpts,
218
+ type: 'BACK',
219
+ })
143
220
  },
144
221
  forward: (navigateOpts) => {
145
- tryNavigation(() => {
146
- opts.forward()
147
- notify()
148
- }, navigateOpts)
222
+ tryNavigation({
223
+ task: () => {
224
+ opts.forward(navigateOpts?.ignoreBlocker ?? false)
225
+ handleIndexChange({ type: 'FORWARD' })
226
+ },
227
+ navigateOpts,
228
+ type: 'FORWARD',
229
+ })
149
230
  },
231
+ canGoBack: () => location.state[stateIndexKey] !== 0,
150
232
  createHref: (str) => opts.createHref(str),
151
233
  block: (blocker) => {
152
- blockers.push(blocker)
153
-
154
- if (blockers.length === 1) {
155
- addEventListener(beforeUnloadEvent, beforeUnloadListener, {
156
- capture: true,
157
- })
158
- }
234
+ if (!opts.setBlockers) return () => {}
235
+ const blockers = opts.getBlockers?.() ?? []
236
+ opts.setBlockers([...blockers, blocker])
159
237
 
160
238
  return () => {
161
- blockers = blockers.filter((b) => b !== blocker)
162
-
163
- if (!blockers.length) {
164
- stopBlocking()
165
- }
239
+ const blockers = opts.getBlockers?.() ?? []
240
+ opts.setBlockers?.(blockers.filter((b) => b !== blocker))
166
241
  }
167
242
  },
168
243
  flush: () => opts.flush?.(),
@@ -171,13 +246,14 @@ export function createHistory(opts: {
171
246
  }
172
247
  }
173
248
 
174
- function assignKey(state: HistoryState | undefined) {
249
+ function assignKeyAndIndex(index: number, state: HistoryState | undefined) {
175
250
  if (!state) {
176
251
  state = {} as HistoryState
177
252
  }
178
253
  return {
179
254
  ...state,
180
255
  key: createRandomKey(),
256
+ [stateIndexKey]: index,
181
257
  }
182
258
  }
183
259
 
@@ -209,6 +285,11 @@ export function createBrowserHistory(opts?: {
209
285
  const originalPushState = win.history.pushState
210
286
  const originalReplaceState = win.history.replaceState
211
287
 
288
+ let blockers: Array<NavigationBlocker> = []
289
+ const _getBlockers = () => blockers
290
+ const _setBlockers = (newBlockers: Array<NavigationBlocker>) =>
291
+ (blockers = newBlockers)
292
+
212
293
  const createHref = opts?.createHref ?? ((path) => path)
213
294
  const parseLocation =
214
295
  opts?.parseLocation ??
@@ -221,6 +302,11 @@ export function createBrowserHistory(opts?: {
221
302
  let currentLocation = parseLocation()
222
303
  let rollbackLocation: HistoryLocation | undefined
223
304
 
305
+ let nextPopIsGo = false
306
+ let ignoreNextPop = false
307
+ let skipBlockerNextPop = false
308
+ let ignoreNextBeforeUnload = false
309
+
224
310
  const getLocation = () => currentLocation
225
311
 
226
312
  let next:
@@ -291,9 +377,94 @@ export function createBrowserHistory(opts?: {
291
377
  }
292
378
  }
293
379
 
294
- const onPushPop = () => {
380
+ // NOTE: this function can probably be removed
381
+ const onPushPop = (type: 'PUSH' | 'REPLACE') => {
295
382
  currentLocation = parseLocation()
296
- history.notify()
383
+ history.notify({ type })
384
+ }
385
+
386
+ const onPushPopEvent = async () => {
387
+ if (ignoreNextPop) {
388
+ ignoreNextPop = false
389
+ return
390
+ }
391
+
392
+ const nextLocation = parseLocation()
393
+ const delta =
394
+ nextLocation.state[stateIndexKey] - currentLocation.state[stateIndexKey]
395
+ const isForward = delta === 1
396
+ const isBack = delta === -1
397
+ const isGo = (!isForward && !isBack) || nextPopIsGo
398
+ nextPopIsGo = false
399
+
400
+ const action = isGo ? 'GO' : isBack ? 'BACK' : 'FORWARD'
401
+ const notify: SubscriberHistoryAction = isGo
402
+ ? {
403
+ type: 'GO',
404
+ index: delta,
405
+ }
406
+ : {
407
+ type: isBack ? 'BACK' : 'FORWARD',
408
+ }
409
+
410
+ if (skipBlockerNextPop) {
411
+ skipBlockerNextPop = false
412
+ } else {
413
+ const blockers = _getBlockers()
414
+ if (typeof document !== 'undefined' && blockers.length) {
415
+ for (const blocker of blockers) {
416
+ const isBlocked = await blocker.blockerFn({
417
+ currentLocation,
418
+ nextLocation,
419
+ action,
420
+ })
421
+ if (isBlocked) {
422
+ ignoreNextPop = true
423
+ win.history.go(1)
424
+ history.notify(notify)
425
+ return
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ currentLocation = parseLocation()
432
+ history.notify(notify)
433
+ }
434
+
435
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
436
+ if (ignoreNextBeforeUnload) {
437
+ ignoreNextBeforeUnload = false
438
+ return
439
+ }
440
+
441
+ let shouldBlock = false
442
+
443
+ // If one blocker has a non-disabled beforeUnload, we should block
444
+ const blockers = _getBlockers()
445
+ if (typeof document !== 'undefined' && blockers.length) {
446
+ for (const blocker of blockers) {
447
+ const shouldHaveBeforeUnload = blocker.enableBeforeUnload ?? true
448
+ if (shouldHaveBeforeUnload === true) {
449
+ shouldBlock = true
450
+ break
451
+ }
452
+
453
+ if (
454
+ typeof shouldHaveBeforeUnload === 'function' &&
455
+ shouldHaveBeforeUnload() === true
456
+ ) {
457
+ shouldBlock = true
458
+ break
459
+ }
460
+ }
461
+ }
462
+
463
+ if (shouldBlock) {
464
+ e.preventDefault()
465
+ return (e.returnValue = '')
466
+ }
467
+ return
297
468
  }
298
469
 
299
470
  const history = createHistory({
@@ -301,40 +472,54 @@ export function createBrowserHistory(opts?: {
301
472
  getLength: () => win.history.length,
302
473
  pushState: (href, state) => queueHistoryAction('push', href, state),
303
474
  replaceState: (href, state) => queueHistoryAction('replace', href, state),
304
- back: () => win.history.back(),
305
- forward: () => win.history.forward(),
306
- go: (n) => win.history.go(n),
475
+ back: (ignoreBlocker) => {
476
+ if (ignoreBlocker) skipBlockerNextPop = true
477
+ ignoreNextBeforeUnload = true
478
+ return win.history.back()
479
+ },
480
+ forward: (ignoreBlocker) => {
481
+ if (ignoreBlocker) skipBlockerNextPop = true
482
+ ignoreNextBeforeUnload = true
483
+ win.history.forward()
484
+ },
485
+ go: (n) => {
486
+ nextPopIsGo = true
487
+ win.history.go(n)
488
+ },
307
489
  createHref: (href) => createHref(href),
308
490
  flush,
309
491
  destroy: () => {
310
492
  win.history.pushState = originalPushState
311
493
  win.history.replaceState = originalReplaceState
312
- win.removeEventListener(pushStateEvent, onPushPop)
313
- win.removeEventListener(popStateEvent, onPushPop)
494
+ win.removeEventListener(beforeUnloadEvent, onBeforeUnload, {
495
+ capture: true,
496
+ })
497
+ win.removeEventListener(popStateEvent, onPushPopEvent)
314
498
  },
315
- onBlocked: (onUpdate) => {
499
+ onBlocked: () => {
316
500
  // If a navigation is blocked, we need to rollback the location
317
501
  // that we optimistically updated in memory.
318
502
  if (rollbackLocation && currentLocation !== rollbackLocation) {
319
503
  currentLocation = rollbackLocation
320
- // Notify subscribers
321
- onUpdate()
322
504
  }
323
505
  },
506
+ getBlockers: _getBlockers,
507
+ setBlockers: _setBlockers,
508
+ notifyOnIndexChange: false,
324
509
  })
325
510
 
326
- win.addEventListener(pushStateEvent, onPushPop)
327
- win.addEventListener(popStateEvent, onPushPop)
511
+ win.addEventListener(beforeUnloadEvent, onBeforeUnload, { capture: true })
512
+ win.addEventListener(popStateEvent, onPushPopEvent)
328
513
 
329
514
  win.history.pushState = function (...args: Array<any>) {
330
- const res = originalPushState.apply(win.history, args)
331
- if (!history._ignoreSubscribers) onPushPop()
515
+ const res = originalPushState.apply(win.history, args as any)
516
+ if (!history._ignoreSubscribers) onPushPop('PUSH')
332
517
  return res
333
518
  }
334
519
 
335
520
  win.history.replaceState = function (...args: Array<any>) {
336
- const res = originalReplaceState.apply(win.history, args)
337
- if (!history._ignoreSubscribers) onPushPop()
521
+ const res = originalReplaceState.apply(win.history, args as any)
522
+ if (!history._ignoreSubscribers) onPushPop('REPLACE')
338
523
  return res
339
524
  }
340
525
 
@@ -365,8 +550,12 @@ export function createMemoryHistory(
365
550
  },
366
551
  ): RouterHistory {
367
552
  const entries = opts.initialEntries
368
- let index = opts.initialIndex ?? entries.length - 1
369
- const states = entries.map(() => ({}) as HistoryState)
553
+ let index = opts.initialIndex
554
+ ? Math.min(Math.max(opts.initialIndex, 0), entries.length - 1)
555
+ : entries.length - 1
556
+ const states = entries.map<HistoryState>((_entry, index) =>
557
+ assignKeyAndIndex(index, undefined),
558
+ )
370
559
 
371
560
  const getLocation = () => parseHref(entries[index]!, states[index])
372
561
 
@@ -424,7 +613,7 @@ export function parseHref(
424
613
  searchIndex > -1
425
614
  ? href.slice(searchIndex, hashIndex === -1 ? undefined : hashIndex)
426
615
  : '',
427
- state: state || {},
616
+ state: state || { [stateIndexKey]: 0 },
428
617
  }
429
618
  }
430
619