@tanstack/router-core 1.171.13 → 1.171.15

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.
@@ -1,5 +1,4 @@
1
1
  import { isServer } from '@tanstack/router-core/isServer'
2
- import { locationHistoryActions } from './router'
3
2
  import type { AnyRouter } from './router'
4
3
  import type { ParsedLocation } from './location'
5
4
 
@@ -133,7 +132,6 @@ export function getElementScrollRestorationEntry(
133
132
 
134
133
  let ignoreScroll = false
135
134
  const windowScrollTarget = 'window'
136
- type ScrollTarget = typeof windowScrollTarget | Element
137
135
 
138
136
  function getElement(selector: string | (() => Element | null | undefined)) {
139
137
  try {
@@ -148,8 +146,8 @@ function getScrollToTopElements(
148
146
  scrollToTopSelectors: NonNullable<
149
147
  AnyRouter['options']['scrollToTopSelectors']
150
148
  >,
151
- ): Array<Element> {
152
- const elements: Array<Element> = []
149
+ ) {
150
+ const elements = new Set<Element>()
153
151
 
154
152
  for (const selector of scrollToTopSelectors) {
155
153
  if (selector === windowScrollTarget) {
@@ -158,7 +156,7 @@ function getScrollToTopElements(
158
156
 
159
157
  const element = getElement(selector)
160
158
  if (element) {
161
- elements.push(element)
159
+ elements.add(element)
162
160
  }
163
161
  }
164
162
 
@@ -180,46 +178,21 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
180
178
 
181
179
  const getKey =
182
180
  router.options.getScrollRestorationKey || defaultGetScrollRestorationKey
183
- const trackedScrollEntries = new Map<ScrollTarget, ScrollRestorationEntry>()
184
- const setTrackedScrollEntry = (
185
- target: ScrollTarget,
186
- scrollX: number,
187
- scrollY: number,
188
- ) => {
189
- const entry =
190
- trackedScrollEntries.get(target) || ({} as ScrollRestorationEntry)
191
- entry.scrollX = scrollX
192
- entry.scrollY = scrollY
193
- trackedScrollEntries.set(target, entry)
194
- }
195
-
196
- const onScroll = (event: Event) => {
197
- if (ignoreScroll || !scroll.restoring) {
198
- return
199
- }
200
-
201
- if (event.target === document) {
202
- setTrackedScrollEntry(windowScrollTarget, scrollX, scrollY)
203
- } else {
204
- const target = event.target as Element
205
- setTrackedScrollEntry(target, target.scrollLeft, target.scrollTop)
206
- }
207
- }
181
+ const trackedScrollTargets = new Set<Document | Element>()
208
182
 
209
183
  // Snapshot the current page's tracked scroll targets before navigation or unload.
210
184
  const snapshotCurrentScrollTargets = (restoreKey: string) => {
211
- if (!scroll.restoring) {
212
- return
213
- }
214
-
215
185
  const keyEntry = (scrollRestorationCache[restoreKey] ||=
216
186
  {} as ScrollRestorationByElement)
217
187
 
218
- for (const [target, position] of trackedScrollEntries) {
219
- if (target === windowScrollTarget) {
220
- keyEntry[windowScrollTarget] = position
221
- } else if (target.isConnected) {
222
- keyEntry[getScrollRestorationSelector(target)] = position
188
+ for (const target of trackedScrollTargets) {
189
+ if (target === document) {
190
+ keyEntry[windowScrollTarget] = { scrollX, scrollY }
191
+ } else if ((target as Element).isConnected) {
192
+ keyEntry[getScrollRestorationSelector(target as Element)] = {
193
+ scrollX: (target as Element).scrollLeft,
194
+ scrollY: (target as Element).scrollTop,
195
+ }
223
196
  }
224
197
  }
225
198
  }
@@ -230,12 +203,21 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
230
203
 
231
204
  history.scrollRestoration = 'manual'
232
205
 
233
- document.addEventListener('scroll', onScroll, true)
206
+ document.addEventListener(
207
+ 'scroll',
208
+ (event) => {
209
+ if (ignoreScroll) {
210
+ return
211
+ }
212
+ trackedScrollTargets.add(event.target as Document | Element)
213
+ },
214
+ true,
215
+ )
234
216
  router.subscribe('onBeforeLoad', (event) => {
235
217
  if (event.fromLocation) {
236
218
  snapshotCurrentScrollTargets(getKey(event.fromLocation))
237
219
  }
238
- trackedScrollEntries.clear()
220
+ trackedScrollTargets.clear()
239
221
  })
240
222
  addEventListener('pagehide', () => {
241
223
  snapshotCurrentScrollTargets(
@@ -258,12 +240,11 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
258
240
  const behavior = router.options.scrollRestorationBehavior
259
241
  const scrollToTopSelectors = router.options.scrollToTopSelectors
260
242
  const shouldResetScroll = scroll.next
261
- let scrollToTopElements: Array<Element> | undefined
262
- trackedScrollEntries.clear()
263
-
264
- if (!shouldResetScroll) {
265
- scroll.next = true
266
- }
243
+ const hashNavigation = scroll.hash
244
+ let scrollToTopElements: Set<Element> | undefined
245
+ trackedScrollTargets.clear()
246
+ scroll.next = true
247
+ scroll.hash = false
267
248
 
268
249
  if (
269
250
  typeof router.options.scrollRestoration === 'function' &&
@@ -295,7 +276,7 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
295
276
  if (shouldResetScroll && scrollToTopSelectors) {
296
277
  scrollToTopElements ??=
297
278
  getScrollToTopElements(scrollToTopSelectors)
298
- if (scrollToTopElements.includes(element)) {
279
+ if (scrollToTopElements.has(element)) {
299
280
  continue
300
281
  }
301
282
  }
@@ -321,11 +302,12 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
321
302
  let windowRestored = false
322
303
 
323
304
  if (shouldResetScroll) {
324
- const action = locationHistoryActions.get(event.toLocation)
305
+ if (!hash && scrollToTopSelectors) {
306
+ scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors)
307
+ }
308
+
325
309
  const skipWindowRestore =
326
- hash &&
327
- hashScrollIntoViewOptions &&
328
- (action === 'PUSH' || action === 'REPLACE')
310
+ hash && hashScrollIntoViewOptions && hashNavigation
329
311
 
330
312
  const elementEntries = scroll.restoring
331
313
  ? scrollRestorationCache[cacheKey]
@@ -351,21 +333,23 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
351
333
  if (element) {
352
334
  element.scrollLeft = scrollX
353
335
  element.scrollTop = scrollY
336
+ scrollToTopElements?.delete(element)
354
337
  }
355
338
  }
356
339
  }
357
340
  }
358
341
 
359
- if (!windowRestored && !hash) {
342
+ if (!hash) {
360
343
  const scrollOptions = {
361
344
  top: 0,
362
345
  left: 0,
363
346
  behavior,
364
347
  }
365
348
 
366
- scrollTo(scrollOptions)
367
- if (scrollToTopSelectors) {
368
- scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors)
349
+ if (!windowRestored) {
350
+ scrollTo(scrollOptions)
351
+ }
352
+ if (scrollToTopElements) {
369
353
  for (const element of scrollToTopElements) {
370
354
  element.scrollTo(scrollOptions)
371
355
  }
package/src/utils.ts CHANGED
@@ -523,15 +523,26 @@ export function findLast<T>(
523
523
  }
524
524
 
525
525
  /**
526
- * Remove control characters that can cause open redirect vulnerabilities.
527
- * Characters like \r (CR) and \n (LF) can trick URL parsers into interpreting
528
- * paths like "/\r/evil.com" as "http://evil.com".
526
+ * Re-encode characters that are unsafe in URL paths.
527
+ * Includes ASCII control characters (0x00-0x1F, 0x7F) and a subset of the
528
+ * WHATWG URL "path percent-encode set" (", <, >, `, {, }).
529
+ *
530
+ * Space (0x20) is intentionally excluded — decodeURI decodes %20 to space
531
+ * and the router stores decoded spaces in location.pathname. The existing
532
+ * encodePathLikeUrl already handles re-encoding spaces for outgoing URLs.
533
+ *
534
+ * These characters are decoded by decodeURI but must remain percent-encoded
535
+ * in paths to match how upstream layers (CDNs, edge middleware, browsers)
536
+ * interpret the URL, preventing infinite redirect loops and path mismatches.
529
537
  */
538
+ // eslint-disable-next-line no-control-regex
539
+ const PATH_UNSAFE_RE = /[\x00-\x1f\x7f"<>`{}]/g
540
+
530
541
  function sanitizePathSegment(segment: string): string {
531
- // Remove ASCII control characters (0x00-0x1F) and DEL (0x7F)
532
- // These include CR (\r = 0x0D), LF (\n = 0x0A), and other potentially dangerous characters
533
- // eslint-disable-next-line no-control-regex
534
- return segment.replace(/[\x00-\x1f\x7f]/g, '')
542
+ return segment.replace(
543
+ PATH_UNSAFE_RE,
544
+ (ch) => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'),
545
+ )
535
546
  }
536
547
 
537
548
  function decodeSegment(segment: string): string {
@@ -644,8 +655,9 @@ export function decodePath(path: string) {
644
655
  result = result + decodeSegment(cursor ? path.slice(cursor) : path)
645
656
 
646
657
  // Prevent open redirect via protocol-relative URLs (e.g. "//evil.com")
647
- // After sanitizing control characters, paths like "/\r/evil.com" become "//evil.com"
648
- // Collapse leading double slashes to a single slash
658
+ // This is defense-in-depth: since control characters are no longer decoded,
659
+ // paths like "/%0d/evil.com" can no longer become "//evil.com". But we keep
660
+ // this check to guard against other edge cases.
649
661
  let handledProtocolRelativeURL = false
650
662
  if (result.startsWith('//')) {
651
663
  handledProtocolRelativeURL = true