@tanstack/router-core 1.171.5 → 1.171.7

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 (107) hide show
  1. package/dist/cjs/Matches.cjs.map +1 -1
  2. package/dist/cjs/config.cjs.map +1 -1
  3. package/dist/cjs/defer.cjs.map +1 -1
  4. package/dist/cjs/index.cjs +5 -1
  5. package/dist/cjs/index.d.cts +2 -2
  6. package/dist/cjs/invariant.cjs.map +1 -1
  7. package/dist/cjs/load-matches.cjs.map +1 -1
  8. package/dist/cjs/lru-cache.cjs.map +1 -1
  9. package/dist/cjs/manifest.cjs +43 -17
  10. package/dist/cjs/manifest.cjs.map +1 -1
  11. package/dist/cjs/manifest.d.cts +76 -24
  12. package/dist/cjs/new-process-route-tree.cjs.map +1 -1
  13. package/dist/cjs/not-found.cjs.map +1 -1
  14. package/dist/cjs/path.cjs.map +1 -1
  15. package/dist/cjs/qss.cjs.map +1 -1
  16. package/dist/cjs/redirect.cjs.map +1 -1
  17. package/dist/cjs/rewrite.cjs.map +1 -1
  18. package/dist/cjs/route.cjs.map +1 -1
  19. package/dist/cjs/router.cjs.map +1 -1
  20. package/dist/cjs/router.d.cts +31 -16
  21. package/dist/cjs/scroll-restoration-script/client.cjs.map +1 -1
  22. package/dist/cjs/scroll-restoration-script/server.cjs.map +1 -1
  23. package/dist/cjs/scroll-restoration.cjs.map +1 -1
  24. package/dist/cjs/searchMiddleware.cjs.map +1 -1
  25. package/dist/cjs/searchParams.cjs.map +1 -1
  26. package/dist/cjs/ssr/createRequestHandler.cjs +10 -8
  27. package/dist/cjs/ssr/createRequestHandler.cjs.map +1 -1
  28. package/dist/cjs/ssr/createRequestHandler.d.cts +2 -2
  29. package/dist/cjs/ssr/handlerCallback.cjs +46 -0
  30. package/dist/cjs/ssr/handlerCallback.cjs.map +1 -1
  31. package/dist/cjs/ssr/handlerCallback.d.cts +15 -1
  32. package/dist/cjs/ssr/headers.cjs.map +1 -1
  33. package/dist/cjs/ssr/json.cjs.map +1 -1
  34. package/dist/cjs/ssr/serializer/RawStream.cjs.map +1 -1
  35. package/dist/cjs/ssr/serializer/ShallowErrorPlugin.cjs.map +1 -1
  36. package/dist/cjs/ssr/serializer/seroval-plugins.cjs.map +1 -1
  37. package/dist/cjs/ssr/serializer/transformer.cjs.map +1 -1
  38. package/dist/cjs/ssr/server.cjs +6 -1
  39. package/dist/cjs/ssr/server.d.cts +3 -2
  40. package/dist/cjs/ssr/ssr-client.cjs.map +1 -1
  41. package/dist/cjs/ssr/ssr-match-id.cjs.map +1 -1
  42. package/dist/cjs/ssr/ssr-server.cjs +263 -132
  43. package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
  44. package/dist/cjs/ssr/ssr-server.d.cts +4 -19
  45. package/dist/cjs/ssr/transformStreamWithRouter.cjs +455 -203
  46. package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
  47. package/dist/cjs/ssr/transformStreamWithRouter.d.cts +14 -5
  48. package/dist/cjs/stores.cjs.map +1 -1
  49. package/dist/cjs/utils.cjs.map +1 -1
  50. package/dist/esm/Matches.js.map +1 -1
  51. package/dist/esm/config.js.map +1 -1
  52. package/dist/esm/defer.js.map +1 -1
  53. package/dist/esm/index.d.ts +2 -2
  54. package/dist/esm/index.js +2 -2
  55. package/dist/esm/invariant.js.map +1 -1
  56. package/dist/esm/load-matches.js.map +1 -1
  57. package/dist/esm/lru-cache.js.map +1 -1
  58. package/dist/esm/manifest.d.ts +76 -24
  59. package/dist/esm/manifest.js +39 -17
  60. package/dist/esm/manifest.js.map +1 -1
  61. package/dist/esm/new-process-route-tree.js.map +1 -1
  62. package/dist/esm/not-found.js.map +1 -1
  63. package/dist/esm/path.js.map +1 -1
  64. package/dist/esm/qss.js.map +1 -1
  65. package/dist/esm/redirect.js.map +1 -1
  66. package/dist/esm/rewrite.js.map +1 -1
  67. package/dist/esm/route.js.map +1 -1
  68. package/dist/esm/router.d.ts +31 -16
  69. package/dist/esm/router.js.map +1 -1
  70. package/dist/esm/scroll-restoration-script/client.js.map +1 -1
  71. package/dist/esm/scroll-restoration-script/server.js.map +1 -1
  72. package/dist/esm/scroll-restoration.js.map +1 -1
  73. package/dist/esm/searchMiddleware.js.map +1 -1
  74. package/dist/esm/searchParams.js.map +1 -1
  75. package/dist/esm/ssr/createRequestHandler.d.ts +2 -2
  76. package/dist/esm/ssr/createRequestHandler.js +10 -8
  77. package/dist/esm/ssr/createRequestHandler.js.map +1 -1
  78. package/dist/esm/ssr/handlerCallback.d.ts +15 -1
  79. package/dist/esm/ssr/handlerCallback.js +42 -1
  80. package/dist/esm/ssr/handlerCallback.js.map +1 -1
  81. package/dist/esm/ssr/headers.js.map +1 -1
  82. package/dist/esm/ssr/json.js.map +1 -1
  83. package/dist/esm/ssr/serializer/RawStream.js.map +1 -1
  84. package/dist/esm/ssr/serializer/ShallowErrorPlugin.js.map +1 -1
  85. package/dist/esm/ssr/serializer/seroval-plugins.js.map +1 -1
  86. package/dist/esm/ssr/serializer/transformer.js.map +1 -1
  87. package/dist/esm/ssr/server.d.ts +3 -2
  88. package/dist/esm/ssr/server.js +2 -2
  89. package/dist/esm/ssr/ssr-client.js.map +1 -1
  90. package/dist/esm/ssr/ssr-match-id.js.map +1 -1
  91. package/dist/esm/ssr/ssr-server.d.ts +4 -19
  92. package/dist/esm/ssr/ssr-server.js +264 -133
  93. package/dist/esm/ssr/ssr-server.js.map +1 -1
  94. package/dist/esm/ssr/transformStreamWithRouter.d.ts +14 -5
  95. package/dist/esm/ssr/transformStreamWithRouter.js +455 -203
  96. package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
  97. package/dist/esm/stores.js.map +1 -1
  98. package/dist/esm/utils.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/index.ts +21 -1
  101. package/src/manifest.ts +151 -59
  102. package/src/router.ts +37 -19
  103. package/src/ssr/createRequestHandler.ts +14 -13
  104. package/src/ssr/handlerCallback.ts +84 -1
  105. package/src/ssr/server.ts +14 -2
  106. package/src/ssr/ssr-server.ts +418 -222
  107. package/src/ssr/transformStreamWithRouter.ts +662 -281
