@tanstack/router-core 1.157.4 → 1.157.6

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 (40) hide show
  1. package/dist/cjs/load-matches.cjs +4 -4
  2. package/dist/cjs/load-matches.cjs.map +1 -1
  3. package/dist/cjs/router.cjs +29 -13
  4. package/dist/cjs/router.cjs.map +1 -1
  5. package/dist/cjs/router.d.cts +0 -9
  6. package/dist/cjs/ssr/ssr-client.cjs +2 -2
  7. package/dist/cjs/ssr/ssr-client.cjs.map +1 -1
  8. package/dist/cjs/ssr/ssr-server.cjs +48 -29
  9. package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
  10. package/dist/cjs/ssr/transformStreamWithRouter.cjs +138 -42
  11. package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
  12. package/dist/cjs/utils/batch.cjs +16 -0
  13. package/dist/cjs/utils/batch.cjs.map +1 -0
  14. package/dist/cjs/utils/batch.d.cts +1 -0
  15. package/dist/cjs/utils.cjs +4 -0
  16. package/dist/cjs/utils.cjs.map +1 -1
  17. package/dist/esm/load-matches.js +1 -1
  18. package/dist/esm/load-matches.js.map +1 -1
  19. package/dist/esm/router.d.ts +0 -9
  20. package/dist/esm/router.js +28 -12
  21. package/dist/esm/router.js.map +1 -1
  22. package/dist/esm/ssr/ssr-client.js +1 -1
  23. package/dist/esm/ssr/ssr-client.js.map +1 -1
  24. package/dist/esm/ssr/ssr-server.js +48 -29
  25. package/dist/esm/ssr/ssr-server.js.map +1 -1
  26. package/dist/esm/ssr/transformStreamWithRouter.js +138 -42
  27. package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
  28. package/dist/esm/utils/batch.d.ts +1 -0
  29. package/dist/esm/utils/batch.js +16 -0
  30. package/dist/esm/utils/batch.js.map +1 -0
  31. package/dist/esm/utils.js +4 -0
  32. package/dist/esm/utils.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/load-matches.ts +1 -1
  35. package/src/router.ts +39 -14
  36. package/src/ssr/ssr-client.ts +1 -1
  37. package/src/ssr/ssr-server.ts +69 -32
  38. package/src/ssr/transformStreamWithRouter.ts +187 -108
  39. package/src/utils/batch.ts +18 -0
  40. package/src/utils.ts +4 -0
@@ -107,60 +107,136 @@ export function transformStreamWithRouter(
107
107
  lifetimeMs?: number
108
108
  },
