@tanstack/react-router 1.98.0 → 1.98.3

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 (48) hide show
  1. package/dist/cjs/Match.cjs +60 -22
  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/ScriptOnce.cjs +1 -1
  6. package/dist/cjs/ScriptOnce.cjs.map +1 -1
  7. package/dist/cjs/ScrollRestoration.cjs +39 -0
  8. package/dist/cjs/ScrollRestoration.cjs.map +1 -0
  9. package/dist/cjs/ScrollRestoration.d.cts +15 -0
  10. package/dist/cjs/Transitioner.cjs +3 -33
  11. package/dist/cjs/Transitioner.cjs.map +1 -1
  12. package/dist/cjs/index.cjs +3 -4
  13. package/dist/cjs/index.cjs.map +1 -1
  14. package/dist/cjs/index.d.cts +1 -2
  15. package/dist/cjs/router.cjs +14 -12
  16. package/dist/cjs/router.cjs.map +1 -1
  17. package/dist/cjs/router.d.cts +26 -26
  18. package/dist/cjs/scroll-restoration.cjs +166 -165
  19. package/dist/cjs/scroll-restoration.cjs.map +1 -1
  20. package/dist/cjs/scroll-restoration.d.cts +25 -15
  21. package/dist/esm/Match.js +62 -24
  22. package/dist/esm/Match.js.map +1 -1
  23. package/dist/esm/Matches.js +4 -1
  24. package/dist/esm/Matches.js.map +1 -1
  25. package/dist/esm/ScriptOnce.js +1 -1
  26. package/dist/esm/ScriptOnce.js.map +1 -1
  27. package/dist/esm/ScrollRestoration.d.ts +15 -0
  28. package/dist/esm/ScrollRestoration.js +39 -0
  29. package/dist/esm/ScrollRestoration.js.map +1 -0
  30. package/dist/esm/Transitioner.js +4 -34
  31. package/dist/esm/Transitioner.js.map +1 -1
  32. package/dist/esm/index.d.ts +1 -2
  33. package/dist/esm/index.js +1 -2
  34. package/dist/esm/router.d.ts +26 -26
  35. package/dist/esm/router.js +15 -13
  36. package/dist/esm/router.js.map +1 -1
  37. package/dist/esm/scroll-restoration.d.ts +25 -15
  38. package/dist/esm/scroll-restoration.js +166 -148
  39. package/dist/esm/scroll-restoration.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/Match.tsx +79 -48
  42. package/src/Matches.tsx +1 -1
  43. package/src/ScriptOnce.tsx +1 -1
  44. package/src/ScrollRestoration.tsx +65 -0
  45. package/src/Transitioner.tsx +4 -40
  46. package/src/index.tsx +1 -3
  47. package/src/router.ts +43 -38
  48. package/src/scroll-restoration.tsx +268 -182
@@ -1,241 +1,327 @@
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()
62
-
63
- useLayoutEffect(() => {
64
- const getKey = options?.getKey || defaultGetKey
65
-
66
- const { history } = window
67
- history.scrollRestoration = 'manual'
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
+ }
68
80
 
69
- const onScroll = (event: Event) => {
70
- if (weakScrolledElements.has(event.target)) return
71
- weakScrolledElements.add(event.target)
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
72
94
 
73
- let elementSelector = ''
95
+ try {
96
+ byKey = JSON.parse(sessionStorage.getItem(storageKey) || '{}')
97
+ } catch (error: any) {
98
+ console.error(error)
99
+ return
100
+ }
74
101
 
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)
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
+ window.scrollTo({
117
+ top: entry.scrollY,
118
+ left: entry.scrollX,
119
+ behavior,
120
+ })
121
+ } else if (elementSelector) {
122
+ const element = document.querySelector(elementSelector)
123
+ if (element) {
124
+ element.scrollLeft = entry.scrollX
125
+ element.scrollTop = entry.scrollY
126
+ }
86
127
  }
87
128
  }
88
129
 
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
- }
130
+ return
101
131
  }
102
132
 