@@ -4,7 +4,6 @@ import {
4
4
  createInlineCssPlaceholderAsset,
5
5
  createInlineCssStyleAsset,
6
6
  getStylesheetHref,
7
- isInlinableStylesheet,
8
7
  } from '../manifest'
9
8
  import { decodePath } from '../utils'
10
9
  import { createLRUCache } from '../lru-cache'
@@ -17,24 +16,15 @@ import { makeSsrSerovalPlugin } from './serializer/transformer'
17
16
  import type { LRUCache } from '../lru-cache'
18
17
  import type { DehydratedMatch, DehydratedRouter } from './types'
19
18
  import type { AnySerializationAdapter } from './serializer/transformer'
20
- import type { AnyRouter } from '../router'
19
+ import type { AnyRouter, ServerSsr } from '../router'
21
20
  import type { AnyRouteMatch } from '../Matches'
22
- import type { Manifest, RouterManagedTag } from '../manifest'
23
-
24
- declare module '../router' {
25
- interface ServerSsr {
26
- setRenderFinished: () => void
27
- cleanup: () => void
28
- }
29
- interface RouterEvents {
30
- onInjectedHtml: {
31
- type: 'onInjectedHtml'
32
- }
33
- onSerializationFinished: {
34
- type: 'onSerializationFinished'
35
- }
36
- }
37
- }
21
+ import type {
22
+ Manifest,
23
+ ManifestRoute,
24
+ ManifestRouteAssets,
25
+ RouterManagedTag,
26
+ ServerManifest,
27
+ } from '../manifest'
38
28
 
39
29
  const SCOPE_ID = 'tsr'
40
30
 
@@ -73,14 +63,15 @@ const INITIAL_SCRIPTS = [
73
63
  ]
74
64
 
