Skip to content

Commit 875d1d4

Browse files
fix: script streaming (#5895)
1 parent f830dff commit 875d1d4

File tree

15 files changed

+154
-124
lines changed

15 files changed

+154
-124
lines changed

packages/react-router/src/Asset.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,13 @@ function Script({
144144
}, [attrs, children])
145145

146146
if (!router.isServer) {
147-
return null
147+
// render an empty script on the client just to avoid hydration errors
148+
return (
149+
<script
150+
suppressHydrationWarning
151+
dangerouslySetInnerHTML={{ __html: '' }}
152+
></script>
153+
)
148154
}
149155

150156
if (attrs?.src && typeof attrs.src === 'string') {

packages/react-router/src/HeadContent.tsx

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,17 @@ export const useTags = () => {
113113
structuralSharing: true as any,
114114
})
115115

116-
const preloadMeta = useRouterState({
116+
const preloadLinks = useRouterState({
117117
select: (state) => {
118-
const preloadMeta: Array<RouterManagedTag> = []
118+
const preloadLinks: Array<RouterManagedTag> = []
119119

120120
state.matches
121121
.map((match) => router.looseRoutesById[match.routeId]!)
122122
.forEach((route) =>
123123
router.ssr?.manifest?.routes[route.id]?.preloads
124124
?.filter(Boolean)
125125
.forEach((preload) => {
126-
preloadMeta.push({
126+
preloadLinks.push({
127127
tag: 'link',
128128
attrs: {
129129
rel: 'modulepreload',
@@ -134,7 +134,7 @@ export const useTags = () => {
134134
}),
135135
)
136136

137-
return preloadMeta
137+
return preloadLinks
138138
},
139139
structuralSharing: true as any,
140140
})
@@ -173,29 +173,12 @@ 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-
192176
return uniqBy(
193177
[
194178
...meta,
195-
...preloadMeta,
179+
...preloadLinks,
196180
...links,
197181
...styles,
198-
...(serverHeadScript ? [serverHeadScript] : []),
199182
...headScripts,
200183
] as Array<RouterManagedTag>,
201184
(d) => {

packages/react-router/src/ScriptOnce.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function ScriptOnce({ children }: { children: string }) {
1414
nonce={router.options.ssr?.nonce}
1515
className="$tsr"
1616
dangerouslySetInnerHTML={{
17-
__html: [children].filter(Boolean).join('\n') + ';$_TSR.c()',
17+
__html: children + ';typeof $_TSR !== "undefined" && $_TSR.c()',
1818
}}
1919
/>
2020
)

packages/react-router/src/Scripts.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,18 @@ export const Scripts = () => {
6262
structuralSharing: true as any,
6363
})
6464

65+
let serverBufferedScript: RouterManagedTag | undefined = undefined
66+
67+
if (router.serverSsr) {
68+
serverBufferedScript = router.serverSsr.takeBufferedScripts()
69+
}
70+
6571
const allScripts = [...scripts, ...assetScripts] as Array<RouterManagedTag>
6672

73+
if (serverBufferedScript) {
74+
allScripts.unshift(serverBufferedScript)
75+
}
76+
6777
return (
6878
<>
6979
{allScripts.map((asset, i) => (

packages/router-core/src/router.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import type {
8383
CommitLocationOptions,
8484
NavigateFn,
8585
} from './RouterProvider'
86-
import type { Manifest } from './manifest'
86+
import type { Manifest, RouterManagedTag } from './manifest'
8787
import type { AnySchema, AnyValidator } from './validators'
8888
import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link'
8989
import type { NotFoundError } from './not-found'
@@ -756,7 +756,8 @@ export interface ServerSsr {
756756
isDehydrated: () => boolean
757757
onRenderFinished: (listener: () => void) => void
758758
dehydrate: () => Promise<void>
759-
takeBufferedScripts: () => string | undefined
759+
takeBufferedScripts: () => RouterManagedTag | undefined
760+
liftScriptBarrier: () => void
760761
}
761762

762763
export type AnyRouterWithContext<TContext> = RouterCore<
@@ -2096,7 +2097,6 @@ export class RouterCore<
20962097
updateMatch: this.updateMatch,
20972098
// eslint-disable-next-line @typescript-eslint/require-await
20982099
onReady: async () => {
2099-
// eslint-disable-next-line @typescript-eslint/require-await
21002100
// Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition)
21012101
this.startTransition(() => {
21022102
this.startViewTransition(async () => {

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

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import minifiedTsrBootStrapScript from './tsrScript?script-string'
55
import { GLOBAL_TSR } from './constants'
66
import { defaultSerovalPlugins } from './serializer/seroval-plugins'
77
import { makeSsrSerovalPlugin } from './serializer/transformer'
8+
import { TSR_SCRIPT_BARRIER_ID } from './transformStreamWithRouter'
9+
import type { AnySerializationAdapter} from './serializer/transformer';
810
import type { AnyRouter } from '../router'
911
import type { DehydratedMatch } from './ssr-client'
1012
import type { DehydratedRouter } from './client'
1113
import type { AnyRouteMatch } from '../Matches'
12-
import type { Manifest } from '../manifest'
13-
import type { AnySerializationAdapter } from './serializer/transformer'
14+
import type { Manifest, RouterManagedTag } from '../manifest'
1415

1516
declare module '../router' {
1617
interface ServerSsr {
@@ -140,8 +141,21 @@ export function attachRouterServerSsrUtils({
140141
}
141142
const matches = matchesToDehydrate.map(dehydrateMatch)
142143

144+
let manifestToDehydrate: Manifest | undefined = undefined
145+
// only send manifest of the current routes to the client
146+
if (manifest) {
147+
const filteredRoutes = Object.fromEntries(
148+
router.state.matches.map((k) => [
149+
k.routeId,
150+
manifest.routes[k.routeId],
151+
]),
152+
)
153+
manifestToDehydrate = {
154+
routes: filteredRoutes,
155+
}
156+
}
143157
const dehydratedRouter: DehydratedRouter = {
144-
manifest: router.ssr!.manifest,
158+
manifest: manifestToDehydrate,
145159
matches,
146160
}
147161
const lastMatchId = matchesToDehydrate[matchesToDehydrate.length - 1]?.id
@@ -193,8 +207,19 @@ export function attachRouterServerSsrUtils({
193207
},
194208
takeBufferedScripts() {
195209
const scripts = scriptBuffer.takeAll()
210+
const serverBufferedScript: RouterManagedTag = {
211+
tag: 'script',
212+
attrs: {
213+
nonce: router.options.ssr?.nonce,
214+
className: '$tsr',
215+
id: TSR_SCRIPT_BARRIER_ID,
216+
},
217+
children: scripts,
218+
}
219+
return serverBufferedScript
220+
},
221+
liftScriptBarrier() {
196222
scriptBuffer.liftBarrier()
197-
return scripts
198223
},
199224
}
200225
}

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

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,17 @@ export function transformPipeableStreamWithRouter(
1919
)
2020
}
2121

22+
export const TSR_SCRIPT_BARRIER_ID = '$tsr-stream-barrier'
23+
2224
// regex pattern for matching closing body and html tags
23-
const patternBodyStart = /(<body)/
2425
const patternBodyEnd = /(<\/body>)/
2526
const patternHtmlEnd = /(<\/html>)/
26-
const patternHeadStart = /(<head.*?>)/
2727
// regex pattern for matching closing tags
2828
const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g
2929

30-
const textDecoder = new TextDecoder()
31-
3230
type ReadablePassthrough = {
3331
stream: ReadableStream
34-
write: (chunk: string) => void
32+
write: (chunk: unknown) => void
3533
end: (chunk?: string) => void
3634
destroy: (error: unknown) => void
3735
destroyed: boolean
@@ -49,11 +47,15 @@ function createPassthrough() {
4947
const res: ReadablePassthrough = {
5048
stream,
5149
write: (chunk) => {
52-
controller.enqueue(encoder.encode(chunk))
50+
if (typeof chunk === 'string') {
51+
controller.enqueue(encoder.encode(chunk))
52+
} else {
53+
controller.enqueue(chunk)
54+
}
5355
},
5456
end: (chunk) => {
5557
if (chunk) {
56-
controller.enqueue(encoder.encode(chunk))
58+
res.write(chunk)
5759
}
5860
controller.close()
5961
res.destroyed = true
@@ -90,16 +92,20 @@ async function readStream(
9092
export function transformStreamWithRouter(
9193
router: AnyRouter,
9294
appStream: ReadableStream,
95+
opts?: {
96+
timeoutMs?: number
97+
},
9398
) {
9499
const finalPassThrough = createPassthrough()
100+
const textDecoder = new TextDecoder()
95101

96102
let isAppRendering = true as boolean
97103
let routerStreamBuffer = ''
98104
let pendingClosingTags = ''
99-
let bodyStarted = false as boolean
100-
let headStarted = false as boolean
105+
let streamBarrierLifted = false as boolean
101106
let leftover = ''
102107
let leftoverHtml = ''
108+
let timeoutHandle: NodeJS.Timeout
103109

104110
function getBufferedRouterStream() {
105111
const html = routerStreamBuffer
@@ -109,7 +115,7 @@ export function transformStreamWithRouter(
109115

110116
function decodeChunk(chunk: unknown): string {
111117
if (chunk instanceof Uint8Array) {
112-
return textDecoder.decode(chunk)
118+
return textDecoder.decode(chunk, { stream: true })
113119
}
114120
return String(chunk)
115121
}
@@ -136,7 +142,7 @@ export function transformStreamWithRouter(
136142

137143
promise
138144
.then((html) => {
139-
if (!bodyStarted) {
145+
if (isAppRendering) {
140146
routerStreamBuffer += html
141147
} else {
142148
finalPassThrough.write(html)
@@ -147,14 +153,14 @@ export function transformStreamWithRouter(
147153
processingCount--
148154

149155
if (!isAppRendering && processingCount === 0) {
150-
stopListeningToInjectedHtml()
151156
injectedHtmlDonePromise.resolve()
152157
}
153158
})
154159
}
155160

156161
injectedHtmlDonePromise
157162
.then(() => {
163+
clearTimeout(timeoutHandle)
158164
const finalHtml =
159165
leftoverHtml + getBufferedRouterStream() + pendingClosingTags
160166

@@ -164,44 +170,26 @@ export function transformStreamWithRouter(
164170
console.error('Error reading routerStream:', err)
165171
finalPassThrough.destroy(err)
166172
})
173+
.finally(stopListeningToInjectedHtml)
167174

168175
// Transform the appStream
169176
readStream(appStream, {
170177
onData: (chunk) => {
171178
const text = decodeChunk(chunk.value)
172-
173-
let chunkString = leftover + text
179+
const chunkString = leftover + text
174180
const bodyEndMatch = chunkString.match(patternBodyEnd)
175181
const htmlEndMatch = chunkString.match(patternHtmlEnd)
176182

177-
if (!bodyStarted) {
178-
const bodyStartMatch = chunkString.match(patternBodyStart)
179-
if (bodyStartMatch) {
180-
bodyStarted = true
181-
}
182-
}
183-
184-
if (!headStarted) {
185-
const headStartMatch = chunkString.match(patternHeadStart)
186-
if (headStartMatch) {
187-
headStarted = true
188-
const index = headStartMatch.index!
189-
const headTag = headStartMatch[0]
190-
const remaining = chunkString.slice(index + headTag.length)
191-
finalPassThrough.write(
192-
chunkString.slice(0, index) + headTag + getBufferedRouterStream(),
193-
)
194-
// make sure to only write `remaining` until the next closing tag
195-
chunkString = remaining
183+
if (!streamBarrierLifted) {
184+
const streamBarrierIdIncluded = chunkString.includes(
185+
TSR_SCRIPT_BARRIER_ID,
186+
)
187+
if (streamBarrierIdIncluded) {
188+
streamBarrierLifted = true
189+
router.serverSsr!.liftScriptBarrier()
196190
}
197191
}
198192

199-
if (!bodyStarted) {
200-
finalPassThrough.write(chunkString)
201-
leftover = ''
202-
return
203-
}
204-
205193
// If either the body end or html end is in the chunk,
206194
// We need to get all of our data in asap
207195
if (
@@ -247,11 +235,19 @@ export function transformStreamWithRouter(
247235
// If there are no pending promises, resolve the injectedHtmlDonePromise
248236
if (processingCount === 0) {
249237
injectedHtmlDonePromise.resolve()
238+
} else {
239+
const timeoutMs = opts?.timeoutMs ?? 60000
240+
timeoutHandle = setTimeout(() => {
241+
injectedHtmlDonePromise.reject(
242+
new Error('Injected HTML timeout after app render finished'),
243+
)
244+
}, timeoutMs)
250245
}
251246
},
252247
onError: (error) => {
253248
console.error('Error reading appStream:', error)
254249
finalPassThrough.destroy(error)
250+
injectedHtmlDonePromise.reject(error)
255251
},
256252
})
257253

packages/solid-router/src/Asset.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,9 @@ function Script({
123123
}
124124
})
125125

126-
if (router && !router.isServer) {
127-
return null
126+
if (!router.isServer) {
127+
// render an empty script on the client just to avoid hydration errors
128+
return <script />
128129
}
129130

130131
if (attrs?.src && typeof attrs.src === 'string') {

0 commit comments

Comments
 (0)