@tanstack/router-core 1.168.3 → 1.168.5

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 (46) hide show
  1. package/dist/cjs/hash-scroll.cjs +20 -0
  2. package/dist/cjs/hash-scroll.cjs.map +1 -0
  3. package/dist/cjs/hash-scroll.d.cts +7 -0
  4. package/dist/cjs/index.cjs +3 -3
  5. package/dist/cjs/index.d.cts +2 -1
  6. package/dist/cjs/scroll-restoration-inline.cjs +6 -0
  7. package/dist/cjs/scroll-restoration-inline.cjs.map +1 -0
  8. package/dist/cjs/scroll-restoration-inline.d.cts +6 -0
  9. package/dist/cjs/scroll-restoration-script/client.cjs +9 -0
  10. package/dist/cjs/scroll-restoration-script/client.cjs.map +1 -0
  11. package/dist/cjs/scroll-restoration-script/client.d.cts +2 -0
  12. package/dist/cjs/scroll-restoration-script/server.cjs +30 -0
  13. package/dist/cjs/scroll-restoration-script/server.cjs.map +1 -0
  14. package/dist/cjs/scroll-restoration-script/server.d.cts +2 -0
  15. package/dist/cjs/scroll-restoration.cjs +130 -142
  16. package/dist/cjs/scroll-restoration.cjs.map +1 -1
  17. package/dist/cjs/scroll-restoration.d.cts +15 -38
  18. package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
  19. package/dist/cjs/ssr/tsrScript.cjs +1 -1
  20. package/dist/esm/hash-scroll.d.ts +7 -0
  21. package/dist/esm/hash-scroll.js +20 -0
  22. package/dist/esm/hash-scroll.js.map +1 -0
  23. package/dist/esm/index.d.ts +2 -1
  24. package/dist/esm/index.js +3 -2
  25. package/dist/esm/scroll-restoration-inline.d.ts +6 -0
  26. package/dist/esm/scroll-restoration-inline.js +6 -0
  27. package/dist/esm/scroll-restoration-inline.js.map +1 -0
  28. package/dist/esm/scroll-restoration-script/client.d.ts +2 -0
  29. package/dist/esm/scroll-restoration-script/client.js +8 -0
  30. package/dist/esm/scroll-restoration-script/client.js.map +1 -0
  31. package/dist/esm/scroll-restoration-script/server.d.ts +2 -0
  32. package/dist/esm/scroll-restoration-script/server.js +29 -0
  33. package/dist/esm/scroll-restoration-script/server.js.map +1 -0
  34. package/dist/esm/scroll-restoration.d.ts +15 -38
  35. package/dist/esm/scroll-restoration.js +131 -141
  36. package/dist/esm/scroll-restoration.js.map +1 -1
  37. package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
  38. package/dist/esm/ssr/tsrScript.js +1 -1
  39. package/package.json +25 -4
  40. package/src/hash-scroll.ts +21 -0
  41. package/src/index.ts +3 -3
  42. package/src/scroll-restoration-inline.ts +81 -0
  43. package/src/scroll-restoration-script/client.ts +5 -0
  44. package/src/scroll-restoration-script/server.ts +64 -0
  45. package/src/scroll-restoration.ts +243 -271
  46. package/src/ssr/transformStreamWithRouter.ts +2 -2
@@ -1,42 +1,39 @@
1
- import { functionalUpdate } from "./utils.js";
1
+ import { functionalUpdate, isPlainObject } from "./utils.js";
2
2
  import { isServer } from "@tanstack/router-core/isServer";
3
3
  //#region src/scroll-restoration.ts
4
4
  function getSafeSessionStorage() {
5
5
  try {
6
- if (typeof window !== "undefined" && typeof window.sessionStorage === "object") return window.sessionStorage;
7
- } catch {}
6
+ return typeof window !== "undefined" && typeof window.sessionStorage === "object" ? window.sessionStorage : void 0;
7
+ } catch {
8
+ return;
9
+ }
8
10
  }
9
- /** SessionStorage key used to persist scroll restoration state. */
10
- /** SessionStorage key used to store scroll positions across navigations. */
11
- /** SessionStorage key used to store scroll positions across navigations. */
12
11
  var storageKey = "tsr-scroll-restoration-v1_3";
13
- var throttle = (fn, wait) => {
14
- let timeout;
15
- return (...args) => {
16
- if (!timeout) timeout = setTimeout(() => {
17
- fn(...args);
18
- timeout = null;
19
- }, wait);
20
- };
21
- };
22
12
  function createScrollRestorationCache() {
23
13
  const safeSessionStorage = getSafeSessionStorage();
24
14
  if (!safeSessionStorage) return null;
25
- const persistedState = safeSessionStorage.getItem(storageKey);
26
- let state = persistedState ? JSON.parse(persistedState) : {};
15
+ let state = {};
16
+ try {
17
+ const parsed = JSON.parse(safeSessionStorage.getItem("tsr-scroll-restoration-v1_3") || "{}");
18
+ if (isPlainObject(parsed)) state = parsed;
19
+ } catch {}
20
+ const persist = () => {
21
+ try {
22
+ safeSessionStorage.setItem(storageKey, JSON.stringify(state));
23
+ } catch {
24
+ if (process.env.NODE_ENV !== "production") console.warn("[ts-router] Could not persist scroll restoration state to sessionStorage.");
25
+ }
26
+ };
27
27
  return {
28
- state,
28
+ get state() {
29
+ return state;
30
+ },
29
31
  set: (updater) => {
30
32
  state = functionalUpdate(updater, state) || state;
31
- try {
32
- safeSessionStorage.setItem(storageKey, JSON.stringify(state));
33
- } catch {
34
- console.warn("[ts-router] Could not persist scroll restoration state to sessionStorage.");
35
- }
36
- }
33
+ },
34
+ persist
37
35
  };
38
36
  }
39
- /** In-memory handle to the persisted scroll restoration cache. */
40
37
  var scrollRestorationCache = createScrollRestorationCache();
41
38
  /**
42
39
  * The default `getKey` function for `useScrollRestoration`.
@@ -44,13 +41,9 @@ var scrollRestorationCache = createScrollRestorationCache();
44
41
  *
45
42
  * The `location.href` is used as a fallback to support the use case where the location state is not available like the initial render.
46
43
  */
47
- /**
48
- * Default scroll restoration cache key: location state key or full href.
49
- */
50
44
  var defaultGetScrollRestorationKey = (location) => {
51
45
  return location.state.__TSR_key || location.href;
52
46
  };
53
- /** Best-effort nth-child CSS selector for a given element. */
54
47
  function getCssSelector(el) {
55
48
  const path = [];
56
49
  let parent;
@@ -60,144 +53,141 @@ function getCssSelector(el) {
60
53
  }
61
54
  return `${path.reverse().join(" > ")}`.toLowerCase();
62
55
  }
