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
}
if (attrs?.src && typeof attrs.src === 'string') {
diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx
index 6940fe789d5..103be65c465 100644
--- a/packages/solid-router/src/HeadContent.tsx
+++ b/packages/solid-router/src/HeadContent.tsx
@@ -107,9 +107,9 @@ export const useTags = () => {
},
})
- const preloadMeta = useRouterState({
+ const preloadLinks = useRouterState({
select: (state) => {
- const preloadMeta: Array = []
+ const preloadLinks: Array = []
state.matches
.map((match) => router.looseRoutesById[match.routeId]!)
@@ -117,7 +117,7 @@ export const useTags = () => {
router.ssr?.manifest?.routes[route.id]?.preloads
?.filter(Boolean)
.forEach((preload) => {
- preloadMeta.push({
+ preloadLinks.push({
tag: 'link',
attrs: {
rel: 'modulepreload',
@@ -128,7 +128,7 @@ export const useTags = () => {
}),
)
- return preloadMeta
+ return preloadLinks
},
})
@@ -166,30 +166,13 @@ export const useTags = () => {
})),
})
- let serverHeadScript: RouterManagedTag | undefined = undefined
-
- if (router.serverSsr) {
- const bufferedScripts = router.serverSsr.takeBufferedScripts()
- if (bufferedScripts) {
- serverHeadScript = {
- tag: 'script',
- attrs: {
- nonce,
- class: '$tsr',
- },
- children: bufferedScripts,
- }
- }
- }
-
return () =>
uniqBy(
[
...meta(),
- ...preloadMeta(),
+ ...preloadLinks(),
...links(),
...styles(),
- ...(serverHeadScript ? [serverHeadScript] : []),
...headScripts(),
] as Array,
(d) => {
diff --git a/packages/solid-router/src/ScriptOnce.tsx b/packages/solid-router/src/ScriptOnce.tsx
index 9527c65cbd8..572570c8ddd 100644
--- a/packages/solid-router/src/ScriptOnce.tsx
+++ b/packages/solid-router/src/ScriptOnce.tsx
@@ -15,7 +15,7 @@ export function ScriptOnce({
)
}
diff --git a/packages/solid-router/src/Scripts.tsx b/packages/solid-router/src/Scripts.tsx
index 798ae1adf37..d900faf55de 100644
--- a/packages/solid-router/src/Scripts.tsx
+++ b/packages/solid-router/src/Scripts.tsx
@@ -51,11 +51,21 @@ export const Scripts = () => {
}),
})
+ let serverBufferedScript: RouterManagedTag | undefined = undefined
+
+ if (router.serverSsr) {
+ serverBufferedScript = router.serverSsr.takeBufferedScripts()
+ }
+
const allScripts = [
...scripts().scripts,
...assetScripts(),
] as Array
+ if (serverBufferedScript) {
+ allScripts.unshift(serverBufferedScript)
+ }
+
return (
<>
{allScripts.map((asset, i) => (
diff --git a/packages/start-plugin-core/src/global.d.ts b/packages/start-plugin-core/src/global.d.ts
index 5f647b0c1aa..c974e5019e8 100644
--- a/packages/start-plugin-core/src/global.d.ts
+++ b/packages/start-plugin-core/src/global.d.ts
@@ -1,8 +1,12 @@
-import type { Manifest } from '@tanstack/router-core'
-
/* eslint-disable no-var */
declare global {
- var TSS_ROUTES_MANIFEST: Manifest
+ var TSS_ROUTES_MANIFEST: Record<
+ string,
+ {
+ filePath: string
+ children?: Array
+ }
+ >
var TSS_PRERENDABLE_PATHS: Array<{ path: string }> | undefined
}
export {}
diff --git a/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts b/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts
index de2f57673de..059116839a5 100644
--- a/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts
@@ -6,7 +6,7 @@ import { resolveViteId } from '../utils'
import { ENTRY_POINTS } from '../constants'
import type { GetConfigFn } from '../plugin'
import type { PluginOption, Rollup } from 'vite'
-import type { RouterManagedTag } from '@tanstack/router-core'
+import type { Manifest, RouterManagedTag } from '@tanstack/router-core'
const getCSSRecursively = (
chunk: Rollup.OutputChunk,
@@ -97,7 +97,7 @@ export function startManifestPlugin(opts: {
// This the manifest pulled from the generated route tree and later used by the Router.
// i.e what's located in `src/routeTree.gen.ts`
- const routeTreeRoutes = globalThis.TSS_ROUTES_MANIFEST.routes
+ const routeTreeRoutes = globalThis.TSS_ROUTES_MANIFEST
const cssPerChunkCache = new Map<
Rollup.OutputChunk,
@@ -157,6 +157,7 @@ export function startManifestPlugin(opts: {
}
}
+ const manifest: Manifest = { routes: {} }
// Add preloads to the routes from the vite manifest
Object.entries(routeTreeRoutes).forEach(([routeId, v]) => {
if (!v.filePath) {
@@ -168,8 +169,11 @@ export function startManifestPlugin(opts: {
// Map the relevant imports to their route paths,
// so that it can be imported in the browser.
const preloads = chunk.imports.map((d) => {
- const assetPath = joinURL(resolvedStartConfig.viteAppBase, d)
- return assetPath
+ const preloadPath = joinURL(
+ resolvedStartConfig.viteAppBase,
+ d,
+ )
+ return preloadPath
})
// Since this is the most important JS entry for the route,
@@ -179,26 +183,30 @@ export function startManifestPlugin(opts: {
joinURL(resolvedStartConfig.viteAppBase, chunk.fileName),
)
- const cssAssetsList = getCSSRecursively(
+ const assets = getCSSRecursively(
chunk,
chunksByFileName,
resolvedStartConfig.viteAppBase,
cssPerChunkCache,
)
- routeTreeRoutes[routeId] = {
+ manifest.routes[routeId] = {
...v,
- assets: [...(v.assets || []), ...cssAssetsList],
- preloads: [...(v.preloads || []), ...preloads],
+ assets,
+ preloads,
}
})
+ } else {
+ manifest.routes[routeId] = v
}
})
if (!entryFile) {
throw new Error('No entry file found')
}
- routeTreeRoutes[rootRouteId]!.preloads = [
+
+ manifest.routes[rootRouteId] = manifest.routes[rootRouteId] || {}
+ manifest.routes[rootRouteId].preloads = [
joinURL(resolvedStartConfig.viteAppBase, entryFile.fileName),
...entryFile.imports.map((d) =>
joinURL(resolvedStartConfig.viteAppBase, d),
@@ -214,8 +222,8 @@ export function startManifestPlugin(opts: {
cssPerChunkCache,
)
- routeTreeRoutes[rootRouteId]!.assets = [
- ...(routeTreeRoutes[rootRouteId]!.assets || []),
+ manifest.routes[rootRouteId].assets = [
+ ...(manifest.routes[rootRouteId].assets || []),
...entryCssAssetsList,
]
@@ -236,17 +244,17 @@ export function startManifestPlugin(opts: {
if (route.children) {
route.children.forEach((child) => {
- const childRoute = routeTreeRoutes[child]!
+ const childRoute = manifest.routes[child]!
recurseRoute(childRoute, { ...seenPreloads })
})
}
}
- recurseRoute(routeTreeRoutes[rootRouteId]!)
+ recurseRoute(manifest.routes[rootRouteId])
// Filter out routes that have neither assets nor preloads
- Object.keys(routeTreeRoutes).forEach((routeId) => {
- const route = routeTreeRoutes[routeId]!
+ Object.keys(manifest.routes).forEach((routeId) => {
+ const route = manifest.routes[routeId]!
const hasAssets = route.assets && route.assets.length > 0
const hasPreloads = route.preloads && route.preloads.length > 0
if (!hasAssets && !hasPreloads) {
@@ -255,7 +263,7 @@ export function startManifestPlugin(opts: {
})
const startManifest = {
- routes: routeTreeRoutes,
+ routes: manifest.routes,
clientEntry: joinURL(
resolvedStartConfig.viteAppBase,
entryFile.fileName,
diff --git a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts
index 0b5edec9168..34cb7aded1e 100644
--- a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts
+++ b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts
@@ -10,10 +10,17 @@ export function routesManifestPlugin(): GeneratorPlugin {
return {
name: 'routes-manifest-plugin',
onRouteTreeChanged: ({ routeTree, rootRouteNode, routeNodes }) => {
- const routesManifest = {
+ const allChildren = routeTree.map((d) => d.routePath)
+ const routes: Record<
+ string,
+ {
+ filePath: string
+ children: Array
+ }
+ > = {
[rootRouteId]: {
filePath: rootRouteNode.fullPath,
- children: routeTree.map((d) => d.routePath),
+ children: allChildren,
},
...Object.fromEntries(
routeNodes.map((d) => {
@@ -23,7 +30,6 @@ export function routesManifestPlugin(): GeneratorPlugin {
filePathId,
{
filePath: d.fullPath,
- parent: d.parent?.routePath ? d.parent.routePath : undefined,
children: d.children?.map((childRoute) => childRoute.routePath),
},
]
@@ -31,7 +37,7 @@ export function routesManifestPlugin(): GeneratorPlugin {
),
}
- globalThis.TSS_ROUTES_MANIFEST = { routes: routesManifest }
+ globalThis.TSS_ROUTES_MANIFEST = routes
},
}
}
diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts
index 4c6fda8a66b..f5249f454e2 100644
--- a/packages/start-server-core/src/router-manifest.ts
+++ b/packages/start-server-core/src/router-manifest.ts
@@ -29,7 +29,6 @@ export async function getStartManifest() {
tag: 'script',
attrs: {
type: 'module',
- suppressHydrationWarning: true,
async: true,
},
children: script,
@@ -38,18 +37,17 @@ export async function getStartManifest() {
const manifest = {
routes: Object.fromEntries(
Object.entries(startManifest.routes).map(([k, v]) => {
- const { preloads, assets } = v
const result = {} as {
preloads?: Array
assets?: Array
}
let hasData = false
- if (preloads && preloads.length > 0) {
- result['preloads'] = preloads
+ if (v.preloads && v.preloads.length > 0) {
+ result['preloads'] = v.preloads
hasData = true
}
- if (assets && assets.length > 0) {
- result['assets'] = assets
+ if (v.assets && v.assets.length > 0) {
+ result['assets'] = v.assets
hasData = true
}
if (!hasData) {