109
109
  ) {
110
- let stopListeningToInjectedHtml: (() => void) | undefined
111
- let stopListeningToSerializationFinished: (() => void) | undefined
112
- let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined
113
- let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
114
- let cleanedUp = false
115
-
116
- let controller: ReadableStreamDefaultController<any>
117
- let isStreamClosed = false
118
-
119
110
  // Check upfront if serialization already finished synchronously
120
111
  // This is the fast path for routes with no deferred data
121
112
  const serializationAlreadyFinished =
122
113
  router.serverSsr?.isSerializationFinished() ?? false
123
114
 
124
- /**
125
- * Cleanup function with guards against multiple calls.
126
- * Unsubscribes listeners, clears timeouts, frees buffers, and cleans up router SSR state.
127
- */
128
- function cleanup() {
129
- // Guard against multiple cleanup calls - set flag first to prevent re-entry
130
- if (cleanedUp) return
131
- cleanedUp = true
115
+ // Take any HTML that was buffered before we started listening
116
+ const initialBufferedHtml = router.serverSsr?.takeBufferedHtml()
132
117
 
133
- // Unsubscribe listeners first (wrap in try-catch for safety)
134
- try {
135
- stopListeningToInjectedHtml?.()
136
- stopListeningToSerializationFinished?.()
137
- } catch (e) {
138
- // Ignore errors during unsubscription
118
+ // True passthrough: if serialization already finished and nothing buffered,
119
+ // we can avoid any decoding/scanning while still honoring cleanup + setRenderFinished.
120
+ if (serializationAlreadyFinished && !initialBufferedHtml) {
121
+ let cleanedUp = false
122
+ let controller: ReadableStreamDefaultController<Uint8Array> | undefined
123
+ let isStreamClosed = false
124
+ let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
125
+
126
+ const cleanup = () => {
127
+ if (cleanedUp) return
128
+ cleanedUp = true
129
+
130
+ if (lifetimeTimeoutHandle !== undefined) {
131
+ clearTimeout(lifetimeTimeoutHandle)
132
+ lifetimeTimeoutHandle = undefined
133
+ }
134
+
135
+ router.serverSsr?.cleanup()
139
136
  }
140
- stopListeningToInjectedHtml = undefined
141
- stopListeningToSerializationFinished = undefined
142
137
 
143
- // Clear all timeouts
144
- if (serializationTimeoutHandle !== undefined) {
145
- clearTimeout(serializationTimeoutHandle)
146
- serializationTimeoutHandle = undefined
138
+ const safeClose = () => {
139
+ if (isStreamClosed) return
140
+ isStreamClosed = true
141
+ try {
142
+ controller?.close()
143
+ } catch {
144
+ // ignore
145
+ }
147
146
  }
148
- if (lifetimeTimeoutHandle !== undefined) {
149
- clearTimeout(lifetimeTimeoutHandle)
150
- lifetimeTimeoutHandle = undefined
147
+
148
+ const safeError = (error: unknown) => {
149
+ if (isStreamClosed) return
150
+ isStreamClosed = true
151
+ try {
152
+ controller?.error(error)
153
+ } catch {
154
+ // ignore
155
+ }
151
156
  }
152
157
 
153
- // Clear buffers to free memory
154
- pendingRouterHtmlParts = []
155
- leftover = ''
156
- pendingClosingTags = ''
158
+ const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
159
+ lifetimeTimeoutHandle = setTimeout(() => {
160
+ if (!cleanedUp && !isStreamClosed) {
161
+ console.warn(
162
+ `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
163
+ )
164
+ safeError(new Error('Stream lifetime exceeded'))
165
+ cleanup()
166
+ }
167
+ }, lifetimeMs)
157
168
 
158
- // Clean up router SSR state (has its own guard)
159
- router.serverSsr?.cleanup()
169
+ const stream = new ReadableStream<Uint8Array>({
170
+ start(c) {
171
+ controller = c
172
+ },
173
+ cancel() {
174
+ isStreamClosed = true
175
+ cleanup()
176
+ },
177
+ })
178
+
179
+ ;(async () => {
180
+ const reader = appStream.getReader()
181
+ try {
182
+ while (true) {
183
+ const { done, value } = await reader.read()
184
+ if (done) break
185
+ if (cleanedUp || isStreamClosed) return
186
+ controller?.enqueue(value as unknown as Uint8Array)
187
+ }
188
+
189
+ if (cleanedUp || isStreamClosed) return
190
+
191
+ router.serverSsr?.setRenderFinished()
192
+ safeClose()
193
+ cleanup()
194
+ } catch (error) {
195
+ if (cleanedUp) return
196
+ console.error('Error reading appStream:', error)
197
+ router.serverSsr?.setRenderFinished()
198
+ safeError(error)
199
+ cleanup()
200
+ } finally {
201
+ reader.releaseLock()
202
+ }
203
+ })().catch((error) => {
204
+ if (cleanedUp) return
205
+ console.error('Error in stream transform:', error)
206
+ safeError(error)
207
+ cleanup()
208
+ })
209
+
210
+ return stream
160
211
  }
161
212
 
213
+ let stopListeningToInjectedHtml: (() => void) | undefined
214
+ let stopListeningToSerializationFinished: (() => void) | undefined
215
+ let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined
216
+ let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
217
+ let cleanedUp = false
218
+
219
+ let controller: ReadableStreamDefaultController<any>
220
+ let isStreamClosed = false
221
+
162
222
  const textDecoder = new TextDecoder()
163
223
 
224
+ // concat'd router HTML; avoids array joins on each flush
225
+ let pendingRouterHtml = initialBufferedHtml ?? ''
226
+
227
+ // between-chunk text buffer; keep bounded to avoid unbounded memory
228
+ let leftover = ''
229
+
230
+ // captured closing tags from </body> onward
231
+ let pendingClosingTags = ''
232
+
233
+ // conservative cap: enough to hold any partial closing tag + a bit
234
+ const MAX_LEFTOVER_CHARS = 2048
235
+
236
+ let isAppRendering = true
237
+ let streamBarrierLifted = false
238
+ let serializationFinished = serializationAlreadyFinished
239
+
164
240
  function safeEnqueue(chunk: string | Uint8Array) {
165
241
  if (isStreamClosed) return
166
242
  if (typeof chunk === 'string') {
@@ -176,7 +252,7 @@ export function transformStreamWithRouter(
176
252
  try {
177
253
  controller.close()
178
254
  } catch {
179
- // Stream may already be errored or closed by consumer - safe to ignore
255
+ // ignore
180
256
  }
181
257
  }
182
258
 
@@ -186,10 +262,42 @@ export function transformStreamWithRouter(
186
262
  try {
187
263
  controller.error(error)
188
264
  } catch {
189
- // Stream may already be errored or closed by consumer - safe to ignore
265
+ // ignore
190
266
  }
191
267
  }
192
268
 
269
+ /**
270
+ * Cleanup with guards; must be idempotent.
271
+ */
272
+ function cleanup() {
273
+ if (cleanedUp) return
274
+ cleanedUp = true
275
+
276
+ try {
277
+ stopListeningToInjectedHtml?.()
278
+ stopListeningToSerializationFinished?.()
279
+ } catch {
280
+ // ignore
281
+ }
282
+ stopListeningToInjectedHtml = undefined
283
+ stopListeningToSerializationFinished = undefined
284
+
285
+ if (serializationTimeoutHandle !== undefined) {
286
+ clearTimeout(serializationTimeoutHandle)
287
+ serializationTimeoutHandle = undefined
288
+ }
289
+ if (lifetimeTimeoutHandle !== undefined) {
290
+ clearTimeout(lifetimeTimeoutHandle)
291
+ lifetimeTimeoutHandle = undefined
292
+ }
293
+
294
+ pendingRouterHtml = ''
295
+ leftover = ''
296
+ pendingClosingTags = ''
297
+
298
+ router.serverSsr?.cleanup()
299
+ }
300
+
193
301
  const stream = new ReadableStream({
194
302
  start(c) {
195
303
  controller = c
@@ -200,36 +308,24 @@ export function transformStreamWithRouter(
200
308
  },
201
309
  })
202
310
 
203
- let isAppRendering = true
204
- let streamBarrierLifted = false
205
- let leftover = ''
206
- let pendingClosingTags = ''
207
- let serializationFinished = serializationAlreadyFinished
208
-
209
- let pendingRouterHtmlParts: Array<string> = []
210
-
211
- // Take any HTML that was buffered before we started listening
212
- const bufferedHtml = router.serverSsr?.takeBufferedHtml()
213
- if (bufferedHtml) {
214
- pendingRouterHtmlParts.push(bufferedHtml)
311
+ function flushPendingRouterHtml() {
312
+ if (!pendingRouterHtml) return
313
+ safeEnqueue(pendingRouterHtml)
314
+ pendingRouterHtml = ''
215
315
  }
216
316
 
217
- function flushPendingRouterHtml() {
218
- if (pendingRouterHtmlParts.length > 0) {
219
- safeEnqueue(pendingRouterHtmlParts.join(''))
220
- pendingRouterHtmlParts = []
221
- }
317
+ function appendRouterHtml(html: string) {
318
+ if (!html) return
319
+ pendingRouterHtml += html
222
320
  }
223
321
 
224
322
  /**
225
- * Attempts to finish the stream if all conditions are met.
323
+ * Finish only when app done and serialization complete.
226
324
  */
227
325
  function tryFinish() {
228
- // Can only finish when app is done rendering and serialization is complete
229
326
  if (isAppRendering || !serializationFinished) return
230
327
  if (cleanedUp || isStreamClosed) return
231
328
 
232
- // Clear serialization timeout since we're finishing
233
329
  if (serializationTimeoutHandle !== undefined) {
234
330
  clearTimeout(serializationTimeoutHandle)
235
331
  serializationTimeoutHandle = undefined
@@ -247,8 +343,7 @@ export function transformStreamWithRouter(
247
343
  cleanup()
248
344
  }
249
345
 
250
- // Set up lifetime timeout as a safety net
251
- // This ensures cleanup happens even if the stream is never consumed or gets stuck
346
+ // Safety net: cleanup even if consumer never reads
252
347
  const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
253
348
  lifetimeTimeoutHandle = setTimeout(() => {
254
349
  if (!cleanedUp && !isStreamClosed) {
@@ -260,29 +355,21 @@ export function transformStreamWithRouter(
260
355
  }
261
356
  }, lifetimeMs)
262
357
 
263
- // Only set up listeners if serialization hasn't already finished
264
- // This avoids unnecessary subscriptions for the common case of no deferred data
265
358
  if (!serializationAlreadyFinished) {
266
- // Listen for injected HTML (for deferred data that resolves later)
267
359
  stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {
268
360
  if (cleanedUp || isStreamClosed) return
269
-
270
- // Retrieve buffered HTML
271
361
  const html = router.serverSsr?.takeBufferedHtml()
272
362
  if (!html) return
273
363
 
274
- if (isAppRendering || leftover) {
275
- // Buffer when app is still rendering OR when there's leftover content
276
- // that hasn't been flushed yet. This prevents race conditions where
277
- // injected HTML appears before buffered app content
278
- pendingRouterHtmlParts.push(html)
364
+ // If we've already captured </body> (pendingClosingTags), we must keep appending
365
+ // so injection stays before the stored closing tags.
366
+ if (isAppRendering || leftover || pendingClosingTags) {
367
+ appendRouterHtml(html)
279
368
  } else {
280
- // App done rendering and no leftover - safe to write directly for better streaming
281
369
  safeEnqueue(html)
282
370
  }
283
371
  })
284
372
 
285
- // Listen for serialization finished
286
373
  stopListeningToSerializationFinished = router.subscribe(
287
374
  'onSerializationFinished',
288
375
  () => {
@@ -300,16 +387,16 @@ export function transformStreamWithRouter(
300
387
  const { done, value } = await reader.read()
301
388
  if (done) break
302
389
 
303
- // Don't process if already cleaned up
304
390
  if (cleanedUp || isStreamClosed) return
305
391
 
306
392
  const text =
307
393
  value instanceof Uint8Array
308
394
  ? textDecoder.decode(value, { stream: true })
309
395
  : String(value)
310
- const chunkString = leftover + text
311
396
 
312
- // Check for stream barrier (script placeholder) - use indexOf for efficiency
397
+ // Fast path: most chunks have no pending left-over.
398
+ const chunkString = leftover ? leftover + text : text
399
+
313
400
  if (!streamBarrierLifted) {
314
401
  if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {
315
402
  streamBarrierLifted = true
@@ -317,70 +404,63 @@ export function transformStreamWithRouter(
317
404
  }
318
405
  }
319
406
 
320
- // Check for body/html end tags
407
+ // If we already saw </body>, everything else is part of tail; buffer it.
408
+ if (pendingClosingTags) {
409
+ pendingClosingTags += chunkString
410
+ leftover = ''
411
+ continue
412
+ }
413
+
321
414
  const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)
322
415
  const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)
323
416
 
324
- // If we have both </body> and </html> in proper order,
325
- // insert router HTML before </body> and hold the closing tags
326
417
  if (
327
418
  bodyEndIndex !== -1 &&
328
419
  htmlEndIndex !== -1 &&
329
420
  bodyEndIndex < htmlEndIndex
330
421
  ) {
331
422
  pendingClosingTags = chunkString.slice(bodyEndIndex)
332
-
333
423
  safeEnqueue(chunkString.slice(0, bodyEndIndex))
334
424
  flushPendingRouterHtml()
335
-
336
425
  leftover = ''
337
426
  continue
338
427
  }
339
428
 
340
- // Handling partial closing tags split across chunks:
341
- //
342
- // Since `chunkString = leftover + text`, any incomplete tag fragment from the
343
- // previous chunk is prepended to the current chunk, allowing split tags like
344
- // "</di" + "v>" to be re-detected as a complete "</div>" in the combined string.
345
- //
346
- // - If a closing tag IS found (lastClosingTagEnd > 0): We enqueue content up to
347
- // the end of that tag, flush router HTML, and store the remainder in `leftover`.
348
- // This remainder may contain a partial tag (e.g., "</sp") which will be
349
- // prepended to the next chunk for re-detection.
350
- //
351
- // - If NO closing tag is found: The entire chunk is buffered in `leftover` and
352
- // will be prepended to the next chunk. This ensures partial tags are never
353
- // lost and will be detected once the rest of the tag arrives.
354
- //
355
- // This approach guarantees correct injection points even when closing tags span
356
- // chunk boundaries.
357
429
  const lastClosingTagEnd = findLastClosingTagEnd(chunkString)
358
430
 
359
431
  if (lastClosingTagEnd > 0) {
360
- // Found a closing tag - insert router HTML after it
361
432
  safeEnqueue(chunkString.slice(0, lastClosingTagEnd))
362
433
  flushPendingRouterHtml()
363
434
 
364
435
  leftover = chunkString.slice(lastClosingTagEnd)
436
+ if (leftover.length > MAX_LEFTOVER_CHARS) {
437
+ // Ensure bounded memory even if a consumer streams long text sequences
438
+ // without any closing tags. This may reduce injection granularity but is correct.
439
+ safeEnqueue(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS))
440
+ leftover = leftover.slice(-MAX_LEFTOVER_CHARS)
441
+ }
365
442
  } else {
366
- // No closing tag found - buffer the entire chunk
367
- leftover = chunkString
368
- // Any pending router HTML will be inserted when we find a valid position
443
+ // No closing tag found; keep small tail to handle split closing tags,
444
+ // but stream older bytes to prevent unbounded buffering.
445
+ const combined = chunkString
446
+ if (combined.length > MAX_LEFTOVER_CHARS) {
447
+ const flushUpto = combined.length - MAX_LEFTOVER_CHARS
448
+ safeEnqueue(combined.slice(0, flushUpto))
449
+ leftover = combined.slice(flushUpto)
450
+ } else {
451
+ leftover = combined
452
+ }
369
453
  }
370
454
  }
371
455
 
372
- // Stream ended
373
456
  if (cleanedUp || isStreamClosed) return
374
457
 
375
- // Mark the app as done rendering
376
458
  isAppRendering = false
377
459
  router.serverSsr?.setRenderFinished()
378
460
 
379
- // Try to finish if serialization is already done
380
461
  if (serializationFinished) {
381
462
  tryFinish()
382
463
  } else {
383
- // Set a timeout for serialization to complete
384
464
  const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
385
465
  serializationTimeoutHandle = setTimeout(() => {
386
466
  if (!cleanedUp && !isStreamClosed) {
@@ -403,7 +483,6 @@ export function transformStreamWithRouter(
403
483
  reader.releaseLock()
404
484
  }
405
485
  })().catch((error) => {
406
- // Handle any errors that occur outside the try block (e.g., getReader() failure)
407
486
  if (cleanedUp) return
408
487
  console.error('Error in stream transform:', error)
409
488
  safeError(error)
@@ -0,0 +1,18 @@
1
+ import { batch as storeBatch } from '@tanstack/store'
2
+
3
+ import { isServer } from '@tanstack/router-core/isServer'
4
+
5
+ // `@tanstack/store`'s `batch` is for reactive notification batching.
6
+ // On the server we don't subscribe/render reactively, so a lightweight
7
+ // implementation that just executes is enough.
8
+ export function batch<T>(fn: () => T): T {
9
+ if (isServer) {
10
+ return fn()
11
+ }
12
+
13
+ let result!: T
14
+ storeBatch(() => {
15
+ result = fn()
16
+ })
17
+ return result
18
+ }
package/src/utils.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isServer } from '@tanstack/router-core/isServer'
1
2
  import type { RouteIds } from './routeInfo'
2
3
  import type { AnyRouter } from './router'
3
4
 
@@ -221,6 +222,9 @@ const isEnumerable = Object.prototype.propertyIsEnumerable
221
222
  * Do not use this with signals
222
223
  */
223
224
  export function replaceEqualDeep<T>(prev: any, _next: T, _depth = 0): T {
225
+ if (isServer) {
226
+ return _next
227
+ }
224
228
  if (prev === _next) {
225
229
  return prev
226
230
  }