Skip to content
Open
2 changes: 1 addition & 1 deletion benchmarks/bundle-size/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"vue": "^3.5.16"
},
"devDependencies": {
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/router-plugin": "workspace:^",
"@types/react": "^19.0.8",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-router/rspack-basic-file-based/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/router-e2e-utils": "workspace:^",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/router-e2e-utils": "workspace:^",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.2.2",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/css-inline/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.0",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/node": "^22.10.2",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/custom-server-rsbuild/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/router-e2e-utils": "workspace:^",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/hmr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.2.2",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/import-protection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/node": "^22.10.2",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/rsc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/eslint-plugin-start": "workspace:^",
"@tanstack/router-e2e-utils": "workspace:^",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/server-functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.2.2",
Expand Down
2 changes: 1 addition & 1 deletion e2e/solid-router/rspack-basic-file-based/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-babel": "^1.1.2",
"@rsbuild/plugin-solid": "^1.1.1",
"@tailwindcss/postcss": "^4.2.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-babel": "^1.1.2",
"@rsbuild/plugin-solid": "^1.1.1",
"@tailwindcss/postcss": "^4.2.2",
Expand Down
2 changes: 1 addition & 1 deletion e2e/solid-start/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-babel": "^1.1.2",
"@rsbuild/plugin-solid": "^1.1.1",
"@tailwindcss/postcss": "^4.2.2",
Expand Down
2 changes: 1 addition & 1 deletion e2e/vue-router/rspack-basic-file-based/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-babel": "^1.1.2",
"@rsbuild/plugin-vue": "^1.2.7",
"@rsbuild/plugin-vue-jsx": "^2.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-babel": "^1.1.2",
"@rsbuild/plugin-vue": "^1.2.7",
"@rsbuild/plugin-vue-jsx": "^2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion e2e/vue-start/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-babel": "^1.0.5",
"@rsbuild/plugin-vue": "^1.2.2",
"@rsbuild/plugin-vue-jsx": "^1.1.1",
Expand Down
2 changes: 1 addition & 1 deletion examples/react/quickstart-rspack-file-based/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/router-plugin": "^1.167.35",
"@types/react": "^19.0.8",
Expand Down
2 changes: 1 addition & 1 deletion examples/solid/quickstart-rspack-file-based/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@rsbuild/core": "^2.0.1",
"@rsbuild/core": "^2.0.6",
"@rsbuild/plugin-babel": "^1.1.2",
"@rsbuild/plugin-solid": "^1.1.1",
"@tanstack/router-plugin": "^1.167.35",
Expand Down
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,
}
Loading