From ad0fa804d05bcb39b36bfe4e719cbc6efc4659cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Mon, 23 Mar 2026 13:37:00 +0100 Subject: [PATCH 01/15] fix: rsc performance --- .github/workflows/benchmark.yml | 73 + packages/rsc/.gitignore | 4 + packages/rsc/__bench__/fixtures.mjs | 196 +++ packages/rsc/__bench__/report.mjs | 336 ++++ .../rsc/__bench__/rsc-deserialize.bench.mjs | 47 + .../rsc/__bench__/rsc-roundtrip.bench.mjs | 27 + .../rsc/__bench__/rsc-serialize.bench.mjs | 47 + packages/rsc/__bench__/setup.mjs | 12 + .../__bench__/webpack-deserialize.bench.mjs | 63 + .../rsc/__bench__/webpack-roundtrip.bench.mjs | 55 + .../rsc/__bench__/webpack-serialize.bench.mjs | 69 + .../flight-cross-compat-bound-args.test.mjs | 160 +- .../flight-cross-compat-prerender.test.mjs | 16 +- .../flight-cross-compat-temprefs.test.mjs | 156 +- .../__tests__/flight-cross-compat.test.mjs | 383 ++--- packages/rsc/client/shared.mjs | 1462 +++++++++++++---- packages/rsc/package.json | 5 +- packages/rsc/server/shared.mjs | 176 +- packages/rsc/vitest.bench.config.mjs | 41 + 19 files changed, 2590 insertions(+), 738 deletions(-) create mode 100644 packages/rsc/__bench__/fixtures.mjs create mode 100644 packages/rsc/__bench__/report.mjs create mode 100644 packages/rsc/__bench__/rsc-deserialize.bench.mjs create mode 100644 packages/rsc/__bench__/rsc-roundtrip.bench.mjs create mode 100644 packages/rsc/__bench__/rsc-serialize.bench.mjs create mode 100644 packages/rsc/__bench__/setup.mjs create mode 100644 packages/rsc/__bench__/webpack-deserialize.bench.mjs create mode 100644 packages/rsc/__bench__/webpack-roundtrip.bench.mjs create mode 100644 packages/rsc/__bench__/webpack-serialize.bench.mjs create mode 100644 packages/rsc/vitest.bench.config.mjs diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index ee6297ec..b7ad2b5c 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -187,3 +187,76 @@ jobs: echo "Creating new comment" gh pr comment "$PR_NUMBER" --body-file examples/benchmark/comment.md fi + + flight-benchmark: + name: Flight protocol benchmark + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/workflows/actions/common-setup + + - name: Run flight benchmarks + working-directory: packages/rsc + run: pnpm bench:json + env: + NODE_OPTIONS: --max-old-space-size=6144 + + - name: Process results & generate report + working-directory: packages/rsc + run: node __bench__/report.mjs --current bench-raw.json --output bench-results.json --markdown comment.md --commit ${{ github.sha }} + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: flight-bench-${{ github.sha }} + path: packages/rsc/bench-results.json + retention-days: 90 + + # ── Main branch: save baseline to cache ────────────────────────────── + - name: Save baseline to cache + if: github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: packages/rsc/bench-results.json + key: flight-bench-baseline-${{ github.sha }} + + # ── Pull request: compare against main baseline ────────────────────── + - name: Restore baseline from main + if: github.event_name == 'pull_request' + id: flight-baseline + uses: actions/cache/restore@v4 + with: + path: packages/rsc/flight-baseline.json + key: flight-bench-baseline-${{ github.event.pull_request.base.sha }} + restore-keys: flight-bench-baseline- + + - name: Generate comparison report + if: github.event_name == 'pull_request' && steps.flight-baseline.outputs.cache-matched-key != '' + working-directory: packages/rsc + run: node __bench__/report.mjs --current bench-results.json --baseline flight-baseline.json --markdown comment.md --commit ${{ github.sha }} + + - name: Post or update PR comment + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + REPO=${{ github.repository }} + + # Find existing flight benchmark comment by HTML marker + COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --paginate --jq '.[] | select(.body | contains("")) | .id' \ + | head -1) + + if [ -n "$COMMENT_ID" ]; then + echo "Updating existing comment ${COMMENT_ID}" + gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" \ + -X PATCH \ + -F "body=@packages/rsc/comment.md" + else + echo "Creating new comment" + gh pr comment "$PR_NUMBER" --body-file packages/rsc/comment.md + fi diff --git a/packages/rsc/.gitignore b/packages/rsc/.gitignore index 404abb22..5f474203 100644 --- a/packages/rsc/.gitignore +++ b/packages/rsc/.gitignore @@ -1 +1,5 @@ coverage/ +bench-raw.json +bench-results.json +flight-baseline.json +comment.md diff --git a/packages/rsc/__bench__/fixtures.mjs b/packages/rsc/__bench__/fixtures.mjs new file mode 100644 index 00000000..f4b7bd82 --- /dev/null +++ b/packages/rsc/__bench__/fixtures.mjs @@ -0,0 +1,196 @@ +/** + * Shared fixture factories for Flight protocol benchmarks. + * + * These produce React trees and data structures of varying shape and size. + * Factories are called once in beforeAll and the result reused across + * iterations so we benchmark serialization, not fixture construction. + */ + +import React from "react"; + +// ── React Tree Fixtures ───────────────────────────────────────── + +/** Single
with text child */ +export function minimalElement() { + return React.createElement("div", null, "hello"); +} + +/** 1000 sibling elements under one
*/ +export function shallowWide() { + const children = Array.from({ length: 1000 }, (_, i) => + React.createElement("span", { key: i }, `item ${i}`) + ); + return React.createElement("div", null, ...children); +} + +/** 100-level deep nesting: div > div > ... > span */ +export function deepNested(depth = 100) { + let el = React.createElement("span", null, "leaf"); + for (let i = 0; i < depth; i++) { + el = React.createElement("div", { key: i }, el); + } + return el; +} + +/** Realistic product list with structured children */ +export function productList(count = 50) { + const items = Array.from({ length: count }, (_, i) => + React.createElement( + "li", + { key: i, className: "product" }, + React.createElement("h3", null, `Product ${i}`), + React.createElement( + "p", + null, + `Description for product ${i} with some details about features and specifications.` + ), + React.createElement( + "span", + { className: "price" }, + `$${(i * 9.99).toFixed(2)}` + ), + React.createElement( + "span", + { className: "rating" }, + `${(3 + Math.random() * 2).toFixed(1)} stars` + ) + ) + ); + return React.createElement("ul", { className: "product-list" }, ...items); +} + +/** 500-row x 10-column table */ +export function largeTable(rows = 500, cols = 10) { + const headerCells = Array.from({ length: cols }, (_, c) => + React.createElement("th", { key: c }, `Col ${c}`) + ); + const header = React.createElement( + "thead", + null, + React.createElement("tr", null, ...headerCells) + ); + + const bodyRows = Array.from({ length: rows }, (_, r) => { + const cells = Array.from({ length: cols }, (_, c) => + React.createElement("td", { key: c }, `r${r}c${c}`) + ); + return React.createElement("tr", { key: r }, ...cells); + }); + const body = React.createElement("tbody", null, ...bodyRows); + + return React.createElement("table", null, header, body); +} + +// ── Data Type Fixtures ────────────────────────────────────────── + +/** Primitive values: string, number, boolean, null */ +export function primitives() { + return { + str: "hello world", + num: 42, + float: Math.PI, + bool: true, + nil: null, + negZero: -0, + inf: Infinity, + negInf: -Infinity, + nan: NaN, + }; +} + +/** 100KB string payload */ +export function largeString() { + return "x".repeat(100_000); +} + +/** 20-level deep nested object */ +export function nestedObjects() { + const build = (depth) => + depth === 0 + ? { leaf: true, value: "terminal" } + : { child: build(depth - 1), value: depth, label: `level-${depth}` }; + return build(20); +} + +/** 10,000-element array of objects */ +export function largeArray() { + return Array.from({ length: 10_000 }, (_, i) => ({ + id: i, + name: `item-${i}`, + active: i % 2 === 0, + })); +} + +/** Map with 100 entries, Set with 100 entries */ +export function mapAndSet() { + return { + map: new Map( + Array.from({ length: 100 }, (_, i) => [ + `key-${i}`, + { index: i, data: `val-${i}` }, + ]) + ), + set: new Set(Array.from({ length: 100 }, (_, i) => i * 7)), + }; +} + +/** Date, BigInt, Infinity, NaN, Symbol.for, RegExp */ +export function specialTypes() { + return { + date: new Date("2024-06-15T12:00:00Z"), + bigint: BigInt("12345678901234567890"), + sym: Symbol.for("bench.symbol"), + }; +} + +/** Typed arrays: Uint8Array (10KB), Int32Array (20KB), Float64Array (20KB) */ +export function typedArrays() { + const uint8 = new Uint8Array(10_000); + const int32 = new Int32Array(5_000); + const float64 = new Float64Array(2_500); + // Fill with non-zero data to avoid compression shortcuts + for (let i = 0; i < uint8.length; i++) uint8[i] = i & 0xff; + for (let i = 0; i < int32.length; i++) int32[i] = i * 17; + for (let i = 0; i < float64.length; i++) float64[i] = i * 0.123; + return { uint8, int32, float64 }; +} + +/** Mixed payload combining React elements, collections, and binary data */ +export function mixedPayload() { + return { + tree: productList(10), + data: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `item-${i}` })), + map: new Map([ + ["alpha", 1], + ["beta", 2], + ["gamma", 3], + ]), + date: new Date("2025-01-01T00:00:00Z"), + bigint: BigInt(999), + buffer: new Uint8Array(1000).fill(42), + }; +} + +// ── Scenario registry ─────────────────────────────────────────── + +/** + * All scenarios keyed by name. + * Each bench file iterates this to generate benchmarks programmatically. + */ +export const scenarios = { + // React trees + "react: minimal element": minimalElement, + "react: shallow wide (1000)": shallowWide, + "react: deep nested (100)": deepNested, + "react: product list (50)": productList, + "react: large table (500x10)": largeTable, + // Data types + "data: primitives": primitives, + "data: large string (100KB)": largeString, + "data: nested objects (20)": nestedObjects, + "data: large array (10K)": largeArray, + "data: Map & Set": mapAndSet, + "data: Date/BigInt/Symbol": specialTypes, + "data: typed arrays": typedArrays, + "data: mixed payload": mixedPayload, +}; diff --git a/packages/rsc/__bench__/report.mjs b/packages/rsc/__bench__/report.mjs new file mode 100644 index 00000000..42f57414 --- /dev/null +++ b/packages/rsc/__bench__/report.mjs @@ -0,0 +1,336 @@ +#!/usr/bin/env node + +/** + * Flight Protocol Benchmark Report Generator + * + * Reads vitest bench JSON output (--outputJson), normalizes it, optionally + * compares against a baseline, and produces a markdown report suitable for + * GitHub PR comments. + * + * Usage: + * node __bench__/report.mjs --current bench-raw.json [options] + * + * Options: + * --current Vitest bench JSON output (required) + * --baseline Previous results JSON for comparison + * --output Write normalized JSON results (default: bench-results.json) + * --markdown Write markdown report (default: comment.md) + * --commit Git commit SHA (auto-detected if omitted) + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { execSync } from "node:child_process"; + +// ── CLI args ──────────────────────────────────────────────────── + +const args = process.argv.slice(2); + +function getArg(name, defaultValue = null) { + const idx = args.indexOf(`--${name}`); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue; +} + +const currentFile = getArg("current"); +const baselineFile = getArg("baseline"); +const outputFile = getArg("output", "bench-results.json"); +const markdownFile = getArg("markdown", "comment.md"); +let commitSha = getArg("commit"); + +if (!currentFile) { + console.error("Usage: node report.mjs --current "); + process.exit(1); +} + +if (!commitSha) { + try { + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } catch { + commitSha = "unknown"; + } +} + +const shortSha = commitSha.slice(0, 7); + +// ── Parse vitest bench output ─────────────────────────────────── + +const raw = JSON.parse(readFileSync(currentFile, "utf8")); + +/** + * Vitest bench --outputJson format (v4.x): + * { + * files: [ + * { + * filepath: "...", + * groups: [ + * { + * fullName: "file > group name", + * benchmarks: [ + * { + * name: "bench name", + * hz: number, + * mean: number, + * p75: number, + * p99: number, + * min: number, + * max: number, + * rme: number, + * sampleCount: number, + * ... + * } + * ] + * } + * ] + * } + * ] + * } + */ + +function parseVitestBench(data) { + const results = {}; + + const files = data.files || []; + for (const file of files) { + for (const group of file.groups || []) { + // fullName is "file > group name" — extract just the group name + const fullName = group.fullName || ""; + const groupName = fullName.includes(" > ") + ? fullName.split(" > ").slice(1).join(" > ") + : fullName; + + if (!results[groupName]) results[groupName] = {}; + + for (const bench of group.benchmarks || []) { + results[groupName][bench.name] = { + hz: bench.hz, + mean: bench.mean, + p75: bench.p75, + p99: bench.p99, + min: bench.min, + max: bench.max, + sampleCount: bench.sampleCount, + rme: bench.rme, + }; + } + } + } + + return results; +} + +const results = parseVitestBench(raw); + +// ── Write normalized JSON ─────────────────────────────────────── + +const normalized = { + commit: commitSha, + shortCommit: shortSha, + date: new Date().toISOString(), + results, +}; + +writeFileSync(outputFile, JSON.stringify(normalized, null, 2) + "\n"); +console.log(`Normalized results written to ${outputFile}`); + +// ── Load baseline ─────────────────────────────────────────────── + +let baseline = null; +if (baselineFile) { + try { + baseline = JSON.parse(readFileSync(baselineFile, "utf8")); + console.log( + `Loaded baseline from ${baselineFile} (${baseline.shortCommit || "unknown"})` + ); + } catch (e) { + console.warn(`Warning: could not load baseline: ${e.message}`); + } +} + +// ── Group mapping ─────────────────────────────────────────────── + +/** + * Maps bench group names to library identifiers and operation types. + * Group names come from the describe() blocks in bench files. + */ +const GROUP_MAP = { + "@lazarv/rsc serialize": { lib: "lazarv", op: "serialize" }, + "@lazarv/rsc prerender": { lib: "lazarv", op: "prerender" }, + "@lazarv/rsc deserialize": { lib: "lazarv", op: "deserialize" }, + "@lazarv/rsc roundtrip": { lib: "lazarv", op: "roundtrip" }, + "webpack serialize": { lib: "webpack", op: "serialize" }, + "webpack deserialize": { lib: "webpack", op: "deserialize" }, + "webpack roundtrip": { lib: "webpack", op: "roundtrip" }, +}; + +/** + * Build a lookup: { [op]: { [scenarioName]: { lazarv: benchData, webpack: benchData } } } + */ +function buildComparison(data) { + const comparison = {}; + for (const [group, benches] of Object.entries(data)) { + const mapped = GROUP_MAP[group]; + if (!mapped) continue; + const { lib, op } = mapped; + if (!comparison[op]) comparison[op] = {}; + for (const [name, bench] of Object.entries(benches)) { + if (!comparison[op][name]) comparison[op][name] = {}; + comparison[op][name][lib] = bench; + } + } + return comparison; +} + +const current = buildComparison(results); +const base = baseline ? buildComparison(baseline.results) : null; + +// ── Markdown generation ───────────────────────────────────────── + +function fmtHz(hz) { + if (hz >= 1_000_000) return `${(hz / 1_000_000).toFixed(1)}M`; + if (hz >= 1_000) return `${(hz / 1_000).toFixed(1)}K`; + return hz.toFixed(0); +} + +function fmtTime(ms) { + if (ms < 0.001) return `${(ms * 1_000_000).toFixed(0)} ns`; + if (ms < 1) return `${(ms * 1_000).toFixed(1)} \u00b5s`; + return `${ms.toFixed(2)} ms`; +} + +function fmtDelta(current, base, lowerIsBetter = false) { + if (base == null || base === 0) return ""; + const pct = ((current - base) / base) * 100; + const sign = pct > 0 ? "+" : ""; + const good = lowerIsBetter ? pct < -1 : pct > 1; + const bad = lowerIsBetter ? pct > 1 : pct < -1; + const icon = good ? "\u{1f7e2}" : bad ? "\u{1f534}" : "\u{26aa}"; + return `${icon} ${sign}${pct.toFixed(1)}%`; +} + +function fmtLibDelta(lazarvHz, webpackHz) { + if (!webpackHz || webpackHz === 0) return ""; + const pct = ((lazarvHz - webpackHz) / webpackHz) * 100; + const sign = pct > 0 ? "+" : ""; + const icon = pct > 1 ? "\u{1f7e2}" : pct < -1 ? "\u{1f534}" : "\u{26aa}"; + return `${icon} ${sign}${pct.toFixed(1)}%`; +} + +const lines = []; +lines.push(""); +lines.push("## \u26a1 Flight Protocol Benchmark"); +lines.push(""); + +if (baseline) { + lines.push( + `Comparing \`${shortSha}\` against baseline \`${baseline.shortCommit || "?"}\`` + ); +} else { + lines.push(`Commit: \`${shortSha}\``); +} +lines.push(""); + +// Operation order for the report +const OP_ORDER = ["serialize", "prerender", "deserialize", "roundtrip"]; +const OP_LABELS = { + serialize: "Serialization (`renderToReadableStream`)", + prerender: "Prerender (`prerender`)", + deserialize: "Deserialization (`createFromReadableStream`)", + roundtrip: "Roundtrip (serialize + deserialize)", +}; + +for (const op of OP_ORDER) { + const scenarios = current[op]; + if (!scenarios) continue; + + lines.push(`### ${OP_LABELS[op]}`); + lines.push(""); + + if (baseline) { + if (op === "prerender") { + lines.push("| Scenario | @lazarv/rsc ops/s | mean | vs baseline |"); + lines.push("|:---------|------------------:|-----:|------------:|"); + } else { + lines.push( + "| Scenario | @lazarv/rsc | webpack | vs webpack | vs baseline |" + ); + lines.push( + "|:---------|------------:|--------:|-----------:|------------:|" + ); + } + } else { + if (op === "prerender") { + lines.push("| Scenario | @lazarv/rsc ops/s | mean |"); + lines.push("|:---------|------------------:|-----:|"); + } else { + lines.push("| Scenario | @lazarv/rsc | webpack | vs webpack |"); + lines.push("|:---------|------------:|--------:|-----------:|"); + } + } + + for (const [name, libs] of Object.entries(scenarios)) { + const lazarv = libs.lazarv; + const webpack = libs.webpack; + + if (op === "prerender") { + if (lazarv) { + if (baseline) { + const baseRsc = base?.[op]?.[name]?.lazarv; + const baselineDelta = baseRsc ? fmtDelta(lazarv.hz, baseRsc.hz) : "-"; + lines.push( + `| **${name}** | ${fmtHz(lazarv.hz)} | ${fmtTime(lazarv.mean)} | ${baselineDelta} |` + ); + } else { + lines.push( + `| **${name}** | ${fmtHz(lazarv.hz)} | ${fmtTime(lazarv.mean)} |` + ); + } + } + continue; + } + + const lazarvHz = lazarv ? fmtHz(lazarv.hz) : "-"; + const webpackHz = webpack ? fmtHz(webpack.hz) : "-"; + const delta = lazarv && webpack ? fmtLibDelta(lazarv.hz, webpack.hz) : "-"; + + if (baseline) { + const baseRsc = base?.[op]?.[name]?.lazarv; + const baselineDelta = baseRsc + ? fmtDelta(lazarv?.hz || 0, baseRsc.hz) + : "-"; + lines.push( + `| **${name}** | ${lazarvHz} | ${webpackHz} | ${delta} | ${baselineDelta} |` + ); + } else { + lines.push(`| **${name}** | ${lazarvHz} | ${webpackHz} | ${delta} |`); + } + } + + lines.push(""); +} + +// ── Legend ─────────────────────────────────────────────────────── + +lines.push("
Legend & methodology"); +lines.push(""); +lines.push( + "**Indicators:** \u{1f7e2} > 1% faster | \u{1f534} > 1% slower | \u{26aa} within noise margin" +); +lines.push(""); +lines.push( + "**vs webpack**: compares @lazarv/rsc against react-server-dom-webpack within the same run." +); +lines.push( + "**vs baseline**: compares @lazarv/rsc against the previous main branch run." +); +lines.push(""); +lines.push( + "Values shown are operations/second (higher is better). Each scenario runs for at least 100 iterations with warmup." +); +lines.push(""); +lines.push( + "Benchmarks run on GitHub Actions runners (shared infrastructure) \u2014 expect ~5% variance between runs. Consistent directional changes across multiple scenarios are more meaningful than any single number." +); +lines.push(""); +lines.push("
"); + +writeFileSync(markdownFile, lines.join("\n") + "\n"); +console.log(`Markdown report written to ${markdownFile}`); diff --git a/packages/rsc/__bench__/rsc-deserialize.bench.mjs b/packages/rsc/__bench__/rsc-deserialize.bench.mjs new file mode 100644 index 00000000..d8947ef6 --- /dev/null +++ b/packages/rsc/__bench__/rsc-deserialize.bench.mjs @@ -0,0 +1,47 @@ +/** + * @lazarv/rsc — deserialization benchmarks + * + * Pre-serializes each fixture, then measures createFromReadableStream throughput. + */ + +import { describe, bench, beforeAll } from "vitest"; +import * as RscServer from "../server/shared.mjs"; +import * as RscClient from "../client/shared.mjs"; +import { scenarios } from "./fixtures.mjs"; + +// Pre-serialized payloads: Map +const serialized = {}; + +beforeAll(async () => { + for (const [name, factory] of Object.entries(scenarios)) { + const stream = RscServer.renderToReadableStream(factory()); + const reader = stream.getReader(); + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(new Uint8Array(value)); + } + serialized[name] = chunks; + } +}); + +function makeStream(chunks) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(new Uint8Array(chunk)); + } + controller.close(); + }, + }); +} + +describe("@lazarv/rsc deserialize", () => { + for (const name of Object.keys(scenarios)) { + bench(name, async () => { + const stream = makeStream(serialized[name]); + await RscClient.createFromReadableStream(stream); + }); + } +}); diff --git a/packages/rsc/__bench__/rsc-roundtrip.bench.mjs b/packages/rsc/__bench__/rsc-roundtrip.bench.mjs new file mode 100644 index 00000000..9b6c8482 --- /dev/null +++ b/packages/rsc/__bench__/rsc-roundtrip.bench.mjs @@ -0,0 +1,27 @@ +/** + * @lazarv/rsc — roundtrip benchmarks + * + * Measures full serialize → deserialize cycle for each fixture. + */ + +import { describe, bench, beforeAll } from "vitest"; +import * as RscServer from "../server/shared.mjs"; +import * as RscClient from "../client/shared.mjs"; +import { scenarios } from "./fixtures.mjs"; + +const fixtures = {}; + +beforeAll(() => { + for (const [name, factory] of Object.entries(scenarios)) { + fixtures[name] = factory(); + } +}); + +describe("@lazarv/rsc roundtrip", () => { + for (const name of Object.keys(scenarios)) { + bench(name, async () => { + const stream = RscServer.renderToReadableStream(fixtures[name]); + await RscClient.createFromReadableStream(stream); + }); + } +}); diff --git a/packages/rsc/__bench__/rsc-serialize.bench.mjs b/packages/rsc/__bench__/rsc-serialize.bench.mjs new file mode 100644 index 00000000..0c998d6a --- /dev/null +++ b/packages/rsc/__bench__/rsc-serialize.bench.mjs @@ -0,0 +1,47 @@ +/** + * @lazarv/rsc — serialization benchmarks + * + * Measures renderToReadableStream throughput for various React trees and data types. + */ + +import { describe, bench, beforeAll } from "vitest"; +import * as RscServer from "../server/shared.mjs"; +import { scenarios } from "./fixtures.mjs"; + +// Pre-build fixtures once so we benchmark serialization, not construction. +const fixtures = {}; + +beforeAll(() => { + for (const [name, factory] of Object.entries(scenarios)) { + fixtures[name] = factory(); + } +}); + +async function consumeStream(stream) { + const reader = stream.getReader(); + let bytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + } + return bytes; +} + +describe("@lazarv/rsc serialize", () => { + for (const name of Object.keys(scenarios)) { + bench(name, async () => { + const stream = RscServer.renderToReadableStream(fixtures[name]); + await consumeStream(stream); + }); + } +}); + +describe("@lazarv/rsc prerender", () => { + for (const name of Object.keys(scenarios)) { + bench(name, async () => { + const { prelude } = await RscServer.prerender(fixtures[name]); + await consumeStream(prelude); + }); + } +}); diff --git a/packages/rsc/__bench__/setup.mjs b/packages/rsc/__bench__/setup.mjs new file mode 100644 index 00000000..c81a6c20 --- /dev/null +++ b/packages/rsc/__bench__/setup.mjs @@ -0,0 +1,12 @@ +/** + * Vitest bench setup file + * + * Mock webpack globals required by react-server-dom-webpack. + * Mirrors __tests__/setup.mjs for benchmark isolation. + */ + +globalThis.__webpack_require__ = (_id) => { + return {}; +}; + +globalThis.__webpack_chunk_load__ = () => Promise.resolve(); diff --git a/packages/rsc/__bench__/webpack-deserialize.bench.mjs b/packages/rsc/__bench__/webpack-deserialize.bench.mjs new file mode 100644 index 00000000..f903ac37 --- /dev/null +++ b/packages/rsc/__bench__/webpack-deserialize.bench.mjs @@ -0,0 +1,63 @@ +/** + * react-server-dom-webpack — deserialization benchmarks + * + * Pre-serializes with webpack, then measures createFromReadableStream throughput. + * + * NOTE: webpack's serializer transfers/detaches ArrayBuffers, so typed array + * scenarios create fresh fixtures for each pre-serialization. + */ + +import { describe, bench, beforeAll } from "vitest"; +import { scenarios } from "./fixtures.mjs"; + +let ReactDomServer; +let ReactDomClient; +let skip = false; + +try { + ReactDomServer = await import("react-server-dom-webpack/server"); + ReactDomClient = await import("react-server-dom-webpack/client.browser"); +} catch { + skip = true; +} + +// Pre-serialized payloads: Map +const serialized = {}; + +beforeAll(async () => { + if (skip) return; + for (const [name, factory] of Object.entries(scenarios)) { + // Each call to factory() produces a fresh fixture (important for typed arrays) + const stream = ReactDomServer.renderToReadableStream(factory()); + const reader = stream.getReader(); + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(new Uint8Array(value)); + } + serialized[name] = chunks; + } +}); + +function makeStream(chunks) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(new Uint8Array(chunk)); + } + controller.close(); + }, + }); +} + +const describeIf = skip ? describe.skip : describe; + +describeIf("webpack deserialize", () => { + for (const name of Object.keys(scenarios)) { + bench(name, async () => { + const stream = makeStream(serialized[name]); + await ReactDomClient.createFromReadableStream(stream); + }); + } +}); diff --git a/packages/rsc/__bench__/webpack-roundtrip.bench.mjs b/packages/rsc/__bench__/webpack-roundtrip.bench.mjs new file mode 100644 index 00000000..76574ff7 --- /dev/null +++ b/packages/rsc/__bench__/webpack-roundtrip.bench.mjs @@ -0,0 +1,55 @@ +/** + * react-server-dom-webpack — roundtrip benchmarks + * + * Measures full serialize + deserialize cycle for each fixture. + * + * NOTE: webpack's serializer transfers/detaches ArrayBuffers, so typed array + * scenarios must create fresh fixtures each iteration. + */ + +import { describe, bench, beforeAll } from "vitest"; +import { scenarios } from "./fixtures.mjs"; + +let ReactDomServer; +let ReactDomClient; +let skip = false; + +try { + ReactDomServer = await import("react-server-dom-webpack/server"); + ReactDomClient = await import("react-server-dom-webpack/client.browser"); +} catch { + skip = true; +} + +const TYPED_ARRAY_SCENARIOS = new Set([ + "data: typed arrays", + "data: mixed payload", +]); + +const fixtures = {}; + +beforeAll(() => { + for (const [name, factory] of Object.entries(scenarios)) { + if (!TYPED_ARRAY_SCENARIOS.has(name)) { + fixtures[name] = factory(); + } + } +}); + +const describeIf = skip ? describe.skip : describe; + +describeIf("webpack roundtrip", () => { + for (const [name, factory] of Object.entries(scenarios)) { + if (TYPED_ARRAY_SCENARIOS.has(name)) { + bench(name, async () => { + const stream = ReactDomServer.renderToReadableStream(factory()); + await ReactDomClient.createFromReadableStream(stream); + }); + } else { + bench(name, async () => { + const stream = ReactDomServer.renderToReadableStream(fixtures[name]); + await ReactDomClient.createFromReadableStream(stream); + }); + } + } +}); diff --git a/packages/rsc/__bench__/webpack-serialize.bench.mjs b/packages/rsc/__bench__/webpack-serialize.bench.mjs new file mode 100644 index 00000000..ec6c6920 --- /dev/null +++ b/packages/rsc/__bench__/webpack-serialize.bench.mjs @@ -0,0 +1,69 @@ +/** + * react-server-dom-webpack — serialization benchmarks + * + * Measures renderToReadableStream throughput for the same fixtures as the + * @lazarv/rsc benchmarks, enabling direct comparison. + * + * NOTE: webpack's serializer transfers/detaches ArrayBuffers, so typed array + * scenarios must create fresh fixtures each iteration. + */ + +import { describe, bench, beforeAll } from "vitest"; +import { scenarios } from "./fixtures.mjs"; + +let ReactDomServer; +let skip = false; + +try { + ReactDomServer = await import("react-server-dom-webpack/server"); +} catch { + skip = true; +} + +// Scenarios that contain TypedArrays — these need fresh fixtures per iteration +// because webpack's serializer detaches the underlying ArrayBuffer. +const TYPED_ARRAY_SCENARIOS = new Set([ + "data: typed arrays", + "data: mixed payload", +]); + +// Pre-build fixtures once (for non-typed-array scenarios). +const fixtures = {}; + +beforeAll(() => { + for (const [name, factory] of Object.entries(scenarios)) { + if (!TYPED_ARRAY_SCENARIOS.has(name)) { + fixtures[name] = factory(); + } + } +}); + +async function consumeStream(stream) { + const reader = stream.getReader(); + let bytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + } + return bytes; +} + +const describeIf = skip ? describe.skip : describe; + +describeIf("webpack serialize", () => { + for (const [name, factory] of Object.entries(scenarios)) { + if (TYPED_ARRAY_SCENARIOS.has(name)) { + // Fresh fixture each iteration to avoid detached ArrayBuffer errors + bench(name, async () => { + const stream = ReactDomServer.renderToReadableStream(factory()); + await consumeStream(stream); + }); + } else { + bench(name, async () => { + const stream = ReactDomServer.renderToReadableStream(fixtures[name]); + await consumeStream(stream); + }); + } + } +}); diff --git a/packages/rsc/__tests__/flight-cross-compat-bound-args.test.mjs b/packages/rsc/__tests__/flight-cross-compat-bound-args.test.mjs index 871ad46b..bfec0391 100644 --- a/packages/rsc/__tests__/flight-cross-compat-bound-args.test.mjs +++ b/packages/rsc/__tests__/flight-cross-compat-bound-args.test.mjs @@ -23,8 +23,8 @@ import { describe, expect, test } from "vitest"; // @lazarv/rsc imports -import * as LazarvServer from "../server/shared.mjs"; -import * as LazarvClient from "../client/shared.mjs"; +import * as RscServer from "../server/shared.mjs"; +import * as RscClient from "../client/shared.mjs"; // Try to import react-server-dom-webpack let ReactDomServer; @@ -51,14 +51,14 @@ const describeIf = skipTests ? describe.skip : describe; const REACT_SERVER_REFERENCE = Symbol.for("react.server.reference"); // Helper: create a lazarv-style server ref with optional bound args (for encodeReply tests) -function makeLazarvServerRef(id, boundArgs) { +function makeRscServerRef(id, boundArgs) { const fn = async (...args) => ({ id, args }); fn.$$typeof = REACT_SERVER_REFERENCE; fn.$$id = id; fn.$$bound = boundArgs || null; fn.bind = function (_, ...newArgs) { const newBound = (boundArgs || []).concat(newArgs); - return makeLazarvServerRef(id, newBound); + return makeRscServerRef(id, newBound); }; return fn; } @@ -74,7 +74,7 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { "actions.js", "doStuff" ); - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( () => {}, "actions.js", "doStuff" @@ -105,7 +105,7 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { expect(typeof reactFn.bind).toBe("function"); // lazarv client uses createServerReference which sets $$typeof directly - const lazarvRef = LazarvClient.createServerReference( + const lazarvRef = RscClient.createServerReference( "actions.js#run", () => {} ); @@ -125,7 +125,7 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { "act.js", "fn" ); - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( () => {}, "act.js", "fn" @@ -154,7 +154,7 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { "chain.js", "fn" ); - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( () => {}, "chain.js", "fn" @@ -181,7 +181,7 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { "keep.js", "fn" ); - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( () => {}, "keep.js", "fn" @@ -228,17 +228,17 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { }); test("lazarv: .bind() with bound args → render → client prepends args", async () => { - const fn = LazarvServer.registerServerReference( + const fn = RscServer.registerServerReference( async () => {}, "lz-act.js", "run" ); const boundFn = fn.bind(null, "pre1", 42); - const stream = LazarvServer.renderToReadableStream({ action: boundFn }); + const stream = RscServer.renderToReadableStream({ action: boundFn }); let capturedId, capturedArgs; - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { callServer(id, args) { capturedId = id; capturedArgs = args; @@ -279,19 +279,19 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await reactResult.action("tail"); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "cmp.js", "fn" ); const lazarvBound = lazarvFn.bind(null, "hello", 99, true); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvBound, }); let lazarvCallArgs; - const lazarvResult = await LazarvClient.createFromReadableStream( + const lazarvResult = await RscClient.createFromReadableStream( lazarvStream, { callServer(id, args) { @@ -332,18 +332,18 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await reactResult.action("arg1"); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "plain.js", "fn" ); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvFn, }); let lazarvCallArgs; - const lazarvResult = await LazarvClient.createFromReadableStream( + const lazarvResult = await RscClient.createFromReadableStream( lazarvStream, { callServer(id, args) { @@ -385,18 +385,18 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await reactBound("arg1"); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "bind-test.js", "fn" ); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvFn, }); let lazarvCallArgs; - const lazarvResult = await LazarvClient.createFromReadableStream( + const lazarvResult = await RscClient.createFromReadableStream( lazarvStream, { callServer(id, args) { @@ -440,19 +440,19 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await reactClientBound("call-arg"); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "dbl.js", "fn" ); const lazarvServerBound = lazarvFn.bind(null, "server-bound"); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvServerBound, }); let lazarvCallArgs; - const lazarvResult = await LazarvClient.createFromReadableStream( + const lazarvResult = await RscClient.createFromReadableStream( lazarvStream, { callServer(id, args) { @@ -513,8 +513,8 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { expect(reactMainPart).toContain("$h"); // lazarv: use helper that sets $$typeof - const lazarvRef = makeLazarvServerRef("enc.js#fn", ["arg1"]); - const lazarvEncoded = await LazarvClient.encodeReply(lazarvRef); + const lazarvRef = makeRscServerRef("enc.js#fn", ["arg1"]); + const lazarvEncoded = await RscClient.encodeReply(lazarvRef); // lazarv should also produce FormData with $h reference (matching React) expect(lazarvEncoded).toBeInstanceOf(FormData); @@ -535,8 +535,8 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { expect(reactEncoded).toBeInstanceOf(FormData); // lazarv - const lazarvRef = makeLazarvServerRef("simple.js#fn"); - const lazarvEncoded = await LazarvClient.encodeReply(lazarvRef); + const lazarvRef = makeRscServerRef("simple.js#fn"); + const lazarvEncoded = await RscClient.encodeReply(lazarvRef); // lazarv also produces FormData with $h for unbound refs (matching React) expect(lazarvEncoded).toBeInstanceOf(FormData); @@ -556,8 +556,8 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { expect(reactEncoded).toBeInstanceOf(FormData); // lazarv also encodes Date via FormData parts - const lazarvRef = makeLazarvServerRef("date.js#fn", [date]); - const lazarvEncoded = await LazarvClient.encodeReply(lazarvRef); + const lazarvRef = makeRscServerRef("date.js#fn", [date]); + const lazarvEncoded = await RscClient.encodeReply(lazarvRef); expect(lazarvEncoded).toBeInstanceOf(FormData); // Verify Date appears somewhere in the FormData parts @@ -592,8 +592,8 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { expect(foundBigInt).toBe(true); // lazarv also uses $n for BigInt in FormData parts - const lazarvRef = makeLazarvServerRef("big.js#fn", [big]); - const lazarvEncoded = await LazarvClient.encodeReply(lazarvRef); + const lazarvRef = makeRscServerRef("big.js#fn", [big]); + const lazarvEncoded = await RscClient.encodeReply(lazarvRef); expect(lazarvEncoded).toBeInstanceOf(FormData); let lazarvFoundBigInt = false; @@ -640,12 +640,12 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { }); test("lazarv: encodeReply bound ref → decodeReply restores bound args", async () => { - const ref = makeLazarvServerRef("rt.js#fn", ["a", 42]); + const ref = makeRscServerRef("rt.js#fn", ["a", 42]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction(id) { return (...args) => { @@ -689,11 +689,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { }); test("lazarv: encodeReply unbound ref → decodeReply produces function", async () => { - const ref = makeLazarvServerRef("ub.js#fn"); + const ref = makeRscServerRef("ub.js#fn"); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction(id) { expect(id).toBe("ub.js#fn"); @@ -736,18 +736,18 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await rr.action("end"); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "dt.js", "fn" ); const lazarvBound = lazarvFn.bind(null, date); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvBound, }); let lazarvCallArgs; - const lr = await LazarvClient.createFromReadableStream(lazarvStream, { + const lr = await RscClient.createFromReadableStream(lazarvStream, { callServer(id, args) { lazarvCallArgs = args; return Promise.resolve("ok"); @@ -789,18 +789,18 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await rr.action(); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "bi.js", "fn" ); const lazarvBound = lazarvFn.bind(null, bigVal); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvBound, }); let lazarvCallArgs; - const lr = await LazarvClient.createFromReadableStream(lazarvStream, { + const lr = await RscClient.createFromReadableStream(lazarvStream, { callServer(id, args) { lazarvCallArgs = args; return Promise.resolve("ok"); @@ -840,18 +840,18 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await rr.action(); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "map.js", "fn" ); const lazarvBound = lazarvFn.bind(null, map); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvBound, }); let lazarvCallArgs; - const lr = await LazarvClient.createFromReadableStream(lazarvStream, { + const lr = await RscClient.createFromReadableStream(lazarvStream, { callServer(id, args) { lazarvCallArgs = args; return Promise.resolve("ok"); @@ -892,18 +892,18 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await rr.action(); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "set.js", "fn" ); const lazarvBound = lazarvFn.bind(null, set); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvBound, }); let lazarvCallArgs; - const lr = await LazarvClient.createFromReadableStream(lazarvStream, { + const lr = await RscClient.createFromReadableStream(lazarvStream, { callServer(id, args) { lazarvCallArgs = args; return Promise.resolve("ok"); @@ -943,7 +943,7 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { await rr.action("tail"); // lazarv - const lazarvFn = LazarvServer.registerServerReference( + const lazarvFn = RscServer.registerServerReference( async () => {}, "mix.js", "fn" @@ -958,11 +958,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { 100n ); - const lazarvStream = LazarvServer.renderToReadableStream({ + const lazarvStream = RscServer.renderToReadableStream({ action: lazarvBound, }); let lazarvCallArgs; - const lr = await LazarvClient.createFromReadableStream(lazarvStream, { + const lr = await RscClient.createFromReadableStream(lazarvStream, { callServer(id, args) { lazarvCallArgs = args; return Promise.resolve("ok"); @@ -999,11 +999,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { describe("encodeReply → decodeReply exotic bound arg types", () => { test("lazarv: Date in bound arg survives encodeReply → decodeReply", async () => { const date = new Date("2025-06-15T12:00:00Z"); - const ref = makeLazarvServerRef("exotic.js#fn", [date]); + const ref = makeRscServerRef("exotic.js#fn", [date]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1020,11 +1020,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { }); test("lazarv: BigInt in bound arg survives encodeReply → decodeReply", async () => { - const ref = makeLazarvServerRef("exotic.js#fn", [999999999999999999n]); + const ref = makeRscServerRef("exotic.js#fn", [999999999999999999n]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1043,11 +1043,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { ["x", 1], ["y", 2], ]); - const ref = makeLazarvServerRef("exotic.js#fn", [map]); + const ref = makeRscServerRef("exotic.js#fn", [map]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1065,11 +1065,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { test("lazarv: Set in bound arg survives encodeReply → decodeReply", async () => { const set = new Set(["a", "b", "c"]); - const ref = makeLazarvServerRef("exotic.js#fn", [set]); + const ref = makeRscServerRef("exotic.js#fn", [set]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1089,11 +1089,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { test("lazarv: ArrayBuffer in bound arg survives encodeReply → decodeReply", async () => { const buf = new ArrayBuffer(4); new Uint8Array(buf).set([0xde, 0xad, 0xbe, 0xef]); - const ref = makeLazarvServerRef("exotic.js#fn", [buf]); + const ref = makeRscServerRef("exotic.js#fn", [buf]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1112,11 +1112,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { test("lazarv: Uint8Array in bound arg survives encodeReply → decodeReply", async () => { const arr = new Uint8Array([10, 20, 30]); - const ref = makeLazarvServerRef("exotic.js#fn", [arr]); + const ref = makeRscServerRef("exotic.js#fn", [arr]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1133,11 +1133,11 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { test("lazarv: RegExp in bound arg survives encodeReply → decodeReply", async () => { const regex = /test\d+/gi; - const ref = makeLazarvServerRef("exotic.js#fn", [regex]); + const ref = makeRscServerRef("exotic.js#fn", [regex]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1157,7 +1157,7 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { const date = new Date("2025-01-01"); const buf = new Uint8Array([1, 2]); const regex = /hello/; - const ref = makeLazarvServerRef("exotic.js#fn", [ + const ref = makeRscServerRef("exotic.js#fn", [ date, buf, regex, @@ -1166,9 +1166,9 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { new Set([1]), ]); - const encoded = await LazarvClient.encodeReply(ref); + const encoded = await RscClient.encodeReply(ref); let invokedWith; - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { moduleLoader: { loadServerAction() { return (...args) => { @@ -1197,19 +1197,19 @@ describeIf("Bound Server Action Args Cross-Compatibility", () => { // ───────────────────────────────────────────────────────────────────── describe("full pipeline: render → decode → callServer", () => { test("lazarv: full pipeline preserves server-bound + call args", async () => { - const original = LazarvServer.registerServerReference( + const original = RscServer.registerServerReference( async () => {}, "pipeline.js", "run" ); const serverBound = original.bind(null, "user-42", "delete"); - const stream = LazarvServer.renderToReadableStream({ + const stream = RscServer.renderToReadableStream({ handler: serverBound, }); let capturedId, capturedArgs; - const clientResult = await LazarvClient.createFromReadableStream(stream, { + const clientResult = await RscClient.createFromReadableStream(stream, { callServer(id, args) { capturedId = id; capturedArgs = args; diff --git a/packages/rsc/__tests__/flight-cross-compat-prerender.test.mjs b/packages/rsc/__tests__/flight-cross-compat-prerender.test.mjs index 3c5b5082..e2dbfea0 100644 --- a/packages/rsc/__tests__/flight-cross-compat-prerender.test.mjs +++ b/packages/rsc/__tests__/flight-cross-compat-prerender.test.mjs @@ -17,7 +17,7 @@ import React from "react"; import { beforeAll, describe, expect, test } from "vitest"; // @lazarv/rsc imports -import * as LazarvClient from "../client/shared.mjs"; +import * as RscClient from "../client/shared.mjs"; // Try to import react-server-dom-webpack static - it may fail without --conditions=react-server let ReactStaticEdge; @@ -83,8 +83,7 @@ describe("React Prerender to lazarv Client Cross-Compatibility", () => { }) ); - const result = - await LazarvClient.createFromReadableStream(forConsumption); + const result = await RscClient.createFromReadableStream(forConsumption); expect(result.type).toBe("div"); expect(result.props.className).toBe("react-prerendered"); @@ -107,7 +106,7 @@ describe("React Prerender to lazarv Client Cross-Compatibility", () => { await ReactStaticEdge.prerender(element); const reactData = await streamToString(reactPrelude); - const { forConsumption: forLazarv } = teeStream( + const { forConsumption: forRsc } = teeStream( new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(reactData)); @@ -116,8 +115,7 @@ describe("React Prerender to lazarv Client Cross-Compatibility", () => { }) ); - const lazarvResult = - await LazarvClient.createFromReadableStream(forLazarv); + const lazarvResult = await RscClient.createFromReadableStream(forRsc); expect(lazarvResult.type).toBe("section"); expect(lazarvResult.props.children).toHaveLength(2); @@ -152,8 +150,7 @@ describe("React Prerender to lazarv Client Cross-Compatibility", () => { }) ); - const result = - await LazarvClient.createFromReadableStream(forConsumption); + const result = await RscClient.createFromReadableStream(forConsumption); expect(result.type).toBe("div"); expect(result.props.className).toBe("complex"); @@ -188,8 +185,7 @@ describe("React Prerender to lazarv Client Cross-Compatibility", () => { }) ); - const result = - await LazarvClient.createFromReadableStream(forConsumption); + const result = await RscClient.createFromReadableStream(forConsumption); expect(result.date).toBeInstanceOf(Date); expect(result.date.toISOString()).toBe("2024-01-01T00:00:00.000Z"); diff --git a/packages/rsc/__tests__/flight-cross-compat-temprefs.test.mjs b/packages/rsc/__tests__/flight-cross-compat-temprefs.test.mjs index 28a92d97..9ca7105b 100644 --- a/packages/rsc/__tests__/flight-cross-compat-temprefs.test.mjs +++ b/packages/rsc/__tests__/flight-cross-compat-temprefs.test.mjs @@ -15,8 +15,8 @@ import { describe, expect, test } from "vitest"; // @lazarv/rsc imports -import * as LazarvServer from "../server/shared.mjs"; -import * as LazarvClient from "../client/shared.mjs"; +import * as RscServer from "../server/shared.mjs"; +import * as RscClient from "../client/shared.mjs"; // Try to import react-server-dom-webpack let ReactDomServer; @@ -59,7 +59,7 @@ describeIf("Temporary References Cross-Compatibility", () => { describe("createTemporaryReferenceSet type parity", () => { test("server sets should both be WeakMaps", () => { const reactSet = ReactDomServer.createTemporaryReferenceSet(); - const lazarvSet = LazarvServer.createTemporaryReferenceSet(); + const lazarvSet = RscServer.createTemporaryReferenceSet(); // React uses WeakMap on the server (proxy → id) expect(reactSet).toBeInstanceOf(WeakMap); expect(lazarvSet).toBeInstanceOf(WeakMap); @@ -67,7 +67,7 @@ describeIf("Temporary References Cross-Compatibility", () => { test("client sets should both be Maps", () => { const reactSet = ReactDomClient.createTemporaryReferenceSet(); - const lazarvSet = LazarvClient.createTemporaryReferenceSet(); + const lazarvSet = RscClient.createTemporaryReferenceSet(); // Both clients use Map (path → value) expect(reactSet).toBeInstanceOf(Map); expect(lazarvSet).toBeInstanceOf(Map); @@ -82,13 +82,13 @@ describeIf("Temporary References Cross-Compatibility", () => { const fn = () => {}; const reactTempRefs = ReactDomClient.createTemporaryReferenceSet(); - const lazarvTempRefs = LazarvClient.createTemporaryReferenceSet(); + const lazarvTempRefs = RscClient.createTemporaryReferenceSet(); const reactEncoded = await ReactDomClient.encodeReply( { name: "test", handler: fn }, { temporaryReferences: reactTempRefs } ); - const lazarvEncoded = await LazarvClient.encodeReply( + const lazarvEncoded = await RscClient.encodeReply( { name: "test", handler: fn }, { temporaryReferences: lazarvTempRefs } ); @@ -113,13 +113,13 @@ describeIf("Temporary References Cross-Compatibility", () => { const sym = Symbol("local-only"); const reactTempRefs = ReactDomClient.createTemporaryReferenceSet(); - const lazarvTempRefs = LazarvClient.createTemporaryReferenceSet(); + const lazarvTempRefs = RscClient.createTemporaryReferenceSet(); const reactEncoded = await ReactDomClient.encodeReply( { value: 42, tag: sym }, { temporaryReferences: reactTempRefs } ); - const lazarvEncoded = await LazarvClient.encodeReply( + const lazarvEncoded = await RscClient.encodeReply( { value: 42, tag: sym }, { temporaryReferences: lazarvTempRefs } ); @@ -137,13 +137,13 @@ describeIf("Temporary References Cross-Compatibility", () => { const fn = () => {}; const reactTempRefs = ReactDomClient.createTemporaryReferenceSet(); - const lazarvTempRefs = LazarvClient.createTemporaryReferenceSet(); + const lazarvTempRefs = RscClient.createTemporaryReferenceSet(); await ReactDomClient.encodeReply( { handler: fn }, { temporaryReferences: reactTempRefs } ); - await LazarvClient.encodeReply( + await RscClient.encodeReply( { handler: fn }, { temporaryReferences: lazarvTempRefs } ); @@ -168,14 +168,14 @@ describeIf("Temporary References Cross-Compatibility", () => { const fn = () => "hello"; const clientTempRefs = ReactDomClient.createTemporaryReferenceSet(); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); + const serverTempRefs = RscServer.createTemporaryReferenceSet(); const encoded = await ReactDomClient.encodeReply( { name: "test", handler: fn }, { temporaryReferences: clientTempRefs } ); - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); @@ -193,10 +193,10 @@ describeIf("Temporary References Cross-Compatibility", () => { test("lazarv encodeReply → React decodeReply: function becomes opaque proxy", async () => { const fn = () => "hello"; - const clientTempRefs = LazarvClient.createTemporaryReferenceSet(); + const clientTempRefs = RscClient.createTemporaryReferenceSet(); const serverTempRefs = ReactDomServer.createTemporaryReferenceSet(); - const encoded = await LazarvClient.encodeReply( + const encoded = await RscClient.encodeReply( { name: "test", handler: fn }, { temporaryReferences: clientTempRefs } ); @@ -218,14 +218,14 @@ describeIf("Temporary References Cross-Compatibility", () => { const sym = Symbol("local"); const clientTempRefs = ReactDomClient.createTemporaryReferenceSet(); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); + const serverTempRefs = RscServer.createTemporaryReferenceSet(); const encoded = await ReactDomClient.encodeReply( { result: 42, tag: sym }, { temporaryReferences: clientTempRefs } ); - const decoded = await LazarvServer.decodeReply(encoded, { + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); @@ -239,10 +239,10 @@ describeIf("Temporary References Cross-Compatibility", () => { test("lazarv encodeReply → React decodeReply: local symbol becomes opaque proxy", async () => { const sym = Symbol("local"); - const clientTempRefs = LazarvClient.createTemporaryReferenceSet(); + const clientTempRefs = RscClient.createTemporaryReferenceSet(); const serverTempRefs = ReactDomServer.createTemporaryReferenceSet(); - const encoded = await LazarvClient.encodeReply( + const encoded = await RscClient.encodeReply( { result: 42, tag: sym }, { temporaryReferences: clientTempRefs } ); @@ -272,8 +272,8 @@ describeIf("Temporary References Cross-Compatibility", () => { ); // Decode with lazarv server - const lazarvServerRefs = LazarvServer.createTemporaryReferenceSet(); - const lazarvDecoded = await LazarvServer.decodeReply(encoded, { + const lazarvServerRefs = RscServer.createTemporaryReferenceSet(); + const lazarvDecoded = await RscServer.decodeReply(encoded, { temporaryReferences: lazarvServerRefs, }); @@ -284,7 +284,7 @@ describeIf("Temporary References Cross-Compatibility", () => { }); // Render with lazarv - const lazarvStream = LazarvServer.renderToReadableStream(lazarvDecoded, { + const lazarvStream = RscServer.renderToReadableStream(lazarvDecoded, { temporaryReferences: lazarvServerRefs, }); const lazarvWire = await streamToString(lazarvStream); @@ -329,8 +329,8 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const lazarvServerRefs = LazarvServer.createTemporaryReferenceSet(); - const lazarvDecoded = await LazarvServer.decodeReply(encoded, { + const lazarvServerRefs = RscServer.createTemporaryReferenceSet(); + const lazarvDecoded = await RscServer.decodeReply(encoded, { temporaryReferences: lazarvServerRefs, }); @@ -339,7 +339,7 @@ describeIf("Temporary References Cross-Compatibility", () => { temporaryReferences: reactServerRefs, }); - const lazarvStream = LazarvServer.renderToReadableStream(lazarvDecoded, { + const lazarvStream = RscServer.renderToReadableStream(lazarvDecoded, { temporaryReferences: lazarvServerRefs, }); const lazarvWire = await streamToString(lazarvStream); @@ -383,13 +383,13 @@ describeIf("Temporary References Cross-Compatibility", () => { ); // lazarv server: decode - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded = await LazarvServer.decodeReply(encoded, { + const serverTempRefs = RscServer.createTemporaryReferenceSet(); + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); // lazarv server: render back - const stream = LazarvServer.renderToReadableStream(decoded, { + const stream = RscServer.renderToReadableStream(decoded, { temporaryReferences: serverTempRefs, }); @@ -411,12 +411,12 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded = await LazarvServer.decodeReply(encoded, { + const serverTempRefs = RscServer.createTemporaryReferenceSet(); + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); - const stream = LazarvServer.renderToReadableStream(decoded, { + const stream = RscServer.renderToReadableStream(decoded, { temporaryReferences: serverTempRefs, }); @@ -445,12 +445,12 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded = await LazarvServer.decodeReply(encoded, { + const serverTempRefs = RscServer.createTemporaryReferenceSet(); + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); - const stream = LazarvServer.renderToReadableStream(decoded, { + const stream = RscServer.renderToReadableStream(decoded, { temporaryReferences: serverTempRefs, }); @@ -480,12 +480,12 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded = await LazarvServer.decodeReply(encoded, { + const serverTempRefs = RscServer.createTemporaryReferenceSet(); + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); - const stream = LazarvServer.renderToReadableStream(decoded, { + const stream = RscServer.renderToReadableStream(decoded, { temporaryReferences: serverTempRefs, }); @@ -509,8 +509,8 @@ describeIf("Temporary References Cross-Compatibility", () => { const originalFn = () => "I am the original"; // lazarv client: encode - const clientTempRefs = LazarvClient.createTemporaryReferenceSet(); - const encoded = await LazarvClient.encodeReply( + const clientTempRefs = RscClient.createTemporaryReferenceSet(); + const encoded = await RscClient.encodeReply( { name: "test", handler: originalFn }, { temporaryReferences: clientTempRefs } ); @@ -527,7 +527,7 @@ describeIf("Temporary References Cross-Compatibility", () => { }); // lazarv client: recover - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { temporaryReferences: clientTempRefs, }); @@ -538,8 +538,8 @@ describeIf("Temporary References Cross-Compatibility", () => { test("local symbol survives lazarv encode → React decode+render → lazarv decode", async () => { const sym = Symbol("my-local-sym"); - const clientTempRefs = LazarvClient.createTemporaryReferenceSet(); - const encoded = await LazarvClient.encodeReply( + const clientTempRefs = RscClient.createTemporaryReferenceSet(); + const encoded = await RscClient.encodeReply( { value: "ok", tag: sym }, { temporaryReferences: clientTempRefs } ); @@ -553,7 +553,7 @@ describeIf("Temporary References Cross-Compatibility", () => { temporaryReferences: serverTempRefs, }); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { temporaryReferences: clientTempRefs, }); @@ -565,8 +565,8 @@ describeIf("Temporary References Cross-Compatibility", () => { const fn1 = function onSave() {}; const fn2 = function onCancel() {}; - const clientTempRefs = LazarvClient.createTemporaryReferenceSet(); - const encoded = await LazarvClient.encodeReply( + const clientTempRefs = RscClient.createTemporaryReferenceSet(); + const encoded = await RscClient.encodeReply( { items: [ { label: "save", action: fn1 }, @@ -585,7 +585,7 @@ describeIf("Temporary References Cross-Compatibility", () => { temporaryReferences: serverTempRefs, }); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { temporaryReferences: clientTempRefs, }); @@ -614,11 +614,11 @@ describeIf("Temporary References Cross-Compatibility", () => { const encoded1 = await ReactDomClient.encodeReply(data, { temporaryReferences: clientTempRefs1, }); - const lazarvServerRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded1 = await LazarvServer.decodeReply(encoded1, { + const lazarvServerRefs = RscServer.createTemporaryReferenceSet(); + const decoded1 = await RscServer.decodeReply(encoded1, { temporaryReferences: lazarvServerRefs, }); - const stream1 = LazarvServer.renderToReadableStream(decoded1, { + const stream1 = RscServer.renderToReadableStream(decoded1, { temporaryReferences: lazarvServerRefs, }); const result1 = await ReactDomClient.createFromReadableStream(stream1, { @@ -652,13 +652,13 @@ describeIf("Temporary References Cross-Compatibility", () => { test("lazarv client should recover same values regardless of which server relays", async () => { const fn = () => {}; - const clientTempRefs1 = LazarvClient.createTemporaryReferenceSet(); - const clientTempRefs2 = LazarvClient.createTemporaryReferenceSet(); + const clientTempRefs1 = RscClient.createTemporaryReferenceSet(); + const clientTempRefs2 = RscClient.createTemporaryReferenceSet(); const data = { action: fn, label: "go" }; // Path A: lazarv client → React server → lazarv client - const encoded1 = await LazarvClient.encodeReply(data, { + const encoded1 = await RscClient.encodeReply(data, { temporaryReferences: clientTempRefs1, }); const reactServerRefs = ReactDomServer.createTemporaryReferenceSet(); @@ -668,22 +668,22 @@ describeIf("Temporary References Cross-Compatibility", () => { const stream1 = ReactDomServer.renderToReadableStream(decoded1, null, { temporaryReferences: reactServerRefs, }); - const result1 = await LazarvClient.createFromReadableStream(stream1, { + const result1 = await RscClient.createFromReadableStream(stream1, { temporaryReferences: clientTempRefs1, }); // Path B: lazarv client → lazarv server → lazarv client - const encoded2 = await LazarvClient.encodeReply(data, { + const encoded2 = await RscClient.encodeReply(data, { temporaryReferences: clientTempRefs2, }); - const lazarvServerRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded2 = await LazarvServer.decodeReply(encoded2, { + const lazarvServerRefs = RscServer.createTemporaryReferenceSet(); + const decoded2 = await RscServer.decodeReply(encoded2, { temporaryReferences: lazarvServerRefs, }); - const stream2 = LazarvServer.renderToReadableStream(decoded2, { + const stream2 = RscServer.renderToReadableStream(decoded2, { temporaryReferences: lazarvServerRefs, }); - const result2 = await LazarvClient.createFromReadableStream(stream2, { + const result2 = await RscClient.createFromReadableStream(stream2, { temporaryReferences: clientTempRefs2, }); @@ -708,8 +708,8 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const lazarvServerRefs = LazarvServer.createTemporaryReferenceSet(); - const lazarvDecoded = await LazarvServer.decodeReply(encoded, { + const lazarvServerRefs = RscServer.createTemporaryReferenceSet(); + const lazarvDecoded = await RscServer.decodeReply(encoded, { temporaryReferences: lazarvServerRefs, }); @@ -736,8 +736,8 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const lazarvServerRefs = LazarvServer.createTemporaryReferenceSet(); - const lazarvDecoded = await LazarvServer.decodeReply(encoded, { + const lazarvServerRefs = RscServer.createTemporaryReferenceSet(); + const lazarvDecoded = await RscServer.decodeReply(encoded, { temporaryReferences: lazarvServerRefs, }); @@ -768,8 +768,8 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const lazarvServerRefs = LazarvServer.createTemporaryReferenceSet(); - const lazarvDecoded = await LazarvServer.decodeReply(encoded, { + const lazarvServerRefs = RscServer.createTemporaryReferenceSet(); + const lazarvDecoded = await RscServer.decodeReply(encoded, { temporaryReferences: lazarvServerRefs, }); @@ -803,21 +803,21 @@ describeIf("Temporary References Cross-Compatibility", () => { ).rejects.toThrow(); // lazarv client → lazarv server → lazarv client - const clientTempRefs = LazarvClient.createTemporaryReferenceSet(); - const encoded = await LazarvClient.encodeReply(fn, { + const clientTempRefs = RscClient.createTemporaryReferenceSet(); + const encoded = await RscClient.encodeReply(fn, { temporaryReferences: clientTempRefs, }); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded = await LazarvServer.decodeReply(encoded, { + const serverTempRefs = RscServer.createTemporaryReferenceSet(); + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); - const stream = LazarvServer.renderToReadableStream(decoded, { + const stream = RscServer.renderToReadableStream(decoded, { temporaryReferences: serverTempRefs, }); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { temporaryReferences: clientTempRefs, }); @@ -835,12 +835,12 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded = await LazarvServer.decodeReply(encoded, { + const serverTempRefs = RscServer.createTemporaryReferenceSet(); + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); - const stream = LazarvServer.renderToReadableStream(decoded, { + const stream = RscServer.renderToReadableStream(decoded, { temporaryReferences: serverTempRefs, }); @@ -857,8 +857,8 @@ describeIf("Temporary References Cross-Compatibility", () => { test("deeply nested temp refs survive cross-library round-trip", async () => { const fn = () => {}; - const clientTempRefs = LazarvClient.createTemporaryReferenceSet(); - const encoded = await LazarvClient.encodeReply( + const clientTempRefs = RscClient.createTemporaryReferenceSet(); + const encoded = await RscClient.encodeReply( { a: { b: { c: { handler: fn, value: 123 } } } }, { temporaryReferences: clientTempRefs } ); @@ -872,7 +872,7 @@ describeIf("Temporary References Cross-Compatibility", () => { temporaryReferences: serverTempRefs, }); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { temporaryReferences: clientTempRefs, }); @@ -889,12 +889,12 @@ describeIf("Temporary References Cross-Compatibility", () => { { temporaryReferences: clientTempRefs } ); - const serverTempRefs = LazarvServer.createTemporaryReferenceSet(); - const decoded = await LazarvServer.decodeReply(encoded, { + const serverTempRefs = RscServer.createTemporaryReferenceSet(); + const decoded = await RscServer.decodeReply(encoded, { temporaryReferences: serverTempRefs, }); - const stream = LazarvServer.renderToReadableStream(decoded, { + const stream = RscServer.renderToReadableStream(decoded, { temporaryReferences: serverTempRefs, }); diff --git a/packages/rsc/__tests__/flight-cross-compat.test.mjs b/packages/rsc/__tests__/flight-cross-compat.test.mjs index c01e3b00..ecdca987 100644 --- a/packages/rsc/__tests__/flight-cross-compat.test.mjs +++ b/packages/rsc/__tests__/flight-cross-compat.test.mjs @@ -15,8 +15,8 @@ import { describe, expect, test, beforeAll } from "vitest"; import React from "react"; // @lazarv/rsc imports -import * as LazarvServer from "../server/shared.mjs"; -import * as LazarvClient from "../client/shared.mjs"; +import * as RscServer from "../server/shared.mjs"; +import * as RscClient from "../client/shared.mjs"; // Try to import react-server-dom-webpack - it may fail without --conditions=react-server let ReactDomServer; @@ -67,26 +67,26 @@ describeIf( test("should decode string from react-server-dom-webpack", async () => { const data = "Hello from React!"; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toBe(data); }); test("should decode number from react-server-dom-webpack", async () => { const data = 42; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toBe(data); }); test("should decode boolean from react-server-dom-webpack", async () => { const stream = ReactDomServer.renderToReadableStream(true); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toBe(true); }); test("should decode null from react-server-dom-webpack", async () => { const stream = ReactDomServer.renderToReadableStream(null); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toBeNull(); }); }); @@ -95,14 +95,14 @@ describeIf( test("should decode object from react-server-dom-webpack", async () => { const data = { name: "React", version: 19 }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toEqual(data); }); test("should decode array from react-server-dom-webpack", async () => { const data = [1, 2, 3, "four", { five: 5 }]; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toEqual(data); }); @@ -118,7 +118,7 @@ describeIf( }, }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toEqual(data); }); }); @@ -127,7 +127,7 @@ describeIf( test("should decode Date from react-server-dom-webpack", async () => { const data = { date: new Date("2024-06-15T12:00:00Z") }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.date).toBeInstanceOf(Date); expect(result.date.toISOString()).toBe("2024-06-15T12:00:00.000Z"); }); @@ -135,7 +135,7 @@ describeIf( test("should decode BigInt from react-server-dom-webpack", async () => { const data = { big: BigInt("12345678901234567890") }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.big).toBe(BigInt("12345678901234567890")); }); @@ -145,7 +145,7 @@ describeIf( ["key2", "value2"], ]); const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toBeInstanceOf(Map); expect(result.get("key1")).toBe("value1"); expect(result.get("key2")).toBe("value2"); @@ -154,7 +154,7 @@ describeIf( test("should decode Set from react-server-dom-webpack", async () => { const data = new Set([1, 2, 3, "four"]); const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result).toBeInstanceOf(Set); expect(result.has(1)).toBe(true); expect(result.has("four")).toBe(true); @@ -163,14 +163,14 @@ describeIf( test("should decode Symbol.for from react-server-dom-webpack", async () => { const data = { sym: Symbol.for("test.symbol") }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.sym).toBe(Symbol.for("test.symbol")); }); test("should decode Infinity from react-server-dom-webpack", async () => { const data = { pos: Infinity, neg: -Infinity }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.pos).toBe(Infinity); expect(result.neg).toBe(-Infinity); }); @@ -178,14 +178,14 @@ describeIf( test("should decode NaN from react-server-dom-webpack", async () => { const data = { nan: NaN }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(Number.isNaN(result.nan)).toBe(true); }); test("should decode undefined from react-server-dom-webpack", async () => { const data = { undef: undefined }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.undef).toBeUndefined(); }); }); @@ -194,7 +194,7 @@ describeIf( test("should decode Uint8Array from react-server-dom-webpack", async () => { const data = { bytes: new Uint8Array([1, 2, 3, 4, 5]) }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.bytes).toBeInstanceOf(Uint8Array); expect(Array.from(result.bytes)).toEqual([1, 2, 3, 4, 5]); }); @@ -202,7 +202,7 @@ describeIf( test("should decode Int32Array from react-server-dom-webpack", async () => { const data = { ints: new Int32Array([100, 200, 300]) }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.ints).toBeInstanceOf(Int32Array); }); @@ -212,7 +212,7 @@ describeIf( view.set([1, 2, 3, 4, 5, 6, 7, 8]); const data = { buffer }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.buffer).toBeInstanceOf(ArrayBuffer); expect(result.buffer.byteLength).toBe(8); expect(Array.from(new Uint8Array(result.buffer))).toEqual([ @@ -226,7 +226,7 @@ describeIf( dataView.setInt32(0, 12345, true); const data = { view: dataView }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.view).toBeInstanceOf(DataView); expect(result.view.getInt32(0, true)).toBe(12345); }); @@ -240,7 +240,7 @@ describeIf( "Hello" ); const stream = ReactDomServer.renderToReadableStream(element); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.type).toBe("div"); expect(result.props.className).toBe("test"); @@ -255,7 +255,7 @@ describeIf( React.createElement("span", null, "Child 2") ); const stream = ReactDomServer.renderToReadableStream(element); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.type).toBe("div"); expect(result.props.id).toBe("container"); @@ -269,7 +269,7 @@ describeIf( "content" ); const stream = ReactDomServer.renderToReadableStream(element); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.type).toBe("div"); expect(result.key).toBe("my-key"); @@ -282,7 +282,7 @@ describeIf( test("should decode resolved Promise from react-server-dom-webpack", async () => { const data = { promise: Promise.resolve({ value: 42 }) }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.promise).toBeDefined(); const resolved = await result.promise; @@ -296,7 +296,7 @@ describeIf( }, }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); const resolved = await result.outer.inner; expect(resolved.nested).toBe("value"); @@ -311,7 +311,7 @@ describeIf( ], }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.promises).toHaveLength(3); expect(await result.promises[0]).toBe("first"); @@ -328,7 +328,7 @@ describeIf( describe("Primitive values", () => { test("should decode string from @lazarv/rsc", async () => { const data = "Hello from lazarv!"; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toBe(data); @@ -336,21 +336,21 @@ describeIf( test("should decode number from @lazarv/rsc", async () => { const data = 42; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toBe(data); }); test("should decode boolean from @lazarv/rsc", async () => { - const stream = LazarvServer.renderToReadableStream(false); + const stream = RscServer.renderToReadableStream(false); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toBe(false); }); test("should decode null from @lazarv/rsc", async () => { - const stream = LazarvServer.renderToReadableStream(null); + const stream = RscServer.renderToReadableStream(null); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toBeNull(); @@ -360,7 +360,7 @@ describeIf( describe("Objects and arrays", () => { test("should decode object from @lazarv/rsc", async () => { const data = { framework: "lazarv/rsc", compatible: true }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toEqual(data); @@ -368,7 +368,7 @@ describeIf( test("should decode array from @lazarv/rsc", async () => { const data = ["a", "b", "c", 1, 2, 3]; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toEqual(data); @@ -384,7 +384,7 @@ describeIf( }, }, }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toEqual(data); @@ -395,7 +395,7 @@ describeIf( test("should decode Date from @lazarv/rsc", async () => { const date = new Date("2024-01-01T00:00:00Z"); const data = { created: date }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.created).toBeInstanceOf(Date); @@ -404,7 +404,7 @@ describeIf( test("should decode BigInt from @lazarv/rsc", async () => { const data = { bigNumber: BigInt(9007199254740993n) }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.bigNumber).toBe(BigInt(9007199254740993n)); @@ -416,7 +416,7 @@ describeIf( ["a", 1], ["b", 2], ]); - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toBeInstanceOf(Map); @@ -426,7 +426,7 @@ describeIf( // @lazarv/rsc now uses chunked format "$W" compatible with React test("should decode Set from @lazarv/rsc", async () => { const data = new Set(["x", "y", "z"]); - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result).toBeInstanceOf(Set); @@ -435,7 +435,7 @@ describeIf( test("should decode Symbol.for from @lazarv/rsc", async () => { const data = { symbol: Symbol.for("custom.key") }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.symbol).toBe(Symbol.for("custom.key")); @@ -443,7 +443,7 @@ describeIf( test("should decode Infinity from @lazarv/rsc", async () => { const data = { inf: Infinity, negInf: -Infinity }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.inf).toBe(Infinity); @@ -452,7 +452,7 @@ describeIf( test("should decode NaN from @lazarv/rsc", async () => { const data = { notANumber: NaN }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(Number.isNaN(result.notANumber)).toBe(true); @@ -463,7 +463,7 @@ describeIf( // @lazarv/rsc now uses React-compatible binary row format test("should decode Uint8Array from @lazarv/rsc", async () => { const data = { buffer: new Uint8Array([10, 20, 30]) }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.buffer).toBeInstanceOf(Uint8Array); @@ -472,7 +472,7 @@ describeIf( test("should decode Float64Array from @lazarv/rsc", async () => { const data = { floats: new Float64Array([1.1, 2.2, 3.3]) }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.floats).toBeInstanceOf(Float64Array); @@ -483,7 +483,7 @@ describeIf( const view = new Uint8Array(buffer); view.set([1, 2, 3, 4, 5, 6, 7, 8]); const data = { buffer }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.buffer).toBeInstanceOf(ArrayBuffer); @@ -498,7 +498,7 @@ describeIf( const dataView = new DataView(buffer); dataView.setInt32(0, 12345, true); const data = { view: dataView }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.view).toBeInstanceOf(DataView); @@ -509,7 +509,7 @@ describeIf( describe("React elements", () => { test("should decode simple React element from @lazarv/rsc", async () => { const element = React.createElement("p", { id: "para" }, "Paragraph"); - const stream = LazarvServer.renderToReadableStream(element); + const stream = RscServer.renderToReadableStream(element); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -526,7 +526,7 @@ describeIf( React.createElement("span", null, "A"), React.createElement("span", null, "B") ); - const stream = LazarvServer.renderToReadableStream(element); + const stream = RscServer.renderToReadableStream(element); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -543,7 +543,7 @@ describeIf( { key: "lazarv-key", className: "test" }, "keyed content" ); - const stream = LazarvServer.renderToReadableStream(element); + const stream = RscServer.renderToReadableStream(element); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -557,7 +557,7 @@ describeIf( describe("Promises", () => { test("should decode resolved Promise from @lazarv/rsc", async () => { const data = { promise: Promise.resolve({ value: 100 }) }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -572,7 +572,7 @@ describeIf( inner: Promise.resolve({ nested: "lazarv" }), }, }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -588,7 +588,7 @@ describeIf( Promise.resolve("c"), ], }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -612,11 +612,10 @@ describeIf("Bidirectional Round-trip Tests", () => { // React server → lazarv client const reactStream = ReactDomServer.renderToReadableStream(original); - const lazarvDecoded = - await LazarvClient.createFromReadableStream(reactStream); + const lazarvDecoded = await RscClient.createFromReadableStream(reactStream); // lazarv server → React client - const lazarvStream = LazarvServer.renderToReadableStream(lazarvDecoded); + const lazarvStream = RscServer.renderToReadableStream(lazarvDecoded); const final = await ReactDomClientBrowser.createFromReadableStream(lazarvStream); @@ -633,13 +632,13 @@ describeIf("Bidirectional Round-trip Tests", () => { }; // lazarv server → React client - const lazarvStream = LazarvServer.renderToReadableStream(original); + const lazarvStream = RscServer.renderToReadableStream(original); const reactDecoded = await ReactDomClientBrowser.createFromReadableStream(lazarvStream); // React server → lazarv client const reactStream = ReactDomServer.renderToReadableStream(reactDecoded); - const final = await LazarvClient.createFromReadableStream(reactStream); + const final = await RscClient.createFromReadableStream(reactStream); expect(final.source).toBe(original.source); expect(final.timestamp.toISOString()).toBe( @@ -677,10 +676,10 @@ describeIf("Bidirectional Round-trip Tests", () => { }; // Round trip: lazarv → React → lazarv - const stream1 = LazarvServer.renderToReadableStream(complex); + const stream1 = RscServer.renderToReadableStream(complex); const mid = await ReactDomClientBrowser.createFromReadableStream(stream1); const stream2 = ReactDomServer.renderToReadableStream(mid); - const final = await LazarvClient.createFromReadableStream(stream2); + const final = await RscClient.createFromReadableStream(stream2); expect(final.users).toHaveLength(2); expect(final.users[0].name).toBe("Alice"); @@ -700,7 +699,7 @@ describeIf("Cross-Compatibility Object Identity", () => { nested: { inner: shared }, }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.first).toBe(result.second); @@ -711,7 +710,7 @@ describeIf("Cross-Compatibility Object Identity", () => { const obj = { id: 1 }; const arr = [obj, { ref: obj }, obj]; - const stream = LazarvServer.renderToReadableStream(arr); + const stream = RscServer.renderToReadableStream(arr); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result[0]).toBe(result[2]); @@ -722,7 +721,7 @@ describeIf("Cross-Compatibility Object Identity", () => { const self = { name: "self" }; self.self = self; - const stream = LazarvServer.renderToReadableStream(self); + const stream = RscServer.renderToReadableStream(self); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.self).toBe(result); @@ -734,7 +733,7 @@ describeIf("Cross-Compatibility Object Identity", () => { a.ref = b; b.ref = a; - const stream = LazarvServer.renderToReadableStream({ a, b }); + const stream = RscServer.renderToReadableStream({ a, b }); const result = await ReactDomClientBrowser.createFromReadableStream(stream); expect(result.a.ref).toBe(result.b); @@ -747,7 +746,7 @@ describeIf("Cross-Compatibility Object Identity", () => { const data = { value: "test", nested: { value: "test" } }; const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.value).toBe("test"); expect(result.nested.value).toBe("test"); @@ -762,7 +761,7 @@ describeIf("Protocol Wire Format Comparison", () => { ReactDomServer.renderToReadableStream(data) ); const { forInspection: lazarvInspect } = teeStream( - LazarvServer.renderToReadableStream(data) + RscServer.renderToReadableStream(data) ); const reactWire = await streamToString(reactInspect); @@ -789,15 +788,14 @@ describeIf("Protocol Wire Format Comparison", () => { }; const reactStream = ReactDomServer.renderToReadableStream(data); - const lazarvStream = LazarvServer.renderToReadableStream(data); + const lazarvStream = RscServer.renderToReadableStream(data); // Both should be decodable by each other's client - const reactByLazarv = - await LazarvClient.createFromReadableStream(reactStream); + const reactByRsc = await RscClient.createFromReadableStream(reactStream); const lazarvByReact = await ReactDomClientBrowser.createFromReadableStream(lazarvStream); - expect(reactByLazarv.inf).toBe(Infinity); + expect(reactByRsc.inf).toBe(Infinity); expect(lazarvByReact.inf).toBe(Infinity); }); }); @@ -817,9 +815,7 @@ describeIf("React Path-Based Reference Format", () => { test("should parse simple path reference ($0:first)", async () => { // React format: object with shared nested object using path ref const wire = '0:{"first":{"value":42},"second":"$0:first"}'; - const result = await LazarvClient.createFromReadableStream( - toStream(wire) - ); + const result = await RscClient.createFromReadableStream(toStream(wire)); expect(result.first).toBe(result.second); expect(result.first.value).toBe(42); @@ -828,9 +824,7 @@ describeIf("React Path-Based Reference Format", () => { test("should parse self-reference using chunk ref ($0)", async () => { // React format for self-reference: obj.self = obj const wire = '0:{"self":"$0"}'; - const result = await LazarvClient.createFromReadableStream( - toStream(wire) - ); + const result = await RscClient.createFromReadableStream(toStream(wire)); expect(result.self).toBe(result); }); @@ -838,9 +832,7 @@ describeIf("React Path-Based Reference Format", () => { test("should parse array index path reference ($0:0)", async () => { // React format: array where second element references first const wire = '0:[{"v":1},"$0:0"]'; - const result = await LazarvClient.createFromReadableStream( - toStream(wire) - ); + const result = await RscClient.createFromReadableStream(toStream(wire)); expect(result[0]).toBe(result[1]); expect(result[0].v).toBe(1); @@ -849,9 +841,7 @@ describeIf("React Path-Based Reference Format", () => { test("should parse deep path reference ($0:outer:inner)", async () => { // React format: path navigates multiple levels const wire = '0:{"outer":{"inner":{"val":99}},"ref":"$0:outer:inner"}'; - const result = await LazarvClient.createFromReadableStream( - toStream(wire) - ); + const result = await RscClient.createFromReadableStream(toStream(wire)); expect(result.ref).toBe(result.outer.inner); expect(result.ref.val).toBe(99); @@ -861,9 +851,7 @@ describeIf("React Path-Based Reference Format", () => { // React's actual format for mutual references: { a, b } where a.ref = b and b.ref = a // React inlines `a` with `a.ref` (which is b) containing ref back to a via path const wire = '0:{"a":{"ref":{"ref":"$0:a"}},"b":"$0:a:ref"}'; - const result = await LazarvClient.createFromReadableStream( - toStream(wire) - ); + const result = await RscClient.createFromReadableStream(toStream(wire)); // b is $0:a:ref (chunk 0, property a, property ref) // a.ref.ref is $0:a (chunk 0, property a) @@ -874,9 +862,7 @@ describeIf("React Path-Based Reference Format", () => { test("should parse path ref within nested object", async () => { // Path ref inside a nested structure const wire = '0:{"data":{"items":[{"id":1},"$0:data:items:0"]}}'; - const result = await LazarvClient.createFromReadableStream( - toStream(wire) - ); + const result = await RscClient.createFromReadableStream(toStream(wire)); expect(result.data.items[0]).toBe(result.data.items[1]); expect(result.data.items[0].id).toBe(1); @@ -888,7 +874,7 @@ describeIf("React Path-Based Reference Format", () => { const shared = { value: 42 }; const data = { first: shared, second: shared }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -899,7 +885,7 @@ describeIf("React Path-Based Reference Format", () => { const obj = { name: "circular" }; obj.self = obj; - const stream = LazarvServer.renderToReadableStream(obj); + const stream = RscServer.renderToReadableStream(obj); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -912,7 +898,7 @@ describeIf("React Path-Based Reference Format", () => { a.ref = b; b.ref = a; - const stream = LazarvServer.renderToReadableStream({ a, b }); + const stream = RscServer.renderToReadableStream({ a, b }); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -931,7 +917,7 @@ describeIf("React Path-Based Reference Format", () => { ref: inner, }; - const stream = LazarvServer.renderToReadableStream(data); + const stream = RscServer.renderToReadableStream(data); const result = await ReactDomClientBrowser.createFromReadableStream(stream); @@ -946,7 +932,7 @@ describeIf("React Path-Based Reference Format", () => { // Render with React, decode with @lazarv/rsc const stream = ReactDomServer.renderToReadableStream(data); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); // React may or may not preserve identity, but @lazarv/rsc should decode correctly expect(result.first.value).toBe(42); @@ -963,7 +949,7 @@ describeIf("React Path-Based Reference Format", () => { // Render with React, decode with @lazarv/rsc const stream = ReactDomServer.renderToReadableStream(obj); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.self).toBe(result); }); @@ -976,7 +962,7 @@ describeIf("React Path-Based Reference Format", () => { // Render with React, decode with @lazarv/rsc const stream = ReactDomServer.renderToReadableStream({ a, b }); - const result = await LazarvClient.createFromReadableStream(stream); + const result = await RscClient.createFromReadableStream(stream); expect(result.a.ref).toBe(result.b); expect(result.b.ref).toBe(result.a); @@ -1025,7 +1011,7 @@ describeIf("Cross-Compatibility: Client References", () => { } // Create a moduleResolver for lazarv server - function createLazarvModuleResolver(entries) { + function createRscModuleResolver(entries) { const refMap = new Map(); for (const entry of entries) { refMap.set(entry.moduleId + "#" + entry.exportName, { @@ -1046,7 +1032,7 @@ describeIf("Cross-Compatibility: Client References", () => { } // Create a moduleLoader for lazarv client - function createLazarvModuleLoader() { + function createRscModuleLoader() { return { preloadModule(_metadata) { // No real chunk loading in tests @@ -1162,7 +1148,7 @@ describeIf("Cross-Compatibility: Client References", () => { default: ClientButton, }); - const ref = LazarvServer.registerClientReference( + const ref = RscServer.registerClientReference( ClientButton, moduleId, exportName @@ -1170,10 +1156,10 @@ describeIf("Cross-Compatibility: Client References", () => { const element = React.createElement(ref, { label: "Click me" }); - const moduleResolver = createLazarvModuleResolver([ + const moduleResolver = createRscModuleResolver([ { moduleId, exportName }, ]); - const stream = LazarvServer.renderToReadableStream(element, { + const stream = RscServer.renderToReadableStream(element, { moduleResolver, }); @@ -1199,12 +1185,12 @@ describeIf("Cross-Compatibility: Client References", () => { default: Button, }); - const CardRef = LazarvServer.registerClientReference( + const CardRef = RscServer.registerClientReference( Card, "Card.js", "default" ); - const ButtonRef = LazarvServer.registerClientReference( + const ButtonRef = RscServer.registerClientReference( Button, "Button.js", "default" @@ -1216,12 +1202,12 @@ describeIf("Cross-Compatibility: Client References", () => { React.createElement(ButtonRef, { onClick: "handler" }, "Click") ); - const moduleResolver = createLazarvModuleResolver([ + const moduleResolver = createRscModuleResolver([ { moduleId: "Card.js", exportName: "default" }, { moduleId: "Button.js", exportName: "default" }, ]); - const stream = LazarvServer.renderToReadableStream(element, { + const stream = RscServer.renderToReadableStream(element, { moduleResolver, }); @@ -1242,7 +1228,7 @@ describeIf("Cross-Compatibility: Client References", () => { registerMockModule("utils.js", { NamedExport }); - const ref = LazarvServer.registerClientReference( + const ref = RscServer.registerClientReference( NamedExport, "utils.js", "NamedExport" @@ -1250,11 +1236,11 @@ describeIf("Cross-Compatibility: Client References", () => { const element = React.createElement(ref, { data: "test" }); - const moduleResolver = createLazarvModuleResolver([ + const moduleResolver = createRscModuleResolver([ { moduleId: "utils.js", exportName: "NamedExport" }, ]); - const stream = LazarvServer.renderToReadableStream(element, { + const stream = RscServer.renderToReadableStream(element, { moduleResolver, }); @@ -1269,7 +1255,7 @@ describeIf("Cross-Compatibility: Client References", () => { test("should emit I row in array wire format", async () => { function TestComp() {} - const ref = LazarvServer.registerClientReference( + const ref = RscServer.registerClientReference( TestComp, "test.js", "default" @@ -1277,7 +1263,7 @@ describeIf("Cross-Compatibility: Client References", () => { const element = React.createElement(ref, { value: 42 }); - const moduleResolver = createLazarvModuleResolver([ + const moduleResolver = createRscModuleResolver([ { moduleId: "test.js", exportName: "default", @@ -1285,7 +1271,7 @@ describeIf("Cross-Compatibility: Client References", () => { }, ]); - const stream = LazarvServer.renderToReadableStream(element, { + const stream = RscServer.renderToReadableStream(element, { moduleResolver, }); @@ -1312,7 +1298,7 @@ describeIf("Cross-Compatibility: Client References", () => { default: LazyComponent, }); - const ref = LazarvServer.registerClientReference( + const ref = RscServer.registerClientReference( LazyComponent, "lazy.js", "default" @@ -1320,7 +1306,7 @@ describeIf("Cross-Compatibility: Client References", () => { const element = React.createElement(ref, { loaded: true }); - const moduleResolver = createLazarvModuleResolver([ + const moduleResolver = createRscModuleResolver([ { moduleId: "lazy.js", exportName: "default", @@ -1328,7 +1314,7 @@ describeIf("Cross-Compatibility: Client References", () => { }, ]); - const stream = LazarvServer.renderToReadableStream(element, { + const stream = RscServer.renderToReadableStream(element, { moduleResolver, }); @@ -1374,8 +1360,8 @@ describeIf("Cross-Compatibility: Client References", () => { const stream = ReactDomServer.renderToReadableStream(element, webpackMap); // Decode with lazarv client - const moduleLoader = createLazarvModuleLoader(); - const result = await LazarvClient.createFromReadableStream(stream, { + const moduleLoader = createRscModuleLoader(); + const result = await RscClient.createFromReadableStream(stream, { moduleLoader, }); @@ -1418,8 +1404,8 @@ describeIf("Cross-Compatibility: Client References", () => { const stream = ReactDomServer.renderToReadableStream(element, webpackMap); - const moduleLoader = createLazarvModuleLoader(); - const result = await LazarvClient.createFromReadableStream(stream, { + const moduleLoader = createRscModuleLoader(); + const result = await RscClient.createFromReadableStream(stream, { moduleLoader, }); @@ -1449,8 +1435,8 @@ describeIf("Cross-Compatibility: Client References", () => { const stream = ReactDomServer.renderToReadableStream(element, webpackMap); - const moduleLoader = createLazarvModuleLoader(); - const result = await LazarvClient.createFromReadableStream(stream, { + const moduleLoader = createRscModuleLoader(); + const result = await RscClient.createFromReadableStream(stream, { moduleLoader, }); @@ -1501,7 +1487,7 @@ describeIf("Cross-Compatibility: Client References", () => { function Comp() {} // Register with both servers - const lazarvRef = LazarvServer.registerClientReference( + const lazarvRef = RscServer.registerClientReference( Comp, "shared.js", "default" @@ -1513,14 +1499,14 @@ describeIf("Cross-Compatibility: Client References", () => { ); // Serialize with lazarv - const lazarvResolver = createLazarvModuleResolver([ + const lazarvResolver = createRscModuleResolver([ { moduleId: "shared.js", exportName: "default", chunks: ["c0", "c0.js"], }, ]); - const lazarvStream = LazarvServer.renderToReadableStream( + const lazarvStream = RscServer.renderToReadableStream( React.createElement(lazarvRef, {}), { moduleResolver: lazarvResolver } ); @@ -1573,7 +1559,7 @@ describeIf("Cross-Compatibility: Client References", () => { default: MyModule, }); - const moduleLoader = createLazarvModuleLoader(); + const moduleLoader = createRscModuleLoader(); const stream = new ReadableStream({ start(controller) { @@ -1582,7 +1568,7 @@ describeIf("Cross-Compatibility: Client References", () => { }, }); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { moduleLoader, }); @@ -1612,18 +1598,18 @@ describeIf("Cross-Compatibility: Client References", () => { }); // First trip: lazarv server → React client - const ref1 = LazarvServer.registerClientReference( + const ref1 = RscServer.registerClientReference( RoundTripComp, "rt.js", "default" ); const element1 = React.createElement(ref1, { count: 1 }); - const resolver1 = createLazarvModuleResolver([ + const resolver1 = createRscModuleResolver([ { moduleId: "rt.js", exportName: "default" }, ]); - const stream1 = LazarvServer.renderToReadableStream(element1, { + const stream1 = RscServer.renderToReadableStream(element1, { moduleResolver: resolver1, }); const result1 = @@ -1658,8 +1644,8 @@ describeIf("Cross-Compatibility: Client References", () => { const stream = ReactDomServer.renderToReadableStream(element, webpackMap); - const moduleLoader = createLazarvModuleLoader(); - const result = await LazarvClient.createFromReadableStream(stream, { + const moduleLoader = createRscModuleLoader(); + const result = await RscClient.createFromReadableStream(stream, { moduleLoader, }); @@ -1679,7 +1665,7 @@ describeIf("Cross-Compatibility: Client References", () => { registerMockModule("actions.js", { __esModule: true, default: Action }); - const ref = LazarvServer.registerClientReference( + const ref = RscServer.registerClientReference( Action, "actions.js", "default" @@ -1691,11 +1677,11 @@ describeIf("Cross-Compatibility: Client References", () => { config: { timeout: 5000 }, }; - const resolver = createLazarvModuleResolver([ + const resolver = createRscModuleResolver([ { moduleId: "actions.js", exportName: "default" }, ]); - const stream = LazarvServer.renderToReadableStream(data, { + const stream = RscServer.renderToReadableStream(data, { moduleResolver: resolver, }); @@ -1733,8 +1719,8 @@ describeIf("Cross-Compatibility: Client References", () => { const stream = ReactDomServer.renderToReadableStream(data, webpackMap); - const moduleLoader = createLazarvModuleLoader(); - const result = await LazarvClient.createFromReadableStream(stream, { + const moduleLoader = createRscModuleLoader(); + const result = await RscClient.createFromReadableStream(stream, { moduleLoader, }); @@ -1823,7 +1809,7 @@ describeIf("Cross-Compatibility: Server References", () => { return "webpack"; } - const lazarvRef = LazarvServer.registerServerReference( + const lazarvRef = RscServer.registerServerReference( lazarvAction, "actions.mjs", "doStuff" @@ -1843,7 +1829,7 @@ describeIf("Cross-Compatibility: Server References", () => { async function lazarvAction() {} async function webpackAction() {} - const lazarvRef = LazarvServer.registerServerReference( + const lazarvRef = RscServer.registerServerReference( lazarvAction, "module.js", "myExport" @@ -1862,7 +1848,7 @@ describeIf("Cross-Compatibility: Server References", () => { async function lazarvAction() {} async function webpackAction() {} - const lazarvRef = LazarvServer.registerServerReference( + const lazarvRef = RscServer.registerServerReference( lazarvAction, "mod.js", "fn" @@ -1885,7 +1871,7 @@ describeIf("Cross-Compatibility: Server References", () => { return a + b; } - const lazarvRef = LazarvServer.registerServerReference( + const lazarvRef = RscServer.registerServerReference( lazarvAction, "calc.js", "add" @@ -1917,11 +1903,7 @@ describeIf("Cross-Compatibility: Server References", () => { return [a, b, c]; } - const ref = LazarvServer.registerServerReference( - action, - "multi.js", - "fn" - ); + const ref = RscServer.registerServerReference(action, "multi.js", "fn"); const bound1 = ref.bind(null, "first"); const bound2 = bound1.bind(null, "second"); @@ -1935,7 +1917,7 @@ describeIf("Cross-Compatibility: Server References", () => { return `Hello, ${name}!`; } - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( greet, "greet.js", "default" @@ -1951,14 +1933,14 @@ describeIf("Cross-Compatibility: Server References", () => { describe("Wire format inspection", () => { test("lazarv server serializes server ref with $h prefix (outlined)", async () => { async function myAction() {} - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( myAction, "actions.js", "submit" ); const model = { handler: ref }; - const stream = LazarvServer.renderToReadableStream(model, {}); + const stream = RscServer.renderToReadableStream(model, {}); const wire = await streamToString(stream); // Should contain $h followed by a chunk id (outlined format, same as React) @@ -1969,7 +1951,7 @@ describeIf("Cross-Compatibility: Server References", () => { test("lazarv server serializes bound server ref with $h (outlined)", async () => { async function myAction(_pre, _val) {} - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( myAction, "actions.js", "save" @@ -1977,7 +1959,7 @@ describeIf("Cross-Compatibility: Server References", () => { const bound = ref.bind(null, "prefix"); const model = { handler: bound }; - const stream = LazarvServer.renderToReadableStream(model, {}); + const stream = RscServer.renderToReadableStream(model, {}); const wire = await streamToString(stream); // Should use $h outlined format (same as React) @@ -1989,14 +1971,14 @@ describeIf("Cross-Compatibility: Server References", () => { test("lazarv server serializes server ref with moduleResolver metadata", async () => { async function myAction() {} - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( myAction, "actions.js", "run" ); const model = { handler: ref }; - const stream = LazarvServer.renderToReadableStream(model, { + const stream = RscServer.renderToReadableStream(model, { moduleResolver: { resolveServerReference(value) { if (value && value.$$id === "actions.js#run") { @@ -2042,7 +2024,7 @@ describeIf("Cross-Compatibility: Server References", () => { async function lazarvAction() {} async function webpackAction() {} - const lazarvRef = LazarvServer.registerServerReference( + const lazarvRef = RscServer.registerServerReference( lazarvAction, "shared/actions.js", "doWork" @@ -2053,7 +2035,7 @@ describeIf("Cross-Compatibility: Server References", () => { "doWork" ); - const lazarvStream = LazarvServer.renderToReadableStream( + const lazarvStream = RscServer.renderToReadableStream( { fn: lazarvRef }, {} ); @@ -2074,7 +2056,7 @@ describeIf("Cross-Compatibility: Server References", () => { async function lazarvAction() {} async function webpackAction() {} - const lazarvRef = LazarvServer.registerServerReference( + const lazarvRef = RscServer.registerServerReference( lazarvAction, "actions.js", "submit" @@ -2085,7 +2067,7 @@ describeIf("Cross-Compatibility: Server References", () => { "submit" ); - const lazarvStream = LazarvServer.renderToReadableStream( + const lazarvStream = RscServer.renderToReadableStream( { handler: lazarvRef }, {} ); @@ -2125,14 +2107,14 @@ describeIf("Cross-Compatibility: Server References", () => { }; async function myAction(_input) {} - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( myAction, "actions.js", "submit" ); const model = { handler: ref, label: "go" }; - const stream = LazarvServer.renderToReadableStream(model, {}); + const stream = RscServer.renderToReadableStream(model, {}); const result = await ReactDomClientBrowser.createFromReadableStream( stream, { @@ -2162,7 +2144,7 @@ describeIf("Cross-Compatibility: Server References", () => { }; async function myAction(_pre, _val) {} - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( myAction, "actions.js", "save" @@ -2170,7 +2152,7 @@ describeIf("Cross-Compatibility: Server References", () => { const bound = ref.bind(null, "prefix"); const model = { handler: bound }; - const stream = LazarvServer.renderToReadableStream(model, {}); + const stream = RscServer.renderToReadableStream(model, {}); const result = await ReactDomClientBrowser.createFromReadableStream( stream, { @@ -2208,7 +2190,7 @@ describeIf("Cross-Compatibility: Server References", () => { const model = { handler: ref, label: "click" }; const stream = ReactDomServer.renderToReadableStream(model, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2243,7 +2225,7 @@ describeIf("Cross-Compatibility: Server References", () => { const model = { handler: bound }; const stream = ReactDomServer.renderToReadableStream(model, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2282,7 +2264,7 @@ describeIf("Cross-Compatibility: Server References", () => { const model = { actions: [refA, refB] }; const stream = ReactDomServer.renderToReadableStream(model, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2313,15 +2295,15 @@ describeIf("Cross-Compatibility: Server References", () => { async function myAction(input) { return input; } - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( myAction, "actions.js", "submit" ); const model = { action: ref, label: "Submit" }; - const stream = LazarvServer.renderToReadableStream(model, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const stream = RscServer.renderToReadableStream(model, {}); + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2352,7 +2334,7 @@ describeIf("Cross-Compatibility: Server References", () => { async function myAction(pre, val) { return [pre, val]; } - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( myAction, "actions.js", "save" @@ -2360,8 +2342,8 @@ describeIf("Cross-Compatibility: Server References", () => { const bound = ref.bind(null, "pre-arg"); const model = { action: bound }; - const stream = LazarvServer.renderToReadableStream(model, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const stream = RscServer.renderToReadableStream(model, {}); + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2384,15 +2366,15 @@ describeIf("Cross-Compatibility: Server References", () => { }; async function handleClick() {} - const ref = LazarvServer.registerServerReference( + const ref = RscServer.registerServerReference( handleClick, "handlers.js", "onClick" ); const element = React.createElement("div", { onClick: ref }); - const stream = LazarvServer.renderToReadableStream(element, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const stream = RscServer.renderToReadableStream(element, {}); + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2412,12 +2394,12 @@ describeIf("Cross-Compatibility: Server References", () => { async function actionA() {} async function actionB() {} - const refA = LazarvServer.registerServerReference(actionA, "a.js", "run"); - const refB = LazarvServer.registerServerReference(actionB, "b.js", "run"); + const refA = RscServer.registerServerReference(actionA, "a.js", "run"); + const refB = RscServer.registerServerReference(actionB, "b.js", "run"); const model = { actions: [refA, refB], count: 2 }; - const stream = LazarvServer.renderToReadableStream(model, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const stream = RscServer.renderToReadableStream(model, {}); + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2441,15 +2423,11 @@ describeIf("Cross-Compatibility: Server References", () => { }; async function myAction() {} - const ref = LazarvServer.registerServerReference( - myAction, - "bind.js", - "fn" - ); + const ref = RscServer.registerServerReference(myAction, "bind.js", "fn"); const model = { action: ref }; - const stream = LazarvServer.renderToReadableStream(model, {}); - const result = await LazarvClient.createFromReadableStream(stream, { + const stream = RscServer.renderToReadableStream(model, {}); + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2471,7 +2449,7 @@ describeIf("Cross-Compatibility: Server References", () => { describe("createServerReference client API compatibility", () => { test("lazarv createServerReference creates proxy with correct properties", () => { const mockCallServer = async () => {}; - const ref = LazarvClient.createServerReference( + const ref = RscClient.createServerReference( "module.js#action", mockCallServer ); @@ -2489,7 +2467,7 @@ describeIf("Cross-Compatibility: Server References", () => { return "result"; }; - const ref = LazarvClient.createServerReference( + const ref = RscClient.createServerReference( "api.js#fetch", mockCallServer ); @@ -2505,7 +2483,7 @@ describeIf("Cross-Compatibility: Server References", () => { callLog.push({ id, args }); }; - const ref = LazarvClient.createServerReference( + const ref = RscClient.createServerReference( "api.js#update", mockCallServer ); @@ -2533,7 +2511,7 @@ describeIf("Cross-Compatibility: Server References", () => { return "webpack"; }; - const lazarvRef = LazarvClient.createServerReference( + const lazarvRef = RscClient.createServerReference( "shared.js#action", lazarvCallServer ); @@ -2582,7 +2560,7 @@ describeIf("Cross-Compatibility: Server References", () => { }, }); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2615,7 +2593,7 @@ describeIf("Cross-Compatibility: Server References", () => { }, }); - const result = await LazarvClient.createFromReadableStream(stream, { + const result = await RscClient.createFromReadableStream(stream, { callServer: mockCallServer, }); @@ -2665,12 +2643,9 @@ describe("Debug Info Cross-Compatibility", () => { ); const debugInfos = []; - const result = await LazarvClient.createFromReadableStream( - forConsumption, - { - onDebugInfo: (id, info) => debugInfos.push({ id, info }), - } - ); + const result = await RscClient.createFromReadableStream(forConsumption, { + onDebugInfo: (id, info) => debugInfos.push({ id, info }), + }); // Result should be valid element expect(result.type).toBe("div"); @@ -2691,7 +2666,7 @@ describe("Debug Info Cross-Compatibility", () => { { className: "test" }, "Hello" ); - const stream = LazarvServer.renderToReadableStream(element, { + const stream = RscServer.renderToReadableStream(element, { debug: true, }); const rawData = await streamToString(stream); @@ -2731,7 +2706,7 @@ describe("Debug Info Cross-Compatibility", () => { { className: "prod" }, "Production" ); - const stream = LazarvServer.renderToReadableStream(element); + const stream = RscServer.renderToReadableStream(element); const rawData = await streamToString(stream); // Verify no D rows without debug option @@ -2747,8 +2722,7 @@ describe("Debug Info Cross-Compatibility", () => { }) ); - const result = - await LazarvClient.createFromReadableStream(forConsumption); + const result = await RscClient.createFromReadableStream(forConsumption); expect(result.type).toBe("div"); expect(result.props.className).toBe("prod"); @@ -2765,7 +2739,7 @@ describe("Debug Info Cross-Compatibility", () => { const element = React.createElement(TestComponent, { name: "test" }); // Get lazarv output with debug mode enabled - const lazarvStream = LazarvServer.renderToReadableStream(element, { + const lazarvStream = RscServer.renderToReadableStream(element, { debug: true, }); const lazarvData = await streamToString(lazarvStream); @@ -2823,7 +2797,7 @@ describe("Prerender Cross-Compatibility", () => { ); // Prerender with lazarv - const { prelude } = await LazarvServer.prerender(element); + const { prelude } = await RscServer.prerender(element); const rawData = await streamToString(prelude); // Parse with React client @@ -2860,8 +2834,7 @@ describe("Prerender Cross-Compatibility", () => { ); // Prerender with lazarv, decode with React - const { prelude: lazarvPrelude } = - await LazarvServer.prerender(element); + const { prelude: lazarvPrelude } = await RscServer.prerender(element); const lazarvData = await streamToString(lazarvPrelude); const { forConsumption: forReact } = teeStream( @@ -2894,7 +2867,7 @@ describe("Prerender Cross-Compatibility", () => { }; // Prerender with lazarv (waits for all promises) - const { prelude: lazarvPrelude } = await LazarvServer.prerender(data); + const { prelude: lazarvPrelude } = await RscServer.prerender(data); const lazarvData = await streamToString(lazarvPrelude); // Parse with React client @@ -2930,7 +2903,7 @@ describe("Prerender Cross-Compatibility", () => { ); // Prerender with lazarv - const { prelude } = await LazarvServer.prerender(element); + const { prelude } = await RscServer.prerender(element); const rawData = await streamToString(prelude); // Parse with lazarv client @@ -2943,8 +2916,7 @@ describe("Prerender Cross-Compatibility", () => { }) ); - const result = - await LazarvClient.createFromReadableStream(forConsumption); + const result = await RscClient.createFromReadableStream(forConsumption); expect(result.type).toBe("div"); expect(result.props.className).toBe("self-prerendered"); @@ -2963,7 +2935,7 @@ describe("Prerender Cross-Compatibility", () => { ); // Prerender with lazarv, decode with lazarv - const { prelude } = await LazarvServer.prerender(element); + const { prelude } = await RscServer.prerender(element); const data = await streamToString(prelude); const { forConsumption } = teeStream( @@ -2975,8 +2947,7 @@ describe("Prerender Cross-Compatibility", () => { }) ); - const result = - await LazarvClient.createFromReadableStream(forConsumption); + const result = await RscClient.createFromReadableStream(forConsumption); expect(result.type).toBe("section"); expect(result.props.children).toHaveLength(2); diff --git a/packages/rsc/client/shared.mjs b/packages/rsc/client/shared.mjs index bce9dd33..70c5cacf 100644 --- a/packages/rsc/client/shared.mjs +++ b/packages/rsc/client/shared.mjs @@ -18,6 +18,10 @@ const REACT_SUSPENSE_TYPE = Symbol.for("react.suspense"); const REACT_CLIENT_REFERENCE = Symbol.for("react.client.reference"); const REACT_SERVER_REFERENCE = Symbol.for("react.server.reference"); +// Shared encoder/decoder instances — avoids per-call allocation overhead +const _decoder = new TextDecoder(); +const _encoder = new TextEncoder(); + // Dev mode detection: __DEV__ (bundler/React global), process.env.NODE_ENV (Node.js), // default to false (production) when neither is available const __IS_DEV__ = @@ -34,6 +38,324 @@ const PENDING = 0; const RESOLVED = 1; const REJECTED = 2; +/** + * Stamp development-mode properties onto a React element. + * React's dev server expects elements to have _owner, _store, _debugStack, + * _debugTask, and _debugInfo. Without these, dev-mode rendering crashes + * ("Cannot set properties of undefined (setting 'validated')") or warns + * ("Attempted to render without development properties"). + * In production this is a no-op identity function. + */ +const _devElement = __IS_DEV__ + ? (el) => { + el._owner = null; + el._store = { validated: 1 }; + el._debugStack = new Error("react-stack-top-frame"); + el._debugTask = null; + el._debugInfo = null; + return el; + } + : (el) => el; + +/** + * Skip a single JSON value in `str` starting at position `i`. + * Returns the position immediately after the value, or -1 on error. + * Only handles the small header values (strings, null, numbers, booleans) + * — does NOT recurse into objects/arrays (not needed for element headers). + */ +function _skipJsonValue(str, i) { + const len = str.length; + if (i >= len) return -1; + const ch = str.charCodeAt(i); + if (ch === 0x22) { + // String — scan for unescaped closing quote + i++; + while (i < len) { + const c = str.charCodeAt(i); + if (c === 0x5c) { + i += 2; + continue; + } // backslash escape + if (c === 0x22) return i + 1; + i++; + } + return -1; + } + // Number, true, false, null — scan to delimiter + while (i < len) { + const c = str.charCodeAt(i); + if (c === 0x2c || c === 0x5d || c === 0x7d || c <= 0x20) return i; + i++; + } + return i; +} + +/** + * Skip a JSON value of any type (including nested objects/arrays) starting + * at position `i`. Returns the position immediately after the value. + * Uses depth tracking — scans through nested content character by character + * but doesn't allocate anything. + */ +function _skipJsonValueFull(str, i) { + const len = str.length; + if (i >= len) return -1; + const ch = str.charCodeAt(i); + + // String + if (ch === 0x22) { + i++; + while (i < len) { + const c = str.charCodeAt(i); + if (c === 0x5c) { + i += 2; + continue; + } + if (c === 0x22) return i + 1; + i++; + } + return -1; + } + + // Object or array — track depth + if (ch === 0x7b || ch === 0x5b) { + let depth = 1; + let inStr = false; + i++; + while (i < len && depth > 0) { + const c = str.charCodeAt(i); + if (inStr) { + if (c === 0x5c) { + i += 2; + continue; + } + if (c === 0x22) inStr = false; + i++; + continue; + } + if (c === 0x22) { + inStr = true; + i++; + continue; + } + if (c === 0x7b || c === 0x5b) { + depth++; + } else if (c === 0x7d || c === 0x5d) { + depth--; + } + i++; + } + return i; + } + + // Primitive (number, true, false, null) + while (i < len) { + const c = str.charCodeAt(i); + if (c === 0x2c || c === 0x5d || c === 0x7d || c <= 0x20) return i; + i++; + } + return i; +} + +/** + * Scan a top-level JSON object string to extract key→value-substring pairs + * WITHOUT parsing large nested values. + * + * Returns an array of { key, valueStart, valueEnd, small } entries, or null + * if the string can't be scanned (caller falls back to full JSON.parse). + * + * Values smaller than `threshold` characters are flagged `small: true` so the + * caller can JSON.parse and deserialize them eagerly. Larger values are kept + * as raw substrings for lazy on-demand parsing. + */ +function _scanObjectRow(str, threshold) { + const len = str.length; + if (str.charCodeAt(0) !== 0x7b || str.charCodeAt(len - 1) !== 0x7d) + return null; + + const fields = []; + let i = 1; // skip opening '{' + + while (i < len) { + // Skip whitespace + while (i < len && str.charCodeAt(i) <= 0x20) i++; + + // End of object? + if (str.charCodeAt(i) === 0x7d) break; + + // Expect a string key + if (str.charCodeAt(i) !== 0x22) return null; + const keyStart = i; + i = _skipJsonValue(str, i); // reuse the fast string skipper + if (i === -1) return null; + const key = str.slice(keyStart + 1, i - 1); // strip quotes (no escapes in typical keys) + + // Skip colon + whitespace + while (i < len && str.charCodeAt(i) <= 0x20) i++; + if (str.charCodeAt(i) !== 0x3a) return null; // ':' + i++; + while (i < len && str.charCodeAt(i) <= 0x20) i++; + + // Value starts here + const valueStart = i; + i = _skipJsonValueFull(str, i); + if (i === -1) return null; + const valueEnd = i; + const size = valueEnd - valueStart; + + fields.push({ key, valueStart, valueEnd, small: size <= threshold }); + + // Skip comma + whitespace + while (i < len && str.charCodeAt(i) <= 0x20) i++; + if (str.charCodeAt(i) === 0x2c) { + i++; + } + } + + return fields.length > 0 ? fields : null; +} + +/** + * Scan an element tuple JSON string to extract field boundaries without + * parsing the (potentially huge) props object. + * + * Element tuples: ["$", type, key, props] (React 19 production) + * ["$", type, key, ref, props] (Legacy) + * ["$", type, key, props, ...] (React 19 dev — extra fields) + * + * Key insight: the row string always ends with ']' (the outer array close). + * We only need to scan the HEADER fields ("$", type, key) which are tiny + * (~30–50 chars), then everything from the 3rd comma to the closing ']' is + * the remaining field(s). This makes the scanner O(header_size) ≈ O(1), + * independent of the props size — critical for 200KB+ rows. + * + * Returns { type, key, rawPropsStr, rawRefStr } or null on failure. + */ +function _scanElementTuple(str) { + const len = str.length; + // Outer array must close with ']' + if (str.charCodeAt(len - 1) !== 0x5d) return null; + + // Caller already verified str starts with '["$",' so skip to after it. + let i = 5; // past '["$",' + + // Skip whitespace + while (i < len && str.charCodeAt(i) <= 0x20) i++; + + // Field 1: type (usually a short string like "div" or "$L1") + const typeStart = i; + i = _skipJsonValue(str, i); + if (i === -1) return null; + const typeEnd = i; + + // Expect comma after type + while (i < len && str.charCodeAt(i) <= 0x20) i++; + if (str.charCodeAt(i) !== 0x2c) return null; + i++; + while (i < len && str.charCodeAt(i) <= 0x20) i++; + + // Field 2: key (usually null, a string, or a number) + const keyStart = i; + i = _skipJsonValue(str, i); + if (i === -1) return null; + const keyEnd = i; + + // Expect comma after key + while (i < len && str.charCodeAt(i) <= 0x20) i++; + if (str.charCodeAt(i) !== 0x2c) return null; + i++; + while (i < len && str.charCodeAt(i) <= 0x20) i++; + + // Parse type and key — these are tiny, JSON.parse is fast + const type = JSON.parse(str.slice(typeStart, typeEnd)); + const key = JSON.parse(str.slice(keyStart, keyEnd)); + + // Everything from position i to len-1 (before closing ']') is field 3+. + // The closing ']' at len-1 belongs to the outer array, not to props. + const restEnd = len - 1; + + // Peek at the first character to determine format + const firstChar = str.charCodeAt(i); + + if (firstChar === 0x7b) { + // '{' → React 19 format: 4th element is the props object. + // In production there are no extra fields after props, so + // everything from i to restEnd is the props JSON. + // In dev mode there may be owner/debug fields after props, + // but those trailing fields don't affect JSON.parse of the + // props object — they just become garbage after the first + // top-level '}'. We need to find where the props object ends. + // For production (no extra fields): props = str[i..restEnd) + // For dev (extra fields): need to skip the object to find its end. + // + // Optimization: check if the last non-whitespace char before ']' + // is '}' — if so, props is the only remaining field. + let j = restEnd - 1; + while (j > i && str.charCodeAt(j) <= 0x20) j--; + if (str.charCodeAt(j) === 0x7d) { + // Props is the only remaining field (production path) + return { type, key, rawPropsStr: str.slice(i, j + 1), rawRefStr: null }; + } + // Dev mode with extra fields — fall back to full parse + return null; + } + + // Not an object — legacy format: 4th element is ref, 5th is props. + // Ref is small (usually `null`), so skip it cheaply. + const refStart = i; + i = _skipJsonValue(str, i); + if (i === -1) return null; + const refEnd = i; + + // Expect comma after ref + while (i < len && str.charCodeAt(i) <= 0x20) i++; + if (i >= restEnd || str.charCodeAt(i) !== 0x2c) return null; + i++; + while (i < len && str.charCodeAt(i) <= 0x20) i++; + + // 5th element is props — everything from i to restEnd + // Same optimization: verify last non-ws char before ']' closes the props + let j = restEnd - 1; + while (j > i && str.charCodeAt(j) <= 0x20) j--; + const lastChar = str.charCodeAt(j); + if ( + lastChar === 0x7d || + lastChar === 0x5d || + lastChar === 0x22 || + (lastChar >= 0x30 && lastChar <= 0x39) || + lastChar === 0x65 || + lastChar === 0x6c + ) { + // Ends with }, ], ", digit, 'e' (true/false), 'l' (null) — plausible JSON value end + return { + type, + key, + rawPropsStr: str.slice(i, j + 1), + rawRefStr: str.slice(refStart, refEnd), + }; + } + + return null; +} + +/** + * Binary row tag byte → TypedArray constructor (module-level, allocated once). + * Covers tags that need alignment-safe copy: Int8Array through BigUint64Array. + * Uint8Array (0x6f), ArrayBuffer (0x41), DataView (0x56), Text (0x54) are + * handled as special cases in processBinaryRow. + */ +const _binaryTagConstructors = { + 0x4f: Int8Array, // O + 0x55: Uint8ClampedArray, // U + 0x53: Int16Array, // S + 0x73: Uint16Array, // s + 0x4c: Int32Array, // L + 0x6c: Uint32Array, // l + 0x47: Float32Array, // G + 0x67: Float64Array, // g + 0x4d: BigInt64Array, // M + 0x6d: BigUint64Array, // m +}; + /** * Create a TypedArray from a constructor name and buffer */ @@ -105,32 +427,59 @@ class FlightResponse { } /** - * Create a new pending chunk + * Create a new pending chunk. + * + * Chunks start without a Promise — one is created lazily via + * _ensurePromise() only when async code actually needs to await + * the chunk. For the common synchronous-resolve path this avoids + * a Promise + two closure allocations per chunk. */ createChunk(id) { - let resolve, reject; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - const chunk = { id, status: PENDING, value: undefined, - promise, - resolve, - reject, + _promise: null, + _resolve: null, + _reject: null, }; - - // Make the chunk thenable - promise.status = "pending"; - promise.value = undefined; - this.chunks.set(id, chunk); return chunk; } + /** + * Lazily create a Promise for a chunk. + * - If already resolved/rejected synchronously, returns an already-settled promise. + * - If still pending, allocates the real Promise with resolve/reject captures. + */ + _ensurePromise(chunk) { + if (chunk._promise !== null) return chunk._promise; + + if (chunk.status === RESOLVED) { + const p = Promise.resolve(chunk.value); + p.status = "fulfilled"; + p.value = chunk.value; + chunk._promise = p; + } else if (chunk.status === REJECTED) { + const err = chunk.type === "streaming" ? chunk.error : chunk.value; + const p = Promise.reject(err); + p.catch(() => {}); // suppress unhandled rejection + p.status = "rejected"; + p.value = err; + chunk._promise = p; + } else { + // Still pending — allocate a real promise + const p = new Promise((res, rej) => { + chunk._resolve = res; + chunk._reject = rej; + }); + p.status = "pending"; + p.value = undefined; + chunk._promise = p; + } + return chunk._promise; + } + /** * Get or create a chunk */ @@ -144,7 +493,9 @@ class FlightResponse { /** * Get or create a streaming chunk (for async iterables, ReadableStream) - * These chunks accumulate values instead of being resolved once + * These chunks accumulate values instead of being resolved once. + * Streaming chunks always allocate a real Promise because they are + * consumed via async iteration / ReadableStream readers. */ getOrCreateStreamingChunk(id) { let chunk = this.chunks.get(id); @@ -155,20 +506,17 @@ class FlightResponse { reject = rej; }); - // Streaming chunks are consumed via wrapper status polling, not via - // promise awaiting. Suppress unhandled-rejection warnings so that - // rejecting a streaming chunk (e.g. on transport error) doesn't crash - // the process in Node.js v24+ where unhandled rejections throw. + // Suppress unhandled-rejection warnings (Node.js v24+) promise.catch(() => {}); chunk = { id, status: PENDING, - value: [], // Array to accumulate streamed values + value: [], type: "streaming", - promise, - resolve, - reject, + _promise: promise, + _resolve: resolve, + _reject: reject, }; promise.status = "pending"; @@ -185,14 +533,18 @@ class FlightResponse { resolveChunk(id, value) { const chunk = this.getChunk(id); if (chunk.status !== PENDING) { - return; // Already resolved + return; } chunk.status = RESOLVED; chunk.value = value; - chunk.promise.status = "fulfilled"; - chunk.promise.value = value; - chunk.resolve(value); + + // If a Promise was already created (someone awaited it), fulfill it + if (chunk._promise !== null) { + chunk._promise.status = "fulfilled"; + chunk._promise.value = value; + chunk._resolve(value); + } } /** @@ -201,19 +553,15 @@ class FlightResponse { rejectChunk(id, error) { const chunk = this.getChunk(id); if (chunk.status !== PENDING) { - return; // Already resolved + return; } chunk.status = REJECTED; - // For streaming chunks, preserve the accumulated values and store error separately if (chunk.type === "streaming" && Array.isArray(chunk.value)) { chunk.error = error; } else { chunk.value = error; } - // If the chunk has a ReadableStream controller, error it so that - // any reader (e.g. an outer renderToReadableStream re-serializing - // this stream) is unblocked and receives the error. if (chunk._controller && !chunk._controllerClosed) { try { chunk._controller.error(error); @@ -222,15 +570,18 @@ class FlightResponse { } chunk._controllerClosed = true; } - chunk.promise.status = "rejected"; - chunk.promise.value = error; - chunk.reject(error); + + if (chunk._promise !== null) { + chunk._promise.status = "rejected"; + chunk._promise.value = error; + chunk._reject(error); + } } /** * Process a line of Flight protocol */ - processLine(line) { + processLine(line, hasSpecialBytes = true) { if (!line) return; // Parse the line: "id:tag{json}" or "id:{json}" or ":tag{data}" (global rows) @@ -303,6 +654,51 @@ class FlightResponse { } else { // Model row - JSON data try { + // Fast path for element tuples: scan the raw JSON string to extract + // header fields (type, key) without parsing the potentially huge props + // object. Props is kept as a raw JSON string and only parsed lazily + // when first accessed via the self-replacing getter. + // + // Element tuples start with '["$",' — check the first 5 chars. + // Only use the scanner for rows > 128 bytes — for tiny elements + // (e.g., `["$","div",null,{}]`), V8's native JSON.parse is faster + // than our character-level scanner. The scanner wins on large rows + // (200KB+) where it avoids parsing the dominant props object. + if ( + rest.length > 128 && + rest.charCodeAt(0) === 0x5b && + rest.charCodeAt(1) === 0x22 && + rest.charCodeAt(2) === 0x24 && + rest.charCodeAt(3) === 0x22 && + rest.charCodeAt(4) === 0x2c + ) { + const scan = _scanElementTuple(rest); + if (scan) { + const value = this.deserializeElementFromScan(scan); + this.resolveChunk(id, value); + return; + } + // Scanner failed (unusual tuple shape) — fall through to full parse + } + + // Fast path for wrapper objects: scan the top-level JSON object to + // find key→value boundaries. Large values (> 128 chars) are kept as + // raw JSON substrings and attached as lazy getters — their JSON.parse + // + deserializeValue cost is deferred until first property access. + // Small values are parsed eagerly. This is particularly effective for + // mixed payloads like {tree: , data: [...], date: "$D..."}. + if (rest.length > 256 && rest.charCodeAt(0) === 0x7b) { + const fields = _scanObjectRow(rest, 128); + if (fields) { + const obj = this._buildLazyObject(rest, fields); + if (obj) { + this.resolveChunk(id, obj); + return; + } + } + // Scanner failed — fall through to full parse + } + const json = JSON.parse(rest); // Check if this is a streaming completion marker @@ -320,39 +716,57 @@ class FlightResponse { return; } - // For object/array values, pre-create the placeholder and defer property resolution - // This allows forward references to work - all chunks get placeholders first, - // then properties are filled in after all chunks are parsed + // Try to resolve the model row immediately. Only fall back to + // deferred resolution when the JSON contains chunk references ($N) + // that haven't been resolved yet — which requires placeholders so + // other chunks can reference this one before its properties are filled. + // + // For the common case (data payloads, inlined values), this avoids: + // • _areDepsResolved — recursive dependency scan + // • deferred placeholder + property-copy pass + // • collectPathRefSentinels — recursive sentinel scan const chunk = this.getChunk(id); let value; - if (json && typeof json === "object" && !Array.isArray(json)) { - // Create object placeholder - properties will be filled later - value = {}; - chunk.status = RESOLVED; - chunk.value = value; - chunk._rawJson = json; // Store raw json for immediate access if needed - chunk.promise.status = "fulfilled"; - chunk.promise.value = value; - chunk.resolve(value); - // Defer property resolution until all chunks are parsed - this.deferredChunks.push({ type: "object", value, json, chunk }); - } else if (Array.isArray(json)) { - // Check for element tuple: ["$", type, key, ref, props] - // These shouldn't have circular refs, so process normally - if (json[0] === "$" && json.length >= 3) { + if (Array.isArray(json) && json[0] === "$" && json.length >= 3) { + // React element tuple — always resolved immediately (scanner missed this one) + value = this.deserializeValue(json); + this.resolveChunk(id, value); + } else if (json && typeof json === "object") { + // Object or array — try immediate resolution. + // + // Fast path: hasSpecialBytes is pre-computed from raw bytes in + // processData using single-byte memchr (~5x faster than 2-char + // string indexOf on large payloads). When no '$' or '@' bytes + // exist, the JSON.parse result needs zero transformation — skip + // the entire deserializeValue tree walk. This turns a 10K-element + // array from O(n) walk to O(1). + if (!hasSpecialBytes) { + // Pure data — JSON.parse result is the final value. + // Mark chunk so Map/Set builders can skip deserializeValue. + const chunk = this.getChunk(id); + chunk._plainData = true; + this.resolveChunk(id, json); + } else if (this._areDepsResolved(json)) { value = this.deserializeValue(json); this.resolveChunk(id, value); } else { - // Regular array - Create array placeholder, defer element resolution - value = Array.from({ length: json.length }); + // Forward/circular references exist — use deferred path + const isArray = Array.isArray(json); + value = isArray ? Array.from({ length: json.length }) : {}; chunk.status = RESOLVED; chunk.value = value; - chunk._rawJson = json; // Store raw json for immediate access if needed - chunk.promise.status = "fulfilled"; - chunk.promise.value = value; - chunk.resolve(value); - // Defer element resolution until all chunks are parsed - this.deferredChunks.push({ type: "array", value, json, chunk }); + chunk._rawJson = json; + if (chunk._promise !== null) { + chunk._promise.status = "fulfilled"; + chunk._promise.value = value; + chunk._resolve(value); + } + this.deferredChunks.push({ + type: isArray ? "array" : "object", + value, + json, + chunk, + }); } } else { value = this.deserializeValue(json); @@ -375,14 +789,11 @@ class FlightResponse { status: PENDING, value: [], type: "text", - promise: null, - resolve: null, - reject: null, + _promise: null, + _resolve: null, + _reject: null, }; - chunk.promise = new Promise((resolve, reject) => { - chunk.resolve = resolve; - chunk.reject = reject; - }); + this._ensurePromise(chunk); this.chunks.set(id, chunk); } else if (!chunk.type) { // Existing chunk that wasn't marked as text - upgrade it @@ -398,7 +809,7 @@ class FlightResponse { // If this chunk has a stream controller, push data if (chunk._controller) { try { - chunk._controller.enqueue(new TextEncoder().encode(textContent)); + chunk._controller.enqueue(_encoder.encode(textContent)); } catch { // Controller may be closed } @@ -413,19 +824,15 @@ class FlightResponse { appendBinaryChunk(id, base64Content) { let chunk = this.chunks.get(id); if (!chunk) { - // Create a streaming binary chunk chunk = { status: PENDING, value: [], type: "binary", - promise: null, - resolve: null, - reject: null, + _promise: null, + _resolve: null, + _reject: null, }; - chunk.promise = new Promise((resolve, reject) => { - chunk.resolve = resolve; - chunk.reject = reject; - }); + this._ensurePromise(chunk); this.chunks.set(id, chunk); } else if (!chunk.type) { // Existing chunk that wasn't marked as binary - upgrade it @@ -478,20 +885,18 @@ class FlightResponse { // Controller may already be closed } } - chunk.resolve(chunk.value); + chunk._resolve(chunk.value); return; } if (chunk.type === "text") { - // Concatenate all text chunks const fullText = chunk.value.join(""); chunk.status = RESOLVED; chunk.value = fullText; - chunk.promise.status = "fulfilled"; - chunk.promise.value = fullText; - chunk.resolve(fullText); + chunk._promise.status = "fulfilled"; + chunk._promise.value = fullText; + chunk._resolve(fullText); } else if (chunk.type === "binary") { - // Concatenate all binary chunks const totalLength = chunk.value.reduce( (sum, arr) => sum + arr.byteLength, 0 @@ -503,14 +908,12 @@ class FlightResponse { offset += arr.byteLength; } - // Create the appropriate type based on metadata let finalValue; if (metadata.type === "ArrayBuffer") { finalValue = result.buffer; } else if (metadata.type === "Blob") { finalValue = new Blob([result], { type: metadata.mimeType || "" }); } else { - // TypedArray - need to construct the right type finalValue = createTypedArray( metadata.type, result.buffer, @@ -520,9 +923,9 @@ class FlightResponse { chunk.status = RESOLVED; chunk.value = finalValue; - chunk.promise.status = "fulfilled"; - chunk.promise.value = finalValue; - chunk.resolve(finalValue); + chunk._promise.status = "fulfilled"; + chunk._promise.value = finalValue; + chunk._resolve(finalValue); } } @@ -615,7 +1018,12 @@ class FlightResponse { } /** - * Deserialize a value from Flight format + * Deserialize a value from Flight format. + * + * Uses copy-on-write: if no property in an object/array changes during + * deserialization, the original JSON-parsed object is returned directly — + * avoiding an allocation + property copy for the common case where values + * are plain primitives/strings with no protocol-special prefixes. */ deserializeValue(value) { if (value === null || value === undefined) { @@ -640,7 +1048,22 @@ class FlightResponse { if (value[0] === "$" && value.length >= 3) { return this.deserializeElement(value); } - return value.map((item) => this.deserializeValue(item)); + // Copy-on-write: only allocate a new array if an element changes + let result = value; + for (let i = 0; i < value.length; i++) { + const orig = value[i]; + const deserialized = this.deserializeValue(orig); + if (deserialized !== orig) { + if (result === value) { + // First change — shallow-copy elements seen so far + result = value.slice(0, i); + } + } + if (result !== value) { + result.push(deserialized); + } + } + return result; } if (typeof value === "object") { @@ -654,9 +1077,23 @@ class FlightResponse { ) { return value; } - const result = {}; - for (const key of Object.keys(value)) { - result[key] = this.deserializeValue(value[key]); + // Copy-on-write: only allocate a new object if a property value changes + const keys = Object.keys(value); + let result = value; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const orig = value[key]; + const deserialized = this.deserializeValue(orig); + if (deserialized !== orig) { + if (result === value) { + // First change — copy properties seen so far + result = {}; + for (let j = 0; j < i; j++) result[keys[j]] = value[keys[j]]; + } + } + if (result !== value) { + result[key] = deserialized; + } } return result; } @@ -668,27 +1105,32 @@ class FlightResponse { * Deserialize a string value */ deserializeString(value) { - // Handle @@ escaped strings first (literal @ prefix) - if (value.startsWith("@@")) { - return value.slice(1); // Remove the escape @ - } - - // Handle $@ references (Promise references - React format) - if (value.startsWith("$@")) { - const id = parseInt(value.slice(2), 10); - const chunk = this.getChunk(id); - return chunk.promise; + // Fast exit for the overwhelmingly common case: plain strings with no + // protocol prefix. Checking the first char code avoids the cost of + // multiple startsWith calls on every string in the payload. + const ch = value.charCodeAt(0); + // 0x24 = '$', 0x40 = '@' + if (ch !== 0x24 && ch !== 0x40) { + return value; } - // Handle @ references (Promise references - legacy format for backward compatibility) - if (value.startsWith("@")) { + // Handle @ prefixes + if (ch === 0x40) { + // @@ escaped strings (literal @ prefix) + if (value.charCodeAt(1) === 0x40) { + return value.slice(1); + } + // @ or $@ references (Promise references) const id = parseInt(value.slice(1), 10); const chunk = this.getChunk(id); - return chunk.promise; + return this._ensurePromise(chunk); } - if (!value.startsWith("$")) { - return value; + // Handle $@ references (Promise references - React format) + if (value.charCodeAt(1) === 0x40) { + const id = parseInt(value.slice(2), 10); + const chunk = this.getChunk(id); + return this._ensurePromise(chunk); } if (value === "$undefined") { @@ -763,65 +1205,42 @@ class FlightResponse { } if (value.startsWith("$Q")) { - // Map - either $Q with JSON entries or $Q with row reference + // Map — $Q followed by a chunk id (digit) or inline JSON const rest = value.slice(2); - // Check if it's a row reference (just a number) - if (/^\d+$/.test(rest)) { - // Row reference to map entries + const ch2 = rest.charCodeAt(0); + if (ch2 >= 0x30 && ch2 <= 0x39) { + // Row reference ($Q) const id = parseInt(rest, 10); const chunk = this.getChunk(id); if (chunk.status === RESOLVED) { - // Use raw json if available (deferred resolution case), otherwise use value const entries = chunk._rawJson || chunk.value; - return new Map( - entries.map(([k, v]) => [ - this.deserializeValue(k), - this.deserializeValue(v), - ]) - ); + // If the entries chunk was pure data (no $ or @ in its row), + // all keys/values are plain — skip per-entry deserializeValue. + return this._buildMap(entries, !!chunk._plainData); } - // Return a promise that resolves to the map - return chunk.promise.then( - (entries) => - new Map( - entries.map(([k, v]) => [ - this.deserializeValue(k), - this.deserializeValue(v), - ]) - ) + return this._ensurePromise(chunk).then((entries) => + this._buildMap(entries) ); } - // Inline JSON format - const entries = JSON.parse(rest); - return new Map( - entries.map(([k, v]) => [ - this.deserializeValue(k), - this.deserializeValue(v), - ]) - ); + return this._buildMap(JSON.parse(rest)); } if (value.startsWith("$W")) { - // Set - either $W with JSON items or $W with row reference + // Set — $W followed by a chunk id (digit) or inline JSON const rest = value.slice(2); - // Check if it's a row reference (just a number) - if (/^\d+$/.test(rest)) { - // Row reference to set items + const ch2 = rest.charCodeAt(0); + if (ch2 >= 0x30 && ch2 <= 0x39) { const id = parseInt(rest, 10); const chunk = this.getChunk(id); if (chunk.status === RESOLVED) { - // Use raw json if available (deferred resolution case), otherwise use value const items = chunk._rawJson || chunk.value; - return new Set(items.map((item) => this.deserializeValue(item))); + return this._buildSet(items, !!chunk._plainData); } - // Return a promise that resolves to the set - return chunk.promise.then( - (items) => new Set(items.map((item) => this.deserializeValue(item))) + return this._ensurePromise(chunk).then((items) => + this._buildSet(items) ); } - // Inline JSON format - const items = JSON.parse(rest); - return new Set(items.map((item) => this.deserializeValue(item))); + return this._buildSet(JSON.parse(rest)); } if (value.startsWith("$Y")) { @@ -955,7 +1374,7 @@ class FlightResponse { } // Chunk not yet resolved — shouldn't normally happen since outlined chunks // are emitted before the model row that references them - return chunk.promise.then((model) => { + return this._ensurePromise(chunk).then((model) => { const id = model.id; const bound = model.bound; if (bound && Array.isArray(bound) && bound.length > 0) { @@ -1035,14 +1454,14 @@ class FlightResponse { // Binary stream reference (large TypedArray/ArrayBuffer) const id = parseInt(value.slice(2), 16); const chunk = this.getChunk(id); - return chunk.promise; + return this._ensurePromise(chunk); } if (value.startsWith("$B")) { // Blob stream reference const id = parseInt(value.slice(2), 16); const chunk = this.getChunk(id); - return chunk.promise; + return this._ensurePromise(chunk); } if (value.startsWith("$r")) { @@ -1059,40 +1478,36 @@ class FlightResponse { return this.createAsyncIterableWrapper(chunk); } - // Handle generic chunk references ($1, $2, etc.) - React uses these for deferred values - // This must be after all specific $ handlers to avoid conflicts - if (/^\$\d+$/.test(value)) { - const id = parseInt(value.slice(1), 10); - const chunk = this.getChunk(id); - if (chunk.status === RESOLVED) { - // Return the resolved value directly (don't re-deserialize) - return chunk.value; - } - // Return a promise for async resolution - return chunk.promise.then((v) => v); - } - - // Handle path-based references ($0:path:to:prop) - React uses these for object identity - // Format: $rowId:key1:key2:... where each key navigates into the object - if (/^\$\d+:/.test(value)) { - const colonIndex = value.indexOf(":"); - const id = parseInt(value.slice(1, colonIndex), 10); - const path = value.slice(colonIndex + 1); - const chunk = this.getChunk(id); - - if (chunk.status === RESOLVED) { - // During deferred resolution, all path refs should be deferred to a second pass. - // The path may reference properties that are still being filled in this pass. - if (this._resolvingDeferred) { - // Return a sentinel that will be resolved in second pass - const sentinel = { __pathRef: true, id, path }; - return sentinel; + // Handle generic chunk references ($1, $2, etc.) and path references ($1:key:...) + // The second char must be a digit (0x30-0x39) to distinguish from named prefixes + // like $S, $D, $Q etc. that were already handled above. + { + const ch1 = value.charCodeAt(1); + if (ch1 >= 0x30 && ch1 <= 0x39) { + const colonIndex = value.indexOf(":"); + if (colonIndex === -1) { + // Simple chunk ref: $N + const id = parseInt(value.slice(1), 10); + const chunk = this.getChunk(id); + if (chunk.status === RESOLVED) { + return chunk.value; + } + return this._ensurePromise(chunk).then((v) => v); + } + // Path ref: $N:path:to:prop + const id = parseInt(value.slice(1, colonIndex), 10); + const path = value.slice(colonIndex + 1); + const chunk = this.getChunk(id); + if (chunk.status === RESOLVED) { + if (this._resolvingDeferred) { + return { __pathRef: true, id, path }; + } + return this.resolvePath(chunk.value, path); } - // Navigate the path to find the referenced value - return this.resolvePath(chunk.value, path); + return this._ensurePromise(chunk).then((v) => + this.resolvePath(v, path) + ); } - // Return a promise for async resolution - return chunk.promise.then((v) => this.resolvePath(v, path)); } return value; @@ -1128,7 +1543,7 @@ class FlightResponse { for (const item of chunk.value) { try { if (chunk.type === "text" || typeof item === "string") { - controller.enqueue(new TextEncoder().encode(item)); + controller.enqueue(_encoder.encode(item)); } else { controller.enqueue(item); } @@ -1155,7 +1570,7 @@ class FlightResponse { "The stream was cancelled", "AbortError" ); - chunk.reject(chunk.error); + chunk._reject(chunk.error); } }, }); @@ -1198,76 +1613,316 @@ class FlightResponse { * Deserialize a React element from tuple format */ deserializeElement(tuple) { - // Multiple formats supported: - // React 19 (react-server-dom-webpack): ["$", type, key, props, owner?, debugInfo?, debugStack?] - // @lazarv/rsc: ["$", type, key, props] or ["$", type, key, ref, props] - let type, key, ref, props; + // Formats: + // React 19: ["$", type, key, props, owner?, debugInfo?, debugStack?] + // Legacy: ["$", type, key, ref, props] + // + // Heuristic: if the 4th element is a non-array object (or undefined), + // it's props (React 19 format). Otherwise it's a ref (legacy). + const rawType = tuple[1]; + const rawKey = tuple[2]; + + // Deserialize type — usually a plain string (e.g., "div") which + // deserializeString returns unchanged via the fast charCode path. + const type = + typeof rawType === "string" + ? this.deserializeString(rawType) + : this.deserializeValue(rawType); + + const fourth = tuple[3]; + const isLegacy = + tuple.length === 5 || (fourth === null && tuple[4] !== undefined); + const isReact19 = + !isLegacy && + fourth !== undefined && + fourth !== null && + typeof fourth === "object" && + !Array.isArray(fourth); + + // For trivial cases (no props), resolve immediately — no getter overhead + if (!isLegacy && !isReact19) { + return _devElement({ + $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, + type, + key: rawKey !== undefined ? rawKey : null, + ref: null, + props: {}, + }); + } - if (tuple.length >= 4) { - // Try to determine format by examining the 4th element - const fourthElement = tuple[3]; + // Determine raw props source for lazy deserialization + const rawProps = isLegacy ? tuple[4] : fourth; + const rawRef = isLegacy ? fourth : null; + + // If rawProps is falsy (null/undefined), resolve immediately + if (!rawProps) { + return _devElement({ + $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, + type, + key: rawKey !== undefined ? rawKey : null, + ref: + rawRef !== null && rawRef !== undefined + ? this.deserializeValue(rawRef) + : null, + props: {}, + }); + } - // If 4th element is an object and not null, it could be props (React 19) or ref - if ( - typeof fourthElement === "object" && - fourthElement !== null && - !Array.isArray(fourthElement) - ) { - // React 19 format: ["$", type, key, props, ...] - [, type, key, props] = tuple; - const deserializedProps = props ? this.deserializeValue(props) : {}; - ref = deserializedProps.ref; - props = deserializedProps; - } else if ( - tuple.length === 5 || - (tuple.length === 4 && typeof fourthElement !== "object") - ) { - // Legacy format: ["$", type, key, ref, props] - [, type, key, ref, props] = tuple; - ref = ref !== undefined ? this.deserializeValue(ref) : null; - props = props ? this.deserializeValue(props) : {}; - } else { - // Fallback: treat as React 19 format - [, type, key, props] = tuple; - const deserializedProps = props ? this.deserializeValue(props) : {}; - ref = deserializedProps.ref; - props = deserializedProps; + // For small props objects without nested elements or references, + // eager resolution is faster (avoids Object.defineProperty + closure). + // Use lazy getter only when props is large enough to benefit. + const propKeys = Object.keys(rawProps); + let isSimpleProps = propKeys.length <= 4; + if (isSimpleProps) { + for (let k = 0; k < propKeys.length; k++) { + const v = rawProps[propKeys[k]]; + if ( + typeof v === "string" && + v.length > 0 && + (v.charCodeAt(0) === 0x24 || v.charCodeAt(0) === 0x40) + ) { + isSimpleProps = false; + break; + } + if (typeof v === "object" && v !== null) { + isSimpleProps = false; + break; + } } - } else { - [, type, key, ref, props] = tuple; - ref = ref !== undefined ? this.deserializeValue(ref) : null; - props = props ? this.deserializeValue(props) : {}; } - const deserializedType = this.deserializeValue(type); - const deserializedKey = key !== undefined ? key : null; + if (isSimpleProps) { + // Eager path — deserialize props inline, no lazy getter overhead + const props = this.deserializeValue(rawProps); + const isR19 = !isLegacy; + return _devElement({ + $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, + type, + key: rawKey !== undefined ? rawKey : null, + ref: isR19 + ? props.ref || null + : rawRef !== null && rawRef !== undefined + ? this.deserializeValue(rawRef) + : null, + props, + }); + } - const element = { - $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, - type: deserializedType, - key: deserializedKey, - ref: ref || null, - props: props, - }; + // Lazy props deserialization via self-replacing getter. + // React elements in large trees are often only partially consumed + // (e.g., a list of 1000 items where only 50 are visible). Deferring + // props deserialization until first access avoids recursively walking + // the entire element tree upfront. + return this._makeElementWithLazyProps( + type, + rawKey, + rawRef, + rawProps, + false, + !isLegacy + ); + } + + /** + * Create an element from a scan result (partial JSON parse). + * Props are still a raw JSON string — JSON.parse is deferred to the lazy getter. + */ + deserializeElementFromScan(scan) { + // Deserialize type — usually a plain string (e.g., "div") + const type = + typeof scan.type === "string" + ? this.deserializeString(scan.type) + : this.deserializeValue(scan.type); + + const rawPropsStr = scan.rawPropsStr; + + // Trivial: no props string or "null" / "{}" + if (!rawPropsStr || rawPropsStr === "null") { + return _devElement({ + $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, + type, + key: scan.key !== undefined ? scan.key : null, + ref: scan.rawRefStr + ? this.deserializeValue(JSON.parse(scan.rawRefStr)) + : null, + props: {}, + }); + } - // Add development properties expected by React's dev mode - if (__IS_DEV__) { - element._owner = null; - element._store = { validated: 1 }; - element._debugStack = new Error("react-stack-top-frame"); - element._debugTask = null; - element._debugInfo = null; + if (rawPropsStr === "{}") { + return _devElement({ + $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, + type, + key: scan.key !== undefined ? scan.key : null, + ref: scan.rawRefStr + ? this.deserializeValue(JSON.parse(scan.rawRefStr)) + : null, + props: {}, + }); } + // Determine if legacy (has rawRefStr) or React 19 + const isLegacy = scan.rawRefStr !== null; + const rawRef = isLegacy && scan.rawRefStr ? scan.rawRefStr : null; + + return this._makeElementWithLazyProps( + type, + scan.key, + rawRef, + rawPropsStr, + true, + !isLegacy + ); + } + + /** + * Shared implementation for creating elements with lazy props. + * @param {*} type - Already-deserialized element type + * @param {*} rawKey - Raw key value + * @param {*} rawRef - Raw ref (string for scan path, object for tuple path), or null + * @param {*} rawProps - Either a parsed object (tuple path) or a raw JSON string (scan path) + * @param {boolean} propsIsString - true if rawProps is a JSON string needing JSON.parse first + * @param {boolean} isReact19 - true if React 19 format (ref is inside props) + */ + _makeElementWithLazyProps( + type, + rawKey, + rawRef, + rawProps, + propsIsString, + isReact19 + ) { + const response = this; + const element = _devElement({ + $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, + type, + key: rawKey !== undefined ? rawKey : null, + ref: + !isReact19 && rawRef != null + ? propsIsString + ? response.deserializeValue(JSON.parse(rawRef)) + : response.deserializeValue(rawRef) + : null, + }); + + let _cachedProps; + Object.defineProperty(element, "props", { + get() { + if (_cachedProps === undefined) { + // If props is a raw JSON string, parse it first, then deserialize + const parsed = propsIsString ? JSON.parse(rawProps) : rawProps; + _cachedProps = response.deserializeValue(parsed); + if (isReact19) { + element.ref = _cachedProps.ref || null; + } + // Replace getter with plain data property for subsequent accesses + Object.defineProperty(element, "props", { + value: _cachedProps, + writable: true, + enumerable: true, + configurable: true, + }); + } + return _cachedProps; + }, + set(v) { + _cachedProps = v; + Object.defineProperty(element, "props", { + value: v, + writable: true, + enumerable: true, + configurable: true, + }); + }, + enumerable: true, + configurable: true, + }); + return element; } + /** + * Build an object from scanned field descriptors, using lazy getters for + * large values and eager JSON.parse + deserializeValue for small ones. + * + * Returns the constructed object, or null if any field can't be handled + * (caller falls back to full JSON.parse). + */ + _buildLazyObject(str, fields) { + const response = this; + const obj = {}; + + for (let f = 0; f < fields.length; f++) { + const { key, valueStart, valueEnd, small } = fields[f]; + + if (small) { + // Small value — parse and deserialize eagerly (cheap) + const raw = str.slice(valueStart, valueEnd); + const parsed = JSON.parse(raw); + obj[key] = response.deserializeValue(parsed); + } else { + // Large value — defer JSON.parse + deserializeValue to first access. + // Capture `valueStart` and `valueEnd` in the closure; `str` is shared + // across all fields (one allocation for the whole row string). + const vs = valueStart; + const ve = valueEnd; + let _cached; + Object.defineProperty(obj, key, { + get() { + if (_cached === undefined) { + const raw = str.slice(vs, ve); + // Check if this is an element tuple ["$",... + if ( + raw.length > 5 && + raw.charCodeAt(0) === 0x5b && + raw.charCodeAt(1) === 0x22 && + raw.charCodeAt(2) === 0x24 && + raw.charCodeAt(3) === 0x22 && + raw.charCodeAt(4) === 0x2c + ) { + const scan = _scanElementTuple(raw); + if (scan) { + _cached = response.deserializeElementFromScan(scan); + } else { + _cached = response.deserializeValue(JSON.parse(raw)); + } + } else { + _cached = response.deserializeValue(JSON.parse(raw)); + } + // Replace getter with plain property + Object.defineProperty(obj, key, { + value: _cached, + writable: true, + enumerable: true, + configurable: true, + }); + } + return _cached; + }, + set(v) { + _cached = v; + Object.defineProperty(obj, key, { + value: v, + writable: true, + enumerable: true, + configurable: true, + }); + }, + enumerable: true, + configurable: true, + }); + } + } + + return obj; + } + /** * Create a lazy wrapper for a pending chunk * Supports async module loading via moduleLoader.requireModule */ createLazyWrapper(chunk) { - // Return a thenable that resolves to the chunk value + const response = this; const lazy = { $$typeof: Symbol.for("react.lazy"), _payload: chunk, @@ -1341,7 +1996,7 @@ class FlightResponse { if (payload.status === REJECTED) { throw payload.value; } - throw payload.promise; + throw response._ensurePromise(payload); }, }; return lazy; @@ -1387,7 +2042,37 @@ class FlightResponse { // G = Float32Array, g = Float64Array // M = BigInt64Array, m = BigUint64Array // V = DataView - return "AOoUSsLlGgMmV".includes(char); + // T = large text string (length-prefixed, same framing as typed arrays) + return "TAOoUSsLlGgMmV".includes(char); + } + + /** + * Check if a byte value is a length-prefixed row tag (binary or text). + * Same set as isBinaryRowTag but operates on raw byte values for the + * fast path in processData that avoids decoding the full payload. + */ + isBinaryRowByte(byte) { + // T=0x54 A=0x41 O=0x4f o=0x6f U=0x55 S=0x53 s=0x73 L=0x4c l=0x6c + // G=0x47 g=0x67 M=0x4d m=0x6d V=0x56 + switch (byte) { + case 0x54: + case 0x41: + case 0x4f: + case 0x6f: + case 0x55: + case 0x53: + case 0x73: + case 0x4c: + case 0x6c: + case 0x47: + case 0x67: + case 0x4d: + case 0x6d: + case 0x56: + return true; + default: + return false; + } } /** @@ -1399,7 +2084,7 @@ class FlightResponse { // Convert to bytes if string let bytes; if (typeof data === "string") { - bytes = new TextEncoder().encode(data); + bytes = _encoder.encode(data); } else { bytes = data; } @@ -1422,58 +2107,65 @@ class FlightResponse { // Process bytes let offset = 0; while (offset < bytes.length) { - // Find the next newline - let newlineIndex = -1; - for (let i = offset; i < bytes.length; i++) { - if (bytes[i] === 0x0a) { - // \n - newlineIndex = i; - break; - } - } - - if (newlineIndex === -1) { - // No complete line, save to binary buffer - this.binaryBuffer = bytes.slice(offset); - break; - } + // Fast path: detect length-prefixed binary/text rows directly from raw + // bytes. This avoids decoding the entire (potentially 100KB+) payload + // to a JS string just to read a small header. + // Format: id:TAG, + // The colon (0x3A) separates the numeric id from the tag byte. + const colonIdx = bytes.indexOf(0x3a, offset); // ':' + if (colonIdx !== -1 && colonIdx < bytes.length - 1) { + const tagByte = bytes[colonIdx + 1]; + if (this.isBinaryRowByte(tagByte)) { + // Find the comma that terminates the hex length. + // Limit search to a small window — hex lengths are at most ~8 chars. + const searchEnd = Math.min(colonIdx + 20, bytes.length); + let commaIdx = -1; + for (let j = colonIdx + 2; j < searchEnd; j++) { + const b = bytes[j]; + if (b === 0x2c) { + // ',' + commaIdx = j; + break; + } + // Hex digit? 0-9 (0x30-0x39), a-f (0x61-0x66), A-F (0x41-0x46) + if ( + (b >= 0x30 && b <= 0x39) || + (b >= 0x61 && b <= 0x66) || + (b >= 0x41 && b <= 0x46) + ) { + continue; + } + // Non-hex, non-comma → not a length-prefixed row (e.g. streaming T row) + break; + } + if (commaIdx !== -1) { + // Parse id from ASCII digits (byte arithmetic — avoids TextDecoder) + let id = 0; + for (let j = offset; j < colonIdx; j++) { + id = id * 10 + (bytes[j] - 0x30); + } + // Parse hex length from bytes (byte arithmetic) + let length = 0; + for (let j = colonIdx + 2; j < commaIdx; j++) { + const b = bytes[j]; + length = + (length << 4) | (b <= 0x39 ? b - 0x30 : (b | 0x20) - 0x61 + 10); + } + const dataStart = commaIdx + 1; + const dataEnd = dataStart + length; - // Extract the line as text - const lineBytes = bytes.slice(offset, newlineIndex); - const line = new TextDecoder().decode(lineBytes); - - // Check if this is a binary row (id:TAG,) - // React always uses hex for binary lengths - const colonIndex = line.indexOf(":"); - if (colonIndex !== -1 && colonIndex < line.length) { - const tag = line[colonIndex + 1]; - if (this.isBinaryRowTag(tag)) { - // This is a binary row - parse the hex length - const afterTag = line.slice(colonIndex + 2); - const commaIndex = afterTag.indexOf(","); - if (commaIndex !== -1) { - const id = parseInt(line.slice(0, colonIndex), 10); - const lengthStr = afterTag.slice(0, commaIndex); - const length = parseInt(lengthStr, 16); // Always hex - - // Calculate where binary data starts - const headerLength = colonIndex + 1 + 1 + commaIndex + 1; // id: + tag + length + , - const binaryStart = offset + headerLength; - const binaryEnd = binaryStart + length; - - if (binaryEnd <= bytes.length) { - // We have all the binary data - const binaryData = bytes.slice(binaryStart, binaryEnd); - this.processBinaryRow(id, tag, binaryData); - offset = binaryEnd; + if (dataEnd <= bytes.length) { + const binaryData = bytes.subarray(dataStart, dataEnd); + this.processBinaryRow(id, tagByte, binaryData); + offset = dataEnd; continue; } else { - // Need more data for binary row + // Need more data for this row this.pendingBinaryRow = { id, - tag, + tag: tagByte, length, - data: bytes.slice(binaryStart), + data: bytes.slice(dataStart), }; return; } @@ -1481,8 +2173,24 @@ class FlightResponse { } } - // Regular text line - this.processLine(line); + // Regular newline-delimited row + const newlineIndex = bytes.indexOf(0x0a, offset); + + if (newlineIndex === -1) { + // No complete line, save to binary buffer + this.binaryBuffer = bytes.slice(offset); + break; + } + + // Pre-check raw bytes for protocol prefix characters ($ = 0x24, @ = 0x40). + // Single-byte Uint8Array.includes uses native memchr — ~5x faster than + // 2-char String.indexOf on large payloads (e.g., 0.2ms vs 1.0ms on 452KB). + // For multi-line payloads this may over-scan past the current line, which + // is conservative (takes the slower-but-correct path). + const segment = bytes.subarray(offset, newlineIndex); + const hasSpecialBytes = segment.includes(0x24) || segment.includes(0x40); + const line = _decoder.decode(segment); + this.processLine(line, hasSpecialBytes); offset = newlineIndex + 1; } } @@ -1515,50 +2223,64 @@ class FlightResponse { /** * Process a complete binary row */ - processBinaryRow(id, tag, binaryData) { - // Map tag to TypedArray constructor (React's actual mapping) - const TypedArrayMap = { - A: ArrayBuffer, - O: Int8Array, - o: Uint8Array, - U: Uint8ClampedArray, - S: Int16Array, - s: Uint16Array, - L: Int32Array, - l: Uint32Array, - G: Float32Array, - g: Float64Array, - M: BigInt64Array, - m: BigUint64Array, - V: DataView, - }; - - const Constructor = TypedArrayMap[tag]; - if (Constructor) { - let value; - if (Constructor === DataView) { + processBinaryRow(id, tagByte, binaryData) { + // Tag byte → typed array constructor. Uses a switch instead of per-call + // object-literal lookup. Tag is passed as a byte code, not a string. + let value; + switch (tagByte) { + // T (0x54) — Large text string: decode raw UTF-8 bytes to JS string + case 0x54: + this.resolveChunk(id, _decoder.decode(binaryData)); + return; + // A (0x41) — ArrayBuffer + case 0x41: + value = binaryData.buffer.slice( + binaryData.byteOffset, + binaryData.byteOffset + binaryData.byteLength + ); + break; + // V (0x56) — DataView + case 0x56: value = new DataView( binaryData.buffer.slice( binaryData.byteOffset, binaryData.byteOffset + binaryData.byteLength ) ); - } else if (Constructor === ArrayBuffer) { - value = binaryData.buffer.slice( + break; + // o (0x6f) — Uint8Array (1-byte aligned, always zero-copy view) + case 0x6f: + value = new Uint8Array( + binaryData.buffer, binaryData.byteOffset, - binaryData.byteOffset + binaryData.byteLength + binaryData.byteLength ); - } else { - // Ensure proper alignment by copying to new buffer - const buffer = new ArrayBuffer(binaryData.length); - new Uint8Array(buffer).set(binaryData); - value = new Constructor(buffer); + break; + // All other typed arrays — zero-copy view when aligned, copy otherwise + default: { + const Constructor = _binaryTagConstructors[tagByte]; + if (Constructor) { + const bpe = Constructor.BYTES_PER_ELEMENT; + if (binaryData.byteOffset % bpe === 0) { + // Data is already aligned — create a view without copying. + // Same optimization React's Flight client uses. + value = new Constructor( + binaryData.buffer, + binaryData.byteOffset, + binaryData.byteLength / bpe + ); + } else { + // Unaligned — copy to a fresh buffer for proper alignment + const buffer = new ArrayBuffer(binaryData.length); + new Uint8Array(buffer).set(binaryData); + value = new Constructor(buffer); + } + } else { + value = new Uint8Array(binaryData); + } } - this.resolveChunk(id, value); - } else { - // Unknown binary type, store as Uint8Array - this.resolveChunk(id, new Uint8Array(binaryData)); } + this.resolveChunk(id, value); } /** @@ -1572,14 +2294,11 @@ class FlightResponse { status: PENDING, value: [], type: "binary", - promise: null, - resolve: null, - reject: null, + _promise: null, + _resolve: null, + _reject: null, }; - chunk.promise = new Promise((resolve, reject) => { - chunk.resolve = resolve; - chunk.reject = reject; - }); + this._ensurePromise(chunk); this.chunks.set(id, chunk); } @@ -1599,6 +2318,9 @@ class FlightResponse { * queue for the next call. */ resolveDeferredChunks() { + // Fast exit — called after every reader.read(), usually empty. + if (this.deferredChunks.length === 0) return; + // Separate deferred chunks into ready (all deps resolved) and not-ready. const ready = []; const notReady = []; @@ -1666,6 +2388,48 @@ class FlightResponse { } } + /** + * Build a Map from entries, deserializing keys/values only when needed. + * When plainData is true, entries are known to contain no protocol + * prefixes — skip deserializeValue entirely (avoids 400+ function calls + * for a 100-entry map). + */ + _buildMap(entries, plainData = false) { + const map = new Map(); + if (plainData) { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + map.set(entry[0], entry[1]); + } + } else { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + map.set( + this.deserializeValue(entry[0]), + this.deserializeValue(entry[1]) + ); + } + } + return map; + } + + /** + * Build a Set from items, deserializing values only when needed. + */ + _buildSet(items, plainData = false) { + const set = new Set(); + if (plainData) { + for (let i = 0; i < items.length; i++) { + set.add(items[i]); + } + } else { + for (let i = 0; i < items.length; i++) { + set.add(this.deserializeValue(items[i])); + } + } + return set; + } + /** * Check whether all chunk references in a JSON value are resolved. * Returns false if any $N reference points to a still-pending chunk. @@ -1673,7 +2437,12 @@ class FlightResponse { _areDepsResolved(json) { if (typeof json === "string") { // Check $N (chunk ref) and $N:path (path ref) - if (/^\$\d+/.test(json)) { + // Fast: first char must be '$' (0x24), second char must be a digit (0x30–0x39) + if ( + json.charCodeAt(0) === 0x24 && + json.charCodeAt(1) >= 0x30 && + json.charCodeAt(1) <= 0x39 + ) { const colonIndex = json.indexOf(":"); const idStr = colonIndex === -1 ? json.slice(1) : json.slice(1, colonIndex); @@ -1690,10 +2459,17 @@ class FlightResponse { if (json[0] === "$" && json.length >= 3) { return true; } - return json.every((item) => this._areDepsResolved(item)); + for (let i = 0; i < json.length; i++) { + if (!this._areDepsResolved(json[i])) return false; + } + return true; } if (json && typeof json === "object") { - return Object.values(json).every((v) => this._areDepsResolved(v)); + const vals = Object.values(json); + for (let i = 0; i < vals.length; i++) { + if (!this._areDepsResolved(vals[i])) return false; + } + return true; } return true; } @@ -1707,6 +2483,10 @@ class FlightResponse { if (visited.has(value)) return; visited.add(value); + // Skip React elements — their props use lazy getters; traversing + // into them would trigger eager deserialization defeating the purpose. + if (value.$$typeof === REACT_TRANSITIONAL_ELEMENT_TYPE) return; + if (value.__pathRef) { // Found a sentinel locations.push({ target: parent, key, sentinel: value }); @@ -1732,7 +2512,7 @@ class FlightResponse { * Get the root value (as a promise) */ getRootValue() { - return this.rootChunk.promise; + return this._ensurePromise(this.rootChunk); } } @@ -1772,8 +2552,14 @@ export function createFromReadableStream(stream, options = {}) { // Process any remaining binary buffer if (response.binaryBuffer && response.binaryBuffer.length > 0) { - const line = new TextDecoder().decode(response.binaryBuffer); - response.processLine(line); + // Append a newline so processData's newline-based parsing can handle + // both regular lines and length-prefixed rows in the remaining buffer. + const remaining = response.binaryBuffer; + response.binaryBuffer = null; + const withNewline = new Uint8Array(remaining.length + 1); + withNewline.set(remaining); + withNewline[remaining.length] = 0x0a; + response.processData(withNewline); } } finally { reader.releaseLock(); diff --git a/packages/rsc/package.json b/packages/rsc/package.json index b25db21e..1bfded02 100644 --- a/packages/rsc/package.json +++ b/packages/rsc/package.json @@ -38,7 +38,10 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "bench": "vitest bench --config vitest.bench.config.mjs", + "bench:json": "vitest bench --config vitest.bench.config.mjs --outputJson bench-raw.json", + "bench:report": "node __bench__/report.mjs --current bench-raw.json --markdown comment.md" }, "devDependencies": { "@vitest/coverage-istanbul": "^4.1.0", diff --git a/packages/rsc/server/shared.mjs b/packages/rsc/server/shared.mjs index 77671aee..8bf10034 100644 --- a/packages/rsc/server/shared.mjs +++ b/packages/rsc/server/shared.mjs @@ -116,6 +116,83 @@ function isThenable(value) { ); } +/** + * Pre-scan the model tree to count how many times each object/array + * is referenced. Objects with count > 1 must be emitted as separate + * chunks to preserve identity; count === 1 objects can be inlined. + * + * This walk is O(n) with the tree size and short-circuits on: + * - Primitives, strings, symbols, functions + * - Objects already visited (increments count and stops) + * - Special types handled as chunks anyway (Promises, ReadableStreams, etc.) + */ +function countReferences(model) { + const counts = new Map(); + const stack = [model]; + + while (stack.length > 0) { + const value = stack.pop(); + + if (value === null || value === undefined) continue; + if (typeof value !== "object") continue; + + // Skip types that are always emitted as separate chunks + if ( + value instanceof Date || + value instanceof RegExp || + ArrayBuffer.isView(value) || + value instanceof ArrayBuffer || + (typeof ReadableStream !== "undefined" && + value instanceof ReadableStream) || + (typeof Blob !== "undefined" && value instanceof Blob) || + (typeof FormData !== "undefined" && value instanceof FormData) || + (typeof URL !== "undefined" && value instanceof URL) || + (typeof URLSearchParams !== "undefined" && + value instanceof URLSearchParams) || + value instanceof Error + ) { + continue; + } + + // Skip Promises/thenables + if (typeof value.then === "function") continue; + + const count = (counts.get(value) || 0) + 1; + counts.set(value, count); + if (count > 1) continue; // Already walked children on first visit + + if (Array.isArray(value)) { + for (let i = value.length - 1; i >= 0; i--) { + stack.push(value[i]); + } + } else if (value instanceof Map) { + for (const [k, v] of value) { + stack.push(k); + stack.push(v); + } + } else if (value instanceof Set) { + for (const item of value) { + stack.push(item); + } + } else if (isReactElement(value)) { + // Walk into props (including children) + if (value.props) stack.push(value.props); + } else { + // Plain object + const keys = Object.keys(value); + for (let i = keys.length - 1; i >= 0; i--) { + try { + stack.push(value[keys[i]]); + } catch { + /* skip throwing getters */ + } + } + } + } + + return counts; +} + /** * Internal request state for serialization * @internal Exported for testing purposes only @@ -139,6 +216,12 @@ export class FlightRequest { // Map of serialized objects to their IDs (for deduplication) this.objectMap = new WeakMap(); + // Reference counts for shared object detection. + // Objects that appear more than once in the model tree must be + // emitted as separate chunks to preserve identity on the client. + // Objects that appear exactly once can be inlined in the parent JSON. + this.refCounts = countReferences(model); + // Map of serialized server references to their chunk IDs (for deduplication) this.writtenServerReferences = new Map(); @@ -222,8 +305,9 @@ export class FlightRequest { * Flush completed chunks to destination */ flushChunks() { - while (this.completedChunks.length > 0) { - const chunk = this.completedChunks.shift(); + const chunks = this.completedChunks; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; if (!this.writtenChunks.has(chunk)) { this.writtenChunks.add(chunk); if (this.destination && !this.aborted) { @@ -240,6 +324,7 @@ export class FlightRequest { } } } + this.completedChunks = []; } /** @@ -901,6 +986,22 @@ function serializeValue(request, value, _parentObject, _parentKey) { } if (typeof value === "string") { + // Large strings: emit as a length-prefixed TEXT row to avoid + // JSON.stringify overhead (quoting, escaping, extra allocations). + // Format: id:T, — same length-prefix + // scheme used for binary/TypedArray rows. + if (value.length >= TEXT_CHUNK_SIZE) { + const id = request.getNextChunkId(); + const textBytes = encoder.encode(value); + const hexLength = textBytes.byteLength.toString(16); + const headerStr = `${id}:T${hexLength},`; + const headerBytes = encoder.encode(headerStr); + const row = new Uint8Array(headerBytes.length + textBytes.length); + row.set(headerBytes, 0); + row.set(textBytes, headerBytes.length); + request.writeBinaryChunk(row); + return "$" + id; + } // Escape special characters that have special meaning in the protocol if (value.startsWith("$")) { return "$" + value; @@ -1042,21 +1143,35 @@ function serializeValue(request, value, _parentObject, _parentKey) { return "$" + existing.id; } - // Always emit arrays as separate chunks to preserve object identity - const arrayId = request.getNextChunkId(); - const entry = { id: arrayId }; - request.objectMap.set(value, entry); + // Shared (refCount > 1) or circular-capable: must outline as a chunk + // so all references resolve to the same identity on the client. + const isShared = (request.refCounts.get(value) || 0) > 1; + if (isShared) { + const arrayId = request.getNextChunkId(); + request.objectMap.set(value, { id: arrayId }); + const result = value.map((item, index) => + serializeValue(request, item, value, index) + ); + const row = request.serializeModelRow(arrayId, result); + request.writeChunk(row); + return "$" + arrayId; + } - // Serialize array contents (may encounter circular refs back to this array) + // Unique (refCount === 1): inline directly in the parent JSON. + // Register temporarily for circular reference detection, then clean up. + const entry = { id: null }; + request.objectMap.set(value, entry); const result = value.map((item, index) => serializeValue(request, item, value, index) ); - - // Emit the array as a chunk - const row = request.serializeModelRow(arrayId, result); - request.writeChunk(row); - - return "$" + arrayId; + if (entry.id !== null) { + // Circular reference was detected during serialization — emit as chunk. + const row = request.serializeModelRow(entry.id, result); + request.writeChunk(row); + return "$" + entry.id; + } + request.objectMap.delete(value); + return result; } // Handle React elements @@ -1174,26 +1289,37 @@ function serializeValue(request, value, _parentObject, _parentKey) { // Check for deduplication / circular reference const existing = request.objectMap.get(value); if (existing !== undefined) { - // Already processed - return reference to existing chunk return "$" + existing.id; } - // Always emit objects as separate chunks to preserve object identity - const objectId = request.getNextChunkId(); - const entry = { id: objectId }; - request.objectMap.set(value, entry); + // Shared (refCount > 1): must outline to preserve identity. + const isShared = (request.refCounts.get(value) || 0) > 1; + if (isShared) { + const objectId = request.getNextChunkId(); + request.objectMap.set(value, { id: objectId }); + const result = {}; + for (const key of Object.keys(value)) { + result[key] = serializeValue(request, value[key], value, key); + } + const row = request.serializeModelRow(objectId, result); + request.writeChunk(row); + return "$" + objectId; + } - // Serialize object properties (may encounter circular refs back to this object) + // Unique: inline directly in the parent JSON. + const entry = { id: null }; + request.objectMap.set(value, entry); const result = {}; for (const key of Object.keys(value)) { result[key] = serializeValue(request, value[key], value, key); } - - // Emit the object as a chunk - const row = request.serializeModelRow(objectId, result); - request.writeChunk(row); - - return "$" + objectId; + if (entry.id !== null) { + const row = request.serializeModelRow(entry.id, result); + request.writeChunk(row); + return "$" + entry.id; + } + request.objectMap.delete(value); + return result; } // Should never reach here - all types handled above diff --git a/packages/rsc/vitest.bench.config.mjs b/packages/rsc/vitest.bench.config.mjs new file mode 100644 index 00000000..c1317e71 --- /dev/null +++ b/packages/rsc/vitest.bench.config.mjs @@ -0,0 +1,41 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // Ensure react-server-dom-webpack loads its production build. + // Without this, NODE_ENV defaults to "test" in vitest, which causes + // webpack to load the development bundle (2x larger, with extensive + // validation and debug assertions) — making the comparison unfair. + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + test: { + benchmark: { + include: ["__bench__/**/*.bench.{js,mjs,ts}"], + }, + globals: true, + environment: "node", + setupFiles: ["__bench__/setup.mjs"], + // Isolate each bench file in its own process — React allows only one + // RSC renderer per process, so @lazarv/rsc and react-server-dom-webpack + // must live in separate files/processes. + pool: "forks", + isolate: true, + deps: { + interopDefault: true, + }, + server: { + deps: { + inline: [/react-server-dom-webpack/, /^react$/, /^react-dom$/], + }, + }, + }, + ssr: { + noExternal: [/react-server-dom-webpack/, /^react$/, /^react-dom$/], + resolve: { + conditions: ["react-server", "browser", "import", "module", "default"], + }, + }, + resolve: { + conditions: ["react-server", "browser", "import", "module", "default"], + }, +}); From 064681afd770ab87be006a51f19d6c24686e0e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sat, 11 Apr 2026 20:22:22 +0200 Subject: [PATCH 02/15] wip --- examples/benchmark/profile-minimal.mjs | 71 ++ .../react-server/client/ClientProvider.jsx | 13 +- .../react-server/client/ErrorBoundary.jsx | 2 +- .../client/ReactServerComponent.jsx | 8 +- packages/react-server/lib/build/chunks.mjs | 5 +- .../react-server/lib/build/dependencies.mjs | 21 +- packages/react-server/lib/build/edge.mjs | 8 + packages/react-server/lib/build/resolve.mjs | 18 + packages/react-server/lib/build/server.mjs | 20 +- packages/react-server/lib/build/shared.mjs | 2 +- .../react-server/lib/dev/create-server.mjs | 3 + .../react-server/lib/loader/module-alias.mjs | 13 +- .../react-server/lib/plugins/use-client.mjs | 2 +- .../react-server/server/RemoteComponent.jsx | 2 +- .../react-server/server/action-register.mjs | 100 ++- packages/react-server/server/redirects.mjs | 2 +- packages/react-server/server/render-dom.mjs | 22 +- packages/react-server/server/render-rsc.jsx | 122 ++- packages/react-server/server/render.mjs | 2 +- packages/rsc/client/shared.mjs | 458 +++++++++--- packages/rsc/server/shared.mjs | 703 +++++++++++++++--- 21 files changed, 1320 insertions(+), 277 deletions(-) create mode 100644 examples/benchmark/profile-minimal.mjs diff --git a/examples/benchmark/profile-minimal.mjs b/examples/benchmark/profile-minimal.mjs new file mode 100644 index 00000000..2e674b2b --- /dev/null +++ b/examples/benchmark/profile-minimal.mjs @@ -0,0 +1,71 @@ +/** + * CPU-profile only the server hot path for the "minimal" benchmark. + * + * Boots react-server in-process, runs a brief warm-up, then hits "/" + * in a tight in-process loop while v8 sampling is active. Avoids the + * autocannon subprocess and the noise of npx/cluster startup so the + * resulting .cpuprofile is dominated by request handling. + */ +import { createServer } from "node:http"; +import { writeFileSync } from "node:fs"; +import { Session } from "node:inspector/promises"; + +process.env.NODE_ENV = "production"; + +const PORT = 3211; +const WARMUP_REQUESTS = 200; +const PROFILE_REQUESTS = parseInt(process.env.PROFILE_REQUESTS || "5000", 10); +const PATH = process.env.PROFILE_PATH || "/"; +const OUT = process.env.PROFILE_OUT || "/tmp/rsc-prof/minimal.cpuprofile"; + +const { reactServer } = await import("@lazarv/react-server/node"); +const { middlewares } = await reactServer({ + origin: `http://localhost:${PORT}`, + host: "localhost", + port: PORT, + outDir: ".react-server", +}); + +const server = createServer(middlewares); +await new Promise((resolve) => server.listen(PORT, resolve)); + +const url = `http://localhost:${PORT}${PATH}`; + +// ── warm up ──────────────────────────────────────────────────────────────── +process.stdout.write(`warm-up ${WARMUP_REQUESTS} req...`); +for (let i = 0; i < WARMUP_REQUESTS; i++) { + const r = await fetch(url); + await r.arrayBuffer(); +} +process.stdout.write(" done\n"); + +// ── profile ──────────────────────────────────────────────────────────────── +const session = new Session(); +session.connect(); +await session.post("Profiler.enable"); +await session.post("Profiler.setSamplingInterval", { interval: 100 }); // 100us +await session.post("Profiler.start"); + +const t0 = process.hrtime.bigint(); +let bytes = 0; +for (let i = 0; i < PROFILE_REQUESTS; i++) { + const r = await fetch(url); + const buf = await r.arrayBuffer(); + bytes += buf.byteLength; +} +const t1 = process.hrtime.bigint(); +const elapsedMs = Number(t1 - t0) / 1e6; + +const { profile } = await session.post("Profiler.stop"); +session.disconnect(); + +writeFileSync(OUT, JSON.stringify(profile)); +console.log( + `\n${PROFILE_REQUESTS} requests in ${elapsedMs.toFixed(0)}ms ` + + `(${((PROFILE_REQUESTS / elapsedMs) * 1000).toFixed(0)} req/s, ` + + `${(bytes / PROFILE_REQUESTS).toFixed(0)} B avg)` +); +console.log(`profile → ${OUT}`); + +server.close(); +process.exit(0); diff --git a/packages/react-server/client/ClientProvider.jsx b/packages/react-server/client/ClientProvider.jsx index fc16163e..7415a409 100644 --- a/packages/react-server/client/ClientProvider.jsx +++ b/packages/react-server/client/ClientProvider.jsx @@ -2,7 +2,7 @@ import { createFromReadableStream, encodeReply, createTemporaryReferenceSet, -} from "react-server-dom-webpack/client.browser"; +} from "@lazarv/rsc/client"; import { ClientContext as _ClientContext, @@ -594,6 +594,15 @@ export const streamOptions = ({ outletTemporaryReferences.set(outlet, temporaryReferences); return { temporaryReferences, + moduleLoader: { + requireModule(metadata) { + // Use the global __webpack_require__ which is set up by + // react-server-runtime.mjs (dev) or the inline bootstrap script (prod). + // It handles URL construction, caching, and sets .value/.status on the + // promise for synchronous access by React's lazy protocol. + return self.__webpack_require__(metadata.id); + }, + }, findSourceMapURL: import.meta.env.DEV ? (filename, environment) => new URL( @@ -1002,7 +1011,7 @@ function getFlightResponse(url, options = {}) { controller.enqueue( encoder.encode( - `0:["$L1"]\n1:E{"digest":"${e.digest}","message":"${e.message}","env":"${e.environmentName}","stack":[],"owner":null}\n` + `0:["$","$L1",null,{}]\n1:E{"digest":"${e.digest}","message":"${e.message}","env":"${e.environmentName}","stack":[],"owner":null}\n` ) ); controller.close(); diff --git a/packages/react-server/client/ErrorBoundary.jsx b/packages/react-server/client/ErrorBoundary.jsx index 11efaf7f..1973c5ca 100644 --- a/packages/react-server/client/ErrorBoundary.jsx +++ b/packages/react-server/client/ErrorBoundary.jsx @@ -127,7 +127,7 @@ export class ErrorBoundary extends Component { this.props; const { didCatch, error } = this.state; - if (error?.digest.startsWith("Location=")) { + if (error?.digest?.startsWith("Location=")) { const digestValue = error.digest.slice(9); const semicolonIndex = digestValue.indexOf(";"); if (semicolonIndex !== -1) { diff --git a/packages/react-server/client/ReactServerComponent.jsx b/packages/react-server/client/ReactServerComponent.jsx index e65239c9..2dd2d7d4 100644 --- a/packages/react-server/client/ReactServerComponent.jsx +++ b/packages/react-server/client/ReactServerComponent.jsx @@ -374,7 +374,13 @@ function FlightComponent({ throw error; } } else if ( - Component?.[0]?._payload?.reason?.digest?.startsWith("Location=") + // Detect redirect errors in the resolved flight tree. With @lazarv/rsc + // the root is a React element whose type is a lazy wrapper. The lazy + // wrapper's _payload is the rejected chunk; the error object is stored + // in chunk.value (not chunk.reason — reason lives on chunk.promise). + Component?.type?._payload?.value?.digest?.startsWith("Location=") || + // Legacy array-root fallback + Component?.[0]?._payload?.value?.digest?.startsWith("Location=") ) { componentToRender = prevComponent.current; } else { diff --git a/packages/react-server/lib/build/chunks.mjs b/packages/react-server/lib/build/chunks.mjs index f357296c..fdc5f61d 100644 --- a/packages/react-server/lib/build/chunks.mjs +++ b/packages/react-server/lib/build/chunks.mjs @@ -7,8 +7,5 @@ export const chunks = { [dependencies.reactDom.replace(".react-server.js", ".js")]: "react-dom", [dependencies.reactDomClient.replace(".react-server.js", ".js")]: "react-dom/client", - [dependencies.reactServerDomWebpackClientBrowser.replace( - ".react-server.js", - ".js" - )]: "react-server-dom-webpack/client.browser", + ...(dependencies.rscClient ? { [dependencies.rscClient]: "@lazarv/rsc/client" } : {}), }; diff --git a/packages/react-server/lib/build/dependencies.mjs b/packages/react-server/lib/build/dependencies.mjs index ba828987..18285d08 100644 --- a/packages/react-server/lib/build/dependencies.mjs +++ b/packages/react-server/lib/build/dependencies.mjs @@ -35,18 +35,13 @@ const reactDomServer = reactDom?.replace( const reactDomClient = tryResolve("react-dom/client"); const reactDomServerEdge = tryResolve("react-dom/server.edge"); -const reactServerDomWebpackClientBrowser = tryResolve( - "react-server-dom-webpack/client.browser" -); -const reactServerDomWebpackServerBrowser = tryResolve( - "react-server-dom-webpack/server.browser" -); -const reactServerDomWebpackClientEdge = tryResolve( - "react-server-dom-webpack/client.edge" -); -const reactServerDomWebpackServerEdge = tryResolve( - "react-server-dom-webpack/server.edge" -); +const rscClient = tryResolve("@lazarv/rsc/client"); +const rscServer = tryResolve("@lazarv/rsc/server"); +// Redirect webpack imports → @lazarv/rsc for any remaining third-party references +const reactServerDomWebpackClientBrowser = rscClient; +const reactServerDomWebpackServerBrowser = rscServer; +const reactServerDomWebpackClientEdge = rscClient; +const reactServerDomWebpackServerEdge = rscServer; const reactIs = tryResolve("react-is"); const scheduler = tryResolve("scheduler"); const unstorage = tryResolve("unstorage"); @@ -73,6 +68,8 @@ export { reactJsxRuntime, reactJsxRuntimeServer, reactCompilerRuntime, + rscClient, + rscServer, reactServerDomWebpackClientBrowser, reactServerDomWebpackServerBrowser, reactServerDomWebpackClientEdge, diff --git a/packages/react-server/lib/build/edge.mjs b/packages/react-server/lib/build/edge.mjs index 5dee6c0b..42d8bfaa 100644 --- a/packages/react-server/lib/build/edge.mjs +++ b/packages/react-server/lib/build/edge.mjs @@ -19,6 +19,8 @@ import { reactCompilerRuntime, reactDomServer, reactDomServerEdge, + rscClient, + rscServer, reactServerDomWebpackClientEdge, reactServerDomWebpackServerEdge, reactIs, @@ -86,6 +88,12 @@ export default async function edgeBuild(root, options) { find: /^react-dom\/server\.edge$/, replacement: reactDomServerEdge, }, + ...(rscClient + ? [{ find: /^@lazarv\/rsc\/client$/, replacement: rscClient }] + : []), + ...(rscServer + ? [{ find: /^@lazarv\/rsc\/server$/, replacement: rscServer }] + : []), { find: /^react-server-dom-webpack\/client\.edge$/, replacement: reactServerDomWebpackClientEdge, diff --git a/packages/react-server/lib/build/resolve.mjs b/packages/react-server/lib/build/resolve.mjs index c2eb436f..60d596b3 100644 --- a/packages/react-server/lib/build/resolve.mjs +++ b/packages/react-server/lib/build/resolve.mjs @@ -27,6 +27,24 @@ export const clientAlias = (dev) => [ replacement: dependencies.reactDomClient, id: "react-dom/client", }, + ...(dependencies.rscClient + ? [ + { + find: /^@lazarv\/rsc\/client$/, + replacement: dependencies.rscClient, + id: "@lazarv/rsc/client", + }, + ] + : []), + ...(dependencies.rscServer + ? [ + { + find: /^@lazarv\/rsc\/server$/, + replacement: dependencies.rscServer, + id: "@lazarv/rsc/server", + }, + ] + : []), { find: /^react-server-dom-webpack\/client.browser$/, replacement: dependencies.reactServerDomWebpackClientBrowser, diff --git a/packages/react-server/lib/build/server.mjs b/packages/react-server/lib/build/server.mjs index b57a3d65..66dabd50 100644 --- a/packages/react-server/lib/build/server.mjs +++ b/packages/react-server/lib/build/server.mjs @@ -59,6 +59,8 @@ import { reactCompilerRuntime, reactDomServer, reactDomServerEdge, + rscClient, + rscServer, reactServerDomWebpackClientEdge, reactServerDomWebpackServerEdge, reactIs, @@ -149,6 +151,8 @@ export default async function serverBuild(root, options, clientManifestBus) { "react-dom", "react-dom/client", "react-dom/server.edge", + "@lazarv/rsc/client", + "@lazarv/rsc/server", "react-server-dom-webpack/client.browser", "react-server-dom-webpack/client.edge", "react-server-dom-webpack/server.edge", @@ -523,6 +527,12 @@ export default async function serverBuild(root, options, clientManifestBus) { find: /^react-dom\/server\.edge$/, replacement: reactDomServerEdge, }, + ...(rscClient + ? [{ find: /^@lazarv\/rsc\/client$/, replacement: rscClient }] + : []), + ...(rscServer + ? [{ find: /^@lazarv\/rsc\/server$/, replacement: rscServer }] + : []), { find: /^react-server-dom-webpack\/client\.edge$/, replacement: reactServerDomWebpackClientEdge, @@ -546,6 +556,7 @@ export default async function serverBuild(root, options, clientManifestBus) { conditions: ["react-server"], externalConditions: ["react-server"], dedupe: [ + "@lazarv/rsc", "react-server-dom-webpack", "react-is", "picocolors", @@ -822,6 +833,7 @@ export default async function serverBuild(root, options, clientManifestBus) { : [ "react", "react-dom", + "@lazarv/rsc", "react-server-dom-webpack", "react-is", ...(config.ssr?.external ?? []), @@ -918,6 +930,11 @@ export default async function serverBuild(root, options, clientManifestBus) { find: /^react-dom\/server\.edge$/, replacement: reactDomServerEdge, }, + ...(rscClient + ? [ + { find: /^@lazarv\/rsc\/client$/, replacement: rscClient }, + ] + : []), { find: /^react-server-dom-webpack\/client\.edge$/, replacement: reactServerDomWebpackClientEdge, @@ -939,7 +956,8 @@ export default async function serverBuild(root, options, clientManifestBus) { options.edge && (alias.replacement.includes("/react/") || alias.replacement.includes("/react-dom/") || - alias.replacement.includes("react-server-dom-webpack/")) + alias.replacement.includes("react-server-dom-webpack/") || + alias.replacement.includes("@lazarv/rsc/")) ) ), ], diff --git a/packages/react-server/lib/build/shared.mjs b/packages/react-server/lib/build/shared.mjs index 57f19658..5e386131 100644 --- a/packages/react-server/lib/build/shared.mjs +++ b/packages/react-server/lib/build/shared.mjs @@ -1,4 +1,4 @@ -export const REACT_RE = /\/react\/|\/react-dom\/|\/react-server-dom-webpack\//; +export const REACT_RE = /\/react\/|\/react-dom\/|\/react-server-dom-webpack\/|\/@lazarv\/rsc\//; export const createTreeshake = (config) => ({ moduleSideEffects: (id, external) => { diff --git a/packages/react-server/lib/dev/create-server.mjs b/packages/react-server/lib/dev/create-server.mjs index 75a775e6..850bd505 100644 --- a/packages/react-server/lib/dev/create-server.mjs +++ b/packages/react-server/lib/dev/create-server.mjs @@ -232,6 +232,8 @@ export default async function createServer(root, options) { "react/compiler-runtime", "react-dom", "react-dom/client", + "@lazarv/rsc/client", + "@lazarv/rsc/server", "react-server-dom-webpack/client.browser", "react-server-dom-webpack/server.browser", "react-is", @@ -512,6 +514,7 @@ export default async function createServer(root, options) { dedupe: [ "react", "react-dom", + "@lazarv/rsc", "react-server-dom-webpack", "react-is", "picocolors", diff --git a/packages/react-server/lib/loader/module-alias.mjs b/packages/react-server/lib/loader/module-alias.mjs index 6ebdb74c..fc8dc38a 100644 --- a/packages/react-server/lib/loader/module-alias.mjs +++ b/packages/react-server/lib/loader/module-alias.mjs @@ -66,12 +66,11 @@ export function moduleAliases(condition) { } const reactDomServerEdge = tryResolve("react-dom/server.edge"); - const reactServerDomWebpackClientEdge = tryResolve( - "react-server-dom-webpack/client.edge" - ); - const reactServerDomWebpackServerEdge = tryResolve( - "react-server-dom-webpack/server.edge" - ); + const rscClient = tryResolve("@lazarv/rsc/client"); + const rscServer = tryResolve("@lazarv/rsc/server"); + // Redirect webpack imports → @lazarv/rsc + const reactServerDomWebpackClientEdge = rscClient; + const reactServerDomWebpackServerEdge = rscServer; const reactIs = tryResolve("react-is"); const picocolors = tryResolve("picocolors"); const unstorage = tryResolve("unstorage"); @@ -92,6 +91,8 @@ export function moduleAliases(condition) { "react/jsx-dev-runtime": reactJsxDevRuntime, "react-dom": reactDom, "react-dom/server.edge": reactDomServerEdge, + "@lazarv/rsc/client": rscClient, + "@lazarv/rsc/server": rscServer, "react-server-dom-webpack/client.edge": reactServerDomWebpackClientEdge, "react-server-dom-webpack/server.edge": reactServerDomWebpackServerEdge, "react-is": reactIs, diff --git a/packages/react-server/lib/plugins/use-client.mjs b/packages/react-server/lib/plugins/use-client.mjs index a189ad06..7df40023 100644 --- a/packages/react-server/lib/plugins/use-client.mjs +++ b/packages/react-server/lib/plugins/use-client.mjs @@ -329,7 +329,7 @@ registerClientReference(${name}, "${clientReferenceId}", "${name}");` ) .join("\n\n"); - const clientReferenceCode = `import { registerClientReference } from "react-server-dom-webpack/server.edge";\n\n${ + const clientReferenceCode = `import { registerClientReference } from "@lazarv/rsc/server";\n\n${ defaultExport ? `${namedExports}\n\n${defaultExport}` : namedExports }`; diff --git a/packages/react-server/server/RemoteComponent.jsx b/packages/react-server/server/RemoteComponent.jsx index ec5aeddf..0a751cc3 100644 --- a/packages/react-server/server/RemoteComponent.jsx +++ b/packages/react-server/server/RemoteComponent.jsx @@ -2,7 +2,7 @@ import { createFromFetch, createTemporaryReferenceSet, encodeReply, -} from "react-server-dom-webpack/client.edge"; +} from "@lazarv/rsc/client"; import { useCache } from "@lazarv/react-server/memory-cache"; import { ReactServerComponent } from "@lazarv/react-server/navigation"; diff --git a/packages/react-server/server/action-register.mjs b/packages/react-server/server/action-register.mjs index 286abbea..591ba080 100644 --- a/packages/react-server/server/action-register.mjs +++ b/packages/react-server/server/action-register.mjs @@ -1,28 +1,99 @@ -import { registerServerReference as _registerServerReference } from "react-server-dom-webpack/server.edge"; +import { registerServerReference as _registerServerReference } from "@lazarv/rsc/server"; import { encryptActionId } from "./action-crypto.mjs"; +const REACT_SERVER_REFERENCE = Symbol.for("react.server.reference"); + +/** + * Custom bind for server references that preserves $$typeof, $$id, $$bound, + * and the encrypting $$id getter on bound functions. + */ +function createServerRefBind(fullId) { + return function serverRefBind(thisArg, ...boundArgs) { + const original = this; + const previousBound = original.$$bound || []; + const accumulated = previousBound.concat(boundArgs); + + const boundFn = Function.prototype.bind.call(original, thisArg, ...boundArgs); + + Object.defineProperty(boundFn, "$$typeof", { + value: REACT_SERVER_REFERENCE, + writable: false, + enumerable: true, + configurable: true, + }); + Object.defineProperty(boundFn, "$$bound", { + value: accumulated, + writable: true, + enumerable: true, + configurable: true, + }); + Object.defineProperty(boundFn, "$$originalId", { + value: fullId, + enumerable: false, + configurable: false, + writable: false, + }); + + // Each bound instance gets its own cached encrypted ID + let cachedEncryptedId = null; + Object.defineProperty(boundFn, "$$id", { + get() { + if (!cachedEncryptedId) { + cachedEncryptedId = encryptActionId(fullId); + } + return cachedEncryptedId; + }, + enumerable: true, + configurable: true, + }); + + boundFn.bind = createServerRefBind(fullId); + return boundFn; + }; +} + /** - * Wraps React's registerServerReference to replace the plain-text $$id with - * an encrypting getter. The encrypted token is cached on the function - * instance so that every read of $$id within the same lifecycle returns the - * same value. This is required because React's decodeFormState compares the - * action ID from the submitted form with the current $$id — if they differ - * (e.g. due to a fresh random IV on each read), useActionState cannot match - * the form state back to the component. + * Wraps @lazarv/rsc's registerServerReference to: + * 1. Register the action in the internal server reference registry + * 2. Set $$typeof / $$id / $$bound on the *original* function (fn) so that + * callers who already hold a reference to fn see the metadata + * 3. Replace $$id with an encrypting getter that caches per-instance + * 4. Override bind() to propagate server reference metadata + * + * The encrypted token is cached so that every read of $$id within the same + * lifecycle returns the same value. This is required because React's + * decodeFormState compares the action ID from the submitted form with the + * current $$id — if they differ (e.g. due to a fresh random IV on each + * read), useActionState cannot match the form state back to the component. */ export function registerServerReference(fn, id, name) { - // Let React set up $$typeof, $$id, $$bound, bind, etc. + const fullId = `${id}#${name}`; + + // Register in @lazarv/rsc's internal serverReferenceRegistry so that + // decodeAction / lookupServerReference can find it. _registerServerReference(fn, id, name); - // Capture the original plain-text ID that React just set - const originalId = fn.$$id; + // Set server reference metadata directly on the original fn, because + // the generated code uses fn by reference (the return value is ignored). + Object.defineProperty(fn, "$$typeof", { + value: REACT_SERVER_REFERENCE, + writable: false, + enumerable: true, + configurable: true, + }); + Object.defineProperty(fn, "$$bound", { + value: null, + writable: true, + enumerable: true, + configurable: true, + }); // Store the original plain-text ID as a non-enumerable property so that // server-side code (e.g. useActionState) can compare action identities // without needing to decrypt. Non-enumerable keeps it out of // serialisation payloads sent to the client. Object.defineProperty(fn, "$$originalId", { - value: originalId, + value: fullId, enumerable: false, configurable: false, writable: false, @@ -35,7 +106,7 @@ export function registerServerReference(fn, id, name) { Object.defineProperty(fn, "$$id", { get() { if (!cachedEncryptedId) { - cachedEncryptedId = encryptActionId(originalId); + cachedEncryptedId = encryptActionId(fullId); } return cachedEncryptedId; }, @@ -43,5 +114,8 @@ export function registerServerReference(fn, id, name) { configurable: true, }); + // Override bind to preserve server reference metadata on bound functions. + fn.bind = createServerRefBind(fullId); + return fn; } diff --git a/packages/react-server/server/redirects.mjs b/packages/react-server/server/redirects.mjs index 72960f6c..5b836425 100644 --- a/packages/react-server/server/redirects.mjs +++ b/packages/react-server/server/redirects.mjs @@ -30,7 +30,7 @@ export function redirect(url, status = 302, kind = "navigate") { const renderContext = getContext(RENDER_CONTEXT); if (renderContext?.type === RENDER_TYPE.RSC) { store.response = new Response( - `0:["$L1"]\n1:E{"digest":"Location=${url};kind=${kind}","message":"REDIRECT","env":"server","stack":[],"owner":null}\n`, + `0:["$","$L1",null,{}]\n1:E{"digest":"Location=${url};kind=${kind}","message":"REDIRECT","env":"server","stack":[],"owner":null}\n`, { status: 200, headers: { diff --git a/packages/react-server/server/render-dom.mjs b/packages/react-server/server/render-dom.mjs index e6816ae8..c3cb78d3 100644 --- a/packages/react-server/server/render-dom.mjs +++ b/packages/react-server/server/render-dom.mjs @@ -4,14 +4,13 @@ import { createFromReadableStream, createTemporaryReferenceSet, encodeReply, -} from "react-server-dom-webpack/client.edge"; +} from "@lazarv/rsc/client"; import { HttpContextStorage } from "@lazarv/react-server/http-context"; import { Parser } from "parse5"; import { getEnv, immediate } from "../lib/sys.mjs"; import dom2flight from "./dom-flight.mjs"; -import { ssrManifest } from "./ssr-manifest.mjs"; import { remoteTemporaryReferences } from "./temporary-references.mjs"; import { attachSharedRequestCache } from "../cache/request-cache-shared.mjs"; import { syncHash } from "@lazarv/react-server/storage-cache"; @@ -327,7 +326,13 @@ export const createRenderer = ({ renderStream, { temporaryReferences, - ...ssrManifest, + moduleLoader: { + requireModule(metadata) { + return globalThis.__webpack_require__( + metadata.id + ); + }, + }, } ); @@ -881,6 +886,15 @@ export const createRenderer = ({ // ── Inject remaining request cache entries for browser hydration ── // Final sweep after all rendering completes. yield* flushCacheEntries(); + + // Close the browser-side flight writer so the client's + // createFromReadableStream consume loop sees `done: true` + // and React can complete hydration. + if (bootstrapped && !remote) { + yield encoder.encode( + `` + ); + } }; const remoteWorker = async function* () { @@ -978,6 +992,7 @@ export const createRenderer = ({ controller.close(); parentPort.postMessage({ id, done: true }); } catch (e) { + try { controller.close(); } catch { /* already closed/errored */ } parentPort.postMessage({ id, done: true, @@ -990,6 +1005,7 @@ export const createRenderer = ({ render(); } catch (error) { + try { controller.close(); } catch { /* already closed/errored */ } parentPort.postMessage({ id, done: true, diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index 7a87e6e1..b1780042 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -1,9 +1,12 @@ import { ReadableStream } from "node:stream/web"; - -import server, { +import { createTemporaryReferenceSet, - decodeReply, -} from "react-server-dom-webpack/server.edge"; + decodeReply as _decodeReply, + decodeAction, + decodeFormState, + renderToReadableStream, +} from "@lazarv/rsc/server"; +import React from "react"; import { concat, @@ -68,6 +71,34 @@ let DevToolsHost; const serverReferenceMap = wrapServerReferenceMap(_serverReferenceMap); +// Adapter: wraps the webpack-style client reference Proxy into an @lazarv/rsc +// moduleResolver. The Proxy is keyed by "moduleId#exportName" and returns +// { id, chunks, name, async }. We expose it as resolveClientReference(ref) +// so that @lazarv/rsc's FlightRequest can resolve client components. +function makeModuleResolver(webpackMap) { + return { + resolveClientReference(ref) { + const $$id = ref.$$id ?? ref.$$typeof?.$$id; + if (!$$id) return null; + return webpackMap[$$id]; + }, + }; +} + +// Wrapper: adapts webpack-style decodeReply(body, manifest, opts?) to +// @lazarv/rsc's decodeReply(body, opts). +// The webpack API passes the serverReferenceMap as the 2nd arg; the rsc API +// expects options as the 2nd arg. We detect and skip the manifest Proxy. +function decodeReply(body, _manifestOrOpts, opts) { + const isManifest = + _manifestOrOpts && + typeof _manifestOrOpts === "object" && + !_manifestOrOpts.temporaryReferences && + !_manifestOrOpts.moduleLoader; + const realOpts = isManifest ? opts : _manifestOrOpts; + return _decodeReply(body, realOpts); +} + export async function render(Component, props = {}, options = {}) { const logger = getContext(LOGGER_CONTEXT); const renderStream = getContext(RENDER_STREAM); @@ -152,12 +183,12 @@ export async function render(Component, props = {}, options = {}) { formData.append(key.replace(/^remote:\/\//, ""), value); } try { - input = await server.decodeReply(formData, serverReferenceMap); + input = await decodeReply(formData, serverReferenceMap); } catch { input = formData; } } else { - input = await server.decodeReply( + input = await decodeReply( await context.request.text(), serverReferenceMap ); @@ -240,10 +271,63 @@ export async function render(Component, props = {}, options = {}) { } }; } else { - action = await server.decodeAction( - input[input.length - 1] ?? input, - serverReferenceMap - ); + // Progressive enhancement form submission — action ID is in + // the FormData field name ($ACTION_ID_), not a header. + // Extract and decrypt the action ID, then load the action the + // same way as the header-based path. + const formInput = input[input.length - 1] ?? input; + let resolved = false; + if (formInput instanceof FormData) { + let formActionId = null; + for (const key of formInput.keys()) { + if (key.startsWith("$ACTION_ID_")) { + formActionId = key.slice(11); + break; + } + } + if (formActionId) { + const decryptedId = decryptActionId(formActionId); + const resolvedActionId = decryptedId ?? formActionId; + const [, serverReferenceName] = resolvedActionId.split("#"); + const serverReference = serverReferenceMap[resolvedActionId]; + if (!serverReference) { + throw new ServerFunctionNotFoundError(); + } + resolved = true; + action = async () => { + try { + const mod = await globalThis.__webpack_require__( + serverReference.id.replace( + /^server-action:\/\//, + "server://" + ) + ); + const fn = mod[serverReferenceName]; + if (typeof fn !== "function") { + throw new ServerFunctionNotFoundError(); + } + const data = await fn(formInput); + return { + data, + actionId: resolvedActionId, + error: null, + }; + } catch (error) { + return { + data: null, + actionId: resolvedActionId, + error, + }; + } + }; + } + } + if (!resolved) { + action = await decodeAction( + formInput, + serverReferenceMap + ); + } } if (typeof action !== "function") { @@ -311,7 +395,7 @@ export async function render(Component, props = {}, options = {}) { ) : data; } else { - formState = await server.decodeFormState( + formState = await decodeFormState( data, input[input.length - 1] ?? input, serverReferenceMap @@ -640,13 +724,14 @@ export async function render(Component, props = {}, options = {}) { }) ); - const flight = server.renderToReadableStream( + const flight = renderToReadableStream( app, - clientReferenceMap({ - remote: remote || remoteRSC, - origin, - }), { + react: React, + moduleResolver: makeModuleResolver(clientReferenceMap({ + remote: remote || remoteRSC, + origin, + })), signal, temporaryReferences, onError(e) { @@ -808,10 +893,11 @@ export async function render(Component, props = {}, options = {}) { renderParentCtx ?? undefined ); - const flight = server.renderToReadableStream( + const flight = renderToReadableStream( app, - clientReferenceMap({ remote: remote || remoteRSC, origin }), { + react: React, + moduleResolver: makeModuleResolver(clientReferenceMap({ remote: remote || remoteRSC, origin })), signal, temporaryReferences, onError(e) { diff --git a/packages/react-server/server/render.mjs b/packages/react-server/server/render.mjs index a4f230f4..ef2640c7 100644 --- a/packages/react-server/server/render.mjs +++ b/packages/react-server/server/render.mjs @@ -49,7 +49,7 @@ export function useRender() { export function rscErrorResponse(error) { return new Response( - `0:["$L1"]\n1:E{"digest":"${error.digest ?? ""}","message":"${error.message}","env":"server","stack":[],"owner":null}\n`, + `0:["$","$L1",null,{}]\n1:E{"digest":"${error.digest ?? ""}","message":"${error.message}","env":"server","stack":[],"owner":null}\n`, { status: 200, headers: { diff --git a/packages/rsc/client/shared.mjs b/packages/rsc/client/shared.mjs index 74c7852b..a50d0915 100644 --- a/packages/rsc/client/shared.mjs +++ b/packages/rsc/client/shared.mjs @@ -102,6 +102,11 @@ class FlightResponse { // Deferred path references - path refs that need resolution after all properties are filled this.deferredPathRefs = []; + + // Pending module import Promises — tracked so the consume loop can + // await them before resolving deferred chunks, ensuring import() + // results are available synchronously when React renders. + this.pendingModuleImports = []; } /** @@ -131,6 +136,44 @@ class FlightResponse { return chunk; } + /** + * Lazily create a Promise for a chunk. + * - If already resolved/rejected synchronously, returns an already-settled promise. + * - If still pending, allocates the real Promise with resolve/reject captures. + */ + _ensurePromise(chunk) { + if (chunk._promise !== null) return chunk._promise; + + if (chunk.status === RESOLVED) { + const p = Promise.resolve(chunk.value); + p.status = "fulfilled"; + p.value = chunk.value; + chunk._promise = p; + } else if (chunk.status === REJECTED) { + const err = chunk.type === "streaming" ? chunk.error : chunk.value; + const p = Promise.reject(err); + p.catch(() => {}); // suppress unhandled rejection + p.status = "rejected"; + p.reason = err; + chunk._promise = p; + } else { + // Still pending — allocate a real promise + const p = new Promise((res, rej) => { + chunk._resolve = res; + chunk._reject = rej; + }); + // Suppress unhandled rejection warnings. The rejection will be + // observed by React's use() hook (which reads .status/.value + // synchronously) or by an error boundary — but Node.js fires the + // "unhandledRejection" event before React gets a chance to read it. + p.catch(() => {}); + p.status = "pending"; + p.value = undefined; + chunk._promise = p; + } + return chunk._promise; + } + /** * Get or create a chunk */ @@ -222,8 +265,15 @@ class FlightResponse { } chunk._controllerClosed = true; } + chunk.promise.status = "rejected"; - chunk.promise.value = error; + chunk.promise.reason = error; + // Attach a no-op catch handler so Node.js doesn't fire an unhandled + // rejection when callers haven't awaited the promise yet. The true + // error is already recorded on chunk.value and chunk.promise.reason + // so React's render pipeline (via use()) still observes it when it + // eventually reads the promise. + chunk.promise.catch(() => {}); chunk.reject(error); } @@ -271,7 +321,26 @@ class FlightResponse { if (errorInfo.digest) { error.digest = errorInfo.digest; } - this.rejectChunk(id, error); + // For the root chunk, resolve with a React element that throws during + // rendering instead of rejecting the promise. This matches + // react-server-dom-webpack behavior: createFromReadableStream always + // resolves, and the error propagates through React's render pipeline + // (error boundaries, SSR onError) rather than rejecting the transport + // promise. Rejecting would crash callers that await the result + // (e.g. render-dom's SSR pipeline) before React ever sees the tree. + if (id === 0) { + const ErrorThrower = () => { throw error; }; + ErrorThrower.displayName = "FlightError"; + this.resolveChunk(0, { + $$typeof: REACT_TRANSITIONAL_ELEMENT_TYPE, + type: ErrorThrower, + key: null, + ref: null, + props: {}, + }); + } else { + this.rejectChunk(id, error); + } } else if (tag === "H") { // Hint - preload const hint = JSON.parse(rest.slice(1)); @@ -395,10 +464,11 @@ class FlightResponse { // Append the text content if (Array.isArray(chunk.value)) { chunk.value.push(textContent); - // If this chunk has a stream controller, push data + // If this chunk has a stream controller, push data as a string + // (not encoded to Uint8Array) so consumers see the original text. if (chunk._controller) { try { - chunk._controller.enqueue(new TextEncoder().encode(textContent)); + chunk._controller.enqueue(textContent); } catch { // Controller may be closed } @@ -551,13 +621,79 @@ class FlightResponse { preloadPromise = loader.preloadModule(metadata); } - // Create a lazy reference that loads on demand + // Try to resolve the module now. If the loader returns a sync value, + // resolve the chunk immediately with the actual export (matching + // webpack's synchronous requireModule behavior). If it returns a + // Promise (e.g. Vite's import()), keep the chunk PENDING and track + // the Promise — the consume loop will await all pending imports + // before calling resolveDeferredChunks(), so model rows with $L + // references to this chunk will naturally defer until the module + // is available. This avoids lazy wrappers entirely. + if (loader.requireModule) { + let result; + try { + result = loader.requireModule(metadata); + } catch (syncError) { + // requireModule threw synchronously (e.g. missing module cache). + // Reject the chunk gracefully instead of crashing the stream consumer. + const chunk = this.getChunk(id); + chunk.status = REJECTED; + chunk.value = syncError; + chunk.promise.status = "rejected"; + chunk.promise.reason = syncError; + // Suppress unhandled rejection — React reads .status/.reason synchronously + chunk.promise.catch(() => {}); + chunk.reject(syncError); + return; + } + if (result && typeof result.then === "function") { + // Async module — keep chunk pending, resolve when import completes + const importPromise = result.then( + (module) => { + const exportName = metadata.name || "default"; + const exported = + typeof module === "object" && module !== null + ? (module[exportName] ?? module.default ?? module) + : module; + this.resolveChunk(id, exported); + }, + (error) => { + const chunk = this.getChunk(id); + chunk.status = REJECTED; + chunk.value = error; + chunk.promise.status = "rejected"; + chunk.promise.reason = error; + // Suppress unhandled rejection — React reads .status/.reason + // synchronously via use() hook; the rejection will be observed + // by an error boundary, not by a .catch() handler. + chunk.promise.catch(() => {}); + chunk.reject(error); + } + ); + this.pendingModuleImports.push(importPromise); + return; // chunk stays pending + } + if (result !== undefined) { + // Sync module — resolve directly with the export + const exportName = metadata.name || "default"; + const exported = + typeof result === "object" && result !== null + ? (result[exportName] ?? result.default ?? result) + : result; + this.resolveChunk(id, exported); + return; + } + } + + // No loader or no requireModule — resolve with a client reference + // descriptor (used by server-side fromBuffer where no moduleLoader + // is provided) const reference = { $$typeof: REACT_CLIENT_REFERENCE, $$id: metadata.id + "#" + metadata.name, $$metadata: metadata, $$loader: loader, - $$preload: preloadPromise, // Store preload promise for potential reuse + $$preload: preloadPromise, }; this.resolveChunk(id, reference); @@ -850,25 +986,34 @@ class FlightResponse { // Check if it's a numeric ID (references a module row) or inline format (id#name) if (!isNaN(id)) { - // Numeric ID - references a module row (I row) + // Numeric ID - references a module row (I row). + // With async module loading (browser), resolveModuleReference keeps + // the chunk PENDING until import() completes, then resolves it with + // the actual module export (function). For sync loaders, the chunk + // is already RESOLVED with the export. For no-loader contexts + // (server-side fromBuffer), the chunk holds a client reference + // descriptor — in that case we need a lazy wrapper so the RSC + // renderer can call _init to get the reference. const chunk = this.getChunk(id); - // Always create a lazy wrapper for client references to support async module loading - // Even when chunk is resolved, the module loading itself may be async (e.g., native import()) if (chunk.status === RESOLVED) { const resolvedValue = chunk.value; - // If it's a client reference with a loader, wrap it for async loading - if ( - resolvedValue && - resolvedValue.$$typeof === REACT_CLIENT_REFERENCE && - resolvedValue.$$loader - ) { + // If the resolved value is an actual module export (function/string), + // return it directly — this is the fast path for resolved imports. + if (typeof resolvedValue === "function" || typeof resolvedValue === "string") { + return resolvedValue; + } + // If it's a client reference descriptor (from no-loader context), + // wrap in a lazy wrapper so the RSC server renderer can properly + // process it via _init/_payload. + if (resolvedValue && resolvedValue.$$typeof === REACT_CLIENT_REFERENCE) { return this.createLazyWrapper(chunk); } - // Non-client-reference values can be returned directly + // Other resolved values (plain objects, etc.) — return directly return resolvedValue; } - // Return a lazy wrapper that will resolve when the chunk is ready + // Chunk still pending (async import in progress) — return a lazy + // wrapper so React can suspend and retry when the import completes return this.createLazyWrapper(chunk); } @@ -889,9 +1034,8 @@ class FlightResponse { }; // Start preloading if supported - let preloadPromise = null; if (loader.preloadModule) { - preloadPromise = loader.preloadModule(metadata); + loader.preloadModule(metadata); } // Create a client reference @@ -900,7 +1044,6 @@ class FlightResponse { $$id: rest, $$metadata: metadata, $$loader: loader, - $$preload: preloadPromise, }; // Create a synthetic chunk for the lazy wrapper @@ -948,6 +1091,8 @@ class FlightResponse { lazyAction.$$typeof = originalAction.$$typeof; lazyAction.$$id = originalAction.$$id; lazyAction.$$bound = bound; + lazyAction.$$FORM_ACTION = originalAction.$$FORM_ACTION; + lazyAction.$$IS_SIGNATURE_EQUAL = originalAction.$$IS_SIGNATURE_EQUAL; lazyAction.bind = originalAction.bind; return lazyAction; } @@ -1127,11 +1272,9 @@ class FlightResponse { if (Array.isArray(chunk.value) && chunk.value.length > 0) { for (const item of chunk.value) { try { - if (chunk.type === "text" || typeof item === "string") { - controller.enqueue(new TextEncoder().encode(item)); - } else { - controller.enqueue(item); - } + // Enqueue text as strings, binary as Uint8Array — preserve the + // original type so consumers see what the server sent. + controller.enqueue(item); } catch { // Controller may be closed } @@ -1166,8 +1309,8 @@ class FlightResponse { */ createAsyncIterableWrapper(chunk) { const self = this; - return { - [Symbol.asyncIterator]() { + const wrapper = { + [Symbol.asyncIterator]: function asyncIterator() { let index = 0; const iterator = { async next() { @@ -1192,6 +1335,7 @@ class FlightResponse { return iterator; }, }; + return wrapper; } /** @@ -1264,86 +1408,129 @@ class FlightResponse { /** * Create a lazy wrapper for a pending chunk - * Supports async module loading via moduleLoader.requireModule + * Supports async module loading via moduleLoader.requireModule. + * Module loading is kicked off eagerly in resolveModuleReference + * so the import() Promise is typically already settled by render time. */ createLazyWrapper(chunk) { - // Return a thenable that resolves to the chunk value - const lazy = { - $$typeof: Symbol.for("react.lazy"), - _payload: chunk, - _init: (payload) => { - if (payload.status === RESOLVED) { - const value = payload.value; - - // If the resolved value is a client reference with a loader, - // we need to load the actual module - if ( - value && - value.$$typeof === REACT_CLIENT_REFERENCE && - value.$$loader && - value.$$loader.requireModule - ) { - // Check if module is already loaded or loading (cached) - if (payload._moduleStatus === "fulfilled") { + const response = this; + + // _initPayload resolves the chunk to its final value (module export, + // model value, etc.). It is used both by React's lazy protocol + // (_init/_payload) and by the callable wrapper below. + const _initPayload = (payload) => { + if (payload.status === RESOLVED) { + const value = payload.value; + + // If the resolved value is a client reference with a loader, + // we need to load the actual module + if ( + value && + value.$$typeof === REACT_CLIENT_REFERENCE && + value.$$loader && + value.$$loader.requireModule + ) { + // Check if module is already loaded (cached from a previous call) + if (payload._moduleStatus === "fulfilled") { + return payload._moduleValue; + } + if (payload._moduleStatus === "rejected") { + throw payload._moduleError; + } + + // Module still loading — throw cached promise for Suspense + if (payload._modulePromise) { + // Check if the promise has settled since last time (annotated + // with .status/.value by our .then() handler below). + if (payload._modulePromise.status === "fulfilled") { return payload._moduleValue; } - if (payload._moduleStatus === "rejected") { - throw payload._moduleError; - } - if (payload._modulePromise) { - // Module is loading - throw the cached promise for Suspense - throw payload._modulePromise; - } + throw payload._modulePromise; + } - // Start loading the module - const result = value.$$loader.requireModule(value.$$metadata); - - // Handle async module loading - if (result && typeof result.then === "function") { - // Cache the promise on the payload so subsequent calls don't re-load - payload._modulePromise = result.then( - (module) => { - // Get the exported value by name - const exportName = value.$$metadata.name || "default"; - const exported = - typeof module === "object" && module !== null - ? (module[exportName] ?? module.default ?? module) - : module; - payload._moduleValue = exported; - payload._moduleStatus = "fulfilled"; - return exported; - }, - (error) => { - payload._moduleStatus = "rejected"; - payload._moduleError = error; - throw error; - } + const result = value.$$loader.requireModule(value.$$metadata); + if (result && typeof result.then === "function") { + // Annotate the Promise with .status/.value for synchronous + // unwrapping on subsequent calls — mirrors the pattern used + // by react-server-dom-webpack for chunk loading promises. + if (result.status === undefined) { + result.then( + (v) => { result.status = "fulfilled"; result.value = v; }, + (e) => { result.status = "rejected"; result.reason = e; } ); + } - // Throw the promise for Suspense - throw payload._modulePromise; + // If the promise is already settled (e.g. cached import()), + // unwrap synchronously instead of throwing for Suspense. + if (result.status === "fulfilled") { + const module = result.value; + const exportName = value.$$metadata.name || "default"; + const exported = + typeof module === "object" && module !== null + ? (module[exportName] ?? module.default ?? module) + : module; + payload._moduleValue = exported; + payload._moduleStatus = "fulfilled"; + return exported; } - // Sync module loading - get the exported value - const exportName = value.$$metadata.name || "default"; - const exported = - typeof result === "object" && result !== null - ? (result[exportName] ?? result.default ?? result) - : result; - // Cache sync result too - payload._moduleValue = exported; - payload._moduleStatus = "fulfilled"; - return exported; + payload._modulePromise = result.then( + (module) => { + const exportName = value.$$metadata.name || "default"; + const exported = + typeof module === "object" && module !== null + ? (module[exportName] ?? module.default ?? module) + : module; + payload._moduleValue = exported; + payload._moduleStatus = "fulfilled"; + return exported; + }, + (error) => { + payload._moduleStatus = "rejected"; + payload._moduleError = error; + throw error; + } + ); + throw payload._modulePromise; } - return value; + // Sync module loading + const exportName = value.$$metadata.name || "default"; + const exported = + typeof result === "object" && result !== null + ? (result[exportName] ?? result.default ?? result) + : result; + payload._moduleValue = exported; + payload._moduleStatus = "fulfilled"; + return exported; } - if (payload.status === REJECTED) { - throw payload.value; - } - throw payload.promise; - }, + + return value; + } + if (payload.status === REJECTED) { + throw payload.value; + } + throw payload.promise; + }; + + // Create a callable function as the lazy wrapper. This allows the + // reference to work both as a React lazy component type (React calls + // _init/_payload) AND as a direct function call (e.g. when passed as + // a render callback prop like `render={ErrorMessage}`). + // When called directly, it resolves the module and forwards the call. + const lazy = function (...args) { + // Resolve the underlying module/value + const resolved = _initPayload(chunk); + if (typeof resolved === "function") { + return resolved(...args); + } + // Not a function — return the resolved value (createElement will + // be called by the consumer if needed) + return resolved; }; + lazy.$$typeof = Symbol.for("react.lazy"); + lazy._payload = chunk; + lazy._init = _initPayload; return lazy; } @@ -1366,6 +1553,48 @@ class FlightResponse { } action.$$typeof = REACT_SERVER_REFERENCE; action.$$id = id; + + // $$FORM_ACTION is required by react-dom/server to render progressive + // enhancement
elements with hidden fields that carry the action ID + // and any bound arguments. + action.$$FORM_ACTION = function (identifierPrefix) { + if (boundArgs && boundArgs.length > 0) { + // Bound args need to be serialized into prefixed FormData fields. + // Encode each bound arg as JSON in a FormData part. + const data = new FormData(); + const payload = JSON.stringify( + boundArgs.map((arg) => + typeof arg === "string" + ? arg + : typeof arg === "number" || typeof arg === "boolean" + ? arg + : JSON.stringify(arg) + ) + ); + data.append("$ACTION_" + identifierPrefix + ":0", payload); + return { + name: "$ACTION_REF_" + identifierPrefix, + method: "POST", + encType: "multipart/form-data", + data, + }; + } + return { + name: "$ACTION_ID_" + id, + method: "POST", + encType: "multipart/form-data", + data: null, + }; + }; + + // $$IS_SIGNATURE_EQUAL is used by react-dom to match form state across + // navigations (progressive enhancement). + action.$$IS_SIGNATURE_EQUAL = function (referenceId, numberOfBoundArgs) { + if (id !== referenceId) return false; + const currentBound = boundArgs ? boundArgs.length : 0; + return currentBound === numberOfBoundArgs; + }; + action.bind = (_, ...args) => { const newBound = (boundArgs || []).concat(args); return response.createServerAction(id, newBound); @@ -1750,19 +1979,27 @@ class FlightResponse { */ export function createFromReadableStream(stream, options = {}) { const response = new FlightResponse(options); - // Start consuming the stream in the background. // The root value will be resolved as soon as the root chunk is available, // while streaming chunks (ReadableStream, AsyncIterable) continue to receive // data in the background until the stream ends. const consumePromise = (async () => { const reader = stream.getReader(); - try { while (true) { const { done, value } = await reader.read(); if (done) break; response.processData(value); + // Wait for ALL pending module imports to complete before resolving + // deferred chunks. Unlike webpack (which has a synchronous module + // registry), Vite's import() involves actual network I/O. We must + // wait for those Promises to settle and annotate their .status/.value + // so that _initPayload and deserializeString can unwrap modules + // synchronously when React renders. + if (response.pendingModuleImports.length > 0) { + await Promise.all(response.pendingModuleImports); + response.pendingModuleImports.length = 0; + } // Eagerly resolve deferred chunks so that the root value and any // streaming wrappers (ReadableStream / AsyncIterable) are created // as soon as their model rows arrive, rather than waiting for the @@ -1798,13 +2035,30 @@ export function createFromReadableStream(stream, options = {}) { } }); - // The root value promise resolves as soon as the root chunk (id 0) resolves, - // which typically happens after the first batch of data is processed — - // NOT after the entire stream is consumed. + // The root value promise resolves as soon as the root chunk (id 0) resolves + // AND all pending module imports have completed. The import gate is critical: + // without it, React would receive a tree containing lazy wrappers for client + // modules whose import() hasn't settled yet, causing hydration stalls. + // With the gate, module exports are resolved directly into the chunk values + // so the tree handed to React contains actual component functions — matching + // webpack's synchronous requireModule behavior. + // // We race with consumePromise to ensure transport-level errors are // propagated even if the root chunk was never created. + const gatedRootValue = async () => { + const rootValue = await response.getRootValue(); + // After the root chunk resolves, wait for any pending module imports + // that were kicked off during processData. The consume loop also awaits + // these, but the root chunk's Promise resolves synchronously inside + // processData (before the consume loop's await). This gate ensures + // React doesn't see the tree until modules are available. + if (response.pendingModuleImports.length > 0) { + await Promise.all(response.pendingModuleImports); + } + return rootValue; + }; const resultPromise = Promise.race([ - response.getRootValue(), + gatedRootValue(), consumePromise.then(() => response.getRootValue()), ]); @@ -1818,7 +2072,7 @@ export function createFromReadableStream(stream, options = {}) { }, (error) => { resultPromise.status = "rejected"; - resultPromise.value = error; + resultPromise.reason = error; } ); @@ -1860,7 +2114,7 @@ export function createFromFetch(promiseForResponse, options = {}) { }, (error) => { resultPromise.status = "rejected"; - resultPromise.value = error; + resultPromise.reason = error; } ); diff --git a/packages/rsc/server/shared.mjs b/packages/rsc/server/shared.mjs index ab7eaae4..dc2f01a8 100644 --- a/packages/rsc/server/shared.mjs +++ b/packages/rsc/server/shared.mjs @@ -116,6 +116,251 @@ function isThenable(value) { ); } +// ─── React Hooks Dispatcher for Server Components ─────────────────── +// Server components need React's internal dispatcher (H) set so that +// hooks like use(), useId(), useMemo(), useCallback() work. React's +// official Flight server (react-server-dom-webpack) does the same thing. + +const REACT_MEMO_CACHE_SENTINEL = Symbol.for("react.memo_cache_sentinel"); + +function resolveReactInternals(React) { + return ( + React?.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? + React?.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? + null + ); +} + +// Sentinel thrown by use() when it hits an unresolved thenable. +// Must NOT be caught by user code — it signals the Flight server to retry. +const SuspenseException = new Error( + "Suspense Exception: This is not a real error! It's an implementation " + + "detail of `use` to interrupt the current render. You must either " + + "rethrow it immediately, or move the `use` call outside of the " + + "`try/catch` block. Capturing without rethrowing will lead to " + + "unexpected behavior." +); + +let suspendedThenable = null; +let thenableIndexCounter = 0; +let thenableState = null; +const noop = () => {}; + +function trackUsedThenable(thenableState, thenable, index) { + const previous = thenableState[index]; + if (previous === undefined) { + thenableState.push(thenable); + } else if (previous !== thenable) { + thenable.then(noop, noop); + thenable = previous; + } + switch (thenable.status) { + case "fulfilled": + return thenable.value; + case "rejected": + throw thenable.reason; + default: + if (typeof thenable.status !== "string") { + const pending = thenable; + pending.status = "pending"; + pending.then( + (fulfilledValue) => { + if (thenable.status === "pending") { + thenable.status = "fulfilled"; + thenable.value = fulfilledValue; + } + }, + (error) => { + if (thenable.status === "pending") { + thenable.status = "rejected"; + thenable.reason = error; + } + } + ); + } + switch (thenable.status) { + case "fulfilled": + return thenable.value; + case "rejected": + throw thenable.reason; + } + suspendedThenable = thenable; + throw SuspenseException; + } +} + +function getSuspendedThenable() { + if (suspendedThenable === null) { + throw new Error("Expected a suspended thenable. This is a bug in @lazarv/rsc."); + } + const thenable = suspendedThenable; + suspendedThenable = null; + return thenable; +} + +function getThenableStateAfterSuspending() { + const state = thenableState || []; + thenableState = null; + return state; +} + +function unsupportedHook() { + throw new Error("This Hook is not supported in Server Components."); +} + +function unsupportedContext() { + throw new Error("Cannot read context from a Server Component."); +} + +// The currentRequest is set during serialization work so useId() can +// generate deterministic identifiers. Also used by the public +// setCurrentRequest/getCurrentRequest API. +let currentRequest = null; + +const HooksDispatcher = { + readContext: unsupportedContext, + getOwner() { + return null; + }, + use(usable) { + if ( + (usable !== null && typeof usable === "object") || + typeof usable === "function" + ) { + if (typeof usable.then === "function") { + const index = thenableIndexCounter; + thenableIndexCounter += 1; + if (thenableState === null) thenableState = []; + return trackUsedThenable(thenableState, usable, index); + } + if (usable.$$typeof === REACT_CONTEXT_TYPE) { + unsupportedContext(); + } + } + if (isClientReference(usable)) { + if ( + usable.value != null && + usable.value.$$typeof === REACT_CONTEXT_TYPE + ) { + throw new Error("Cannot read a Client Context from a Server Component."); + } + throw new Error("Cannot use() an already resolved Client Reference."); + } + throw new Error("An unsupported type was passed to use(): " + String(usable)); + }, + useCallback(callback) { + return callback; + }, + useContext: unsupportedContext, + useEffect: unsupportedHook, + useImperativeHandle: unsupportedHook, + useLayoutEffect: unsupportedHook, + useInsertionEffect: unsupportedHook, + useMemo(nextCreate) { + return nextCreate(); + }, + useReducer: unsupportedHook, + useRef: unsupportedHook, + useState: unsupportedHook, + useDebugValue() {}, + useDeferredValue: unsupportedHook, + useTransition: unsupportedHook, + useSyncExternalStore: unsupportedHook, + useId() { + if (currentRequest === null) { + throw new Error("useId can only be used while React is rendering"); + } + const id = currentRequest.identifierCount++; + return ( + "_" + + (currentRequest.identifierPrefix || "S") + + "_" + + id.toString(32) + + "_" + ); + }, + useHostTransitionStatus: unsupportedHook, + useFormState: unsupportedHook, + useActionState: unsupportedHook, + useOptimistic: unsupportedHook, + useMemoCache(size) { + const data = Array(size); + for (let i = 0; i < size; i++) data[i] = REACT_MEMO_CACHE_SENTINEL; + return data; + }, + useCacheRefresh() { + return unsupportedHook(); + }, +}; + +// DefaultAsyncDispatcher for React.cache() / async transitions +const DefaultAsyncDispatcher = { + getOwner() { + return null; + }, + getCacheForType(resourceType) { + if (!currentRequest) throw new Error("Cannot call getCacheForType outside of rendering"); + const cacheMap = currentRequest._cache || (currentRequest._cache = new Map()); + let entry = cacheMap.get(resourceType); + if (entry === undefined) { + entry = resourceType(); + cacheMap.set(resourceType, entry); + } + return entry; + }, +}; + +/** + * Set up the React dispatcher before calling a server component function, + * and restore it afterwards. Returns the component result. + * + * If the component throws a SuspenseException (from use()), we extract + * the pending thenable and return it — the caller should handle it like + * any other thrown promise. + */ +function callComponentWithDispatcher(request, type, props, prevThenableState) { + const internals = request.reactInternals; + if (!internals) { + // No React internals provided — call directly (hooks won't work + // but pure server components without hooks will still function) + return type(props); + } + + const prevDispatcher = internals.H; + const prevAsyncDispatcher = internals.A; + const prevRequest = currentRequest; + internals.H = HooksDispatcher; + internals.A = DefaultAsyncDispatcher; + currentRequest = request; + thenableIndexCounter = 0; + // Restore thenableState from a previous suspended render (if retrying). + // On first render prevThenableState is undefined → thenableState stays null. + thenableState = prevThenableState || null; + try { + const result = type(props); + // Successful render — clear thenableState for other components. + thenableState = null; + return result; + } catch (error) { + if (error === SuspenseException) { + // use() hit an unresolved thenable — capture the tracked thenables + // so the retry can restore them (matches React's per-task save). + const savedThenableState = thenableState; + thenableState = null; + const thenable = getSuspendedThenable(); + // Attach the saved state so the retry site can pass it back. + thenable._savedThenableState = savedThenableState; + throw thenable; + } + thenableState = null; + throw error; + } finally { + internals.H = prevDispatcher; + internals.A = prevAsyncDispatcher; + currentRequest = prevRequest; + } +} + /** * Internal request state for serialization * @internal Exported for testing purposes only @@ -136,6 +381,15 @@ export class FlightRequest { this.closed = false; this.temporaryReferences = options.temporaryReferences || undefined; + // React internals for hooks dispatcher (optional — pass `react` option) + this.reactInternals = options.react + ? resolveReactInternals(options.react) + : null; + + // useId() counter and prefix + this.identifierCount = 0; + this.identifierPrefix = options.identifierPrefix || "S"; + // Map of serialized objects to their IDs (for deduplication) this.objectMap = new WeakMap(); @@ -944,31 +1198,28 @@ function serializeValue(request, value, _parentObject, _parentKey) { // Handle client references (must be checked before generic function/object checks) // Client references can be either functions or objects with $$typeof if (isClientReference(value)) { + let metadata; const resolver = request.moduleResolver.resolveClientReference; if (resolver) { - const metadata = resolver(value); - if (metadata) { - // Create a reference chunk - const id = request.getNextChunkId(); - const row = request.serializeModuleRow(id, metadata); - request.writeChunk(row); - return "$L" + id; - } + metadata = resolver(value); } - // Fallback: use the reference's internal ID if available - if (value.$$id) { - // Create a module reference chunk with the ID - const id = request.getNextChunkId(); + // Fallback: use embedded $$metadata from the deserialized reference + // (re-serialization path via fromBuffer/fromStream with no moduleLoader) + if (!metadata && value.$$metadata) { + metadata = value.$$metadata; + } + // Fallback: parse $$id to reconstruct minimal metadata + if (!metadata && value.$$id) { const [moduleId, name] = value.$$id.split("#"); - const row = request.serializeModuleRow(id, { - id: moduleId, - name: name || "default", - chunks: [], - }); - request.writeChunk(row); - return "$L" + id; + metadata = { id: moduleId, name: name || "default", chunks: [] }; + } + if (!metadata) { + throw new Error("Client reference could not be resolved"); } - throw new Error("Client reference could not be resolved"); + const id = request.getNextChunkId(); + const row = request.serializeModuleRow(id, metadata); + request.writeChunk(row); + return "$L" + id; } // Handle functions @@ -1202,6 +1453,45 @@ function serializeValue(request, value, _parentObject, _parentKey) { return value; } +/** + * Create a promise that retries rendering a server component after suspension. + * Handles re-suspension (multiple use() calls) by chaining retries until the + * component either succeeds or throws a non-thenable error. This matches + * React's Flight server behavior of retrying tasks until all thenables resolve. + * + * @param {FlightRequest} request + * @param {Function} type - The component function + * @param {object} props + * @param {Array|null} savedState - thenableState from the initial suspension + * @param {object|null} componentDebugRef + * @param {object|null} previousOwnerRef + * @param {Promise} blockedThenable - The thenable that caused the suspension + */ +function retryComponentRender(request, type, props, savedState, componentDebugRef, previousOwnerRef, blockedThenable) { + return new Promise((resolve, reject) => { + function attempt(prevThenableState, waitFor) { + waitFor.then(() => { + try { + request.currentOwnerRef = componentDebugRef; + const result = callComponentWithDispatcher(request, type, props, prevThenableState); + request.currentOwnerRef = previousOwnerRef; + resolve(result); + } catch (error) { + request.currentOwnerRef = previousOwnerRef; + if (isThenable(error)) { + // Component suspended again (another use() call not yet resolved). + // Chain another retry with updated thenableState. + attempt(error._savedThenableState, error); + } else { + reject(error); + } + } + }, reject); + } + attempt(savedState, blockedThenable); + }); +} + /** * Serialize a React element */ @@ -1220,21 +1510,50 @@ function serializeElement(request, element) { } else if (typeof type === "function") { // Check if client reference if (isClientReference(type)) { + let metadata; const resolver = request.moduleResolver.resolveClientReference; if (resolver) { - const metadata = resolver(type); - if (metadata) { - // Create a module reference chunk - const id = request.getNextChunkId(); - const row = request.serializeModuleRow(id, metadata); - request.writeChunk(row); - serializedType = "$L" + id; - } - } else if (type.$$id) { - serializedType = "$L" + type.$$id; - } else { + metadata = resolver(type); + } + // Fallback: use embedded $$metadata from the deserialized reference + // (re-serialization path via fromBuffer/fromStream with no moduleLoader) + if (!metadata && type.$$metadata) { + metadata = type.$$metadata; + } + // Fallback: parse $$id to reconstruct minimal metadata + if (!metadata && type.$$id) { + const [moduleId, name] = type.$$id.split("#"); + metadata = { id: moduleId, name: name || "default", chunks: [] }; + } + if (!metadata) { throw new Error("Client component could not be resolved"); } + const id = request.getNextChunkId(); + const row = request.serializeModuleRow(id, metadata); + request.writeChunk(row); + serializedType = "$L" + id; + } else if (type.$$typeof === REACT_LAZY_TYPE) { + // Lazy wrapper that is a callable function (e.g. from @lazarv/rsc/client + // deserialization without a moduleLoader — used by fromBuffer/fromStream + // during RSC re-serialization). Resolve the lazy type and re-enter + // serializeElement with the resolved type so the element structure + // (key, props, children) is preserved. + const payload = type._payload; + const init = type._init; + try { + const resolved = init(payload); + return serializeElement(request, { ...element, type: resolved }); + } catch (error) { + if (isThenable(error)) { + return serializePromise( + request, + error.then((resolved) => { + return serializeElement(request, { ...element, type: resolved }); + }) + ); + } + throw error; + } } else { // Server component - render it // In dev mode, emit component debug info before rendering @@ -1266,7 +1585,7 @@ function serializeElement(request, element) { } try { - const result = type(props); + const result = callComponentWithDispatcher(request, type, props); if (isThenable(result)) { // Restore owner context after async resolution @@ -1295,16 +1614,12 @@ function serializeElement(request, element) { request.currentOwnerRef = previousOwnerRef; if (isThenable(error)) { - // Suspense - component threw a promise + // Suspense - component threw a promise (from use() or direct throw). + // Capture saved thenableState so the retry can restore it. + const savedState = error._savedThenableState; return serializePromise( request, - error.then(() => { - // Retry rendering after promise resolves - request.currentOwnerRef = componentDebugRef; - const retryResult = type(props); - request.currentOwnerRef = previousOwnerRef; - return retryResult; - }) + retryComponentRender(request, type, props, savedState, componentDebugRef, previousOwnerRef, error) ); } throw error; @@ -1389,8 +1704,33 @@ function serializeElement(request, element) { serializedType = "$@unknown"; } } else if (type && typeof type === "object") { + // Handle client references that are objects (e.g., proxy-based references) + if (isClientReference(type)) { + let metadata; + const resolver = request.moduleResolver.resolveClientReference; + if (resolver) { + metadata = resolver(type); + } + // Fallback: use embedded $$metadata from the deserialized reference + // (re-serialization path via fromBuffer/fromStream with no moduleLoader) + if (!metadata && type.$$metadata) { + metadata = type.$$metadata; + } + // Fallback: parse $$id to reconstruct minimal metadata + if (!metadata && type.$$id) { + const [moduleId, name] = type.$$id.split("#"); + metadata = { id: moduleId, name: name || "default", chunks: [] }; + } + if (!metadata) { + throw new Error("Client component could not be resolved"); + } + const id = request.getNextChunkId(); + const row = request.serializeModuleRow(id, metadata); + request.writeChunk(row); + serializedType = "$L" + id; + } // Handle Context.Provider - if (type.$$typeof === REACT_PROVIDER_TYPE) { + else if (type.$$typeof === REACT_PROVIDER_TYPE) { // Context Provider - render children with context value // In RSC, providers are transparent - we just render their children // The context value is passed through during rendering @@ -1439,15 +1779,54 @@ function serializeElement(request, element) { if (type.$$typeof === REACT_FORWARD_REF_TYPE) { // Client reference forwardRef if (isClientReference(type.render || type)) { + let metadata; const resolver = request.moduleResolver.resolveClientReference; + const target = type.render || type; if (resolver) { - const metadata = resolver(type.render || type); - if (metadata) { - const id = request.getNextChunkId(); - const row = request.serializeModuleRow(id, metadata); - request.writeChunk(row); - serializedType = "$L" + id; + metadata = resolver(target); + } + if (!metadata && target.$$metadata) { + metadata = target.$$metadata; + } + if (!metadata && target.$$id) { + const [moduleId, name] = target.$$id.split("#"); + metadata = { id: moduleId, name: name || "default", chunks: [] }; + } + if (!metadata) { + throw new Error("Client component could not be resolved"); + } + const id = request.getNextChunkId(); + const row = request.serializeModuleRow(id, metadata); + request.writeChunk(row); + serializedType = "$L" + id; + } else if (typeof type.render === "function") { + // Server-side forwardRef — invoke the render function with (props, ref) + // through the dispatcher so hooks (use, etc.) work inside it. Wrap in + // a single-argument shim so callComponentWithDispatcher can call it. + const renderFn = type.render; + const forwardRefWrapper = (p) => renderFn(p, ref); + // Preserve name for debug info. + Object.defineProperty(forwardRefWrapper, "name", { + value: renderFn.name || "ForwardRef", + }); + try { + const rendered = callComponentWithDispatcher( + request, + forwardRefWrapper, + props + ); + if (isThenable(rendered)) { + return serializePromise(request, rendered); + } + return serializeValue(request, rendered); + } catch (error) { + if (isThenable(error)) { + return serializePromise( + request, + error.then((r) => serializeValue(request, r)) + ); } + throw error; } } } @@ -1473,7 +1852,19 @@ function serializeElement(request, element) { } if (serializedType === undefined) { - throw new Error(`Unsupported element type: ${String(type)}`); + let detail = String(type); + if (type && typeof type === "object") { + const $$typeof = type.$$typeof; + const typeofTag = + typeof $$typeof === "symbol" + ? Symbol.keyFor($$typeof) || $$typeof.toString() + : typeof $$typeof; + const keys = Object.keys(type).slice(0, 10).join(", "); + detail = `object with $$typeof=${typeofTag} keys=[${keys}]`; + } else if (typeof type === "function") { + detail = `function ${type.name || "(anonymous)"}`; + } + throw new Error(`Unsupported element type: ${detail}`); } // Serialize props (excluding children which we handle specially) @@ -1605,6 +1996,56 @@ function serializeElement(request, element) { return tuple; } +/** + * Emit an error row for a chunk, decrement pending count, and close if done. + * Handles postpone errors, digest generation, and ensures pending-chunk + * accounting is always correct even when the caller itself throws. + */ +function emitErrorRow(request, id, error) { + try { + // Check if this is a postpone error + if (error && error.$$typeof === Symbol.for("react.postpone")) { + request.emitPostpone(id, error.reason); + if (request.options.onPostpone) { + request.options.onPostpone(error.reason); + } + } else { + // Generate error digest if handler provided + let digest; + if (request.options.onError) { + digest = request.options.onError(error); + } + + // Build errorInfo with digest FIRST — the client-side redirect + // detector regex-matches raw bytes and expects "digest" to be + // the leading JSON property (matching react-server-dom-webpack). + const errorInfo = {}; + if (digest !== undefined) { + errorInfo.digest = String(digest); + } + errorInfo.message = error?.message || String(error); + errorInfo.stack = error?.stack; + + const row = request.serializeRow(id, ROW_TAG.ERROR, errorInfo); + request.writeChunk(row); + } + } catch { + // Last resort — emit a minimal error row if the error handler itself fails + try { + const row = request.serializeRow(id, ROW_TAG.ERROR, { + message: "Internal serialization error", + }); + request.writeChunk(row); + } catch { + // Nothing we can do — at least we'll decrement pendingChunks below + } + } + request.pendingChunks--; + if (request.pendingChunks === 0) { + request.closeStream(); + } +} + /** * Serialize a Promise/Thenable */ @@ -1620,50 +2061,22 @@ function serializePromise(request, thenable) { thenable.then( (result) => { - const serialized = serializeValue(request, result, null, null); - const row = request.serializeModelRow(id, serialized); - request.writeChunk(row); - request.pendingChunks--; - if (request.pendingChunks === 0) { - request.closeStream(); - } - }, - (error) => { - // Check if this is a postpone error - if (error && error.$$typeof === Symbol.for("react.postpone")) { - request.emitPostpone(id, error.reason); + try { + const serialized = serializeValue(request, result, null, null); + const row = request.serializeModelRow(id, serialized); + request.writeChunk(row); request.pendingChunks--; - if (request.options.onPostpone) { - request.options.onPostpone(error.reason); - } if (request.pendingChunks === 0) { request.closeStream(); } - return; - } - - // Generate error digest if handler provided - let digest; - if (request.options.onError) { - digest = request.options.onError(error); - } - - const errorInfo = { - message: error?.message || String(error), - stack: error?.stack, - }; - - // Add digest for production error hiding - if (digest !== undefined) { - errorInfo.digest = String(digest); - } - - const row = request.serializeRow(id, ROW_TAG.ERROR, errorInfo); - request.writeChunk(row); - request.pendingChunks--; - if (request.pendingChunks === 0) { - request.closeStream(); + } catch (error) { + // Serialization failed — emit an error row for this chunk so + // the client receives a proper error instead of hanging. + emitErrorRow(request, id, error); } + }, + (error) => { + emitErrorRow(request, id, error); } ); @@ -1695,14 +2108,20 @@ function startWork(request) { request.closeStream(); } } catch (error) { + let digest; if (request.options.onError) { - request.options.onError(error); + digest = request.options.onError(error); } if (request.destination) { - const errorInfo = { - message: error?.message || String(error), - stack: error?.stack, - }; + // Build errorInfo with digest FIRST — the client-side redirect + // detector regex-matches the raw bytes and expects "digest" to be + // the leading JSON property (matching react-server-dom-webpack). + const errorInfo = {}; + if (digest !== undefined) { + errorInfo.digest = String(digest); + } + errorInfo.message = error?.message || String(error); + errorInfo.stack = error?.stack; const row = request.serializeRow(0, ROW_TAG.ERROR, errorInfo); try { request.destination.enqueue(encoder.encode(row)); @@ -2080,7 +2499,48 @@ export async function decodeAction(body, serverManifestOrOptions) { return null; } - const actionId = body.get("$ACTION_ID"); + // React DOM encodes action references in form field *names*, not values: + // $ACTION_ID_ — unbound action (id is in the key name) + // $ACTION_REF_ — bound action (metadata in prefixed fields) + // $ACTION_ID (legacy) — action id as the field value + let actionId = null; + let boundPrefix = null; + + for (const key of body.keys()) { + if (key.startsWith("$ACTION_ID_")) { + actionId = key.slice(11); // strip "$ACTION_ID_" + break; + } + if (key.startsWith("$ACTION_REF_")) { + boundPrefix = "$ACTION_" + key.slice(12) + ":"; + break; + } + } + + // Legacy fallback: $ACTION_ID as a field with the id as value + if (!actionId && !boundPrefix) { + actionId = body.get("$ACTION_ID"); + } + + if (boundPrefix) { + // Bound action: decode metadata from prefixed FormData fields + const metadataPayload = body.get(boundPrefix + "0"); + if (metadataPayload && typeof metadataPayload === "string") { + const parsed = JSON.parse(metadataPayload); + // parsed is the reply-encoded {id, bound} or just bound args reference + if (parsed && typeof parsed === "object" && parsed.id) { + actionId = parsed.id; + } else if (typeof parsed === "string" && parsed.startsWith("$h")) { + // Server reference in reply format + const refPayload = body.get(boundPrefix + parsed.slice(2)); + if (refPayload && typeof refPayload === "string") { + const ref = JSON.parse(refPayload); + actionId = ref.id; + } + } + } + } + if (!actionId || typeof actionId !== "string") { return null; } @@ -2147,8 +2607,47 @@ export function decodeFormState(actionResult, body) { return null; } - // Get the action reference ID from form data - const actionId = body.get("$ACTION_ID"); + // Get the action reference ID from form data. + // React DOM encodes action IDs in field names: + // $ACTION_ID_ — unbound action + // $ACTION_REF_ — bound action (metadata in prefixed fields) + // $ACTION_ID (legacy) — action id as field value + let actionId = null; + let boundArgsLength = 0; + + for (const key of body.keys()) { + if (key.startsWith("$ACTION_ID_")) { + actionId = key.slice(11); + } else if (key.startsWith("$ACTION_REF_")) { + // Bound action — decode metadata from prefixed fields + const prefix = "$ACTION_" + key.slice(12) + ":"; + const metadataPayload = body.get(prefix + "0"); + if (metadataPayload && typeof metadataPayload === "string") { + try { + const parsed = JSON.parse(metadataPayload); + if (parsed && typeof parsed === "object" && parsed.id) { + actionId = parsed.id; + } + } catch { + // ignore parse errors + } + } + // Count bound arg fields + for (const k of body.keys()) { + if (k.startsWith(prefix) && /^\d+$/.test(k.slice(prefix.length))) { + boundArgsLength++; + } + } + } else if (/^\$\d+$/.test(key)) { + boundArgsLength++; + } + } + + // Legacy fallback + if (!actionId) { + actionId = body.get("$ACTION_ID"); + } + if (!actionId || typeof actionId !== "string") { return null; } @@ -2156,14 +2655,6 @@ export function decodeFormState(actionResult, body) { // Get the key path (used for form state matching) const keyPath = body.get("$ACTION_KEY") || ""; - // Count bound arguments (prefixed with $ followed by a number) - let boundArgsLength = 0; - for (const key of body.keys()) { - if (/^\$\d+$/.test(key)) { - boundArgsLength++; - } - } - // Return ReactFormState tuple: [value, keyPath, referenceId, boundArgsLength] return [actionResult, String(keyPath), actionId, boundArgsLength]; } @@ -2571,12 +3062,6 @@ export function emitHint(request, code, model) { } } -/** - * Get current request from rendering context - * This is a placeholder - in a real implementation this would use AsyncLocalStorage - */ -let currentRequest = null; - export function setCurrentRequest(request) { currentRequest = request; } From efea7d7fcf8001b30734f2432685d5dcae9a65a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sat, 11 Apr 2026 23:31:30 +0200 Subject: [PATCH 03/15] fix: client --- .../react-server/server/module-loader.mjs | 12 +- packages/react-server/server/render-dom.mjs | 89 ++++++++- packages/rsc/server/shared.mjs | 187 ++++++++---------- 3 files changed, 177 insertions(+), 111 deletions(-) diff --git a/packages/react-server/server/module-loader.mjs b/packages/react-server/server/module-loader.mjs index 3d0c7862..22e4392e 100644 --- a/packages/react-server/server/module-loader.mjs +++ b/packages/react-server/server/module-loader.mjs @@ -137,9 +137,17 @@ export async function init$( : `client://${specifier}`, linkQueueStorage ); + // Annotate the cached promise with React's use() protocol so + // consumers (e.g. @lazarv/rsc's resolveModuleReference) can skip + // the microtask-hop fast path once the module is hot: they can + // return `modulePromise.value` synchronously when + // `modulePromise.status === "fulfilled"`. Previously `.value` was + // set to the promise itself, which forced every steady-state SSR + // request into the async resolution path even though the module + // was long since materialized — see render-dom.mjs's requireModule. modulePromise.then( - () => { - modulePromise.value = modulePromise; + (module) => { + modulePromise.value = module; modulePromise.status = "fulfilled"; }, (reason) => { diff --git a/packages/react-server/server/render-dom.mjs b/packages/react-server/server/render-dom.mjs index 10b6e2a3..eed5efd0 100644 --- a/packages/react-server/server/render-dom.mjs +++ b/packages/react-server/server/render-dom.mjs @@ -22,6 +22,79 @@ import { LINK_QUEUE, MODULE_CACHE, REQUEST_CACHE_SHARED } from "./symbols.mjs"; const streamMap = new Map(); const preludeMap = new Map(); +// ── Persistent client-module namespace cache for the SSR moduleLoader adapter ── +// +// The per-request module cache (`moduleCacheStorage.run(new Map(), ...)` in +// ssr-handler.mjs) is allocated fresh on every request, which means +// `globalThis.__webpack_require__` in module-loader.mjs always creates a brand +// new `modulePromise` per request. At the moment `requireModule` is called +// inside `createFromReadableStream`'s consume loop, the promise's +// `.status`/`.value` annotation has not yet been set (the `.then` microtask +// hasn't fired), so the previous "sync fast-path" check was dead code and every +// client reference was routed through `@lazarv/rsc/client`'s async branch +// (which pushes into `pendingModuleImports` and makes the consume loop await +// `Promise.all(pendingModuleImports)` on every reader tick). +// +// To actually take the sync branch in resolveModuleReference we need the +// adapter itself to hold a long-lived map from `metadata.id` to the resolved +// module namespace. After the first request warms an entry, every subsequent +// request returns the namespace synchronously, skipping the pending-module +// gate entirely. The rsc/client sync branch does its own +// `result[exportName] ?? result.default ?? result` unwrap, so we must return +// the full namespace here (not a pre-picked export) to preserve existing +// semantics for multi-export modules and object-valued exports. +// +// HMR: in dev mode, editing a client component will not naturally invalidate +// this cache. Use `invalidateClientModuleNamespaceCache(id?)` from the dev +// HMR path (or ssr-handler's restart hook) to purge stale entries. +const clientModuleNamespaceCache = new Map(); + +export function invalidateClientModuleNamespaceCache(id) { + if (id === undefined) { + clientModuleNamespaceCache.clear(); + return; + } + clientModuleNamespaceCache.delete(id); +} + +function resolveClientModuleSync(metadata) { + const id = metadata.id; + const cached = clientModuleNamespaceCache.get(id); + if (cached !== undefined) return cached; + + const mod = globalThis.__webpack_require__(id); + + // Already-resolved case — either a plain namespace returned directly by + // module-loader.mjs (react-client-reference:, server-action://, ...) or + // a modulePromise that has already fulfilled and been annotated by + // module-loader.mjs (`.status === "fulfilled"`, `.value = module`). + if (mod && typeof mod === "object" && mod.status === "fulfilled") { + clientModuleNamespaceCache.set(id, mod.value); + return mod.value; + } + if (mod && typeof mod.then !== "function") { + clientModuleNamespaceCache.set(id, mod); + return mod; + } + + // Async path: the modulePromise is still pending. Attach a .then to + // populate the persistent cache once the import resolves so the next + // request hits the sync branch above. Return the promise itself to + // resolveModuleReference, which will push it onto pendingModuleImports + // for the consume loop to await. + if (mod && typeof mod.then === "function") { + mod.then( + (value) => { + if (value !== undefined) clientModuleNamespaceCache.set(id, value); + }, + () => { + // Swallow; rejection handling lives in rsc/client's async branch. + } + ); + } + return mod; +} + // ── Byte-level JS string escaping for inline