@tanstack/react-router 1.97.26 → 1.98.1

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.
Files changed (55) hide show
  1. package/dist/cjs/Match.cjs +78 -28
  2. package/dist/cjs/Match.cjs.map +1 -1
  3. package/dist/cjs/Matches.cjs +4 -1
  4. package/dist/cjs/Matches.cjs.map +1 -1
  5. package/dist/cjs/Matches.d.cts +2 -0
  6. package/dist/cjs/ScriptOnce.cjs +1 -1
  7. package/dist/cjs/ScriptOnce.cjs.map +1 -1
  8. package/dist/cjs/ScrollRestoration.cjs +39 -0
  9. package/dist/cjs/ScrollRestoration.cjs.map +1 -0
  10. package/dist/cjs/ScrollRestoration.d.cts +15 -0
  11. package/dist/cjs/Transitioner.cjs +3 -33
  12. package/dist/cjs/Transitioner.cjs.map +1 -1
  13. package/dist/cjs/index.cjs +3 -4
  14. package/dist/cjs/index.cjs.map +1 -1
  15. package/dist/cjs/index.d.cts +2 -3
  16. package/dist/cjs/route.cjs.map +1 -1
  17. package/dist/cjs/route.d.cts +10 -2
  18. package/dist/cjs/router.cjs +37 -23
  19. package/dist/cjs/router.cjs.map +1 -1
  20. package/dist/cjs/router.d.cts +28 -27
  21. package/dist/cjs/scroll-restoration.cjs +168 -165
  22. package/dist/cjs/scroll-restoration.cjs.map +1 -1
  23. package/dist/cjs/scroll-restoration.d.cts +25 -15
  24. package/dist/esm/Match.js +80 -30
  25. package/dist/esm/Match.js.map +1 -1
  26. package/dist/esm/Matches.d.ts +2 -0
  27. package/dist/esm/Matches.js +4 -1
  28. package/dist/esm/Matches.js.map +1 -1
  29. package/dist/esm/ScriptOnce.js +1 -1
  30. package/dist/esm/ScriptOnce.js.map +1 -1
  31. package/dist/esm/ScrollRestoration.d.ts +15 -0
  32. package/dist/esm/ScrollRestoration.js +39 -0
  33. package/dist/esm/ScrollRestoration.js.map +1 -0
  34. package/dist/esm/Transitioner.js +4 -34
  35. package/dist/esm/Transitioner.js.map +1 -1
  36. package/dist/esm/index.d.ts +2 -3
  37. package/dist/esm/index.js +1 -2
  38. package/dist/esm/route.d.ts +10 -2
  39. package/dist/esm/route.js.map +1 -1
  40. package/dist/esm/router.d.ts +28 -27
  41. package/dist/esm/router.js +38 -24
  42. package/dist/esm/router.js.map +1 -1
  43. package/dist/esm/scroll-restoration.d.ts +25 -15
  44. package/dist/esm/scroll-restoration.js +168 -148
  45. package/dist/esm/scroll-restoration.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/Match.tsx +101 -54
  48. package/src/Matches.tsx +3 -1
  49. package/src/ScriptOnce.tsx +1 -1
  50. package/src/ScrollRestoration.tsx +65 -0
  51. package/src/Transitioner.tsx +4 -40
  52. package/src/index.tsx +3 -3
  53. package/src/route.ts +38 -1
  54. package/src/router.ts +75 -49
  55. package/src/scroll-restoration.tsx +271 -183
@@ -1,241 +1,329 @@
1
- import * as React from 'react'
2
1
  import { functionalUpdate } from '@tanstack/router-core'
3
2
  import { useRouter } from './useRouter'
3
+ import { ScriptOnce } from './ScriptOnce'
4
+ import type { AnyRouter } from './router'
4
5
  import type { NonNullableUpdater, ParsedLocation } from '@tanstack/router-core'
5
6
 
6
- const useLayoutEffect =
7
- typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
7
+ export type ScrollRestorationEntry = { scrollX: number; scrollY: number }
8
8
 
9
- const windowKey = 'window'
10
- const delimiter = '___'
9
+ export type ScrollRestorationByElement = Record<string, ScrollRestorationEntry>
11
10
 
12
- let weakScrolledElements = new WeakSet<any>()
11
+ export type ScrollRestorationByKey = Record<string, ScrollRestorationByElement>
13
12
 
