Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/react-router/src/Asset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: '' }}
></script>
)
}

if (attrs?.src && typeof attrs.src === 'string') {
Expand Down
27 changes: 5 additions & 22 deletions packages/react-router/src/HeadContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,17 @@ export const useTags = () => {
structuralSharing: true as any,
})

const preloadMeta = useRouterState({
const preloadLinks = useRouterState({
select: (state) => {
const preloadMeta: Array<RouterManagedTag> = []
const preloadLinks: Array<RouterManagedTag> = []

state.matches
.map((match) => router.looseRoutesById[match.routeId]!)
.forEach((route) =>
router.ssr?.manifest?.routes[route.id]?.preloads
?.filter(Boolean)
.forEach((preload) => {
preloadMeta.push({
preloadLinks.push({
tag: 'link',
attrs: {
rel: 'modulepreload',
Expand All @@ -134,7 +134,7 @@ export const useTags = () => {
}),
)

return preloadMeta
return preloadLinks
},
structuralSharing: true as any,
})
Expand Down Expand Up @@ -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<RouterManagedTag>,
(d) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/ScriptOnce.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()',
}}
/>
)
Expand Down
10 changes: 10 additions & 0 deletions packages/react-router/src/Scripts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouterManagedTag>

if (serverBufferedScript) {
allScripts.unshift(serverBufferedScript)
}

return (
<>
{allScripts.map((asset, i) => (
Expand Down
6 changes: 3 additions & 3 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -756,7 +756,8 @@ export interface ServerSsr {
isDehydrated: () => boolean
onRenderFinished: (listener: () => void) => void
dehydrate: () => Promise<void>
takeBufferedScripts: () => string | undefined
takeBufferedScripts: () => RouterManagedTag | undefined
liftScriptBarrier: () => void
Comment on lines +759 to +760
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Breaking API change: Return type updated to structured wrapper.

The takeBufferedScripts return type has changed from string | undefined to RouterManagedTag | undefined. This is a breaking change for consumers that expect a plain string, though it provides better type safety by wrapping script content with metadata (nonce, className, id).

The new liftScriptBarrier method extends the public API surface for SSR script barrier control.

Optional: Consider adding JSDoc documentation for the new method.

Adding brief JSDoc comments for liftScriptBarrier would help consumers understand when and how to use this new API:

  dehydrate: () => Promise<void>
  takeBufferedScripts: () => RouterManagedTag | undefined
+  /**
+   * Lifts the script barrier to allow subsequent scripts to be streamed.
+   * Used in SSR to control script ordering and streaming behavior.
+   */
  liftScriptBarrier: () => void

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
packages/router-core/src/router.ts lines 759-760: The change updates
takeBufferedScripts from returning string|undefined to
RouterManagedTag|undefined (breaking consumers) and introduces liftScriptBarrier
without documentation; to fix, restore backwards-compatible behavior by
providing either (a) an overload or adapter that preserves the original
signature (keep takeBufferedScripts returning string|undefined and add
takeBufferedScriptsManaged or similar that returns RouterManagedTag), or (b)
keep the new return type but add a helper method that extracts the string
content from RouterManagedTag for existing callers, and add a brief JSDoc
comment above liftScriptBarrier explaining its purpose, intended usage, and SSR
barrier semantics so consumers know when to call it.

}

export type AnyRouterWithContext<TContext> = RouterCore<
Expand Down Expand Up @@ -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 () => {
Expand Down
33 changes: 29 additions & 4 deletions packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
},
}
}
Expand Down
74 changes: 35 additions & 39 deletions packages/router-core/src/ssr/transformStreamWithRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /(<body)/
const patternBodyEnd = /(<\/body>)/
const patternHtmlEnd = /(<\/html>)/
const patternHeadStart = /(<head.*?>)/
// 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -136,7 +142,7 @@ export function transformStreamWithRouter(

promise
.then((html) => {
if (!bodyStarted) {
if (isAppRendering) {
routerStreamBuffer += html
} else {
finalPassThrough.write(html)
Expand All @@ -147,14 +153,14 @@ export function transformStreamWithRouter(
processingCount--

if (!isAppRendering && processingCount === 0) {
stopListeningToInjectedHtml()
injectedHtmlDonePromise.resolve()
}
})
}

injectedHtmlDonePromise
.then(() => {
clearTimeout(timeoutHandle)
const finalHtml =
leftoverHtml + getBufferedRouterStream() + pendingClosingTags

Expand All @@ -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 (
Expand Down Expand Up @@ -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)
},
})

Expand Down
5 changes: 3 additions & 2 deletions packages/solid-router/src/Asset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script />
}

if (attrs?.src && typeof attrs.src === 'string') {
Expand Down
Loading
Loading