63
- var ignoreScroll = false;
64
- function restoreScroll({ storageKey, key, behavior, shouldScrollRestoration, scrollToTopSelectors, location }) {
65
- let byKey;
66
- try {
67
- byKey = JSON.parse(sessionStorage.getItem(storageKey) || "{}");
68
- } catch (error) {
69
- console.error(error);
70
- return;
71
- }
72
- const resolvedKey = key || window.history.state?.__TSR_key;
73
- const elementEntries = byKey[resolvedKey];
74
- ignoreScroll = true;
75
- scroll: {
76
- if (shouldScrollRestoration && elementEntries && Object.keys(elementEntries).length > 0) {
77
- for (const elementSelector in elementEntries) {
78
- const entry = elementEntries[elementSelector];
79
- if (elementSelector === "window") window.scrollTo({
80
- top: entry.scrollY,
81
- left: entry.scrollX,
82
- behavior
83
- });
84
- else if (elementSelector) {
85
- const element = document.querySelector(elementSelector);
86
- if (element) {
87
- element.scrollLeft = entry.scrollX;
88
- element.scrollTop = entry.scrollY;
89
- }
90
- }
91
- }
92
- break scroll;
93
- }
94
- const hash = (location ?? window.location).hash.split("#", 2)[1];
95
- if (hash) {
96
- const hashScrollIntoViewOptions = window.history.state?.__hashScrollIntoViewOptions ?? true;
97
- if (hashScrollIntoViewOptions) {
98
- const el = document.getElementById(hash);
99
- if (el) el.scrollIntoView(hashScrollIntoViewOptions);
100
- }
101
- break scroll;
102
- }
103
- const scrollOptions = {
104
- top: 0,
105
- left: 0,
106
- behavior
107
- };
108
- window.scrollTo(scrollOptions);
109
- if (scrollToTopSelectors) for (const selector of scrollToTopSelectors) {
110
- if (selector === "window") continue;
111
- const element = typeof selector === "function" ? selector() : document.querySelector(selector);
112
- if (element) element.scrollTo(scrollOptions);
113
- }
114
- }
115
- ignoreScroll = false;
56
+ function getElementScrollRestorationEntry(router, options) {
57
+ const restoreKey = (options.getKey || defaultGetScrollRestorationKey)(router.latestLocation);
58
+ if (options.id) return scrollRestorationCache?.state[restoreKey]?.[`[${scrollRestorationIdAttribute}="${options.id}"]`];
59
+ const element = options.getElement?.();
60
+ if (!element) return;
61
+ return scrollRestorationCache?.state[restoreKey]?.[element instanceof Window ? windowScrollTarget : getCssSelector(element)];
116
62
  }
117
- /** Setup global listeners and hooks to support scroll restoration. */
118
- /** Setup global listeners and hooks to support scroll restoration. */
63
+ var ignoreScroll = false;
64
+ var windowScrollTarget = "window";
65
+ var scrollRestorationIdAttribute = "data-scroll-restoration-id";
119
66
  function setupScrollRestoration(router, force) {
120
67
  if (!scrollRestorationCache && !(isServer ?? router.isServer)) return;
68
+ const cache = scrollRestorationCache;
121
69
  if (force ?? router.options.scrollRestoration ?? false) router.isScrollRestoring = true;
122
- if ((isServer ?? router.isServer) || router.isScrollRestorationSetup || !scrollRestorationCache) return;
70
+ if ((isServer ?? router.isServer) || router.isScrollRestorationSetup || !cache) return;
123
71
  router.isScrollRestorationSetup = true;
124
72
  ignoreScroll = false;
125
73
  const getKey = router.options.getScrollRestorationKey || defaultGetScrollRestorationKey;
74
+ const trackedScrollEntries = /* @__PURE__ */ new Map();
126
75
  window.history.scrollRestoration = "manual";
127
76
  const onScroll = (event) => {
128
77
  if (ignoreScroll || !router.isScrollRestoring) return;
129
- let elementSelector = "";
130
- if (event.target === document || event.target === window) elementSelector = "window";
78
+ if (event.target === document || event.target === window) trackedScrollEntries.set(windowScrollTarget, {
79
+ scrollX: window.scrollX || 0,
80
+ scrollY: window.scrollY || 0
81
+ });
131
82
  else {
132
- const attrId = event.target.getAttribute("data-scroll-restoration-id");
133
- if (attrId) elementSelector = `[data-scroll-restoration-id="${attrId}"]`;
134
- else elementSelector = getCssSelector(event.target);
83
+ const target = event.target;
84
+ trackedScrollEntries.set(target, {
85
+ scrollX: target.scrollLeft || 0,
86
+ scrollY: target.scrollTop || 0
87
+ });
135
88
  }
136
- const restoreKey = getKey(router.stores.location.state);
137
- scrollRestorationCache.set((state) => {
138
- const keyEntry = state[restoreKey] ||= {};
139
- const elementEntry = keyEntry[elementSelector] ||= {};
140
- if (elementSelector === "window") {
141
- elementEntry.scrollX = window.scrollX || 0;
142
- elementEntry.scrollY = window.scrollY || 0;
143
- } else if (elementSelector) {
144
- const element = document.querySelector(elementSelector);
145
- if (element) {
146
- elementEntry.scrollX = element.scrollLeft || 0;
147
- elementEntry.scrollY = element.scrollTop || 0;
148
- }
89
+ };
90
+ const snapshotCurrentScrollTargets = (restoreKey) => {
91
+ if (!router.isScrollRestoring || !restoreKey || trackedScrollEntries.size === 0 || !cache) return;
92
+ const keyEntry = cache.state[restoreKey] ||= {};
93
+ for (const [target, position] of trackedScrollEntries) {
94
+ let selector;
95
+ if (target === windowScrollTarget) selector = windowScrollTarget;
96
+ else if (target.isConnected) {
97
+ const attrId = target.getAttribute(scrollRestorationIdAttribute);
98
+ selector = attrId ? `[${scrollRestorationIdAttribute}="${attrId}"]` : getCssSelector(target);
149
99
  }
150
- return state;
151
- });
100
+ if (!selector) continue;
101
+ keyEntry[selector] = position;
102
+ }
152
103
  };
153
- if (typeof document !== "undefined") document.addEventListener("scroll", throttle(onScroll, 100), true);
104
+ document.addEventListener("scroll", onScroll, true);
105
+ router.subscribe("onBeforeLoad", (event) => {
106
+ snapshotCurrentScrollTargets(event.fromLocation ? getKey(event.fromLocation) : void 0);
107
+ trackedScrollEntries.clear();
108
+ });
109
+ window.addEventListener("pagehide", () => {
110
+ snapshotCurrentScrollTargets(getKey(router.stores.resolvedLocation.state ?? router.stores.location.state));
111
+ cache.persist();
112
+ });
154
113
  router.subscribe("onRendered", (event) => {
155
114
  const cacheKey = getKey(event.toLocation);
115
+ const behavior = router.options.scrollRestorationBehavior;
116
+ const scrollToTopSelectors = router.options.scrollToTopSelectors;
117
+ trackedScrollEntries.clear();
156
118
  if (!router.resetNextScroll) {
157
119
  router.resetNextScroll = true;
158
120
  return;
159
121
  }
160
- if (typeof router.options.scrollRestoration === "function") {
161
- if (!router.options.scrollRestoration({ location: router.latestLocation })) return;
162
- }
163
- restoreScroll({
164
- storageKey,
165
- key: cacheKey,
166
- behavior: router.options.scrollRestorationBehavior,
167
- shouldScrollRestoration: router.isScrollRestoring,
168
- scrollToTopSelectors: router.options.scrollToTopSelectors,
169
- location: router.history.location
122
+ if (typeof router.options.scrollRestoration === "function" && !router.options.scrollRestoration({ location: router.latestLocation })) return;
123
+ const fromIndex = event.fromLocation?.state.__TSR_index;
124
+ const toIndex = event.toLocation.state.__TSR_index;
125
+ if (typeof fromIndex === "number" && typeof toIndex === "number" ? toIndex > fromIndex || toIndex === fromIndex && event.fromLocation?.href !== event.toLocation.href : true) cache.set((state) => {
126
+ delete state[cacheKey];
127
+ return state;
170
128
  });
171
- if (router.isScrollRestoring) scrollRestorationCache.set((state) => {
129
+ ignoreScroll = true;
130
+ try {
131
+ const elementEntries = router.isScrollRestoring ? cache.state[cacheKey] : void 0;
132
+ let restored = false;
133
+ if (elementEntries) for (const elementSelector in elementEntries) {
134
+ const entry = elementEntries[elementSelector];
135
+ if (!isPlainObject(entry)) continue;
136
+ const { scrollX, scrollY } = entry;
137
+ if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) continue;
138
+ if (elementSelector === windowScrollTarget) {
139
+ window.scrollTo({
140
+ top: scrollY,
141
+ left: scrollX,
142
+ behavior
143
+ });
144
+ restored = true;
145
+ } else if (elementSelector) {
146
+ let element;
147
+ try {
148
+ element = document.querySelector(elementSelector);
149
+ } catch {
150
+ continue;
151
+ }
152
+ if (element) {
153
+ element.scrollLeft = scrollX;
154
+ element.scrollTop = scrollY;
155
+ restored = true;
156
+ }
157
+ }
158
+ }
159
+ if (!restored) {
160
+ const hash = router.history.location.hash.slice(1);
161
+ if (hash) {
162
+ const hashScrollIntoViewOptions = window.history.state?.__hashScrollIntoViewOptions ?? true;
163
+ if (hashScrollIntoViewOptions) {
164
+ const el = document.getElementById(hash);
165
+ if (el) el.scrollIntoView(hashScrollIntoViewOptions);
166
+ }
167
+ } else {
168
+ const scrollOptions = {
169
+ top: 0,
170
+ left: 0,
171
+ behavior
172
+ };
173
+ window.scrollTo(scrollOptions);
174
+ if (scrollToTopSelectors) for (const selector of scrollToTopSelectors) {
175
+ if (selector === windowScrollTarget) continue;
176
+ const element = typeof selector === "function" ? selector() : document.querySelector(selector);
177
+ if (element) element.scrollTo(scrollOptions);
178
+ }
179
+ }
180
+ }
181
+ } finally {
182
+ ignoreScroll = false;
183
+ }
184
+ if (router.isScrollRestoring) cache.set((state) => {
172
185
  state[cacheKey] ||= {};
173
186
  return state;
174
187
  });
175
188
  });
176
189
  }
177
- /**
178
- * @private
179
- * Handles hash-based scrolling after navigation completes.
180
- * To be used in framework-specific <Transitioner> components during the onResolved event.
181
- *
182
- * Provides hash scrolling for programmatic navigation when default browser handling is prevented.
183
- * @param router The router instance containing current location and state
184
- */
185
- /**
186
- * @private
187
- * Handles hash-based scrolling after navigation completes.
188
- * To be used in framework-specific Transitioners.
189
- */
190
- function handleHashScroll(router) {
191
- if (typeof document !== "undefined" && document.querySelector) {
192
- const location = router.stores.location.state;
193
- const hashScrollIntoViewOptions = location.state.__hashScrollIntoViewOptions ?? true;
194
- if (hashScrollIntoViewOptions && location.hash !== "") {
195
- const el = document.getElementById(location.hash);
196
- if (el) el.scrollIntoView(hashScrollIntoViewOptions);
197
- }
198
- }
199
- }
200
190
  //#endregion
201
- export { defaultGetScrollRestorationKey, getCssSelector, handleHashScroll, restoreScroll, scrollRestorationCache, setupScrollRestoration, storageKey };
191
+ export { defaultGetScrollRestorationKey, getElementScrollRestorationEntry, scrollRestorationCache, setupScrollRestoration, storageKey };
202
192
 
203
193
  //# sourceMappingURL=scroll-restoration.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"scroll-restoration.js","names":[],"sources":["../../src/scroll-restoration.ts"],"sourcesContent":["import { isServer } from '@tanstack/router-core/isServer'\nimport { functionalUpdate } from './utils'\nimport type { AnyRouter } from './router'\nimport type { ParsedLocation } from './location'\nimport type { NonNullableUpdater } from './utils'\nimport type { HistoryLocation } from '@tanstack/history'\n\nexport type ScrollRestorationEntry = { scrollX: number; scrollY: number }\n\nexport type ScrollRestorationByElement = Record<string, ScrollRestorationEntry>\n\nexport type ScrollRestorationByKey = Record<string, ScrollRestorationByElement>\n\nexport type ScrollRestorationCache = {\n state: ScrollRestorationByKey\n set: (updater: NonNullableUpdater<ScrollRestorationByKey>) => void\n}\nexport type ScrollRestorationOptions = {\n getKey?: (location: ParsedLocation) => string\n scrollBehavior?: ScrollToOptions['behavior']\n}\n\nfunction getSafeSessionStorage() {\n try {\n if (\n typeof window !== 'undefined' &&\n typeof window.sessionStorage === 'object'\n ) {\n return window.sessionStorage\n }\n } catch {\n // silent\n }\n return undefined\n}\n\n/** SessionStorage key used to persist scroll restoration state. */\n/** SessionStorage key used to store scroll positions across navigations. */\n/** SessionStorage key used to store scroll positions across navigations. */\nexport const storageKey = 'tsr-scroll-restoration-v1_3'\n\nconst throttle = (fn: (...args: Array<any>) => void, wait: number) => {\n let timeout: any\n return (...args: Array<any>) => {\n if (!timeout) {\n timeout = setTimeout(() => {\n fn(...args)\n timeout = null\n }, wait)\n }\n }\n}\n\nfunction createScrollRestorationCache(): ScrollRestorationCache | null {\n const safeSessionStorage = getSafeSessionStorage()\n if (!safeSessionStorage) {\n return null\n }\n\n const persistedState = safeSessionStorage.getItem(storageKey)\n let state: ScrollRestorationByKey = persistedState\n ? JSON.parse(persistedState)\n : {}\n\n return {\n state,\n // This setter is simply to make sure that we set the sessionStorage right\n // after the state is updated. It doesn't necessarily need to be a functional\n // update.\n set: (updater) => {\n state = functionalUpdate(updater, state) || state\n try {\n safeSessionStorage.setItem(storageKey, JSON.stringify(state))\n } catch {\n console.warn(\n '[ts-router] Could not persist scroll restoration state to sessionStorage.',\n )\n }\n },\n }\n}\n\n/** In-memory handle to the persisted scroll restoration cache. */\nexport const scrollRestorationCache = createScrollRestorationCache()\n\n/**\n * The default `getKey` function for `useScrollRestoration`.\n * It returns the `key` from the location state or the `href` of the location.\n *\n * The `location.href` is used as a fallback to support the use case where the location state is not available like the initial render.\n */\n\n/**\n * Default scroll restoration cache key: location state key or full href.\n */\nexport const defaultGetScrollRestorationKey = (location: ParsedLocation) => {\n return location.state.__TSR_key! || location.href\n}\n\n/** Best-effort nth-child CSS selector for a given element. */\nexport function getCssSelector(el: any): string {\n const path = []\n let parent: HTMLElement\n while ((parent = el.parentNode)) {\n path.push(\n `${el.tagName}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`,\n )\n el = parent\n }\n return `${path.reverse().join(' > ')}`.toLowerCase()\n}\n\nlet ignoreScroll = false\n\n// NOTE: This function must remain pure and not use any outside variables\n// unless they are passed in as arguments. Why? Because we need to be able to\n// toString() it into a script tag to execute as early as possible in the browser\n// during SSR. Additionally, we also call it from within the router lifecycle\nexport function restoreScroll({\n storageKey,\n key,\n behavior,\n shouldScrollRestoration,\n scrollToTopSelectors,\n location,\n}: {\n storageKey: string\n key?: string\n behavior?: ScrollToOptions['behavior']\n shouldScrollRestoration?: boolean\n scrollToTopSelectors?: Array<string | (() => Element | null | undefined)>\n location?: HistoryLocation\n}) {\n let byKey: ScrollRestorationByKey\n\n try {\n byKey = JSON.parse(sessionStorage.getItem(storageKey) || '{}')\n } catch (error) {\n console.error(error)\n return\n }\n\n const resolvedKey = key || window.history.state?.__TSR_key\n const elementEntries = byKey[resolvedKey]\n\n //\n ignoreScroll = true\n\n //\n scroll: {\n // If we have a cached entry for this location state,\n // we always need to prefer that over the hash scroll.\n if (\n shouldScrollRestoration &&\n elementEntries &&\n Object.keys(elementEntries).length > 0\n ) {\n for (const elementSelector in elementEntries) {\n const entry = elementEntries[elementSelector]!\n if (elementSelector === 'window') {\n window.scrollTo({\n top: entry.scrollY,\n left: entry.scrollX,\n behavior,\n })\n } else if (elementSelector) {\n const element = document.querySelector(elementSelector)\n if (element) {\n element.scrollLeft = entry.scrollX\n element.scrollTop = entry.scrollY\n }\n }\n }\n\n break scroll\n }\n\n // If we don't have a cached entry for the hash,\n // Which means we've never seen this location before,\n // we need to check if there is a hash in the URL.\n // If there is, we need to scroll it's ID into view.\n const hash = (location ?? window.location).hash.split('#', 2)[1]\n\n if (hash) {\n const hashScrollIntoViewOptions =\n window.history.state?.__hashScrollIntoViewOptions ?? true\n\n if (hashScrollIntoViewOptions) {\n const el = document.getElementById(hash)\n if (el) {\n el.scrollIntoView(hashScrollIntoViewOptions)\n }\n }\n\n break scroll\n }\n\n // If there is no cached entry for the hash and there is no hash in the URL,\n // we need to scroll to the top of the page for every scrollToTop element\n const scrollOptions = { top: 0, left: 0, behavior }\n window.scrollTo(scrollOptions)\n if (scrollToTopSelectors) {\n for (const selector of scrollToTopSelectors) {\n if (selector === 'window') continue\n const element =\n typeof selector === 'function'\n ? selector()\n : document.querySelector(selector)\n if (element) element.scrollTo(scrollOptions)\n }\n }\n }\n\n //\n ignoreScroll = false\n}\n\n/** Setup global listeners and hooks to support scroll restoration. */\n/** Setup global listeners and hooks to support scroll restoration. */\nexport function setupScrollRestoration(router: AnyRouter, force?: boolean) {\n if (!scrollRestorationCache && !(isServer ?? router.isServer)) {\n return\n }\n const shouldScrollRestoration =\n force ?? router.options.scrollRestoration ?? false\n\n if (shouldScrollRestoration) {\n router.isScrollRestoring = true\n }\n\n if (\n (isServer ?? router.isServer) ||\n router.isScrollRestorationSetup ||\n !scrollRestorationCache\n ) {\n return\n }\n\n router.isScrollRestorationSetup = true\n\n //\n ignoreScroll = false\n\n const getKey =\n router.options.getScrollRestorationKey || defaultGetScrollRestorationKey\n\n window.history.scrollRestoration = 'manual'\n\n // // Create a MutationObserver to monitor DOM changes\n // const mutationObserver = new MutationObserver(() => {\n // ;ignoreScroll = true\n // requestAnimationFrame(() => {\n // ;ignoreScroll = false\n\n // // Attempt to restore scroll position on each dom\n // // mutation until the user scrolls. We do this\n // // because dynamic content may come in at different\n // // ticks after the initial render and we want to\n // // keep up with that content as much as possible.\n // // As soon as the user scrolls, we no longer need\n // // to attempt router.\n // // console.log('mutation observer restoreScroll')\n // restoreScroll(\n // storageKey,\n // getKey(router.stores.location.state),\n // router.options.scrollRestorationBehavior,\n // )\n // })\n // })\n\n // const observeDom = () => {\n // // Observe changes to the entire document\n // mutationObserver.observe(document, {\n // childList: true, // Detect added or removed child nodes\n // subtree: true, // Monitor all descendants\n // characterData: true, // Detect text content changes\n // })\n // }\n\n // const unobserveDom = () => {\n // mutationObserver.disconnect()\n // }\n\n // observeDom()\n\n const onScroll = (event: Event) => {\n // unobserveDom()\n\n if (ignoreScroll || !router.isScrollRestoring) {\n return\n }\n\n let elementSelector = ''\n\n if (event.target === document || event.target === window) {\n elementSelector = 'window'\n } else {\n const attrId = (event.target as Element).getAttribute(\n 'data-scroll-restoration-id',\n )\n\n if (attrId) {\n elementSelector = `[data-scroll-restoration-id=\"${attrId}\"]`\n } else {\n elementSelector = getCssSelector(event.target)\n }\n }\n\n const restoreKey = getKey(router.stores.location.state)\n\n scrollRestorationCache.set((state) => {\n const keyEntry = (state[restoreKey] ||= {} as ScrollRestorationByElement)\n\n const elementEntry = (keyEntry[elementSelector] ||=\n {} as ScrollRestorationEntry)\n\n if (elementSelector === 'window') {\n elementEntry.scrollX = window.scrollX || 0\n elementEntry.scrollY = window.scrollY || 0\n } else if (elementSelector) {\n const element = document.querySelector(elementSelector)\n if (element) {\n elementEntry.scrollX = element.scrollLeft || 0\n elementEntry.scrollY = element.scrollTop || 0\n }\n }\n\n return state\n })\n }\n\n // Throttle the scroll event to avoid excessive updates\n if (typeof document !== 'undefined') {\n document.addEventListener('scroll', throttle(onScroll, 100), true)\n }\n\n router.subscribe('onRendered', (event) => {\n // unobserveDom()\n\n const cacheKey = getKey(event.toLocation)\n\n // If the user doesn't want to restore the scroll position,\n // we don't need to do anything.\n if (!router.resetNextScroll) {\n router.resetNextScroll = true\n return\n }\n if (typeof router.options.scrollRestoration === 'function') {\n const shouldRestore = router.options.scrollRestoration({\n location: router.latestLocation,\n })\n if (!shouldRestore) {\n return\n }\n }\n\n restoreScroll({\n storageKey,\n key: cacheKey,\n behavior: router.options.scrollRestorationBehavior,\n shouldScrollRestoration: router.isScrollRestoring,\n scrollToTopSelectors: router.options.scrollToTopSelectors,\n location: router.history.location,\n })\n\n if (router.isScrollRestoring) {\n // Mark the location as having been seen\n scrollRestorationCache.set((state) => {\n state[cacheKey] ||= {} as ScrollRestorationByElement\n\n return state\n })\n }\n })\n}\n\n/**\n * @private\n * Handles hash-based scrolling after navigation completes.\n * To be used in framework-specific <Transitioner> components during the onResolved event.\n *\n * Provides hash scrolling for programmatic navigation when default browser handling is prevented.\n * @param router The router instance containing current location and state\n */\n/**\n * @private\n * Handles hash-based scrolling after navigation completes.\n * To be used in framework-specific Transitioners.\n */\nexport function handleHashScroll(router: AnyRouter) {\n if (typeof document !== 'undefined' && (document as any).querySelector) {\n const location = router.stores.location.state\n const hashScrollIntoViewOptions =\n location.state.__hashScrollIntoViewOptions ?? true\n\n if (hashScrollIntoViewOptions && location.hash !== '') {\n const el = document.getElementById(location.hash)\n if (el) {\n el.scrollIntoView(hashScrollIntoViewOptions)\n }\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"scroll-restoration.js","names":[],"sources":["../../src/scroll-restoration.ts"],"sourcesContent":["import { isServer } from '@tanstack/router-core/isServer'\nimport { functionalUpdate, isPlainObject } from './utils'\nimport type { AnyRouter } from './router'\nimport type { ParsedLocation } from './location'\nimport type { NonNullableUpdater } from './utils'\n\nexport type ScrollRestorationEntry = { scrollX: number; scrollY: number }\n\ntype ScrollRestorationByElement = Record<string, ScrollRestorationEntry>\n\ntype ScrollRestorationByKey = Record<string, ScrollRestorationByElement>\n\ntype ScrollRestorationCache = {\n readonly state: ScrollRestorationByKey\n set: (updater: NonNullableUpdater<ScrollRestorationByKey>) => void\n persist: () => void\n}\n\nexport type ScrollRestorationOptions = {\n getKey?: (location: ParsedLocation) => string\n scrollBehavior?: ScrollToOptions['behavior']\n}\n\nfunction getSafeSessionStorage() {\n try {\n return typeof window !== 'undefined' &&\n typeof window.sessionStorage === 'object'\n ? window.sessionStorage\n : undefined\n } catch {\n // silent\n return undefined\n }\n}\n\n// SessionStorage key used to store scroll positions across navigations.\nexport const storageKey = 'tsr-scroll-restoration-v1_3'\n\nfunction createScrollRestorationCache(): ScrollRestorationCache | null {\n const safeSessionStorage = getSafeSessionStorage()\n if (!safeSessionStorage) {\n return null\n }\n\n let state: ScrollRestorationByKey = {}\n\n try {\n const parsed = JSON.parse(safeSessionStorage.getItem(storageKey) || '{}')\n if (isPlainObject(parsed)) {\n state = parsed as ScrollRestorationByKey\n }\n } catch {\n // ignore invalid session storage payloads\n }\n\n const persist = () => {\n try {\n safeSessionStorage.setItem(storageKey, JSON.stringify(state))\n } catch {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[ts-router] Could not persist scroll restoration state to sessionStorage.',\n )\n }\n }\n }\n\n return {\n get state() {\n return state\n },\n set: (updater) => {\n state = functionalUpdate(updater, state) || state\n },\n persist,\n }\n}\n\nexport const scrollRestorationCache = createScrollRestorationCache()\n\n/**\n * The default `getKey` function for `useScrollRestoration`.\n * It returns the `key` from the location state or the `href` of the location.\n *\n * The `location.href` is used as a fallback to support the use case where the location state is not available like the initial render.\n */\nexport const defaultGetScrollRestorationKey = (location: ParsedLocation) => {\n return location.state.__TSR_key! || location.href\n}\n\nfunction getCssSelector(el: any): string {\n const path = []\n let parent: HTMLElement\n while ((parent = el.parentNode)) {\n path.push(\n `${el.tagName}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`,\n )\n el = parent\n }\n return `${path.reverse().join(' > ')}`.toLowerCase()\n}\n\nexport function getElementScrollRestorationEntry(\n router: AnyRouter,\n options: (\n | {\n id: string\n getElement?: () => Window | Element | undefined | null\n }\n | {\n id?: string\n getElement: () => Window | Element | undefined | null\n }\n ) & {\n getKey?: (location: ParsedLocation) => string\n },\n): ScrollRestorationEntry | undefined {\n const getKey = options.getKey || defaultGetScrollRestorationKey\n const restoreKey = getKey(router.latestLocation)\n\n if (options.id) {\n return scrollRestorationCache?.state[restoreKey]?.[\n `[${scrollRestorationIdAttribute}=\"${options.id}\"]`\n ]\n }\n\n const element = options.getElement?.()\n if (!element) {\n return\n }\n\n return scrollRestorationCache?.state[restoreKey]?.[\n element instanceof Window ? windowScrollTarget : getCssSelector(element)\n ]\n}\n\nlet ignoreScroll = false\nconst windowScrollTarget = 'window'\nconst scrollRestorationIdAttribute = 'data-scroll-restoration-id'\ntype ScrollTarget = typeof windowScrollTarget | Element\n\nexport function setupScrollRestoration(router: AnyRouter, force?: boolean) {\n if (!scrollRestorationCache && !(isServer ?? router.isServer)) {\n return\n }\n\n const cache = scrollRestorationCache\n\n const shouldScrollRestoration =\n force ?? router.options.scrollRestoration ?? false\n\n if (shouldScrollRestoration) {\n router.isScrollRestoring = true\n }\n\n if (\n (isServer ?? router.isServer) ||\n router.isScrollRestorationSetup ||\n !cache\n ) {\n return\n }\n\n router.isScrollRestorationSetup = true\n ignoreScroll = false\n\n const getKey =\n router.options.getScrollRestorationKey || defaultGetScrollRestorationKey\n const trackedScrollEntries = new Map<ScrollTarget, ScrollRestorationEntry>()\n\n window.history.scrollRestoration = 'manual'\n\n const onScroll = (event: Event) => {\n if (ignoreScroll || !router.isScrollRestoring) {\n return\n }\n\n if (event.target === document || event.target === window) {\n trackedScrollEntries.set(windowScrollTarget, {\n scrollX: window.scrollX || 0,\n scrollY: window.scrollY || 0,\n })\n } else {\n const target = event.target as Element\n trackedScrollEntries.set(target, {\n scrollX: target.scrollLeft || 0,\n scrollY: target.scrollTop || 0,\n })\n }\n }\n\n // Snapshot the current page's tracked scroll targets before navigation or unload.\n const snapshotCurrentScrollTargets = (restoreKey?: string) => {\n if (\n !router.isScrollRestoring ||\n !restoreKey ||\n trackedScrollEntries.size === 0 ||\n !cache\n ) {\n return\n }\n\n const keyEntry = (cache.state[restoreKey] ||=\n {} as ScrollRestorationByElement)\n\n for (const [target, position] of trackedScrollEntries) {\n let selector: string | undefined\n\n if (target === windowScrollTarget) {\n selector = windowScrollTarget\n } else if (target.isConnected) {\n const attrId = target.getAttribute(scrollRestorationIdAttribute)\n selector = attrId\n ? `[${scrollRestorationIdAttribute}=\"${attrId}\"]`\n : getCssSelector(target)\n }\n\n if (!selector) {\n continue\n }\n\n keyEntry[selector] = position\n }\n }\n\n document.addEventListener('scroll', onScroll, true)\n router.subscribe('onBeforeLoad', (event) => {\n snapshotCurrentScrollTargets(\n event.fromLocation ? getKey(event.fromLocation) : undefined,\n )\n trackedScrollEntries.clear()\n })\n window.addEventListener('pagehide', () => {\n snapshotCurrentScrollTargets(\n getKey(\n router.stores.resolvedLocation.state ?? router.stores.location.state,\n ),\n )\n cache.persist()\n })\n\n // Restore destination scroll after the new route has rendered.\n router.subscribe('onRendered', (event) => {\n const cacheKey = getKey(event.toLocation)\n const behavior = router.options.scrollRestorationBehavior\n const scrollToTopSelectors = router.options.scrollToTopSelectors\n trackedScrollEntries.clear()\n\n if (!router.resetNextScroll) {\n router.resetNextScroll = true\n return\n }\n\n if (\n typeof router.options.scrollRestoration === 'function' &&\n !router.options.scrollRestoration({ location: router.latestLocation })\n ) {\n return\n }\n\n const fromIndex = event.fromLocation?.state.__TSR_index\n const toIndex = event.toLocation.state.__TSR_index\n // Clear on forward navigations, and on same-entry replace navigations where\n // the href changed. Preserve back/restore entries so they can be restored.\n const shouldClearCache =\n typeof fromIndex === 'number' && typeof toIndex === 'number'\n ? toIndex > fromIndex ||\n (toIndex === fromIndex &&\n event.fromLocation?.href !== event.toLocation.href)\n : true\n\n if (shouldClearCache) {\n cache.set((state) => {\n delete state[cacheKey]\n return state\n })\n }\n\n ignoreScroll = true\n\n try {\n const elementEntries = router.isScrollRestoring\n ? cache.state[cacheKey]\n : undefined\n let restored = false\n\n if (elementEntries) {\n for (const elementSelector in elementEntries) {\n const entry = elementEntries[elementSelector]\n\n if (!isPlainObject(entry)) {\n continue\n }\n\n const { scrollX, scrollY } = entry as {\n scrollX?: unknown\n scrollY?: unknown\n }\n\n if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) {\n continue\n }\n\n if (elementSelector === windowScrollTarget) {\n window.scrollTo({\n top: scrollY as number,\n left: scrollX as number,\n behavior,\n })\n restored = true\n } else if (elementSelector) {\n let element\n\n try {\n element = document.querySelector(elementSelector)\n } catch {\n continue\n }\n\n if (element) {\n element.scrollLeft = scrollX as number\n element.scrollTop = scrollY as number\n restored = true\n }\n }\n }\n }\n\n if (!restored) {\n const hash = router.history.location.hash.slice(1)\n\n if (hash) {\n const hashScrollIntoViewOptions =\n window.history.state?.__hashScrollIntoViewOptions ?? true\n\n if (hashScrollIntoViewOptions) {\n const el = document.getElementById(hash)\n if (el) {\n el.scrollIntoView(hashScrollIntoViewOptions)\n }\n }\n } else {\n const scrollOptions = {\n top: 0,\n left: 0,\n behavior,\n }\n\n window.scrollTo(scrollOptions)\n if (scrollToTopSelectors) {\n for (const selector of scrollToTopSelectors) {\n if (selector === windowScrollTarget) continue\n const element =\n typeof selector === 'function'\n ? selector()\n : document.querySelector(selector)\n if (element) {\n element.scrollTo(scrollOptions)\n }\n }\n }\n }\n }\n } finally {\n ignoreScroll = false\n }\n\n if (router.isScrollRestoring) {\n cache.set((state) => {\n state[cacheKey] ||= {} as ScrollRestorationByElement\n return state\n })\n }\n })\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"transformStreamWithRouter.js","names":[],"sources":["../../../src/ssr/transformStreamWithRouter.ts"],"sourcesContent":["import { ReadableStream } from 'node:stream/web'\nimport { Readable } from 'node:stream'\nimport { TSR_SCRIPT_BARRIER_ID } from './constants'\nimport type { AnyRouter } from '../router'\n\nexport function transformReadableStreamWithRouter(\n router: AnyRouter,\n routerStream: ReadableStream,\n) {\n return transformStreamWithRouter(router, routerStream)\n}\n\nexport function transformPipeableStreamWithRouter(\n router: AnyRouter,\n routerStream: Readable,\n) {\n return Readable.fromWeb(\n transformStreamWithRouter(router, Readable.toWeb(routerStream)),\n )\n}\n\n// Use string constants for simple indexOf matching\nconst BODY_END_TAG = '</body>'\nconst HTML_END_TAG = '</html>'\n\n// Minimum length of a valid closing tag: </a> = 4 characters\nconst MIN_CLOSING_TAG_LENGTH = 4\n\n// Default timeout values (in milliseconds)\nconst DEFAULT_SERIALIZATION_TIMEOUT_MS = 60000\nconst DEFAULT_LIFETIME_TIMEOUT_MS = 60000\n\n// Module-level encoder (stateless, safe to reuse)\nconst textEncoder = new TextEncoder()\n\n/**\n * Finds the position just after the last valid HTML closing tag in the string.\n *\n * Valid closing tags match the pattern: </[a-zA-Z][\\w:.-]*>\n * Examples: </div>, </my-component>, </slot:name.nested>\n *\n * @returns Position after the last closing tag, or -1 if none found\n */\nfunction findLastClosingTagEnd(str: string): number {\n const len = str.length\n if (len < MIN_CLOSING_TAG_LENGTH) return -1\n\n let i = len - 1\n\n while (i >= MIN_CLOSING_TAG_LENGTH - 1) {\n // Look for > (charCode 62)\n if (str.charCodeAt(i) === 62) {\n // Look backwards for valid tag name characters\n let j = i - 1\n\n // Skip through valid tag name characters\n while (j >= 1) {\n const code = str.charCodeAt(j)\n // Check if it's a valid tag name char: [a-zA-Z0-9_:.-]\n if (\n (code >= 97 && code <= 122) || // a-z\n (code >= 65 && code <= 90) || // A-Z\n (code >= 48 && code <= 57) || // 0-9\n code === 95 || // _\n code === 58 || // :\n code === 46 || // .\n code === 45 // -\n ) {\n j--\n } else {\n break\n }\n }\n\n // Check if the first char after </ is a valid start char (letter only)\n const tagNameStart = j + 1\n if (tagNameStart < i) {\n const startCode = str.charCodeAt(tagNameStart)\n // Tag name must start with a letter (a-z or A-Z)\n if (\n (startCode >= 97 && startCode <= 122) ||\n (startCode >= 65 && startCode <= 90)\n ) {\n // Check for </ (charCodes: < = 60, / = 47)\n if (\n j >= 1 &&\n str.charCodeAt(j) === 47 &&\n str.charCodeAt(j - 1) === 60\n ) {\n return i + 1 // Return position after the closing >\n }\n }\n }\n }\n i--\n }\n return -1\n}\n\nexport function transformStreamWithRouter(\n router: AnyRouter,\n appStream: ReadableStream,\n opts?: {\n /** Timeout for serialization to complete after app render finishes (default: 60000ms) */\n timeoutMs?: number\n /** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */\n lifetimeMs?: number\n },\n) {\n // Check upfront if serialization already finished synchronously\n // This is the fast path for routes with no deferred data\n const serializationAlreadyFinished =\n router.serverSsr?.isSerializationFinished() ?? false\n\n // Take any HTML that was buffered before we started listening\n const initialBufferedHtml = router.serverSsr?.takeBufferedHtml()\n\n // True passthrough: if serialization already finished and nothing buffered,\n // we can avoid any decoding/scanning while still honoring cleanup + setRenderFinished.\n if (serializationAlreadyFinished && !initialBufferedHtml) {\n let cleanedUp = false\n let controller: ReadableStreamDefaultController<Uint8Array> | undefined\n let isStreamClosed = false\n let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n\n const cleanup = () => {\n if (cleanedUp) return\n cleanedUp = true\n\n if (lifetimeTimeoutHandle !== undefined) {\n clearTimeout(lifetimeTimeoutHandle)\n lifetimeTimeoutHandle = undefined\n }\n\n router.serverSsr?.cleanup()\n }\n\n const safeClose = () => {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller?.close()\n } catch {\n // ignore\n }\n }\n\n const safeError = (error: unknown) => {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller?.error(error)\n } catch {\n // ignore\n }\n }\n\n const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS\n lifetimeTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.warn(\n `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,\n )\n safeError(new Error('Stream lifetime exceeded'))\n cleanup()\n }\n }, lifetimeMs)\n\n const stream = new ReadableStream<Uint8Array>({\n start(c) {\n controller = c\n },\n cancel() {\n isStreamClosed = true\n cleanup()\n },\n })\n\n ;(async () => {\n const reader = appStream.getReader()\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (cleanedUp || isStreamClosed) return\n controller?.enqueue(value as unknown as Uint8Array)\n }\n\n if (cleanedUp || isStreamClosed) return\n\n router.serverSsr?.setRenderFinished()\n safeClose()\n cleanup()\n } catch (error) {\n if (cleanedUp) return\n console.error('Error reading appStream:', error)\n router.serverSsr?.setRenderFinished()\n safeError(error)\n cleanup()\n } finally {\n reader.releaseLock()\n }\n })().catch((error) => {\n if (cleanedUp) return\n console.error('Error in stream transform:', error)\n safeError(error)\n cleanup()\n })\n\n return stream\n }\n\n let stopListeningToInjectedHtml: (() => void) | undefined\n let stopListeningToSerializationFinished: (() => void) | undefined\n let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n let cleanedUp = false\n\n let controller: ReadableStreamDefaultController<any>\n let isStreamClosed = false\n\n const textDecoder = new TextDecoder()\n\n // concat'd router HTML; avoids array joins on each flush\n let pendingRouterHtml = initialBufferedHtml ?? ''\n\n // between-chunk text buffer; keep bounded to avoid unbounded memory\n let leftover = ''\n\n // captured closing tags from </body> onward\n let pendingClosingTags = ''\n\n // conservative cap: enough to hold any partial closing tag + a bit\n const MAX_LEFTOVER_CHARS = 2048\n\n let isAppRendering = true\n let streamBarrierLifted = false\n let serializationFinished = serializationAlreadyFinished\n\n function safeEnqueue(chunk: string | Uint8Array) {\n if (isStreamClosed) return\n if (typeof chunk === 'string') {\n controller.enqueue(textEncoder.encode(chunk))\n } else {\n controller.enqueue(chunk)\n }\n }\n\n function safeClose() {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller.close()\n } catch {\n // ignore\n }\n }\n\n function safeError(error: unknown) {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller.error(error)\n } catch {\n // ignore\n }\n }\n\n /**\n * Cleanup with guards; must be idempotent.\n */\n function cleanup() {\n if (cleanedUp) return\n cleanedUp = true\n\n try {\n stopListeningToInjectedHtml?.()\n stopListeningToSerializationFinished?.()\n } catch {\n // ignore\n }\n stopListeningToInjectedHtml = undefined\n stopListeningToSerializationFinished = undefined\n\n if (serializationTimeoutHandle !== undefined) {\n clearTimeout(serializationTimeoutHandle)\n serializationTimeoutHandle = undefined\n }\n if (lifetimeTimeoutHandle !== undefined) {\n clearTimeout(lifetimeTimeoutHandle)\n lifetimeTimeoutHandle = undefined\n }\n\n pendingRouterHtml = ''\n leftover = ''\n pendingClosingTags = ''\n\n router.serverSsr?.cleanup()\n }\n\n const stream = new ReadableStream({\n start(c) {\n controller = c\n },\n cancel() {\n isStreamClosed = true\n cleanup()\n },\n })\n\n function flushPendingRouterHtml() {\n if (!pendingRouterHtml) return\n safeEnqueue(pendingRouterHtml)\n pendingRouterHtml = ''\n }\n\n function appendRouterHtml(html: string) {\n if (!html) return\n pendingRouterHtml += html\n }\n\n /**\n * Finish only when app done and serialization complete.\n */\n function tryFinish() {\n if (isAppRendering || !serializationFinished) return\n if (cleanedUp || isStreamClosed) return\n\n if (serializationTimeoutHandle !== undefined) {\n clearTimeout(serializationTimeoutHandle)\n serializationTimeoutHandle = undefined\n }\n\n // Flush any remaining bytes in the TextDecoder\n const decoderRemainder = textDecoder.decode()\n\n if (leftover) safeEnqueue(leftover)\n if (decoderRemainder) safeEnqueue(decoderRemainder)\n flushPendingRouterHtml()\n if (pendingClosingTags) safeEnqueue(pendingClosingTags)\n\n safeClose()\n cleanup()\n }\n\n // Safety net: cleanup even if consumer never reads\n const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS\n lifetimeTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.warn(\n `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,\n )\n safeError(new Error('Stream lifetime exceeded'))\n cleanup()\n }\n }, lifetimeMs)\n\n if (!serializationAlreadyFinished) {\n stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {\n if (cleanedUp || isStreamClosed) return\n const html = router.serverSsr?.takeBufferedHtml()\n if (!html) return\n\n // If we've already captured </body> (pendingClosingTags), we must keep appending\n // so injection stays before the stored closing tags.\n if (isAppRendering || leftover || pendingClosingTags) {\n appendRouterHtml(html)\n } else {\n safeEnqueue(html)\n }\n })\n\n stopListeningToSerializationFinished = router.subscribe(\n 'onSerializationFinished',\n () => {\n serializationFinished = true\n tryFinish()\n },\n )\n }\n\n // Transform the appStream\n ;(async () => {\n const reader = appStream.getReader()\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n\n if (cleanedUp || isStreamClosed) return\n\n const text =\n value instanceof Uint8Array\n ? textDecoder.decode(value, { stream: true })\n : String(value)\n\n // Fast path: most chunks have no pending left-over.\n const chunkString = leftover ? leftover + text : text\n\n if (!streamBarrierLifted) {\n if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {\n streamBarrierLifted = true\n router.serverSsr?.liftScriptBarrier()\n }\n }\n\n // If we already saw </body>, everything else is part of tail; buffer it.\n if (pendingClosingTags) {\n pendingClosingTags += chunkString\n leftover = ''\n continue\n }\n\n const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)\n const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)\n\n if (\n bodyEndIndex !== -1 &&\n htmlEndIndex !== -1 &&\n bodyEndIndex < htmlEndIndex\n ) {\n pendingClosingTags = chunkString.slice(bodyEndIndex)\n safeEnqueue(chunkString.slice(0, bodyEndIndex))\n flushPendingRouterHtml()\n leftover = ''\n continue\n }\n\n const lastClosingTagEnd = findLastClosingTagEnd(chunkString)\n\n if (lastClosingTagEnd > 0) {\n safeEnqueue(chunkString.slice(0, lastClosingTagEnd))\n flushPendingRouterHtml()\n\n leftover = chunkString.slice(lastClosingTagEnd)\n if (leftover.length > MAX_LEFTOVER_CHARS) {\n // Ensure bounded memory even if a consumer streams long text sequences\n // without any closing tags. This may reduce injection granularity but is correct.\n safeEnqueue(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS))\n leftover = leftover.slice(-MAX_LEFTOVER_CHARS)\n }\n } else {\n // No closing tag found; keep small tail to handle split closing tags,\n // but stream older bytes to prevent unbounded buffering.\n const combined = chunkString\n if (combined.length > MAX_LEFTOVER_CHARS) {\n const flushUpto = combined.length - MAX_LEFTOVER_CHARS\n safeEnqueue(combined.slice(0, flushUpto))\n leftover = combined.slice(flushUpto)\n } else {\n leftover = combined\n }\n }\n }\n\n if (cleanedUp || isStreamClosed) return\n\n isAppRendering = false\n router.serverSsr?.setRenderFinished()\n\n if (serializationFinished) {\n tryFinish()\n } else {\n const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS\n serializationTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.error('Serialization timeout after app render finished')\n safeError(\n new Error('Serialization timeout after app render finished'),\n )\n cleanup()\n }\n }, timeoutMs)\n }\n } catch (error) {\n if (cleanedUp) return\n console.error('Error reading appStream:', error)\n isAppRendering = false\n router.serverSsr?.setRenderFinished()\n safeError(error)\n cleanup()\n } finally {\n reader.releaseLock()\n }\n })().catch((error) => {\n if (cleanedUp) return\n console.error('Error in stream transform:', error)\n safeError(error)\n cleanup()\n })\n\n return stream\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"transformStreamWithRouter.js","names":[],"sources":["../../../src/ssr/transformStreamWithRouter.ts"],"sourcesContent":["import { ReadableStream } from 'node:stream/web'\nimport { Readable } from 'node:stream'\nimport { TSR_SCRIPT_BARRIER_ID } from './constants'\nimport type { AnyRouter } from '../router'\n\nexport function transformReadableStreamWithRouter(\n router: AnyRouter,\n routerStream: ReadableStream,\n) {\n return transformStreamWithRouter(router, routerStream)\n}\n\nexport function transformPipeableStreamWithRouter(\n router: AnyRouter,\n routerStream: Readable,\n) {\n return Readable.fromWeb(\n transformStreamWithRouter(router, Readable.toWeb(routerStream)),\n )\n}\n\n// Use string constants for simple indexOf matching\nconst BODY_END_TAG = '</body>'\nconst HTML_END_TAG = '</html>'\n\n// Minimum length of a valid closing tag: </a> = 4 characters\nconst MIN_CLOSING_TAG_LENGTH = 4\n\n// Default timeout values (in milliseconds)\nconst DEFAULT_SERIALIZATION_TIMEOUT_MS = 60000\nconst DEFAULT_LIFETIME_TIMEOUT_MS = 60000\n\n// Module-level encoder (stateless, safe to reuse)\nconst textEncoder = new TextEncoder()\n\n/**\n * Finds the position just after the last valid HTML closing tag in the string.\n *\n * Valid closing tags match the pattern: </[a-zA-Z][\\w:.-]*>\n * Examples: </div>, </my-component>, </slot:name.nested>\n *\n * @returns Position after the last closing tag, or -1 if none found\n */\nfunction findLastClosingTagEnd(str: string): number {\n const len = str.length\n if (len < MIN_CLOSING_TAG_LENGTH) return -1\n\n let i = len - 1\n\n while (i >= MIN_CLOSING_TAG_LENGTH - 1) {\n // Look for > (charCode 62)\n if (str.charCodeAt(i) === 62) {\n // Look backwards for valid tag name characters\n let j = i - 1\n\n // Skip through valid tag name characters\n while (j >= 1) {\n const code = str.charCodeAt(j)\n // Check if it's a valid tag name char: [a-zA-Z0-9_:.-]\n if (\n (code >= 97 && code <= 122) || // a-z\n (code >= 65 && code <= 90) || // A-Z\n (code >= 48 && code <= 57) || // 0-9\n code === 95 || // _\n code === 58 || // :\n code === 46 || // .\n code === 45 // -\n ) {\n j--\n } else {\n break\n }\n }\n\n // Check if the first char after </ is a valid start char (letter only)\n const tagNameStart = j + 1\n if (tagNameStart < i) {\n const startCode = str.charCodeAt(tagNameStart)\n // Tag name must start with a letter (a-z or A-Z)\n if (\n (startCode >= 97 && startCode <= 122) ||\n (startCode >= 65 && startCode <= 90)\n ) {\n // Check for </ (charCodes: < = 60, / = 47)\n if (\n j >= 1 &&\n str.charCodeAt(j) === 47 &&\n str.charCodeAt(j - 1) === 60\n ) {\n return i + 1 // Return position after the closing >\n }\n }\n }\n }\n i--\n }\n return -1\n}\n\nexport function transformStreamWithRouter(\n router: AnyRouter,\n appStream: ReadableStream,\n opts?: {\n /** Timeout for serialization to complete after app render finishes (default: 60000ms) */\n timeoutMs?: number\n /** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */\n lifetimeMs?: number\n },\n) {\n // Check upfront if serialization already finished synchronously\n // This is the fast path for routes with no deferred data\n const serializationAlreadyFinished =\n router.serverSsr?.isSerializationFinished() ?? false\n\n // Take any HTML that was buffered before we started listening\n const initialBufferedHtml = router.serverSsr?.takeBufferedHtml()\n\n // True passthrough: if serialization already finished and nothing buffered,\n // we can avoid any decoding/scanning while still honoring cleanup + setRenderFinished.\n if (serializationAlreadyFinished && !initialBufferedHtml) {\n let cleanedUp = false\n let controller: ReadableStreamDefaultController<Uint8Array> | undefined\n let isStreamClosed = false\n let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n\n const cleanup = () => {\n if (cleanedUp) return\n cleanedUp = true\n\n if (lifetimeTimeoutHandle !== undefined) {\n clearTimeout(lifetimeTimeoutHandle)\n lifetimeTimeoutHandle = undefined\n }\n\n router.serverSsr?.cleanup()\n }\n\n const safeClose = () => {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller?.close()\n } catch {\n // ignore\n }\n }\n\n const safeError = (error: unknown) => {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller?.error(error)\n } catch {\n // ignore\n }\n }\n\n const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS\n lifetimeTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.warn(\n `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,\n )\n safeError(new Error('Stream lifetime exceeded'))\n cleanup()\n }\n }, lifetimeMs)\n\n const stream = new ReadableStream<Uint8Array>({\n start(c: ReadableStreamDefaultController<Uint8Array>) {\n controller = c\n },\n cancel() {\n isStreamClosed = true\n cleanup()\n },\n })\n\n ;(async () => {\n const reader = appStream.getReader()\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (cleanedUp || isStreamClosed) return\n controller?.enqueue(value as unknown as Uint8Array)\n }\n\n if (cleanedUp || isStreamClosed) return\n\n router.serverSsr?.setRenderFinished()\n safeClose()\n cleanup()\n } catch (error) {\n if (cleanedUp) return\n console.error('Error reading appStream:', error)\n router.serverSsr?.setRenderFinished()\n safeError(error)\n cleanup()\n } finally {\n reader.releaseLock()\n }\n })().catch((error) => {\n if (cleanedUp) return\n console.error('Error in stream transform:', error)\n safeError(error)\n cleanup()\n })\n\n return stream\n }\n\n let stopListeningToInjectedHtml: (() => void) | undefined\n let stopListeningToSerializationFinished: (() => void) | undefined\n let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n let cleanedUp = false\n\n let controller: ReadableStreamDefaultController<any>\n let isStreamClosed = false\n\n const textDecoder = new TextDecoder()\n\n // concat'd router HTML; avoids array joins on each flush\n let pendingRouterHtml = initialBufferedHtml ?? ''\n\n // between-chunk text buffer; keep bounded to avoid unbounded memory\n let leftover = ''\n\n // captured closing tags from </body> onward\n let pendingClosingTags = ''\n\n // conservative cap: enough to hold any partial closing tag + a bit\n const MAX_LEFTOVER_CHARS = 2048\n\n let isAppRendering = true\n let streamBarrierLifted = false\n let serializationFinished = serializationAlreadyFinished\n\n function safeEnqueue(chunk: string | Uint8Array) {\n if (isStreamClosed) return\n if (typeof chunk === 'string') {\n controller.enqueue(textEncoder.encode(chunk))\n } else {\n controller.enqueue(chunk)\n }\n }\n\n function safeClose() {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller.close()\n } catch {\n // ignore\n }\n }\n\n function safeError(error: unknown) {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller.error(error)\n } catch {\n // ignore\n }\n }\n\n /**\n * Cleanup with guards; must be idempotent.\n */\n function cleanup() {\n if (cleanedUp) return\n cleanedUp = true\n\n try {\n stopListeningToInjectedHtml?.()\n stopListeningToSerializationFinished?.()\n } catch {\n // ignore\n }\n stopListeningToInjectedHtml = undefined\n stopListeningToSerializationFinished = undefined\n\n if (serializationTimeoutHandle !== undefined) {\n clearTimeout(serializationTimeoutHandle)\n serializationTimeoutHandle = undefined\n }\n if (lifetimeTimeoutHandle !== undefined) {\n clearTimeout(lifetimeTimeoutHandle)\n lifetimeTimeoutHandle = undefined\n }\n\n pendingRouterHtml = ''\n leftover = ''\n pendingClosingTags = ''\n\n router.serverSsr?.cleanup()\n }\n\n const stream = new ReadableStream({\n start(c: ReadableStreamDefaultController<any>) {\n controller = c\n },\n cancel() {\n isStreamClosed = true\n cleanup()\n },\n })\n\n function flushPendingRouterHtml() {\n if (!pendingRouterHtml) return\n safeEnqueue(pendingRouterHtml)\n pendingRouterHtml = ''\n }\n\n function appendRouterHtml(html: string) {\n if (!html) return\n pendingRouterHtml += html\n }\n\n /**\n * Finish only when app done and serialization complete.\n */\n function tryFinish() {\n if (isAppRendering || !serializationFinished) return\n if (cleanedUp || isStreamClosed) return\n\n if (serializationTimeoutHandle !== undefined) {\n clearTimeout(serializationTimeoutHandle)\n serializationTimeoutHandle = undefined\n }\n\n // Flush any remaining bytes in the TextDecoder\n const decoderRemainder = textDecoder.decode()\n\n if (leftover) safeEnqueue(leftover)\n if (decoderRemainder) safeEnqueue(decoderRemainder)\n flushPendingRouterHtml()\n if (pendingClosingTags) safeEnqueue(pendingClosingTags)\n\n safeClose()\n cleanup()\n }\n\n // Safety net: cleanup even if consumer never reads\n const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS\n lifetimeTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.warn(\n `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,\n )\n safeError(new Error('Stream lifetime exceeded'))\n cleanup()\n }\n }, lifetimeMs)\n\n if (!serializationAlreadyFinished) {\n stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {\n if (cleanedUp || isStreamClosed) return\n const html = router.serverSsr?.takeBufferedHtml()\n if (!html) return\n\n // If we've already captured </body> (pendingClosingTags), we must keep appending\n // so injection stays before the stored closing tags.\n if (isAppRendering || leftover || pendingClosingTags) {\n appendRouterHtml(html)\n } else {\n safeEnqueue(html)\n }\n })\n\n stopListeningToSerializationFinished = router.subscribe(\n 'onSerializationFinished',\n () => {\n serializationFinished = true\n tryFinish()\n },\n )\n }\n\n // Transform the appStream\n ;(async () => {\n const reader = appStream.getReader()\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n\n if (cleanedUp || isStreamClosed) return\n\n const text =\n value instanceof Uint8Array\n ? textDecoder.decode(value, { stream: true })\n : String(value)\n\n // Fast path: most chunks have no pending left-over.\n const chunkString = leftover ? leftover + text : text\n\n if (!streamBarrierLifted) {\n if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {\n streamBarrierLifted = true\n router.serverSsr?.liftScriptBarrier()\n }\n }\n\n // If we already saw </body>, everything else is part of tail; buffer it.\n if (pendingClosingTags) {\n pendingClosingTags += chunkString\n leftover = ''\n continue\n }\n\n const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)\n const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)\n\n if (\n bodyEndIndex !== -1 &&\n htmlEndIndex !== -1 &&\n bodyEndIndex < htmlEndIndex\n ) {\n pendingClosingTags = chunkString.slice(bodyEndIndex)\n safeEnqueue(chunkString.slice(0, bodyEndIndex))\n flushPendingRouterHtml()\n leftover = ''\n continue\n }\n\n const lastClosingTagEnd = findLastClosingTagEnd(chunkString)\n\n if (lastClosingTagEnd > 0) {\n safeEnqueue(chunkString.slice(0, lastClosingTagEnd))\n flushPendingRouterHtml()\n\n leftover = chunkString.slice(lastClosingTagEnd)\n if (leftover.length > MAX_LEFTOVER_CHARS) {\n // Ensure bounded memory even if a consumer streams long text sequences\n // without any closing tags. This may reduce injection granularity but is correct.\n safeEnqueue(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS))\n leftover = leftover.slice(-MAX_LEFTOVER_CHARS)\n }\n } else {\n // No closing tag found; keep small tail to handle split closing tags,\n // but stream older bytes to prevent unbounded buffering.\n const combined = chunkString\n if (combined.length > MAX_LEFTOVER_CHARS) {\n const flushUpto = combined.length - MAX_LEFTOVER_CHARS\n safeEnqueue(combined.slice(0, flushUpto))\n leftover = combined.slice(flushUpto)\n } else {\n leftover = combined\n }\n }\n }\n\n if (cleanedUp || isStreamClosed) return\n\n isAppRendering = false\n router.serverSsr?.setRenderFinished()\n\n if (serializationFinished) {\n tryFinish()\n } else {\n const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS\n serializationTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.error('Serialization timeout after app render finished')\n safeError(\n new Error('Serialization timeout after app render finished'),\n )\n cleanup()\n }\n }, timeoutMs)\n }\n } catch (error) {\n if (cleanedUp) return\n console.error('Error reading appStream:', error)\n isAppRendering = false\n router.serverSsr?.setRenderFinished()\n safeError(error)\n cleanup()\n } finally {\n reader.releaseLock()\n }\n })().catch((error) => {\n if (cleanedUp) return\n console.error('Error in stream transform:', error)\n safeError(error)\n cleanup()\n })\n\n return stream\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  //#region src/ssr/tsrScript.ts?script-string
2
- var tsrScript_default = "self.$_TSR={h(){this.hydrated=!0,this.c()},e(){this.streamEnded=!0,this.c()},c(){this.hydrated&&this.streamEnded&&(delete self.$_TSR,delete self.$R.tsr)},p(e){this.initialized?e():this.buffer.push(e)},buffer:[]};\n";
2
+ var tsrScript_default = "self.$_TSR={h(){this.hydrated=!0,this.c()},e(){this.streamEnded=!0,this.c()},c(){this.hydrated&&this.streamEnded&&(delete self.$_TSR,delete self.$R.tsr)},p(e){this.initialized?e():this.buffer.push(e)},buffer:[]}";
3
3
  //#endregion
4
4
  export { tsrScript_default as default };
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/router-core",
3
- "version": "1.168.3",
3
+ "version": "1.168.5",
4
4
  "description": "Modern and scalable routing for React applications",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -133,6 +133,26 @@
133
133
  "default": "./dist/cjs/isServer/client.cjs"
134
134
  }
135
135
  },
136
+ "./scroll-restoration-script": {
137
+ "browser": {
138
+ "import": {
139
+ "types": "./dist/esm/scroll-restoration-script/client.d.ts",
140
+ "default": "./dist/esm/scroll-restoration-script/client.js"
141
+ },
142
+ "require": {
143
+ "types": "./dist/cjs/scroll-restoration-script/client.d.cts",
144
+ "default": "./dist/cjs/scroll-restoration-script/client.cjs"
145
+ }
146
+ },
147
+ "import": {
148
+ "types": "./dist/esm/scroll-restoration-script/server.d.ts",
149
+ "default": "./dist/esm/scroll-restoration-script/server.js"
150
+ },
151
+ "require": {
152
+ "types": "./dist/cjs/scroll-restoration-script/server.d.cts",
153
+ "default": "./dist/cjs/scroll-restoration-script/server.cjs"
154
+ }
155
+ },
136
156
  "./package.json": "./package.json"
137
157
  },
138
158
  "sideEffects": false,
@@ -153,8 +173,9 @@
153
173
  "@tanstack/history": "1.161.6"
154
174
  },
155
175
  "devDependencies": {
156
- "@tanstack/store": "^0.9.2",
157
176
  "@tanstack/intent": "^0.0.14",
177
+ "@tanstack/store": "^0.9.3",
178
+ "@types/node": "25.0.9",
158
179
  "esbuild": "^0.27.4",
159
180
  "vite": "*"
160
181
  },
@@ -165,12 +186,12 @@
165
186
  "clean": "rimraf ./dist && rimraf ./coverage",
166
187
  "test:eslint": "eslint ./src",
167
188
  "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"",
168
- "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js",
169
189
  "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js",
170
190
  "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js",
171
191
  "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js",
172
192
  "test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js",
173
- "test:types:ts59": "tsc",
193
+ "test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js",
194
+ "test:types:ts60": "tsc",
174
195
  "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .",
175
196
  "test:unit": "vitest",
176
197
  "test:unit:dev": "pnpm run test:unit --watch",
@@ -0,0 +1,21 @@
1
+ import type { AnyRouter } from './router'
2
+
3
+ /**
4
+ * @private
5
+ * Handles hash-based scrolling after navigation completes.
6
+ * To be used in framework-specific <Transitioner> components during the onResolved event.
7
+ */
8
+ export function handleHashScroll(router: AnyRouter) {
9
+ if (typeof document !== 'undefined' && (document as any).querySelector) {
10
+ const location = router.stores.location.state
11
+ const hashScrollIntoViewOptions =
12
+ location.state.__hashScrollIntoViewOptions ?? true
13
+
14
+ if (hashScrollIntoViewOptions && location.hash !== '') {
15
+ const el = document.getElementById(location.hash)
16
+ if (el) {
17
+ el.scrollIntoView(hashScrollIntoViewOptions)
18
+ }
19
+ }
20
+ }
21
+ }
package/src/index.ts CHANGED
@@ -397,14 +397,14 @@ export { isNotFound, notFound } from './not-found'
397
397
 
398
398
  export {
399
399
  defaultGetScrollRestorationKey,
400
- restoreScroll,
400
+ getElementScrollRestorationEntry,
401
401
  storageKey,
402
- getCssSelector,
403
402
  scrollRestorationCache,
404
403
  setupScrollRestoration,
405
- handleHashScroll,
406
404
  } from './scroll-restoration'
407
405
 
406
+ export { handleHashScroll } from './hash-scroll'
407
+
408
408
  export type {
409
409
  ScrollRestorationOptions,
410
410
  ScrollRestorationEntry,