103
- if (typeof document !== 'undefined') {
104
- document.addEventListener('scroll', onScroll, true)
105
- }
133
+ // If we don't have a cached entry for the hash,
134
+ // Which means we've never seen this location before,
135
+ // we need to check if there is a hash in the URL.
136
+ // If there is, we need to scroll it's ID into view.
137
+ const hash = window.location.hash.split('#')[1]
106
138
 
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
- }
139
+ if (hash) {
140
+ const hashScrollIntoViewOptions =
141
+ (window.history.state || {}).__hashScrollIntoViewOptions ?? true
120
142
 
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
- })
143
+ if (hashScrollIntoViewOptions) {
144
+ const el = document.getElementById(hash)
145
+ if (el) {
146
+ el.scrollIntoView(hashScrollIntoViewOptions)
134
147
  }
135
148
  }
149
+
150
+ return
151
+ }
152
+
153
+ // If there is no cached entry for the hash and there is no hash in the URL,
154
+ // we need to scroll to the top of the page.
155
+ window.scrollTo({
156
+ top: 0,
157
+ left: 0,
158
+ behavior,
136
159
  })
160
+ })()
137
161
 
138
- const unsubOnBeforeRouteMount = router.subscribe(
139
- 'onBeforeRouteMount',
140
- (event) => {
141
- if (event.hrefChanged) {
142
- if (!router.resetNextScroll) {
143
- return
144
- }
162
+ //
163
+ ignoreScroll = false
164
+ }
145
165
 
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
- }
166
+ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
167
+ const shouldScrollRestoration =
168
+ force ?? router.options.scrollRestoration ?? false
171
169
 
172
- if (!windowRestored) {
173
- window.scrollTo(0, 0)
174
- }
170
+ if (shouldScrollRestoration) {
171
+ router.isScrollRestoring = true
172
+ }
175
173
 
176
- cache.set((c) => ({ ...c, next: {} }))
177
- weakScrolledElements = new WeakSet<any>()
178
- }
179
- },
180
- )
174
+ if (typeof document === 'undefined' || router.isScrollRestorationSetup) {
175
+ return
176
+ }
181
177
 
182
- return () => {
183
- document.removeEventListener('scroll', onScroll)
184
- unsubOnBeforeLoad()
185
- unsubOnBeforeRouteMount()
178
+ router.isScrollRestorationSetup = true
179
+
180
+ //
181
+ ignoreScroll = false
182
+
183
+ const getKey =
184
+ router.options.getScrollRestorationKey || defaultGetScrollRestorationKey
185
+
186
+ window.history.scrollRestoration = 'manual'
187
+
188
+ // // Create a MutationObserver to monitor DOM changes
189
+ // const mutationObserver = new MutationObserver(() => {
190
+ // ;ignoreScroll = true
191
+ // requestAnimationFrame(() => {
192
+ // ;ignoreScroll = false
193
+
194
+ // // Attempt to restore scroll position on each dom
195
+ // // mutation until the user scrolls. We do this
196
+ // // because dynamic content may come in at different
197
+ // // ticks after the initial render and we want to
198
+ // // keep up with that content as much as possible.
199
+ // // As soon as the user scrolls, we no longer need
200
+ // // to attempt router.
201
+ // // console.log('mutation observer restoreScroll')
202
+ // restoreScroll(
203
+ // storageKey,
204
+ // getKey(router.state.location),
205
+ // router.options.scrollRestorationBehavior,
206
+ // )
207
+ // })
208
+ // })
209
+
210
+ // const observeDom = () => {
211
+ // // Observe changes to the entire document
212
+ // mutationObserver.observe(document, {
213
+ // childList: true, // Detect added or removed child nodes
214
+ // subtree: true, // Monitor all descendants
215
+ // characterData: true, // Detect text content changes
216
+ // })
217
+ // }
218
+
219
+ // const unobserveDom = () => {
220
+ // mutationObserver.disconnect()
221
+ // }
222
+
223
+ // observeDom()
224
+
225
+ const onScroll = (event: Event) => {
226
+ // unobserveDom()
227
+
228
+ if (ignoreScroll || !router.isScrollRestoring) {
229
+ return
186
230
  }
187
- }, [options?.getKey, options?.scrollBehavior, router])
188
- }
189
231
 
190
- export function ScrollRestoration(props: ScrollRestorationOptions) {
191
- useScrollRestoration(props)
192
- return null
193
- }
232
+ let elementSelector = ''
194
233
 
