diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx index 37347631cf4..79ceb8ed77a 100644 --- a/packages/react-router/src/Asset.tsx +++ b/packages/react-router/src/Asset.tsx @@ -144,7 +144,13 @@ function Script({ }, [attrs, children]) if (!router.isServer) { - return null + // render an empty script on the client just to avoid hydration errors + return ( + + ) } if (attrs?.src && typeof attrs.src === 'string') { diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx index df39578aa0d..617498c8572 100644 --- a/packages/react-router/src/HeadContent.tsx +++ b/packages/react-router/src/HeadContent.tsx @@ -113,9 +113,9 @@ export const useTags = () => { structuralSharing: true as any, }) - const preloadMeta = useRouterState({ + const preloadLinks = useRouterState({ select: (state) => { - const preloadMeta: Array = [] + const preloadLinks: Array = [] state.matches .map((match) => router.looseRoutesById[match.routeId]!) @@ -123,7 +123,7 @@ export const useTags = () => { router.ssr?.manifest?.routes[route.id]?.preloads ?.filter(Boolean) .forEach((preload) => { - preloadMeta.push({ + preloadLinks.push({ tag: 'link', attrs: { rel: 'modulepreload', @@ -134,7 +134,7 @@ export const useTags = () => { }), ) - return preloadMeta + return preloadLinks }, structuralSharing: true as any, }) @@ -173,29 +173,12 @@ export const useTags = () => { structuralSharing: true as any, }) - let serverHeadScript: RouterManagedTag | undefined = undefined - - if (router.serverSsr) { - const bufferedScripts = router.serverSsr.takeBufferedScripts() - if (bufferedScripts) { - serverHeadScript = { - tag: 'script', - attrs: { - nonce, - className: '$tsr', - }, - children: bufferedScripts, - } - } - } - return uniqBy( [ ...meta, - ...preloadMeta, + ...preloadLinks, ...links, ...styles, - ...(serverHeadScript ? [serverHeadScript] : []), ...headScripts, ] as Array, (d) => { diff --git a/packages/react-router/src/ScriptOnce.tsx b/packages/react-router/src/ScriptOnce.tsx index c43c60d0a61..f00317d52fb 100644 --- a/packages/react-router/src/ScriptOnce.tsx +++ b/packages/react-router/src/ScriptOnce.tsx @@ -14,7 +14,7 @@ export function ScriptOnce({ children }: { children: string }) { nonce={router.options.ssr?.nonce} className="$tsr" dangerouslySetInnerHTML={{ - __html: [children].filter(Boolean).join('\n') + ';$_TSR.c()', + __html: children + ';typeof $_TSR !== "undefined" && $_TSR.c()', }} /> ) diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index b73eea9a1ed..3765e5790d8 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -62,8 +62,18 @@ export const Scripts = () => { structuralSharing: true as any, }) + let serverBufferedScript: RouterManagedTag | undefined = undefined + + if (router.serverSsr) { + serverBufferedScript = router.serverSsr.takeBufferedScripts() + } + const allScripts = [...scripts, ...assetScripts] as Array + if (serverBufferedScript) { + allScripts.unshift(serverBufferedScript) + } + return ( <> {allScripts.map((asset, i) => ( diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 770a526607d..c19d149d266 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -83,7 +83,7 @@ import type { CommitLocationOptions, NavigateFn, } from './RouterProvider' -import type { Manifest } from './manifest' +import type { Manifest, RouterManagedTag } from './manifest' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' import type { NotFoundError } from './not-found' @@ -756,7 +756,8 @@ export interface ServerSsr { isDehydrated: () => boolean onRenderFinished: (listener: () => void) => void dehydrate: () => Promise - takeBufferedScripts: () => string | undefined + takeBufferedScripts: () => RouterManagedTag | undefined + liftScriptBarrier: () => void } export type AnyRouterWithContext = RouterCore< @@ -2096,7 +2097,6 @@ export class RouterCore< updateMatch: this.updateMatch, // eslint-disable-next-line @typescript-eslint/require-await onReady: async () => { - // eslint-disable-next-line @typescript-eslint/require-await // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) this.startTransition(() => { this.startViewTransition(async () => { diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index b9b8d907c55..e2b0654782d 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -5,12 +5,13 @@ import minifiedTsrBootStrapScript from './tsrScript?script-string' import { GLOBAL_TSR } from './constants' import { defaultSerovalPlugins } from './serializer/seroval-plugins' import { makeSsrSerovalPlugin } from './serializer/transformer' +import { TSR_SCRIPT_BARRIER_ID } from './transformStreamWithRouter' +import type { AnySerializationAdapter} from './serializer/transformer'; import type { AnyRouter } from '../router' import type { DehydratedMatch } from './ssr-client' import type { DehydratedRouter } from './client' import type { AnyRouteMatch } from '../Matches' -import type { Manifest } from '../manifest' -import type { AnySerializationAdapter } from './serializer/transformer' +import type { Manifest, RouterManagedTag } from '../manifest' declare module '../router' { interface ServerSsr { @@ -140,8 +141,21 @@ export function attachRouterServerSsrUtils({ } const matches = matchesToDehydrate.map(dehydrateMatch) + let manifestToDehydrate: Manifest | undefined = undefined + // only send manifest of the current routes to the client + if (manifest) { + const filteredRoutes = Object.fromEntries( + router.state.matches.map((k) => [ + k.routeId, + manifest.routes[k.routeId], + ]), + ) + manifestToDehydrate = { + routes: filteredRoutes, + } + } const dehydratedRouter: DehydratedRouter = { - manifest: router.ssr!.manifest, + manifest: manifestToDehydrate, matches, } const lastMatchId = matchesToDehydrate[matchesToDehydrate.length - 1]?.id @@ -193,8 +207,19 @@ export function attachRouterServerSsrUtils({ }, takeBufferedScripts() { const scripts = scriptBuffer.takeAll() + const serverBufferedScript: RouterManagedTag = { + tag: 'script', + attrs: { + nonce: router.options.ssr?.nonce, + className: '$tsr', + id: TSR_SCRIPT_BARRIER_ID, + }, + children: scripts, + } + return serverBufferedScript + }, + liftScriptBarrier() { scriptBuffer.liftBarrier() - return scripts }, } } diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 54c0197bb58..dec03fe80b8 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -19,19 +19,17 @@ export function transformPipeableStreamWithRouter( ) } +export const TSR_SCRIPT_BARRIER_ID = '$tsr-stream-barrier' + // regex pattern for matching closing body and html tags -const patternBodyStart = /()/ const patternHtmlEnd = /(<\/html>)/ -const patternHeadStart = /()/ // regex pattern for matching closing tags const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g -const textDecoder = new TextDecoder() - type ReadablePassthrough = { stream: ReadableStream - write: (chunk: string) => void + write: (chunk: unknown) => void end: (chunk?: string) => void destroy: (error: unknown) => void destroyed: boolean @@ -49,11 +47,15 @@ function createPassthrough() { const res: ReadablePassthrough = { stream, write: (chunk) => { - controller.enqueue(encoder.encode(chunk)) + if (typeof chunk === 'string') { + controller.enqueue(encoder.encode(chunk)) + } else { + controller.enqueue(chunk) + } }, end: (chunk) => { if (chunk) { - controller.enqueue(encoder.encode(chunk)) + res.write(chunk) } controller.close() res.destroyed = true @@ -90,16 +92,20 @@ async function readStream( export function transformStreamWithRouter( router: AnyRouter, appStream: ReadableStream, + opts?: { + timeoutMs?: number + }, ) { const finalPassThrough = createPassthrough() + const textDecoder = new TextDecoder() let isAppRendering = true as boolean let routerStreamBuffer = '' let pendingClosingTags = '' - let bodyStarted = false as boolean - let headStarted = false as boolean + let streamBarrierLifted = false as boolean let leftover = '' let leftoverHtml = '' + let timeoutHandle: NodeJS.Timeout function getBufferedRouterStream() { const html = routerStreamBuffer @@ -109,7 +115,7 @@ export function transformStreamWithRouter( function decodeChunk(chunk: unknown): string { if (chunk instanceof Uint8Array) { - return textDecoder.decode(chunk) + return textDecoder.decode(chunk, { stream: true }) } return String(chunk) } @@ -136,7 +142,7 @@ export function transformStreamWithRouter( promise .then((html) => { - if (!bodyStarted) { + if (isAppRendering) { routerStreamBuffer += html } else { finalPassThrough.write(html) @@ -147,7 +153,6 @@ export function transformStreamWithRouter( processingCount-- if (!isAppRendering && processingCount === 0) { - stopListeningToInjectedHtml() injectedHtmlDonePromise.resolve() } }) @@ -155,6 +160,7 @@ export function transformStreamWithRouter( injectedHtmlDonePromise .then(() => { + clearTimeout(timeoutHandle) const finalHtml = leftoverHtml + getBufferedRouterStream() + pendingClosingTags @@ -164,44 +170,26 @@ export function transformStreamWithRouter( console.error('Error reading routerStream:', err) finalPassThrough.destroy(err) }) + .finally(stopListeningToInjectedHtml) // Transform the appStream readStream(appStream, { onData: (chunk) => { const text = decodeChunk(chunk.value) - - let chunkString = leftover + text + const chunkString = leftover + text const bodyEndMatch = chunkString.match(patternBodyEnd) const htmlEndMatch = chunkString.match(patternHtmlEnd) - if (!bodyStarted) { - const bodyStartMatch = chunkString.match(patternBodyStart) - if (bodyStartMatch) { - bodyStarted = true - } - } - - if (!headStarted) { - const headStartMatch = chunkString.match(patternHeadStart) - if (headStartMatch) { - headStarted = true - const index = headStartMatch.index! - const headTag = headStartMatch[0] - const remaining = chunkString.slice(index + headTag.length) - finalPassThrough.write( - chunkString.slice(0, index) + headTag + getBufferedRouterStream(), - ) - // make sure to only write `remaining` until the next closing tag - chunkString = remaining + if (!streamBarrierLifted) { + const streamBarrierIdIncluded = chunkString.includes( + TSR_SCRIPT_BARRIER_ID, + ) + if (streamBarrierIdIncluded) { + streamBarrierLifted = true + router.serverSsr!.liftScriptBarrier() } } - if (!bodyStarted) { - finalPassThrough.write(chunkString) - leftover = '' - return - } - // If either the body end or html end is in the chunk, // We need to get all of our data in asap if ( @@ -247,11 +235,19 @@ export function transformStreamWithRouter( // If there are no pending promises, resolve the injectedHtmlDonePromise if (processingCount === 0) { injectedHtmlDonePromise.resolve() + } else { + const timeoutMs = opts?.timeoutMs ?? 60000 + timeoutHandle = setTimeout(() => { + injectedHtmlDonePromise.reject( + new Error('Injected HTML timeout after app render finished'), + ) + }, timeoutMs) } }, onError: (error) => { console.error('Error reading appStream:', error) finalPassThrough.destroy(error) + injectedHtmlDonePromise.reject(error) }, }) diff --git a/packages/solid-router/src/Asset.tsx b/packages/solid-router/src/Asset.tsx index 7e7a7584288..e86940b2aae 100644 --- a/packages/solid-router/src/Asset.tsx +++ b/packages/solid-router/src/Asset.tsx @@ -123,8 +123,9 @@ function Script({ } }) - if (router && !router.isServer) { - return null + if (!router.isServer) { + // render an empty script on the client just to avoid hydration errors + return