Skip to content
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@
"@tanstack/eslint-plugin-router": "workspace:*",
"@tanstack/router-utils": "workspace:*",
"@tanstack/start-static-server-functions": "workspace:*",
"@tanstack/nitro-v2-vite-plugin": "workspace:*"
"@tanstack/nitro-v2-vite-plugin": "workspace:*",
"@rspack/core": "npm:@rspack-canary/core@2.0.3-canary-9a18d280-20260509102358"
}
}
}
17 changes: 10 additions & 7 deletions packages/react-start-rsc/src/awaitLazyElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { ReactElement, ReactLazy, ReactSuspense } from './reactSymbols'

/**
* Optional callback for collecting CSS hrefs during tree traversal.
* Only called server-side when processing <link rel="stylesheet" data-rsc-css-href>
* Only called when processing explicitly marked RSC CSS stylesheet links.
*/
export type CssHrefCollector = (href: string) => void

/**
* Yields pending lazy element payloads from a tree, stopping at Suspense boundaries.
* Also collects CSS hrefs from <link rel="stylesheet" data-rsc-css-href> elements.
* Also collects CSS hrefs from explicitly marked RSC CSS stylesheet links.
*/
function* findPendingLazyPayloads(
obj: unknown,
Expand All @@ -26,14 +26,18 @@ function* findPendingLazyPayloads(
return
}

// Collect CSS hrefs from <link rel="stylesheet" data-rsc-css-href>
// The active RSC bundler adapter injects these for CSS module imports
// Collect CSS hrefs from explicit Start-managed CSS markers. Do not collect
// ordinary React 19 stylesheet resources here: preiniting those before render
// marks them inserted and bypasses React's suspensey stylesheet commit wait.
if (
el.$$typeof === ReactElement &&
el.type === 'link' &&
el.props?.rel === 'stylesheet'
) {
const cssHref = el.props['data-rsc-css-href'] as string | undefined
let cssHref: string | undefined
if ('data-rsc-css-href' in el.props) {
cssHref = el.props.href
}
if (cssHref && cssCollector) {
cssCollector(cssHref)
}
Expand Down Expand Up @@ -71,8 +75,7 @@ function* findPendingLazyPayloads(
* This ensures client component chunks are fully loaded before rendering,
* preventing Suspense boundaries from flashing during SWR navigation.
*
* Also collects CSS hrefs from <link rel="stylesheet" data-rsc-css-href>
* elements for preloading in <head>.
* Also collects CSS hrefs from explicitly marked RSC CSS stylesheet links.
*
* @param tree - The tree to process
* @param cssCollector - Optional callback to collect CSS hrefs (server-only)
Expand Down
179 changes: 177 additions & 2 deletions packages/react-start-rsc/src/rsbuild/ssr-decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,182 @@
* Flight decode.
*/

import { setOnClientReference } from '@rspack/core/rsc/ssr'
import { createFromReadableStream } from 'react-server-dom-rspack/client.node'

export { createFromReadableStream, setOnClientReference }
type ResolvedAssetDeps = {
js: Array<string>
css: Array<string>
}

type OnClientReference = (reference: {
id: string
deps: ResolvedAssetDeps
runtime: 'rsbuild'
}) => void

declare const __rspack_rsc_manifest__:
| {
moduleLoading?: {
prefix?: string
}
}
| undefined

let onClientReference: OnClientReference | undefined

const FLIGHT_IMPORT_ROW_TAG = 'I'.charCodeAt(0)
const FLIGHT_IMPORT_METADATA_START_OFFSET = 2
const FLIGHT_ROW_SEPARATOR = ':'
const FLIGHT_ROW_TERMINATOR = '\n'
const FIRST_CHUNK_FILE_INDEX = 1
const CHUNK_PAIR_SIZE = 2

function getModuleLoadingPrefix() {
if (typeof __rspack_rsc_manifest__ === 'undefined') return ''
return __rspack_rsc_manifest__.moduleLoading?.prefix ?? ''
}

function emitClientReferencePreloads(
emit: OnClientReference,
id: string,
chunks: Array<unknown>,
prefix: string,
) {
let js: Array<string> | undefined

// Rsbuild's RSC import metadata stores client reference chunks as alternating
// metadata/file entries. The file entries are the browser JS modules that
// need to be surfaced to the SSR layer as modulepreload hrefs.
for (let i = FIRST_CHUNK_FILE_INDEX; i < chunks.length; i += CHUNK_PAIR_SIZE) {
const chunkFile = chunks[i]
if (typeof chunkFile === 'string') {
if (!js) js = []
js.push(prefix + chunkFile)
}
}

if (!js) return

emit({
id,
deps: { js, css: [] },
runtime: 'rsbuild',
})
}

function getFlightImportMetadataStart(row: string) {
const colonIndex = row.indexOf(FLIGHT_ROW_SEPARATOR)
if (
colonIndex === -1 ||
row.charCodeAt(colonIndex + 1) !== FLIGHT_IMPORT_ROW_TAG
) {
return -1
}

return colonIndex + FLIGHT_IMPORT_METADATA_START_OFFSET
}

function processFlightRowForPreloads(
row: string,
prefix: string,
emit: OnClientReference,
) {
const metadataStart = getFlightImportMetadataStart(row)
if (metadataStart === -1) return

try {
const metadata = JSON.parse(row.slice(metadataStart))
if (!Array.isArray(metadata)) return

const [id, chunks] = metadata
if (typeof id !== 'string' || !Array.isArray(chunks)) return

emitClientReferencePreloads(emit, id, chunks, prefix)
} catch {
// Ignore Flight rows that are not plain JSON import metadata.
}
}

function processBufferedFlightRows(
buffer: string,
prefix: string,
emit: OnClientReference,
) {
let rowStart = 0
let newlineIndex = buffer.indexOf(FLIGHT_ROW_TERMINATOR, rowStart)

while (newlineIndex !== -1) {
processFlightRowForPreloads(
buffer.slice(rowStart, newlineIndex),
prefix,
emit,
)
rowStart = newlineIndex + 1
newlineIndex = buffer.indexOf(FLIGHT_ROW_TERMINATOR, rowStart)
}

return rowStart === 0 ? buffer : buffer.slice(rowStart)
}

async function collectClientReferencePreloads(
stream: ReadableStream<Uint8Array>,
prefix: string,
emit: OnClientReference,
) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffered = ''

try {
for (;;) {
const { value, done } = await reader.read()
if (done) break

buffered += decoder.decode(value, { stream: true })
buffered = processBufferedFlightRows(buffered, prefix, emit)
}

buffered += decoder.decode()
if (buffered) processFlightRowForPreloads(buffered, prefix, emit)
} finally {
reader.releaseLock()
}
}

function setOnClientReference(callback: OnClientReference | undefined) {
onClientReference = callback
}

async function createFromReadableStreamCollectingClientPreloads<T = unknown>(
stream: ReadableStream<Uint8Array>,
options?: object,
): Promise<T> {
const emit = onClientReference

if (!emit || typeof stream.tee !== 'function') {
return createFromReadableStream<T>(stream, options)
}

const prefix = getModuleLoadingPrefix()

// Decode the Flight stream normally while a second reader scans the same
// bytes for import rows. This lets SSR collect client component JS discovered
// during RSC decode and attach it to the renderable proxy, so pages like
// /rsc-client-preload can emit extra <link rel="modulepreload"> tags for
// nested client components before hydration starts.
const [decodeStream, preloadStream] = stream.tee()
const preloadPromise = collectClientReferencePreloads(
preloadStream,
prefix,
emit,
)

const result = await createFromReadableStream<T>(decodeStream, options)
await preloadPromise
return result
}

export {
setOnClientReference,
createFromReadableStreamCollectingClientPreloads as createFromReadableStream,
}
3 changes: 3 additions & 0 deletions packages/react-start-rsc/src/serialization.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ setOnClientReference(
}
}

// Rsbuild injects collected assets when the decoded RSC is actually
// rendered via ReactDOM.preinit/preloadModule. Keeping them off the
// request manifest avoids emitting assets for decoded-but-unrendered trees.
if (!ctx || runtime === 'rsbuild') return

if (!ctx.requestAssets) ctx.requestAssets = []
Expand Down
12 changes: 12 additions & 0 deletions packages/start-plugin-core/src/rsbuild/planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const RSBUILD_RSC_LAYERS = {
ssr: 'server-side-rendering',
} as const

export const RSBUILD_CLIENT_ASSETS_DIR = 'assets'

export type RsbuildEnvironmentName =
(typeof RSBUILD_ENVIRONMENT_NAMES)[keyof typeof RSBUILD_ENVIRONMENT_NAMES]

Expand Down Expand Up @@ -105,6 +107,16 @@ export function createRsbuildEnvironmentPlan(opts: {
module: true,
distPath: {
root: opts.clientOutputDirectory,
js: `${RSBUILD_CLIENT_ASSETS_DIR}/js`,
jsAsync: `${RSBUILD_CLIENT_ASSETS_DIR}/js/async`,
css: `${RSBUILD_CLIENT_ASSETS_DIR}/css`,
cssAsync: `${RSBUILD_CLIENT_ASSETS_DIR}/css/async`,
svg: `${RSBUILD_CLIENT_ASSETS_DIR}/svg`,
font: `${RSBUILD_CLIENT_ASSETS_DIR}/font`,
wasm: `${RSBUILD_CLIENT_ASSETS_DIR}/wasm`,
image: `${RSBUILD_CLIENT_ASSETS_DIR}/image`,
media: `${RSBUILD_CLIENT_ASSETS_DIR}/media`,
assets: `${RSBUILD_CLIENT_ASSETS_DIR}/assets`,
},
assetPrefix: opts.publicBase,
},
Expand Down
12 changes: 8 additions & 4 deletions packages/start-plugin-core/src/rsbuild/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { normalizePath } from '../utils'
import { createServerFnBasePath, normalizePublicBase } from '../planning'
import { parseStartConfig } from './schema'
import {
RSBUILD_CLIENT_ASSETS_DIR,
RSBUILD_ENVIRONMENT_NAMES,
RSBUILD_RSC_LAYERS,
createRsbuildEnvironmentPlan,
Expand Down Expand Up @@ -276,7 +277,7 @@ export function tanStackStartRsbuild(
ssrIsProvider,
serializationAdapters: corePluginOpts.serializationAdapters,
getDevClientEntryUrl: (publicBase: string) =>
joinURL(publicBase, 'static/js/index.js'),
joinURL(publicBase, RSBUILD_CLIENT_ASSETS_DIR, 'js/index.js'),
rscEnabled,
})
updateServerFnResolver = virtualModuleState.updateServerFnResolver
Expand Down Expand Up @@ -452,9 +453,12 @@ export function tanStackStartRsbuild(
// Add ServerPlugin with HMR callback
config.plugins.push(
new rscPlugins.ServerPlugin({
clientEntryName: 'index',
runtimeEntryName: 'index',
injectSsrModulesToEntries: ['index'],
cssLink: {
precedence: false,
props: {
'data-rsc-css-href': '',
},
},
onServerComponentChanges: () => {
// Send rsc:update to connected clients for HMR
devServerRef?.sockWrite('custom', {
Expand Down
2 changes: 1 addition & 1 deletion packages/start-plugin-core/src/rsbuild/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export interface RegisterVirtualModulesOptions {
/**
* Get the URL at which the rsbuild dev server serves the client entry JS.
* Called lazily inside modifyRspackConfig when getConfig() is available.
* Example return: '/static/js/index.js'
* Example return: '/assets/js/index.js'
*/
getDevClientEntryUrl: (publicBase: string) => string
/** Whether RSC virtual modules should be registered. */
Expand Down
Loading