195
- export function useElementScrollRestoration(
196
- options: (
197
- | {
198
- id: string
199
- getElement?: () => Element | undefined | null
234
+ if (event.target === document || event.target === window) {
235
+ elementSelector = 'window'
236
+ } else {
237
+ const attrId = (event.target as Element).getAttribute(
238
+ 'data-scroll-restoration-id',
239
+ )
240
+
241
+ if (attrId) {
242
+ elementSelector = `[data-scroll-restoration-id="${attrId}"]`
243
+ } else {
244
+ elementSelector = getCssSelector(event.target)
200
245
  }
201
- | {
202
- id?: string
203
- getElement: () => Element | undefined | null
246
+ }
247
+
248
+ const restoreKey = getKey(router.state.location)
249
+
250
+ scrollRestorationCache.set((state) => {
251
+ const keyEntry = (state[restoreKey] =
252
+ state[restoreKey] || ({} as ScrollRestorationByElement))
253
+
254
+ const elementEntry = (keyEntry[elementSelector] =
255
+ keyEntry[elementSelector] || ({} as ScrollRestorationEntry))
256
+
257
+ if (elementSelector === 'window') {
258
+ elementEntry.scrollX = window.scrollX || 0
259
+ elementEntry.scrollY = window.scrollY || 0
260
+ } else if (elementSelector) {
261
+ const element = document.querySelector(elementSelector)
262
+ if (element) {
263
+ elementEntry.scrollX = element.scrollLeft || 0
264
+ elementEntry.scrollY = element.scrollTop || 0
265
+ }
204
266
  }
205
- ) & {
206
- getKey?: (location: ParsedLocation) => string
207
- },
208
- ) {
209
- const router = useRouter()
210
- const getKey = options.getKey || defaultGetKey
211
267
 
212
- let elementSelector = ''
268
+ return state
269
+ })
270
+ }
271
+
272
+ // Throttle the scroll event to avoid excessive updates
273
+ if (typeof document !== 'undefined') {
274
+ document.addEventListener('scroll', throttle(onScroll, 100), true)
275
+ }
276
+
277
+ router.subscribe('onRendered', (event) => {
278
+ // unobserveDom()
213
279
 
214
- if (options.id) {
215
- elementSelector = `[data-scroll-restoration-id="${options.id}"]`
216
- } else {
217
- const element = options.getElement?.()
218
- if (!element) {
280
+ const cacheKey = getKey(event.toLocation)
281
+
282
+ // If the user doesn't want to restore the scroll position,
283
+ // we don't need to do anything.
284
+ if (!router.resetNextScroll) {
285
+ router.resetNextScroll = true
219
286
  return
220
287
  }
221
- elementSelector = getCssSelector(element)
222
- }
223
288
 
224
- const restoreKey = getKey(router.latestLocation)
225
- const cacheKey = [restoreKey, elementSelector].join(delimiter)
226
- return cache.state.cached[cacheKey]
289
+ restoreScroll(
290
+ storageKey,
291
+ cacheKey,
292
+ router.options.scrollRestorationBehavior,
293
+ router.isScrollRestoring,
294
+ )
295
+
296
+ if (router.isScrollRestoring) {
297
+ // Mark the location as having been seen
298
+ scrollRestorationCache.set((state) => {
299
+ state[cacheKey] = state[cacheKey] || ({} as ScrollRestorationByElement)
300
+
301
+ return state
302
+ })
303
+ }
304
+ })
227
305
  }
228
306
 
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
307
+ export function ScrollRestoration() {
308
+ const router = useRouter()
309
+ const getKey =
310
+ router.options.getScrollRestorationKey || defaultGetScrollRestorationKey
311
+ const userKey = getKey(router.latestLocation)
312
+ const resolvedKey =
313
+ userKey !== defaultGetScrollRestorationKey(router.latestLocation)
314
+ ? userKey
315
+ : null
316
+
317
+ if (!router.isScrollRestoring || !router.isServer) {
318
+ return null
239
319
  }
240
- return `${path.join(' > ')}`.toLowerCase()
320
+
321
+ return (
322
+ <ScriptOnce
323
+ children={`(${restoreScroll.toString()})(${JSON.stringify(storageKey)},${JSON.stringify(resolvedKey)}, undefined, true)`}
324
+ log={false}
325
+ />
326
+ )
241
327
  }