@tanstack/router-core 1.142.7 → 1.142.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,9 @@
1
1
  import { crossSerializeStream, getCrossReferenceHeader } from 'seroval'
2
2
  import invariant from 'tiny-invariant'
3
- import { createControlledPromise } from '../utils'
4
3
  import minifiedTsrBootStrapScript from './tsrScript?script-string'
5
- import { GLOBAL_TSR } from './constants'
4
+ import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants'
6
5
  import { defaultSerovalPlugins } from './serializer/seroval-plugins'
7
6
  import { makeSsrSerovalPlugin } from './serializer/transformer'
8
- import { TSR_SCRIPT_BARRIER_ID } from './transformStreamWithRouter'
9
7
  import type { DehydratedMatch, DehydratedRouter } from './types'
10
8
  import type { AnySerializationAdapter } from './serializer/transformer'
11
9
  import type { AnyRouter } from '../router'
@@ -20,7 +18,9 @@ declare module '../router' {
20
18
  interface RouterEvents {
21
19
  onInjectedHtml: {
22
20
  type: 'onInjectedHtml'
23
- promise: Promise<string>
21
+ }
22
+ onSerializationFinished: {
23
+ type: 'onSerializationFinished'
24
24
  }
25
25
  }
26
26
  }
@@ -56,50 +56,76 @@ const INITIAL_SCRIPTS = [
56
56
 
57
57
  class ScriptBuffer {
58
58
  private router: AnyRouter | undefined
59
- private _queue: Array<string> = [...INITIAL_SCRIPTS]
59
+ private _queue: Array<string>
60
60
  private _scriptBarrierLifted = false
61
61
  private _cleanedUp = false
62
+ private _pendingMicrotask = false
62
63
 
63
64
  constructor(router: AnyRouter) {
64
65
  this.router = router
66
+ // Copy INITIAL_SCRIPTS to avoid mutating the shared array
67
+ this._queue = INITIAL_SCRIPTS.slice()
65
68
  }
66
69
 
67
70
  enqueue(script: string) {
68
71
  if (this._cleanedUp) return
69
- if (this._scriptBarrierLifted && this._queue.length === 0) {
72
+ this._queue.push(script)
73
+ // If barrier is lifted, schedule injection (if not already scheduled)
74
+ if (this._scriptBarrierLifted && !this._pendingMicrotask) {
75
+ this._pendingMicrotask = true
70
76
  queueMicrotask(() => {
77
+ this._pendingMicrotask = false
71
78
  this.injectBufferedScripts()
72
79
  })
73
80
  }
74
- this._queue.push(script)
75
81
  }
76
82
 
77
83
  liftBarrier() {
78
84
  if (this._scriptBarrierLifted || this._cleanedUp) return
79
85
  this._scriptBarrierLifted = true
80
- if (this._queue.length > 0) {
86
+ if (this._queue.length > 0 && !this._pendingMicrotask) {
87
+ this._pendingMicrotask = true
81
88
  queueMicrotask(() => {
89
+ this._pendingMicrotask = false
82
90
  this.injectBufferedScripts()
83
91
  })
84
92
  }
85
93
  }
86
94
 
95
+ /**
96
+ * Flushes any pending scripts synchronously.
97
+ * Call this before emitting onSerializationFinished to ensure all scripts are injected.
98
+ *
99
+ * IMPORTANT: Only injects if the barrier has been lifted. Before the barrier is lifted,
100
+ * scripts should remain in the queue so takeBufferedScripts() can retrieve them
101
+ */
102
+ flush() {
103
+ if (!this._scriptBarrierLifted) return
104
+ if (this._cleanedUp) return
105
+ this._pendingMicrotask = false
106
+ const scriptsToInject = this.takeAll()
107
+ if (scriptsToInject && this.router?.serverSsr) {
108
+ this.router.serverSsr.injectScript(scriptsToInject)
109
+ }
110
+ }
111
+
87
112
  takeAll() {
88
113
  const bufferedScripts = this._queue
89
114
  this._queue = []
90
115
  if (bufferedScripts.length === 0) {
91
116
  return undefined
92
117
  }
93
- bufferedScripts.push(`document.currentScript.remove()`)
94
- const joinedScripts = bufferedScripts.join(';')
95
- return joinedScripts
118
+ // Append cleanup script and join - avoid push() to not mutate then iterate
119
+ return bufferedScripts.join(';') + ';document.currentScript.remove()'
96
120
  }
97
121
 
98
122
  injectBufferedScripts() {
99
123
  if (this._cleanedUp) return
124
+ // Early return if queue is empty (avoids unnecessary takeAll() call)
125
+ if (this._queue.length === 0) return
100
126
  const scriptsToInject = this.takeAll()
101
127
  if (scriptsToInject && this.router?.serverSsr) {
102
- this.router.serverSsr.injectScript(() => scriptsToInject)
128
+ this.router.serverSsr.injectScript(scriptsToInject)
103
129
  }
104
130
  }
105
131
 
@@ -121,29 +147,26 @@ export function attachRouterServerSsrUtils({
121
147
  manifest,
122
148
  }
123
149
  let _dehydrated = false
124
- const listeners: Array<() => void> = []
150
+ let _serializationFinished = false
151
+ const renderFinishedListeners: Array<() => void> = []
152
+ const serializationFinishedListeners: Array<() => void> = []
125
153
  const scriptBuffer = new ScriptBuffer(router)
154
+ let injectedHtmlBuffer: Array<string> = []
126
155
 
127
156
  router.serverSsr = {
128
- injectedHtml: [],
129
- injectHtml: (getHtml) => {
130
- const promise = Promise.resolve().then(getHtml)
131
- router.serverSsr!.injectedHtml.push(promise)
157
+ injectHtml: (html: string) => {
158
+ if (!html) return
159
+ // Buffer the HTML so it can be retrieved via takeBufferedHtml()
160
+ injectedHtmlBuffer.push(html)
161
+ // Emit event to notify subscribers that new HTML is available
132
162
  router.emit({
133
163
  type: 'onInjectedHtml',
134
- promise,
135
164
  })
136
-
137
- return promise.then(() => {})
138
165
  },
139
- injectScript: (getScript) => {
140
- return router.serverSsr!.injectHtml(async () => {
141
- const script = await getScript()
142
- if (!script) {
143
- return ''
144
- }
145
- return `<script${router.options.ssr?.nonce ? ` nonce='${router.options.ssr.nonce}'` : ''}>${script}</script>`
146
- })
166
+ injectScript: (script: string) => {
167
+ if (!script) return
168
+ const html = `<script${router.options.ssr?.nonce ? ` nonce='${router.options.ssr.nonce}'` : ''}>${script}</script>`
169
+ router.serverSsr!.injectHtml(html)
147
170
  },
148
171
  dehydrate: async () => {
149
172
  invariant(!_dehydrated, 'router is already dehydrated!')
@@ -201,18 +224,32 @@ export function attachRouterServerSsrUtils({
201
224
  }
202
225
  _dehydrated = true
203
226
 
204
- const p = createControlledPromise<string>()
205
227
  const trackPlugins = { didRun: false }
206
- const plugins =
207
- (
208
- router.options.serializationAdapters as
209
- | Array<AnySerializationAdapter>
210
- | undefined
211
- )?.map((t) => makeSsrSerovalPlugin(t, trackPlugins)) ?? []
228
+ const serializationAdapters = router.options.serializationAdapters as
229
+ | Array<AnySerializationAdapter>
230
+ | undefined
231
+ const plugins = serializationAdapters
232
+ ? serializationAdapters
233
+ .map((t) => makeSsrSerovalPlugin(t, trackPlugins))
234
+ .concat(defaultSerovalPlugins)
235
+ : defaultSerovalPlugins
236
+
237
+ const signalSerializationComplete = () => {
238
+ _serializationFinished = true
239
+ try {
240
+ serializationFinishedListeners.forEach((l) => l())
241
+ router.emit({ type: 'onSerializationFinished' })
242
+ } catch (err) {
243
+ console.error('Serialization listener error:', err)
244
+ } finally {
245
+ serializationFinishedListeners.length = 0
246
+ renderFinishedListeners.length = 0
247
+ }
248
+ }
212
249
 
213
250
  crossSerializeStream(dehydratedRouter, {
214
251
  refs: new Map(),
215
- plugins: [...plugins, ...defaultSerovalPlugins],
252
+ plugins,
216
253
  onSerialize: (data, initial) => {
217
254
  let serialized = initial ? GLOBAL_TSR + '.router=' + data : data
218
255
  if (trackPlugins.didRun) {
@@ -223,21 +260,36 @@ export function attachRouterServerSsrUtils({
223
260
  scopeId: SCOPE_ID,
224
261
  onDone: () => {
225
262
  scriptBuffer.enqueue(GLOBAL_TSR + '.e()')
226
- p.resolve('')
263
+ // Flush all pending scripts synchronously before signaling completion
264
+ // This ensures all scripts are injected before onSerializationFinished is emitted
265
+ scriptBuffer.flush()
266
+ signalSerializationComplete()
267
+ },
268
+ onError: (err) => {
269
+ console.error('Serialization error:', err)
270
+ signalSerializationComplete()
227
271
  },
228
- onError: (err) => p.reject(err),
229
272
  })
230
- // make sure the stream is kept open until the promise is resolved
231
- router.serverSsr!.injectHtml(() => p)
232
273
  },
233
274
  isDehydrated() {
234
275
  return _dehydrated
235
276
  },
236
- onRenderFinished: (listener) => listeners.push(listener),
277
+ isSerializationFinished() {
278
+ return _serializationFinished
279
+ },
280
+ onRenderFinished: (listener) => renderFinishedListeners.push(listener),
281
+ onSerializationFinished: (listener) =>
282
+ serializationFinishedListeners.push(listener),
237
283
  setRenderFinished: () => {
238
- listeners.forEach((l) => l())
239
- // Clear listeners after calling them to prevent memory leaks
240
- listeners.length = 0
284
+ // Wrap in try-catch to ensure scriptBuffer.liftBarrier() is always called
285
+ try {
286
+ renderFinishedListeners.forEach((l) => l())
287
+ } catch (err) {
288
+ console.error('Error in render finished listener:', err)
289
+ } finally {
290
+ // Clear listeners after calling them to prevent memory leaks
291
+ renderFinishedListeners.length = 0
292
+ }
241
293
  scriptBuffer.liftBarrier()
242
294
  },
243
295
  takeBufferedScripts() {
@@ -256,12 +308,21 @@ export function attachRouterServerSsrUtils({
256
308
  liftScriptBarrier() {
257
309
  scriptBuffer.liftBarrier()
258
310
  },
311
+ takeBufferedHtml() {
312
+ if (injectedHtmlBuffer.length === 0) {
313
+ return undefined
314
+ }
315
+ const buffered = injectedHtmlBuffer.join('')
316
+ injectedHtmlBuffer = []
317
+ return buffered
318
+ },
259
319
  cleanup() {
260
320
  // Guard against multiple cleanup calls
261
321
  if (!router.serverSsr) return
262
- listeners.length = 0
322
+ renderFinishedListeners.length = 0
323
+ serializationFinishedListeners.length = 0
324
+ injectedHtmlBuffer = []
263
325
  scriptBuffer.cleanup()
264
- router.serverSsr.injectedHtml = []
265
326
  router.serverSsr = undefined
266
327
  },
267
328
  }