[PR #250] [MERGED] fix(rsc): emit actionable hint when non-plain object crosses server-client boundary #418

Closed
opened 2026-05-06 12:39:42 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/250
Author: @SeolJaeHyeok
Created: 3/4/2026
Status: Merged
Merged: 3/4/2026
Merged by: @southpolesteve

Base: mainHead: fix/rsc-serialization-error-hint


📝 Commits (3)

  • 8fb2002 fix(rsc): emit actionable hint when non-plain object crosses server-client boundary
  • 2908d79 test(rsc): add unit tests for rscOnError non-plain object dev hint
  • 7ea4fce fix: escape newlines in rscOnError hint and add runtime tests

📊 Changes

2 files changed (+156 additions, -1 deletions)

View changed files

📝 packages/vinext/src/server/app-dev-server.ts (+42 -0)
📝 tests/app-router.test.ts (+114 -1)

📄 Description

Fixes #237

Problem

When a Server Component passes a non-serializable value (class instance, ESM module namespace object, Date, Map, or Object.create(null)) as a prop to a Client Component, React's RSC serializer throws:

Only plain objects, and a few built-ins, can be passed to Client Components
from Server Components. Classes or null prototypes are not supported.
[..., Module, Null, ..., ..., [], 1]
^^^^^^

The error surfaces in the browser with no indication of which component or prop triggered it, making it nearly impossible to debug in large applications. Users migrating from plain Next.js are especially affected because the same code silently works there (see root cause below).


Root Cause

This is a fundamental difference between how Vite and webpack handle ES modules:

Next.js (webpack) vinext (Vite/ESM)
import * as X from './mod' { __esModule: true, ...exports }plain object True ESM Module Namespace Object — non-plain
RSC serializable? Yes (passes isPlainObject check) No (internal [[Module]] slot)

When users pass import * as utils as a prop to a Client Component, webpack wraps it as a plain __esModule object that React's RSC serializer accepts. Vite produces a real ESM Module Namespace Object with a null-like prototype that React correctly rejects. vinext's behavior is actually more spec-correct — Next.js/webpack was accidentally permitting invalid semantics.


Solutions Considered

Solution 1 — User-land fix (always applicable)

Convert non-serializable values to plain objects before passing them across
the RSC boundary:

// ❌ Fails: class instance
const service = new UserService();
return <UserCard service={service} />;

// ✅ Works: extract plain data
const user = await service.getUser(1);
return <UserCard user={{ id: user.id, name: user.name }} />;

// ❌ Fails: module namespace object
import * as config from './config';
return <AppInfo config={config} />;

// ✅ Works: spread into a plain object
return <AppInfo config={{ ...config }} />;

// ❌ Fails: Date instance
return <Card createdAt={new Date()} />;

// ✅ Works: serialize to string
return <Card createdAt={new Date().toISOString()} />;

Solution 2 — Actionable dev-mode hint in rscOnError (this PR)

Enhanced rscOnError in app-dev-server.ts to detect this specific error
in dev mode and print a structured, actionable message to the server console:

[vinext] RSC serialization error: a non-plain object was passed from a Server Component to a Client Component.

Common causes:
  * Passing a module namespace (import * as X) directly as a prop.
    Unlike Next.js (webpack), Vite produces real ESM module namespace objects
    which are not serializable. Fix: pass individual values instead,
    e.g. <Comp value={module.value} />
  * Passing a class instance (new Foo()) as a prop.
    Fix: convert to a plain object, e.g. { id: foo.id, name: foo.name }
  * Passing a Date, Map, or Set. Use .toISOString(), [...map.entries()], etc.
  * Passing Object.create(null). Use { ...obj } to restore a prototype.

No behavior change in production — the existing digest hash logic is unchanged.

Solution 3 — Auto-convert Module Namespace Objects in vinext (not applied)

An alternative approach was considered: intercept props before
renderToReadableStream and automatically convert ESM Module Namespace
Objects to plain objects via { ...moduleNS } or
Object.fromEntries(Object.entries(moduleNS)).

This was rejected for three reasons:

  1. Semantic corruption. Silently flattening a module namespace drops
    important distinctions — live bindings become static snapshots, and any
    non-enumerable exports or symbols are lost without warning. It would make
    vinext behave like webpack rather than being correct.
  2. Tree-shaking breakage. Auto-converting import * as X to a plain
    object and passing it to a Client Component means all exports of that
    module would be bundled into the client, defeating the RSC boundary's
    purpose of keeping server-only code out of the client bundle.
  3. Hides the real bug. The user's code has a genuine error — passing
    non-serializable data across an RSC boundary. Silently fixing it
    server-side masks the problem instead of helping the user understand and
    fix their code. A clear error message is the correct resolution.

Changes

  • packages/vinext/src/server/app-dev-server.ts: added non-plain object detection block in rscOnError (dev-only, no production impact)

Testing

Minimal reproduction:

  // app/components/UserCard.tsx
  'use client';
  export function UserCard({ service }: { service: any }) {
    return <div />;
  }

  // app/page.tsx  (Server Component)
  import { UserCard } from './components/UserCard';
  class UserService { constructor(public baseUrl: string) {} }

  export default function Page() {
    // Triggers the error — class instance across RSC boundary
    return <UserCard service={new UserService('https://api.example.com')} />;
  }

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/250 **Author:** [@SeolJaeHyeok](https://github.com/SeolJaeHyeok) **Created:** 3/4/2026 **Status:** ✅ Merged **Merged:** 3/4/2026 **Merged by:** [@southpolesteve](https://github.com/southpolesteve) **Base:** `main` ← **Head:** `fix/rsc-serialization-error-hint` --- ### 📝 Commits (3) - [`8fb2002`](https://github.com/cloudflare/vinext/commit/8fb200265e03f9e6218fa516c856c51b37772892) fix(rsc): emit actionable hint when non-plain object crosses server-client boundary - [`2908d79`](https://github.com/cloudflare/vinext/commit/2908d79ba52547aca05b2cbe7478b60140aa4aad) test(rsc): add unit tests for rscOnError non-plain object dev hint - [`7ea4fce`](https://github.com/cloudflare/vinext/commit/7ea4fce0a7db9d5204c4214a6234e417600a9e7c) fix: escape newlines in rscOnError hint and add runtime tests ### 📊 Changes **2 files changed** (+156 additions, -1 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/server/app-dev-server.ts` (+42 -0) 📝 `tests/app-router.test.ts` (+114 -1) </details> ### 📄 Description Fixes #237 ### Problem When a Server Component passes a non-serializable value (class instance, ESM module namespace object, `Date`, `Map`, or `Object.create(null)`) as a prop to a Client Component, React's RSC serializer throws: > Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported. [..., Module, Null, ..., ..., [], 1] ^^^^^^ The error surfaces in the browser with no indication of **which component or prop** triggered it, making it nearly impossible to debug in large applications. Users migrating from plain Next.js are especially affected because the same code silently works there (see root cause below). --- ### Root Cause This is a fundamental difference between how Vite and webpack handle ES modules: | | Next.js (webpack) | vinext (Vite/ESM) | |---|---|---| | `import * as X from './mod'` | `{ __esModule: true, ...exports }` — **plain object** | True ESM Module Namespace Object — **non-plain** | | RSC serializable? | ✅ Yes (passes `isPlainObject` check) | ❌ No (internal `[[Module]]` slot) | When users pass `import * as utils` as a prop to a Client Component, webpack wraps it as a plain `__esModule` object that React's RSC serializer accepts. Vite produces a real ESM Module Namespace Object with a null-like prototype that React correctly rejects. **vinext's behavior is actually more spec-correct** — Next.js/webpack was accidentally permitting invalid semantics. --- ### Solutions Considered #### ✅ Solution 1 — User-land fix (always applicable) Convert non-serializable values to plain objects before passing them across the RSC boundary: ```ts // ❌ Fails: class instance const service = new UserService(); return <UserCard service={service} />; // ✅ Works: extract plain data const user = await service.getUser(1); return <UserCard user={{ id: user.id, name: user.name }} />; // ❌ Fails: module namespace object import * as config from './config'; return <AppInfo config={config} />; // ✅ Works: spread into a plain object return <AppInfo config={{ ...config }} />; // ❌ Fails: Date instance return <Card createdAt={new Date()} />; // ✅ Works: serialize to string return <Card createdAt={new Date().toISOString()} />; ``` #### ✅ Solution 2 — Actionable dev-mode hint in rscOnError (this PR) Enhanced rscOnError in app-dev-server.ts to detect this specific error in dev mode and print a structured, actionable message to the server console: ``` [vinext] RSC serialization error: a non-plain object was passed from a Server Component to a Client Component. Common causes: * Passing a module namespace (import * as X) directly as a prop. Unlike Next.js (webpack), Vite produces real ESM module namespace objects which are not serializable. Fix: pass individual values instead, e.g. <Comp value={module.value} /> * Passing a class instance (new Foo()) as a prop. Fix: convert to a plain object, e.g. { id: foo.id, name: foo.name } * Passing a Date, Map, or Set. Use .toISOString(), [...map.entries()], etc. * Passing Object.create(null). Use { ...obj } to restore a prototype. ``` No behavior change in production — the existing digest hash logic is unchanged. #### ❌ Solution 3 — Auto-convert Module Namespace Objects in vinext (not applied) An alternative approach was considered: intercept props before renderToReadableStream and automatically convert ESM Module Namespace Objects to plain objects via { ...moduleNS } or Object.fromEntries(Object.entries(moduleNS)). This was rejected for three reasons: 1. Semantic corruption. Silently flattening a module namespace drops important distinctions — live bindings become static snapshots, and any non-enumerable exports or symbols are lost without warning. It would make vinext behave like webpack rather than being correct. 2. Tree-shaking breakage. Auto-converting import * as X to a plain object and passing it to a Client Component means all exports of that module would be bundled into the client, defeating the RSC boundary's purpose of keeping server-only code out of the client bundle. 3. Hides the real bug. The user's code has a genuine error — passing non-serializable data across an RSC boundary. Silently fixing it server-side masks the problem instead of helping the user understand and fix their code. A clear error message is the correct resolution. --- ### Changes - packages/vinext/src/server/app-dev-server.ts: added non-plain object detection block in rscOnError (dev-only, no production impact) --- ### Testing Minimal reproduction: ```ts // app/components/UserCard.tsx 'use client'; export function UserCard({ service }: { service: any }) { return <div />; } // app/page.tsx (Server Component) import { UserCard } from './components/UserCard'; class UserService { constructor(public baseUrl: string) {} } export default function Page() { // Triggers the error — class instance across RSC boundary return <UserCard service={new UserService('https://api.example.com')} />; } ``` --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 12:39:42 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#418
No description provided.