Skip to content

Commit af77b55

Browse files
fix: cleanup streamed values (#5870)
1 parent 559856b commit af77b55

File tree

9 files changed

+128
-18
lines changed

9 files changed

+128
-18
lines changed

packages/react-router/src/HeadContent.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export const useTags = () => {
155155
structuralSharing: true as any,
156156
})
157157

158-
const headScripts = useRouterState({
158+
const headScripts: Array<RouterManagedTag> = useRouterState({
159159
select: (state) =>
160160
(
161161
state.matches
@@ -173,12 +173,29 @@ export const useTags = () => {
173173
structuralSharing: true as any,
174174
})
175175

176+
let serverHeadScript: RouterManagedTag | undefined = undefined
177+
178+
if (router.serverSsr) {
179+
const bufferedScripts = router.serverSsr.takeBufferedScripts()
180+
if (bufferedScripts) {
181+
serverHeadScript = {
182+
tag: 'script',
183+
attrs: {
184+
nonce,
185+
className: '$tsr',
186+
},
187+
children: bufferedScripts,
188+
}
189+
}
190+
}
191+
176192
return uniqBy(
177193
[
178194
...meta,
179195
...preloadMeta,
180196
...links,
181197
...styles,
198+
...(serverHeadScript ? [serverHeadScript] : []),
182199
...headScripts,
183200
] as Array<RouterManagedTag>,
184201
(d) => {

packages/react-router/src/ScriptOnce.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useRouter } from './useRouter'
22

33
/**
44
* Server-only helper to emit a script tag exactly once during SSR.
5-
* Appends an internal marker to signal hydration completion.
65
*/
76
export function ScriptOnce({ children }: { children: string }) {
87
const router = useRouter()

packages/router-core/src/router.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ export interface ServerSsr {
754754
isDehydrated: () => boolean
755755
onRenderFinished: (listener: () => void) => void
756756
dehydrate: () => Promise<void>
757+
takeBufferedScripts: () => string | undefined
757758
}
758759

759760
export type AnyRouterWithContext<TContext> = RouterCore<
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const GLOBAL_TSR = '$_TSR'
2+
export declare const GLOBAL_SEROVAL: '$R'

packages/router-core/src/ssr/ssr-client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import type { AnyRouter } from '../router'
66
import type { Manifest } from '../manifest'
77
import type { RouteContextOptions } from '../route'
88
import type { AnySerializationAdapter } from './serializer/transformer'
9-
import type { GLOBAL_TSR } from './constants'
9+
import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './constants'
1010

1111
declare global {
1212
interface Window {
1313
[GLOBAL_TSR]?: TsrSsrGlobal
14+
[GLOBAL_SEROVAL]?: any
1415
}
1516
}
1617

@@ -25,6 +26,10 @@ export interface TsrSsrGlobal {
2526
t?: Map<string, (value: any) => any>
2627
// this flag indicates whether the transformers were initialized
2728
initialized?: boolean
29+
// router is hydrated and doesnt need the streamed values anymore
30+
hydrated?: boolean
31+
// stream has ended
32+
streamEnd?: boolean
2833
}
2934

3035
function hydrateMatch(
@@ -165,6 +170,10 @@ export async function hydrate(router: AnyRouter): Promise<any> {
165170
// Allow the user to handle custom hydration data
166171
await router.options.hydrate?.(dehydratedData)
167172

173+
window.$_TSR.hydrated = true
174+
// potentially clean up streamed values IF stream has ended already
175+
window.$_TSR.c()
176+
168177
// now that all necessary data is hydrated:
169178
// 1) fully reconstruct the route context
170179
// 2) execute `head()` and `scripts()` for each match

packages/router-core/src/ssr/ssr-server.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,54 @@ export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch {
4848
return dehydratedMatch
4949
}
5050

51+
const INITIAL_SCRIPTS = [
52+
getCrossReferenceHeader(SCOPE_ID),
53+
minifiedTsrBootStrapScript,
54+
]
55+
56+
class ScriptBuffer {
57+
constructor(private router: AnyRouter) {}
58+
private _queue: Array<string> = [...INITIAL_SCRIPTS]
59+
private _scriptBarrierLifted = false
60+
61+
enqueue(script: string) {
62+
if (this._scriptBarrierLifted && this._queue.length === 0) {
63+
queueMicrotask(() => {
64+
this.injectBufferedScripts()
65+
})
66+
}
67+
this._queue.push(script)
68+
}
69+
70+
liftBarrier() {
71+
if (this._scriptBarrierLifted) return
72+
this._scriptBarrierLifted = true
73+
if (this._queue.length > 0) {
74+
queueMicrotask(() => {
75+
this.injectBufferedScripts()
76+
})
77+
}
78+
}
79+
80+
takeAll() {
81+
const bufferedScripts = this._queue
82+
this._queue = []
83+
if (bufferedScripts.length === 0) {
84+
return undefined
85+
}
86+
bufferedScripts.push(`${GLOBAL_TSR}.c()`)
87+
const joinedScripts = bufferedScripts.join(';')
88+
return joinedScripts
89+
}
90+
91+
injectBufferedScripts() {
92+
const scriptsToInject = this.takeAll()
93+
if (scriptsToInject) {
94+
this.router.serverSsr!.injectScript(() => scriptsToInject)
95+
}
96+
}
97+
}
98+
5199
export function attachRouterServerSsrUtils({
52100
router,
53101
manifest,
@@ -58,16 +106,9 @@ export function attachRouterServerSsrUtils({
58106
router.ssr = {
59107
manifest,
60108
}
61-
let initialScriptSent = false
62-
const getInitialScript = () => {
63-
if (initialScriptSent) {
64-
return ''
65-
}
66-
initialScriptSent = true
67-
return `${getCrossReferenceHeader(SCOPE_ID)};${minifiedTsrBootStrapScript};`
68-
}
69109
let _dehydrated = false
70110
const listeners: Array<() => void> = []
111+
const scriptBuffer = new ScriptBuffer(router)
71112

72113
router.serverSsr = {
73114
injectedHtml: [],
@@ -84,7 +125,10 @@ export function attachRouterServerSsrUtils({
84125
injectScript: (getScript) => {
85126
return router.serverSsr!.injectHtml(async () => {
86127
const script = await getScript()
87-
return `<script ${router.options.ssr?.nonce ? `nonce='${router.options.ssr.nonce}'` : ''} class='$tsr'>${getInitialScript()}${script};$_TSR.c()</script>`
128+
if (!script) {
129+
return ''
130+
}
131+
return `<script${router.options.ssr?.nonce ? `nonce='${router.options.ssr.nonce}' ` : ''} class='$tsr'>${script}</script>`
88132
})
89133
},
90134
dehydrate: async () => {
@@ -104,7 +148,10 @@ export function attachRouterServerSsrUtils({
104148
if (lastMatchId) {
105149
dehydratedRouter.lastMatchId = lastMatchId
106150
}
107-
dehydratedRouter.dehydratedData = await router.options.dehydrate?.()
151+
const dehydratedData = await router.options.dehydrate?.()
152+
if (dehydratedData) {
153+
dehydratedRouter.dehydratedData = dehydratedData
154+
}
108155
_dehydrated = true
109156

110157
const p = createControlledPromise<string>()
@@ -115,6 +162,7 @@ export function attachRouterServerSsrUtils({
115162
| Array<AnySerializationAdapter>
116163
| undefined
117164
)?.map((t) => makeSsrSerovalPlugin(t, trackPlugins)) ?? []
165+
118166
crossSerializeStream(dehydratedRouter, {
119167
refs: new Map(),
120168
plugins: [...plugins, ...defaultSerovalPlugins],
@@ -123,10 +171,13 @@ export function attachRouterServerSsrUtils({
123171
if (trackPlugins.didRun) {
124172
serialized = GLOBAL_TSR + '.p(()=>' + serialized + ')'
125173
}
126-
router.serverSsr!.injectScript(() => serialized)
174+
scriptBuffer.enqueue(serialized)
127175
},
128176
scopeId: SCOPE_ID,
129-
onDone: () => p.resolve(''),
177+
onDone: () => {
178+
scriptBuffer.enqueue(GLOBAL_TSR + '.streamEnd=true')
179+
p.resolve('')
180+
},
130181
onError: (err) => p.reject(err),
131182
})
132183
// make sure the stream is kept open until the promise is resolved
@@ -138,6 +189,12 @@ export function attachRouterServerSsrUtils({
138189
onRenderFinished: (listener) => listeners.push(listener),
139190
setRenderFinished: () => {
140191
listeners.forEach((l) => l())
192+
scriptBuffer.liftBarrier()
193+
},
194+
takeBufferedScripts() {
195+
const scripts = scriptBuffer.takeAll()
196+
scriptBuffer.liftBarrier()
197+
return scripts
141198
},
142199
}
143200
}

packages/router-core/src/ssr/tsrScript.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ self.$_TSR = {
33
document.querySelectorAll('.\\$tsr').forEach((o) => {
44
o.remove()
55
})
6+
if (this.hydrated && this.streamEnd) {
7+
delete self.$_TSR
8+
delete self.$R['tsr']
9+
}
610
},
711
p(script) {
812
!this.initialized ? this.buffer.push(script) : script()

packages/solid-router/src/HeadContent.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,30 @@ export const useTags = () => {
166166
})),
167167
})
168168

169+
let serverHeadScript: RouterManagedTag | undefined = undefined
170+
171+
if (router.serverSsr) {
172+
const bufferedScripts = router.serverSsr.takeBufferedScripts()
173+
if (bufferedScripts) {
174+
serverHeadScript = {
175+
tag: 'script',
176+
attrs: {
177+
nonce,
178+
class: '$tsr',
179+
},
180+
children: bufferedScripts,
181+
}
182+
}
183+
}
184+
169185
return () =>
170186
uniqBy(
171187
[
172188
...meta(),
173189
...preloadMeta(),
174190
...links(),
175191
...styles(),
192+
...(serverHeadScript ? [serverHeadScript] : []),
176193
...headScripts(),
177194
] as Array<RouterManagedTag>,
178195
(d) => {

packages/start-server-core/src/router-manifest.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,24 @@ export async function getStartManifest() {
3636
})
3737

3838
const manifest = {
39-
...startManifest,
4039
routes: Object.fromEntries(
4140
Object.entries(startManifest.routes).map(([k, v]) => {
4241
const { preloads, assets } = v
4342
const result = {} as {
4443
preloads?: Array<string>
4544
assets?: Array<RouterManagedTag>
4645
}
47-
if (preloads) {
46+
let hasData = false
47+
if (preloads && preloads.length > 0) {
4848
result['preloads'] = preloads
49+
hasData = true
4950
}
50-
if (assets) {
51+
if (assets && assets.length > 0) {
5152
result['assets'] = assets
53+
hasData = true
54+
}
55+
if (!hasData) {
56+
return []
5257
}
5358
return [k, result]
5459
}),

0 commit comments

Comments
 (0)