14
- type CacheValue = Record<string, { scrollX: number; scrollY: number }>
15
- type CacheState = {
16
- cached: CacheValue
17
- next: CacheValue
13
+ export type ScrollRestorationCache = {
14
+ state: ScrollRestorationByKey
15
+ set: (updater: NonNullableUpdater<ScrollRestorationByKey>) => void
18
16
  }
19
-
20
- type Cache = {
21
- state: CacheState
22
- set: (updater: NonNullableUpdater<CacheState>) => void
17
+ export type ScrollRestorationOptions = {
18
+ getKey?: (location: ParsedLocation) => string
19
+ scrollBehavior?: ScrollToOptions['behavior']
23
20
  }
24
21
 
22
+ export const storageKey = 'tsr-scroll-restoration-v1_3'
25
23
  const sessionsStorage = typeof window !== 'undefined' && window.sessionStorage
26
-
27
- const cache: Cache = sessionsStorage
24
+ const throttle = (fn: (...args: Array<any>) => void, wait: number) => {
25
+ let timeout: any
26
+ return (...args: Array<any>) => {
27
+ if (!timeout) {
28
+ timeout = setTimeout(() => {
29
+ fn(...args)
30
+ timeout = null
31
+ }, wait)
32
+ }
33
+ }
34
+ }
35
+ export const scrollRestorationCache: ScrollRestorationCache = sessionsStorage
28
36
  ? (() => {
29
- const storageKey = 'tsr-scroll-restoration-v2'
30
-
31
- const state: CacheState = JSON.parse(
32
- window.sessionStorage.getItem(storageKey) || 'null',
33
- ) || { cached: {}, next: {} }
37
+ const state: ScrollRestorationByKey =
38
+ JSON.parse(window.sessionStorage.getItem(storageKey) || 'null') || {}
34
39
 
35
40
  return {
36
41
  state,
37
- set: (updater) => {
38
- cache.state = functionalUpdate(updater, cache.state)
39
- window.sessionStorage.setItem(storageKey, JSON.stringify(cache.state))
40
- },
42
+ // This setter is simply to make sure that we set the sessionStorage right
43
+ // after the state is updated. It doesn't necessarily need to be a functional
44
+ // update.
45
+ set: (updater) => (
46
+ (scrollRestorationCache.state =
47
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
48
+ functionalUpdate(updater, scrollRestorationCache.state) ||
49
+ scrollRestorationCache.state),
50
+ window.sessionStorage.setItem(
51
+ storageKey,
52
+ JSON.stringify(scrollRestorationCache.state),
53
+ )
54
+ ),
41
55
  }
42
56
  })()
43
57
  : (undefined as any)
44
-
45
- export type ScrollRestorationOptions = {
46
- getKey?: (location: ParsedLocation) => string
47
- scrollBehavior?: ScrollToOptions['behavior']
48
- }
49
-
50
58
  /**
51
59
  * The default `getKey` function for `useScrollRestoration`.
52
60
  * It returns the `key` from the location state or the `href` of the location.
53
61
  *
54
62
  * The `location.href` is used as a fallback to support the use case where the location state is not available like the initial render.
55
63
  */
56
- const defaultGetKey = (location: ParsedLocation) => {
64
+
65
+ export const defaultGetScrollRestorationKey = (location: ParsedLocation) => {
57
66
  return location.state.key! || location.href
58
67
  }
59
68
 
60
- export function useScrollRestoration(options?: ScrollRestorationOptions) {
61
- const router = useRouter()
69
+ export function getCssSelector(el: any): string {
70
+ const path = []
71
+ let parent
72
+ while ((parent = el.parentNode)) {
73
+ path.unshift(
74
+ `${el.tagName}:nth-child(${([].indexOf as any).call(parent.children, el) + 1})`,
75
+ )
76
+ el = parent
77
+ }
78
+ return `${path.join(' > ')}`.toLowerCase()
79
+ }
62
80
 
63
- useLayoutEffect(() => {
64
- const getKey = options?.getKey || defaultGetKey
81
+ let ignoreScroll = false
82
+
83
+ // NOTE: This function must remain pure and not use any outside variables
84
+ // unless they are passed in as arguments. Why? Because we need to be able to
85
+ // toString() it into a script tag to execute as early as possible in the browser
86
+ // during SSR. Additionally, we also call it from within the router lifecycle
87
+ export function restoreScroll(
88
+ storageKey: string,
89
+ key?: string,
90
+ behavior?: ScrollToOptions['behavior'],
91
+ shouldScrollRestoration?: boolean,
92
+ ) {
93
+ let byKey: ScrollRestorationByKey
65
94
 
66
- const { history } = window
67
- history.scrollRestoration = 'manual'
95
+ try {
96
+ byKey = JSON.parse(sessionStorage.getItem(storageKey) || '{}')
97
+ } catch (error: any) {
98
+ console.error(error)
99
+ return
100
+ }
68
101
 
69
- const onScroll = (event: Event) => {
70
- if (weakScrolledElements.has(event.target)) return
71
- weakScrolledElements.add(event.target)
102
+ const resolvedKey = key || window.history.state?.key
103
+ const elementEntries = byKey[resolvedKey]
104
+
105
+ //
106
+ ignoreScroll = true
107
+
108
+ //
109
+ ;(() => {
110
+ // If we have a cached entry for this location state,
111
+ // we always need to prefer that over the hash scroll.
112
+ if (shouldScrollRestoration && elementEntries) {
113
+ for (const elementSelector in elementEntries) {
114
+ const entry = elementEntries[elementSelector]!
115
+ if (elementSelector === 'window') {
116
+ console.log('windowRestored', entry)
117
+ window.scrollTo({
118
+ top: entry.scrollY,
119
+ left: entry.scrollX,
120
+ behavior,
121
+ })
122
+ } else if (elementSelector) {
123
+ const element = document.querySelector(elementSelector)
124
+ if (element) {
125
+ element.scrollLeft = entry.scrollX
126
+ element.scrollTop = entry.scrollY
127
+ }
128
+ }
129
+ }
72
130
 
73
- let elementSelector = ''
131
+ return
132
+ }
74
133
 
75
- if (event.target === document || event.target === window) {
76
- elementSelector = windowKey
77
- } else {
78
- const attrId = (event.target as Element).getAttribute(
79
- 'data-scroll-restoration-id',
80
- )
81
-
82
- if (attrId) {
83
- elementSelector = `[data-scroll-restoration-id="${attrId}"]`
84
- } else {
85
- elementSelector = getCssSelector(event.target)
134
+ // If we don't have a cached entry for the hash,
135
+ // Which means we've never seen this location before,
136
+ // we need to check if there is a hash in the URL.
137
+ // If there is, we need to scroll it's ID into view.
138
+ const hash = window.location.hash.split('#')[1]
139
+
140
+ if (hash) {
141
+ const hashScrollIntoViewOptions =
142
+ (window.history.state || {}).__hashScrollIntoViewOptions ?? true
143
+
144
+ if (hashScrollIntoViewOptions) {
145
+ console.log('scrollIntoView')
146
+ const el = document.getElementById(hash)
147
+ if (el) {
148
+ el.scrollIntoView(hashScrollIntoViewOptions)
86
149
  }
87
150
  }
88
151
 
89
- if (!cache.state.next[elementSelector]) {
90
- cache.set((c) => ({
91
- ...c,
92
- next: {
93
- ...c.next,
94
- [elementSelector]: {
95
- scrollX: NaN,
96
- scrollY: NaN,
97
- },
98
- },
99
- }))
100
- }
152
+ return
101
153
  }
102
154
 
103
- if (typeof document !== 'undefined') {
104
- document.addEventListener('scroll', onScroll, true)
155
+ // If there is no cached entry for the hash and there is no hash in the URL,
156
+ // we need to scroll to the top of the page.
157
+ window.scrollTo({
158
+ top: 0,
159
+ left: 0,
160
+ behavior,
161
+ })
162
+ })()
163
+
164
+ //
165
+ ignoreScroll = false
166
+ }
167
+
168
+ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
169
+ const shouldScrollRestoration =
170
+ force ?? router.options.scrollRestoration ?? false
171
+
172
+ if (shouldScrollRestoration) {
173
+ router.isScrollRestoring = true
174
+ }
175
+
176
+ if (typeof document === 'undefined' || router.isScrollRestorationSetup) {
177
+ return
178
+ }
179
+
180
+ router.isScrollRestorationSetup = true
181
+
182
+ //
183
+ ignoreScroll = false
184
+
185
+ const getKey =
186
+ router.options.getScrollRestorationKey || defaultGetScrollRestorationKey
187
+
188
+ window.history.scrollRestoration = 'manual'
189
+
190
+ // // Create a MutationObserver to monitor DOM changes
191
+ // const mutationObserver = new MutationObserver(() => {
192
+ // ;ignoreScroll = true
193
+ // requestAnimationFrame(() => {
194
+ // ;ignoreScroll = false
195
+
196
+ // // Attempt to restore scroll position on each dom
197
+ // // mutation until the user scrolls. We do this
198
+ // // because dynamic content may come in at different
199
+ // // ticks after the initial render and we want to
200
+ // // keep up with that content as much as possible.
201
+ // // As soon as the user scrolls, we no longer need
202
+ // // to attempt router.
203
+ // // console.log('mutation observer restoreScroll')
204
+ // restoreScroll(
205
+ // storageKey,
206
+ // getKey(router.state.location),
207
+ // router.options.scrollRestorationBehavior,
208
+ // )
209
+ // })
210
+ // })
211
+
212
+ // const observeDom = () => {
213
+ // // Observe changes to the entire document
214
+ // mutationObserver.observe(document, {
215
+ // childList: true, // Detect added or removed child nodes
216
+ // subtree: true, // Monitor all descendants
217
+ // characterData: true, // Detect text content changes
218
+ // })
219
+ // }
220
+
221
+ // const unobserveDom = () => {
222
+ // mutationObserver.disconnect()
223
+ // }
224
+
225
+ // observeDom()
226
+
227
+ const onScroll = (event: Event) => {
228
+ // unobserveDom()
229
+
230
+ if (ignoreScroll || !router.isScrollRestoring) {
231
+ return
105
232
  }
106
233
 
107
- const unsubOnBeforeLoad = router.subscribe('onBeforeLoad', (event) => {
108
- if (event.hrefChanged) {
109
- const restoreKey = getKey(event.fromLocation)
110
- for (const elementSelector in cache.state.next) {
111
- const entry = cache.state.next[elementSelector]!
112
- if (elementSelector === windowKey) {
113
- entry.scrollX = window.scrollX || 0
114
- entry.scrollY = window.scrollY || 0
115
- } else if (elementSelector) {
116
- const element = document.querySelector(elementSelector)
117
- entry.scrollX = element?.scrollLeft || 0
118
- entry.scrollY = element?.scrollTop || 0
119
- }
234
+ let elementSelector = ''
120
235
 
121
- cache.set((c) => {
122
- const next = { ...c.next }
123
- delete next[elementSelector]
124
-
125
- return {
126
- ...c,
127
- next,
128
- cached: {
129
- ...c.cached,
130
- [[restoreKey, elementSelector].join(delimiter)]: entry,
131
- },
132
- }
133
- })
134
- }
236
+ if (event.target === document || event.target === window) {
237
+ elementSelector = 'window'
238
+ } else {
239
+ const attrId = (event.target as Element).getAttribute(
240
+ 'data-scroll-restoration-id',
241
+ )
242
+
243
+ if (attrId) {
244
+ elementSelector = `[data-scroll-restoration-id="${attrId}"]`
245
+ } else {
246
+ elementSelector = getCssSelector(event.target)
135
247
  }
136
- })
248
+ }
137
249
 
138
- const unsubOnBeforeRouteMount = router.subscribe(
139
- 'onBeforeRouteMount',
140
- (event) => {
141
- if (event.hrefChanged) {
142
- if (!router.resetNextScroll) {
143
- return
144
- }
250
+ const restoreKey = getKey(router.state.location)
145
251
 
146
- router.resetNextScroll = true
147
-
148
- const restoreKey = getKey(event.toLocation)
149
- let windowRestored = false
150
-
151
- for (const cacheKey in cache.state.cached) {
152
- const entry = cache.state.cached[cacheKey]!
153
- const [key, elementSelector] = cacheKey.split(delimiter)
154
- if (key === restoreKey) {
155
- if (elementSelector === windowKey) {
156
- windowRestored = true
157
- window.scrollTo({
158
- top: entry.scrollY,
159
- left: entry.scrollX,
160
- behavior: options?.scrollBehavior,
161
- })
162
- } else if (elementSelector) {
163
- const element = document.querySelector(elementSelector)
164
- if (element) {
165
- element.scrollLeft = entry.scrollX
166
- element.scrollTop = entry.scrollY
167
- }
168
- }
169
- }
170
- }
252
+ scrollRestorationCache.set((state) => {
253
+ const keyEntry = (state[restoreKey] =
254
+ state[restoreKey] || ({} as ScrollRestorationByElement))
171
255
 
172
- if (!windowRestored) {
173
- window.scrollTo(0, 0)
174
- }
256
+ const elementEntry = (keyEntry[elementSelector] =
257
+ keyEntry[elementSelector] || ({} as ScrollRestorationEntry))
175
258
 
176
- cache.set((c) => ({ ...c, next: {} }))
177
- weakScrolledElements = new WeakSet<any>()
259
+ if (elementSelector === 'window') {
260
+ elementEntry.scrollX = window.scrollX || 0
261
+ elementEntry.scrollY = window.scrollY || 0
262
+ } else if (elementSelector) {
263
+ const element = document.querySelector(elementSelector)
264
+ if (element) {
265
+ elementEntry.scrollX = element.scrollLeft || 0
266
+ elementEntry.scrollY = element.scrollTop || 0
178
267
  }
179
- },
180
- )
268
+ }
181
269
 
182
- return () => {
183
- document.removeEventListener('scroll', onScroll)
184
- unsubOnBeforeLoad()
185
- unsubOnBeforeRouteMount()
186
- }
187
- }, [options?.getKey, options?.scrollBehavior, router])
188
- }
270
+ return state
271
+ })
272
+ }
189
273
 
190
- export function ScrollRestoration(props: ScrollRestorationOptions) {
191
- useScrollRestoration(props)
192
- return null
193
- }
274
+ // Throttle the scroll event to avoid excessive updates
275
+ if (typeof document !== 'undefined') {
276
+ document.addEventListener('scroll', throttle(onScroll, 100), true)
277
+ }
194
278
 
195
- export function useElementScrollRestoration(
196
- options: (
197
- | {
198
- id: string
199
- getElement?: () => Element | undefined | null
200
- }
201
- | {
202
- id?: string
203
- getElement: () => Element | undefined | null
204
- }
205
- ) & {
206
- getKey?: (location: ParsedLocation) => string
207
- },
208
- ) {
209
- const router = useRouter()
210
- const getKey = options.getKey || defaultGetKey
279
+ router.subscribe('onRendered', (event) => {
280
+ // unobserveDom()
211
281
 
212
- let elementSelector = ''
282
+ const cacheKey = getKey(event.toLocation)
213
283
 
214
- if (options.id) {
215
- elementSelector = `[data-scroll-restoration-id="${options.id}"]`
216
- } else {
217
- const element = options.getElement?.()
218
- if (!element) {
284
+ // If the user doesn't want to restore the scroll position,
285
+ // we don't need to do anything.
286
+ if (!router.resetNextScroll) {
287
+ router.resetNextScroll = true
219
288
  return
220
289
  }
221
- elementSelector = getCssSelector(element)
222
- }
223
290
 
224
- const restoreKey = getKey(router.latestLocation)
225
- const cacheKey = [restoreKey, elementSelector].join(delimiter)
226
- return cache.state.cached[cacheKey]
291
+ restoreScroll(
292
+ storageKey,
293
+ cacheKey,
294
+ router.options.scrollRestorationBehavior,
295
+ router.isScrollRestoring,
296
+ )
297
+
298
+ if (router.isScrollRestoring) {
299
+ // Mark the location as having been seen
300
+ scrollRestorationCache.set((state) => {
301
+ state[cacheKey] = state[cacheKey] || ({} as ScrollRestorationByElement)
302
+
303
+ return state
304
+ })
305
+ }
306
+ })
227
307
  }
228
308
 
229
- function getCssSelector(el: any): string {
230
- const path = []
231
- let parent
232
- while ((parent = el.parentNode)) {
233
- path.unshift(
234
- `${el.tagName}:nth-child(${
235
- ([].indexOf as any).call(parent.children, el) + 1
236
- })`,
237
- )
238
- el = parent
309
+ export function ScrollRestoration() {
310
+ const router = useRouter()
311
+ const getKey =
312
+ router.options.getScrollRestorationKey || defaultGetScrollRestorationKey
313
+ const userKey = getKey(router.latestLocation)
314
+ const resolvedKey =
315
+ userKey !== defaultGetScrollRestorationKey(router.latestLocation)
316
+ ? userKey
317
+ : null
318
+
319
+ if (!router.isScrollRestoring || !router.isServer) {
320
+ return null
239
321
  }
240
- return `${path.join(' > ')}`.toLowerCase()
322
+
323
+ return (
324
+ <ScriptOnce
325
+ children={`(${restoreScroll.toString()})(${JSON.stringify(storageKey)},${JSON.stringify(resolvedKey)}, undefined, true)`}
326
+ log={false}
327
+ />
328
+ )
241
329
  }