75
65
  class ScriptBuffer {
76
- private router: AnyRouter | undefined
66
+ private injectScript: ((script: string) => void) | undefined
77
67
  private _queue: Array<string>
78
68
  private _scriptBarrierLifted = false
79
69
  private _cleanedUp = false
80
- private _pendingMicrotask = false
70
+ private _microtaskVersion = 0
71
+ private _pendingMicrotaskVersion = 0
81
72
 
82
- constructor(router: AnyRouter) {
83
- this.router = router
73
+ constructor(injectScript: (script: string) => void) {
74
+ this.injectScript = injectScript
84
75
  // Copy INITIAL_SCRIPTS to avoid mutating the shared array
85
76
  this._queue = INITIAL_SCRIPTS.slice()
86
77
  }
@@ -88,31 +79,39 @@ class ScriptBuffer {
88
79
  enqueue(script: string) {
89
80
  if (this._cleanedUp) return
90
81
  this._queue.push(script)
91
- // If barrier is lifted, schedule injection (if not already scheduled)
92
- if (this._scriptBarrierLifted && !this._pendingMicrotask) {
93
- this._pendingMicrotask = true
94
- queueMicrotask(() => {
95
- this._pendingMicrotask = false
96
- this.injectBufferedScripts()
97
- })
82
+ if (this._scriptBarrierLifted) {
83
+ this.scheduleInjectBufferedScripts()
98
84
  }
99
85
  }
100
86
 
101
87
  liftBarrier() {
102
88
  if (this._scriptBarrierLifted || this._cleanedUp) return
103
89
  this._scriptBarrierLifted = true
104
- if (this._queue.length > 0 && !this._pendingMicrotask) {
105
- this._pendingMicrotask = true
106
- queueMicrotask(() => {
107
- this._pendingMicrotask = false
108
- this.injectBufferedScripts()
109
- })
90
+ if (this._queue.length > 0) {
91
+ this.scheduleInjectBufferedScripts()
110
92
  }
111
93
  }
112
94
 
95
+ scheduleInjectBufferedScripts() {
96
+ if (this._pendingMicrotaskVersion !== 0) return
97
+ const pendingVersion = ++this._microtaskVersion
98
+ this._pendingMicrotaskVersion = pendingVersion
99
+ queueMicrotask(() => {
100
+ if (this._pendingMicrotaskVersion !== pendingVersion) return
101
+ this._pendingMicrotaskVersion = 0
102
+ this.injectBufferedScripts()
103
+ })
104
+ }
105
+
106
+ clearPendingMicrotask() {
107
+ if (this._pendingMicrotaskVersion === 0) return
108
+ this._pendingMicrotaskVersion = 0
109
+ this._microtaskVersion++
110
+ }
111
+
113
112
  /**
114
113
  * Flushes any pending scripts synchronously.
115
- * Call this before emitting onSerializationFinished to ensure all scripts are injected.
114
+ * Call this before signaling serialization finished to ensure all scripts are injected.
116
115
  *
117
116
  * IMPORTANT: Only injects if the barrier has been lifted. Before the barrier is lifted,
118
117
  * scripts should remain in the queue so takeBufferedScripts() can retrieve them
@@ -120,16 +119,17 @@ class ScriptBuffer {
120
119
  flush() {
121
120
  if (!this._scriptBarrierLifted) return
122
121
  if (this._cleanedUp) return
123
- this._pendingMicrotask = false
124
- const scriptsToInject = this.takeAll()
125
- if (scriptsToInject && this.router?.serverSsr) {
126
- this.router.serverSsr.injectScript(scriptsToInject)
127
- }
122
+ this.clearPendingMicrotask()
123
+ this.injectBufferedScripts()
128
124
  }
129
125
 
130
126
  takeAll() {
131
- const bufferedScripts = this._queue
132
- this._queue = []
127
+ return this.takeScripts(this._queue.length)
128
+ }
129
+
130
+ takeScripts(count: number) {
131
+ if (count <= 0) return undefined
132
+ const bufferedScripts = this._queue.splice(0, count)
133
133
  if (bufferedScripts.length === 0) {
134
134
  return undefined
135
135
  }
@@ -141,20 +141,25 @@ class ScriptBuffer {
141
141
  return bufferedScripts.join(';') + ';document.currentScript.remove()'
142
142
  }
143
143
 
144
+ hasPending() {
145
+ return this._queue.length > 0
146
+ }
147
+
144
148
  injectBufferedScripts() {
145
149
  if (this._cleanedUp) return
146
150
  // Early return if queue is empty (avoids unnecessary takeAll() call)
147
151
  if (this._queue.length === 0) return
148
152
  const scriptsToInject = this.takeAll()
149
- if (scriptsToInject && this.router?.serverSsr) {
150
- this.router.serverSsr.injectScript(scriptsToInject)
153
+ if (scriptsToInject) {
154
+ this.injectScript?.(scriptsToInject)
151
155
  }
152
156
  }
153
157
 
154
158
  cleanup() {
155
159
  this._cleanedUp = true
160
+ this.clearPendingMicrotask()
156
161
  this._queue = []
157
- this.router = undefined
162
+ this.injectScript = undefined
158
163
  }
159
164
  }
160
165
 
@@ -162,133 +167,222 @@ const isProd = process.env.NODE_ENV === 'production'
162
167
 
163
168
  type FilteredRoutes = Manifest['routes']
164
169
 
165
- type ManifestLRU = LRUCache<string, FilteredRoutes>
166
- type InlineCssLRU = LRUCache<string, string>
170
+ type PreparedMatchedManifestRoutes = {
171
+ routes: FilteredRoutes
172
+ hasStrippedRoutes: boolean
173
+ inlineCssHrefs?: Array<string>
174
+ inlineCss?: string
175
+ }
176
+
177
+ type ManifestLRU = LRUCache<string, PreparedMatchedManifestRoutes>
167
178
 
168
179
  const MANIFEST_CACHE_SIZE = 100
169
- const manifestCaches = new WeakMap<Manifest, ManifestLRU>()
170
- const inlineCssCaches = new WeakMap<Manifest, InlineCssLRU>()
180
+ const manifestCaches = new WeakMap<ServerManifest, ManifestLRU>()
171
181
 
172
- function getManifestCache(manifest: Manifest): ManifestLRU {
182
+ function getManifestCache(manifest: ServerManifest): ManifestLRU {
173
183
  const cache = manifestCaches.get(manifest)
174
184
  if (cache) return cache
175
- const newCache = createLRUCache<string, FilteredRoutes>(MANIFEST_CACHE_SIZE)
185
+ const newCache = createLRUCache<string, PreparedMatchedManifestRoutes>(
186
+ MANIFEST_CACHE_SIZE,
187
+ )
176
188
  manifestCaches.set(manifest, newCache)
177
189
  return newCache
178
190
  }
179
191
 
180
- function getInlineCssCache(manifest: Manifest): InlineCssLRU {
181
- const cache = inlineCssCaches.get(manifest)
182
- if (cache) return cache
183
- const newCache = createLRUCache<string, string>(MANIFEST_CACHE_SIZE)
184
- inlineCssCaches.set(manifest, newCache)
185
- return newCache
186
- }
187
-
188
- function getInlineCssHrefsForMatches(
189
- manifest: Manifest | undefined,
190
- matches: Array<AnyRouteMatch>,
192
+ function getInlineCssForPreparedRoutes(
193
+ manifest: ServerManifest,
194
+ preparedRoutes: PreparedMatchedManifestRoutes,
191
195
  ) {
192
- const styles = manifest?.inlineCss?.styles
193
- if (!styles) return []
196
+ if (preparedRoutes.inlineCss !== undefined) return preparedRoutes.inlineCss
194
197
 
195
- const seen = new Set<string>()
196
- const hrefs: Array<string> = []
198
+ const styles = manifest.inlineCss?.styles
199
+ const hrefs = preparedRoutes.inlineCssHrefs
200
+ if (!styles || !hrefs?.length) return undefined
197
201
 
198
- for (const match of matches) {
199
- const assets = manifest?.routes[match.routeId]?.assets ?? []
200
- for (const asset of assets) {
201
- const href = getStylesheetHref(asset)
202
- if (!href || seen.has(href) || styles[href] === undefined) {
203
- continue
204
- }
205
- seen.add(href)
206
- hrefs.push(href)
207
- }
202
+ let css = ''
203
+ for (const href of hrefs) {
204
+ css += styles[href]!
208
205
  }
209
206
 
210
- return hrefs
207
+ preparedRoutes.inlineCss = css
208
+ return css
211
209
  }
212
210
 
213
- function getInlineCssForHrefs(manifest: Manifest, hrefs: Array<string>) {
214
- const styles = manifest.inlineCss?.styles
215
- if (!styles || hrefs.length === 0) return undefined
211
+ function getInlineCssAssetForPreparedRoutes(
212
+ manifest: ServerManifest,
213
+ preparedRoutes: PreparedMatchedManifestRoutes,
214
+ ) {
215
+ const css = getInlineCssForPreparedRoutes(manifest, preparedRoutes)
216
216
 
217
- const cacheKey = hrefs.join('\0')
217
+ return css === undefined ? undefined : createInlineCssStyleAsset(css)
218
+ }
219
+
220
+ function getMatchedRoutesCacheKey(matches: Array<AnyRouteMatch>) {
221
+ let cacheKey = ''
222
+ for (let i = 0; i < matches.length; i++) {
223
+ cacheKey += (i === 0 ? '' : '\0') + matches[i]!.routeId
224
+ }
225
+ return cacheKey
226
+ }
227
+
228
+ function getPreparedMatchedManifestRoutes(
229
+ manifest: ServerManifest,
230
+ matches: Array<AnyRouteMatch>,
231
+ cacheKey: string,
232
+ ) {
218
233
  if (isProd) {
219
- const cached = getInlineCssCache(manifest).get(cacheKey)
220
- if (cached !== undefined) return cached
234
+ const cached = getManifestCache(manifest).get(cacheKey)
235
+ if (cached) {
236
+ return cached
237
+ }
221
238
  }
222
239
 
223
- const css = hrefs.map((href) => styles[href]!).join('')
240
+ const preparedRoutes = prepareMatchedManifestRoutes(manifest, matches)
224
241
 
225
242
  if (isProd) {
226
- getInlineCssCache(manifest).set(cacheKey, css)
243
+ getManifestCache(manifest).set(cacheKey, preparedRoutes)
227
244
  }
228
245
 
229
- return css
246
+ return preparedRoutes
230
247
  }
231
248
 
232
- function getInlineCssAssetForMatches(
233
- manifest: Manifest | undefined,
249
+ function prepareMatchedManifestRoutes(
250
+ manifest: ServerManifest,
234
251
  matches: Array<AnyRouteMatch>,
235
- ) {
236
- if (!manifest?.inlineCss) return undefined
252
+ ): PreparedMatchedManifestRoutes {
253
+ const inlineStyles = manifest.inlineCss?.styles
254
+ const routes: FilteredRoutes = {}
255
+
256
+ if (!inlineStyles) {
257
+ for (const match of matches) {
258
+ const route = manifest.routes[match.routeId]
259
+ if (route) {
260
+ routes[match.routeId] = route
261
+ }
262
+ }
263
+ return { routes, hasStrippedRoutes: false }
264
+ }
237
265
 
238
- const hrefs = getInlineCssHrefsForMatches(manifest, matches)
239
- const css = getInlineCssForHrefs(manifest, hrefs)
266
+ const inlineCssHrefs: Array<string> = []
267
+ const seenInlineCssHrefs = new Set<string>()
268
+ let hasStrippedRoutes = false
240
269
 
241
- return css === undefined ? undefined : createInlineCssStyleAsset(css)
242
- }
270
+ for (const match of matches) {
271
+ const routeId = match.routeId
272
+ const route = manifest.routes[routeId]
273
+ if (!route) {
274
+ continue
275
+ }
243
276
 
244
- function stripInlinedStylesheetAssets(
245
- manifest: Manifest,
246
- routes: FilteredRoutes,
247
- matches: Array<AnyRouteMatch>,
248
- ): FilteredRoutes {
249
- if (!manifest.inlineCss) {
250
- return routes
277
+ const nextRoute = stripInlinedStylesheetAssetsFromRoute(
278
+ inlineStyles,
279
+ route,
280
+ inlineCssHrefs,
281
+ seenInlineCssHrefs,
282
+ )
283
+
284
+ if (nextRoute !== route) {
285
+ hasStrippedRoutes = true
286
+ }
287
+ routes[routeId] = nextRoute
251
288
  }
252
289
 
253
- const nextRoutes: FilteredRoutes = {}
290
+ return {
291
+ routes,
292
+ hasStrippedRoutes,
293
+ ...(inlineCssHrefs.length ? { inlineCssHrefs } : {}),
294
+ }
295
+ }
254
296
 
255
- for (const [routeId, route] of Object.entries(routes)) {
256
- const assets = route.assets?.filter(
257
- (asset) => !isInlinableStylesheet(manifest, asset),
258
- )
297
+ function stripInlinedStylesheetAssetsFromRoute(
298
+ inlineStyles: Record<string, string>,
299
+ route: ManifestRoute,
300
+ inlineCssHrefs: Array<string>,
301
+ seenInlineCssHrefs: Set<string>,
302
+ ): ManifestRoute {
303
+ const css = route.css
304
+ if (!css) {
305
+ return route
306
+ }
259
307
 
308
+ if (css.length === 0) {
260
309
  const nextRoute = { ...route }
261
- if (assets) {
262
- if (assets.length > 0) {
263
- nextRoute.assets = assets
264
- } else {
265
- delete nextRoute.assets
310
+ delete nextRoute.css
311
+ return nextRoute
312
+ }
313
+
314
+ let cssLinks: typeof css | undefined
315
+ for (let i = 0; i < css.length; i++) {
316
+ const link = css[i]!
317
+ const href = getStylesheetHref(link)
318
+ if (inlineStyles[href] === undefined) {
319
+ if (cssLinks) {
320
+ cssLinks.push(link)
266
321
  }
322
+ continue
323
+ }
324
+
325
+ if (!seenInlineCssHrefs.has(href)) {
326
+ seenInlineCssHrefs.add(href)
327
+ inlineCssHrefs.push(href)
267
328
  }
268
- nextRoutes[routeId] = nextRoute
269
- }
270
329
 
271
- if (getInlineCssAssetForMatches(manifest, matches)) {
272
- const rootRoute = nextRoutes[rootRouteId] ?? {}
273
- nextRoutes[rootRouteId] = {
274
- ...rootRoute,
275
- assets: [createInlineCssPlaceholderAsset(), ...(rootRoute.assets ?? [])],
330
+ if (!cssLinks) {
331
+ cssLinks = css.slice(0, i)
276
332
  }
277
333
  }
278
334
 
279
- return nextRoutes
335
+ if (!cssLinks) {
336
+ return route
337
+ }
338
+
339
+ if (cssLinks.length > 0) {
340
+ return { ...route, css: cssLinks }
341
+ }
342
+
343
+ const nextRoute = { ...route }
344
+ delete nextRoute.css
345
+ return nextRoute
346
+ }
347
+
348
+ function hasRouteAssets(route: ManifestRoute) {
349
+ return !!route.scripts?.length || !!route.css?.length
350
+ }
351
+
352
+ function hasRequestAssets(assets: ManifestRouteAssets | undefined) {
353
+ return !!assets && (!!assets.preloads?.length || hasRouteAssets(assets))
354
+ }
355
+
356
+ function mergeRequestAssetsIntoRootRoute(
357
+ rootRoute: ManifestRoute | undefined,
358
+ requestAssets: ManifestRouteAssets | undefined,
359
+ ): ManifestRoute {
360
+ const preloads = requestAssets?.preloads?.length
361
+ ? [...requestAssets.preloads, ...(rootRoute?.preloads ?? [])]
362
+ : rootRoute?.preloads
363
+ const scripts = requestAssets?.scripts?.length
364
+ ? [...requestAssets.scripts, ...(rootRoute?.scripts ?? [])]
365
+ : rootRoute?.scripts
366
+ const cssLinks = requestAssets?.css?.length
367
+ ? [...requestAssets.css, ...(rootRoute?.css ?? [])]
368
+ : rootRoute?.css
369
+
370
+ return {
371
+ ...(rootRoute ?? {}),
372
+ ...(preloads?.length ? { preloads } : {}),
373
+ ...(scripts?.length ? { scripts } : {}),
374
+ ...(cssLinks?.length ? { css: cssLinks } : {}),
375
+ }
280
376
  }
281
377
 
282
378
  export function attachRouterServerSsrUtils({
283
379
  router,
284
380
  manifest,
285
381
  getRequestAssets,
286
- includeUnmatchedRouteAssets = true,
287
382
  }: {
288
383
  router: AnyRouter
289
- manifest: Manifest | undefined
290
- getRequestAssets?: () => Array<RouterManagedTag> | undefined
291
- includeUnmatchedRouteAssets?: boolean
384
+ manifest: ServerManifest | undefined
385
+ getRequestAssets?: () => ManifestRouteAssets | undefined
292
386
  }) {
293
387
  router.ssr = {
294
388
  get manifest() {
@@ -296,52 +390,104 @@ export function attachRouterServerSsrUtils({
296
390
 
297
391
  const requestAssets = getRequestAssets?.()
298
392
  const matches = router.stores.matches.get()
299
- const inlineCssAsset = getInlineCssAssetForMatches(manifest, matches)
393
+ const hasAssets = hasRequestAssets(requestAssets)
300
394
 
301
- if (!requestAssets?.length && !inlineCssAsset) {
395
+ if (!hasAssets && !manifest.inlineCss) {
302
396
  return manifest
303
397
  }
304
398
 
399
+ let inlineCssAsset: Manifest['inlineStyle'] | undefined
400
+ let routes = manifest.routes
401
+ if (manifest.inlineCss) {
402
+ const cacheKey = getMatchedRoutesCacheKey(matches)
403
+ const preparedManifest = getPreparedMatchedManifestRoutes(
404
+ manifest,
405
+ matches,
406
+ cacheKey,
407
+ )
408
+ inlineCssAsset = getInlineCssAssetForPreparedRoutes(
409
+ manifest,
410
+ preparedManifest,
411
+ )
412
+ if (preparedManifest.hasStrippedRoutes) {
413
+ routes = { ...manifest.routes, ...preparedManifest.routes }
414
+ }
415
+ }
416
+
417
+ if (!hasAssets) {
418
+ return {
419
+ ...(manifest.scriptFormat
420
+ ? { scriptFormat: manifest.scriptFormat }
421
+ : {}),
422
+ ...(inlineCssAsset ? { inlineStyle: inlineCssAsset } : {}),
423
+ routes,
424
+ }
425
+ }
426
+
427
+ const rootRoute = routes[rootRouteId]
428
+
305
429
  // Merge request-scoped assets into root route without mutating cached manifest
306
430
  return {
307
- ...manifest,
431
+ ...(manifest.scriptFormat
432
+ ? { scriptFormat: manifest.scriptFormat }
433
+ : {}),
434
+ ...(inlineCssAsset ? { inlineStyle: inlineCssAsset } : {}),
308
435
  routes: {
309
- ...manifest.routes,
310
- [rootRouteId]: {
311
- ...manifest.routes[rootRouteId],
312
- assets: [
313
- ...(requestAssets ?? []),
314
- ...(inlineCssAsset ? [inlineCssAsset] : []),
315
- ...(manifest.routes[rootRouteId]?.assets ?? []),
316
- ],
317
- },
436
+ ...routes,
437
+ [rootRouteId]: mergeRequestAssetsIntoRootRoute(
438
+ rootRoute,
439
+ requestAssets,
440
+ ),
318
441
  },
319
442
  }
320
443
  },
321
444
  }
322
445
  let _dehydrated = false
323
446
  let _serializationFinished = false
447
+ let streamFastPathReserved = false
324
448
  const renderFinishedListeners: Array<() => void> = []
449
+ const injectedHtmlListeners: Array<() => void> = []
325
450
  const serializationFinishedListeners: Array<() => void> = []
326
- const scriptBuffer = new ScriptBuffer(router)
451
+ const cleanupListeners: Array<() => void> = []
452
+ let cleanupStarted = false
327
453
  let injectedHtmlBuffer = ''
328
454
 
329
- router.serverSsr = {
455
+ const callListeners = (listeners: Array<() => void>, errorPrefix: string) => {
456
+ const snapshot = listeners.slice()
457
+ for (const l of snapshot) {
458
+ try {
459
+ l()
460
+ } catch (err) {
461
+ console.error(`${errorPrefix}:`, err)
462
+ }
463
+ }
464
+ }
465
+
466
+ const removeListener = (
467
+ listeners: Array<() => void>,
468
+ listener: () => void,
469
+ ) => {
470
+ const index = listeners.indexOf(listener)
471
+ if (index >= 0) listeners.splice(index, 1)
472
+ }
473
+
474
+ const scriptBuffer = new ScriptBuffer((script) => {
475
+ serverSsr.injectScript(script)
476
+ })
477
+
478
+ const serverSsr: ServerSsr = {
330
479
  injectHtml: (html: string) => {
331
- if (!html) return
480
+ if (!html || cleanupStarted) return
332
481
  // Buffer the HTML so it can be retrieved via takeBufferedHtml()
333
482
  injectedHtmlBuffer += html
334
- // Emit event to notify subscribers that new HTML is available
335
- router.emit({
336
- type: 'onInjectedHtml',
337
- })
483
+ callListeners(injectedHtmlListeners, 'SSR injected HTML listener error')
338
484
  },
339
485
  injectScript: (script: string) => {
340
- if (!script) return
486
+ if (!script || cleanupStarted) return
341
487
  const html = `<script${router.options.ssr?.nonce ? ` nonce='${router.options.ssr.nonce}'` : ''}>${script}</script>`
342
- router.serverSsr!.injectHtml(html)
488
+ serverSsr.injectHtml(html)
343
489
  },
344
- dehydrate: async (opts?: { requestAssets?: Array<RouterManagedTag> }) => {
490
+ dehydrate: async (opts?: { requestAssets?: ManifestRouteAssets }) => {
345
491
  if (_dehydrated) {
346
492
  if (process.env.NODE_ENV !== 'production') {
347
493
  throw new Error('Invariant failed: router is already dehydrated!')
@@ -357,61 +503,36 @@ export function attachRouterServerSsrUtils({
357
503
  const matches = matchesToDehydrate.map(dehydrateMatch)
358
504
 
359
505
  let manifestToDehydrate: Manifest | undefined = undefined
360
- // For currently matched routes, send full manifest (preloads + assets).
361
- // For unmatched routes, include assets only when includeUnmatchedRouteAssets
362
- // is true; otherwise omit them entirely. Preloads for unmatched routes are
363
- // still excluded because they are handled via dynamic imports.
506
+ // Only currently matched routes are dehydrated. Other route assets are
507
+ // loaded through dynamic imports when those routes become active.
364
508
  if (manifest) {
365
- // Prod-only caching; in dev manifests may be replaced/updated (HMR)
366
- const currentRouteIdsList = matchesToDehydrate.map((m) => m.routeId)
367
- const manifestCacheKey = `${currentRouteIdsList.join('\0')}\0includeUnmatchedRouteAssets=${includeUnmatchedRouteAssets}`
368
-
369
- let filteredRoutes: FilteredRoutes | undefined
370
-
371
- if (isProd) {
372
- filteredRoutes = getManifestCache(manifest).get(manifestCacheKey)
373
- }
374
-
375
- if (!filteredRoutes) {
376
- const currentRouteIds = new Set(currentRouteIdsList)
377
- const nextFilteredRoutes: FilteredRoutes = {}
378
-
379
- for (const routeId in manifest.routes) {
380
- const routeManifest = manifest.routes[routeId]!
381
- if (currentRouteIds.has(routeId)) {
382
- nextFilteredRoutes[routeId] = routeManifest
383
- } else if (
384
- includeUnmatchedRouteAssets &&
385
- routeManifest.assets &&
386
- routeManifest.assets.length > 0
387
- ) {
388
- nextFilteredRoutes[routeId] = {
389
- assets: routeManifest.assets,
390
- }
391
- }
392
- }
393
-
394
- filteredRoutes = stripInlinedStylesheetAssets(
395
- manifest,
396
- nextFilteredRoutes,
397
- matchesToDehydrate,
398
- )
399
-
400
- if (isProd) {
401
- getManifestCache(manifest).set(manifestCacheKey, filteredRoutes)
402
- }
403
- }
509
+ const cacheKey = getMatchedRoutesCacheKey(matchesToDehydrate)
510
+ const preparedManifest = getPreparedMatchedManifestRoutes(
511
+ manifest,
512
+ matchesToDehydrate,
513
+ cacheKey,
514
+ )
404
515
 
405
516
  manifestToDehydrate = {
406
- routes: { ...filteredRoutes },
517
+ ...(manifest.scriptFormat
518
+ ? { scriptFormat: manifest.scriptFormat }
519
+ : {}),
520
+ ...(preparedManifest.inlineCssHrefs
521
+ ? { inlineStyle: createInlineCssPlaceholderAsset() }
522
+ : {}),
523
+ routes: preparedManifest.routes,
407
524
  }
408
525
 
409
526
  // Merge request-scoped assets into root route (without mutating cached manifest)
410
- if (opts?.requestAssets?.length) {
527
+ const requestAssets = opts?.requestAssets
528
+ if (hasRequestAssets(requestAssets)) {
411
529
  const existingRoot = manifestToDehydrate.routes[rootRouteId]
412
- manifestToDehydrate.routes[rootRouteId] = {
413
- ...existingRoot,
414
- assets: [...opts.requestAssets, ...(existingRoot?.assets ?? [])],
530
+ manifestToDehydrate.routes = {
531
+ ...manifestToDehydrate.routes,
532
+ [rootRouteId]: mergeRequestAssetsIntoRootRoute(
533
+ existingRoot,
534
+ requestAssets,
535
+ ),
415
536
  }
416
537
  }
417
538
  }
@@ -439,19 +560,34 @@ export function attachRouterServerSsrUtils({
439
560
  .concat(defaultSerovalPlugins)
440
561
  : defaultSerovalPlugins
441
562
 
563
+ let serializationCompleteSignaled = false
442
564
  const signalSerializationComplete = () => {
565
+ if (serializationCompleteSignaled || cleanupStarted) return
566
+ serializationCompleteSignaled = true
443
567
  _serializationFinished = true
444
- try {
445
- serializationFinishedListeners.forEach((l) => l())
446
- router.emit({ type: 'onSerializationFinished' })
447
- } catch (err) {
448
- console.error('Serialization listener error:', err)
449
- } finally {
450
- serializationFinishedListeners.length = 0
451
- renderFinishedListeners.length = 0
568
+
569
+ const listeners = serializationFinishedListeners.slice()
570
+ serializationFinishedListeners.length = 0
571
+
572
+ for (const l of listeners) {
573
+ try {
574
+ l()
575
+ } catch (err) {
576
+ console.error('Serialization listener error:', err)
577
+ }
452
578
  }
453
579
  }
454
580
 
581
+ const finishScriptSerialization = () => {
582
+ if (serializationCompleteSignaled || cleanupStarted) return
583
+ scriptBuffer.enqueue(GLOBAL_TSR + '.e()')
584
+ // Must synchronously notify injected HTML listeners before signaling
585
+ // completion; otherwise the held </body> tail could flush ahead of the
586
+ // end script.
587
+ scriptBuffer.flush()
588
+ signalSerializationComplete()
589
+ }
590
+
455
591
  crossSerializeStream(dehydratedRouter, {
456
592
  refs: new Map(),
457
593
  plugins,
@@ -467,15 +603,11 @@ export function attachRouterServerSsrUtils({
467
603
  if (err && (err as any).stack) {
468
604
  console.error((err as any).stack)
469
605
  }
470
- signalSerializationComplete()
606
+ finishScriptSerialization()
471
607
  },
472
608
  scopeId: SCOPE_ID,
473
609
  onDone: () => {
474
- scriptBuffer.enqueue(GLOBAL_TSR + '.e()')
475
- // Flush all pending scripts synchronously before signaling completion
476
- // This ensures all scripts are injected before onSerializationFinished is emitted
477
- scriptBuffer.flush()
478
- signalSerializationComplete()
610
+ finishScriptSerialization()
479
611
  },
480
612
  })
481
613
  },
@@ -485,23 +617,65 @@ export function attachRouterServerSsrUtils({
485
617
  isSerializationFinished() {
486
618
  return _serializationFinished
487
619
  },
488
- onRenderFinished: (listener) => renderFinishedListeners.push(listener),
489
- onSerializationFinished: (listener) =>
490
- serializationFinishedListeners.push(listener),
491
- setRenderFinished: () => {
492
- // Wrap in try-catch to ensure scriptBuffer.liftBarrier() is always called
493
- try {
494
- renderFinishedListeners.forEach((l) => l())
495
- } catch (err) {
496
- console.error('Error in render finished listener:', err)
497
- } finally {
498
- // Clear listeners after calling them to prevent memory leaks
499
- renderFinishedListeners.length = 0
620
+ reserveStreamFastPath() {
621
+ if (
622
+ !cleanupStarted &&
623
+ _serializationFinished &&
624
+ !streamFastPathReserved &&
625
+ renderFinishedListeners.length === 0 &&
626
+ !injectedHtmlBuffer &&
627
+ !scriptBuffer.hasPending()
628
+ ) {
629
+ streamFastPathReserved = true
630
+ return true
500
631
  }
632
+ return false
633
+ },
634
+ onInjectedHtml: (listener) => {
635
+ if (cleanupStarted) return () => {}
636
+ injectedHtmlListeners.push(listener)
637
+ return () => removeListener(injectedHtmlListeners, listener)
638
+ },
639
+ onRenderFinished: (listener) => {
640
+ if (cleanupStarted || streamFastPathReserved) return
641
+ renderFinishedListeners.push(listener)
642
+ },
643
+ onSerializationFinished: (listener) => {
644
+ if (cleanupStarted) return () => {}
645
+ if (_serializationFinished && !cleanupStarted) {
646
+ try {
647
+ listener()
648
+ } catch (err) {
649
+ console.error('Serialization listener error:', err)
650
+ }
651
+ return () => {}
652
+ }
653
+ serializationFinishedListeners.push(listener)
654
+ return () => removeListener(serializationFinishedListeners, listener)
655
+ },
656
+ onCleanup: (listener) => {
657
+ if (cleanupStarted) return
658
+ cleanupListeners.push(listener)
659
+ },
660
+ setRenderFinished: () => {
661
+ if (cleanupStarted) return
501
662
  scriptBuffer.liftBarrier()
663
+ const listeners = renderFinishedListeners.slice()
664
+ renderFinishedListeners.length = 0
665
+ for (const l of listeners) {
666
+ try {
667
+ l()
668
+ } catch (err) {
669
+ console.error('Error in render finished listener:', err)
670
+ }
671
+ }
672
+ if (_serializationFinished) {
673
+ scriptBuffer.flush()
674
+ }
502
675
  },
503
676
  takeBufferedScripts() {
504
677
  const scripts = scriptBuffer.takeAll()
678
+ if (!scripts) return undefined
505
679
  const serverBufferedScript: RouterManagedTag = {
506
680
  tag: 'script',
507
681
  attrs: {
@@ -525,15 +699,37 @@ export function attachRouterServerSsrUtils({
525
699
  return buffered
526
700
  },
527
701
  cleanup() {
528
- // Guard against multiple cleanup calls
529
- if (!router.serverSsr) return
702
+ // Guard against multiple/reentrant cleanup calls. A listener could call
703
+ // cleanup() again indirectly; snapshot + clear before invoking so each
704
+ // listener runs exactly once and reentry is a no-op.
705
+ if (cleanupStarted) return
706
+ cleanupStarted = true
707
+ const listeners = cleanupListeners.slice()
708
+ cleanupListeners.length = 0
709
+ for (const l of listeners) {
710
+ try {
711
+ l()
712
+ } catch (err) {
713
+ console.error('Error in SSR cleanup listener:', err)
714
+ }
715
+ }
530
716
  renderFinishedListeners.length = 0
717
+ injectedHtmlListeners.length = 0
531
718
  serializationFinishedListeners.length = 0
532
719
  injectedHtmlBuffer = ''
533
720
  scriptBuffer.cleanup()
534
721
  router.serverSsr = undefined
535
722
  },
536
723
  }
724
+
725
+ router.serverSsr = serverSsr
726
+ for (const listener of router.serverSsrLifecycle?.onServerSsrAttach ?? []) {
727
+ try {
728
+ listener(serverSsr)
729
+ } catch (err) {
730
+ console.error('SSR attach listener error:', err)
731
+ }
732
+ }
537
733
  }
538
734
 
539
735
  /**