Skip to content

fix: normalize null-prototype params from matchPattern() for RSC serialization#243

Merged
southpolesteve merged 1 commit intocloudflare:mainfrom
gagipro:fix/rsc-null-prototype-params
Mar 4, 2026
Merged

fix: normalize null-prototype params from matchPattern() for RSC serialization#243
southpolesteve merged 1 commit intocloudflare:mainfrom
gagipro:fix/rsc-null-prototype-params

Conversation

@gagipro
Copy link
Copy Markdown
Contributor

@gagipro gagipro commented Mar 3, 2026

Summary

matchPattern() uses Object.create(null), producing objects without Object.prototype. The RSC serializer rejects these during dynamic route rendering, causing 500 errors on all dynamic routes (e.g. /dashboards/[id]).

Fixes #241

Fix

A makeThenableParams() helper that satisfies both constraints:

function makeThenableParams(obj) {
  const plain = { ...obj };
  return Object.assign(Promise.resolve(plain), plain);
}
  1. Spread ({...obj}) normalizes the null prototype → RSC serializer accepts it
  2. Object.assign onto the Promise preserves synchronous property access (params.id, params.slug) for pre-Next.js 15 code

This replaces the previous Object.assign(Promise.resolve(params), params) pattern at all 6 call sites in app-dev-server.ts and 3 in metadata.tsx. The old pattern passed the original null-prototype object to both Promise.resolve() and Object.assign() — while the thenable worked, the resolved value still had a null prototype causing RSC serialization failure.

Files Changed (2 files, 1 commit)

File Changes
packages/vinext/src/server/app-dev-server.ts Added makeThenableParams(), replaced 6 Object.assign(Promise.resolve(x), x) sites, updated stale comment
packages/vinext/src/shims/metadata.tsx Added typed makeThenableParams<T>(), replaced 3 call sites, updated comment

Testing

  • 2110 Vitest tests pass (51/51 files, 0 failures)
  • 387 Playwright E2E tests pass (0 failures, 12 skipped)
  • Specifically verified: dynamic routes with params.id, params.slug, catch-all routes (params.slug.join()), generateMetadata() with async params — all pass
  • Tested with a production Next.js 16 / React 19 app on Vinext (dynamic routes, metadata, server actions)

vs. PR #242

This is a replacement for #242, which was correctly identified by review as breaking synchronous params access. Changes from #242:

  • Fixed: Promise.resolve({...obj})Object.assign(Promise.resolve({...obj}), {...obj}) — preserves backward compat
  • Removed: Unrelated credentials: "same-origin" changes (4 fetch calls)
  • Removed: Unrelated findReactServerPackages() feature
  • Removed: README.md overwrite
  • Removed: .gitignore change
  • Removed: link.tsx / navigation.ts changes
  • Removed: Accidentally tracked build artifact update

…alization

matchPattern() uses Object.create(null), producing objects without
Object.prototype. The RSC serializer rejects these during dynamic route
rendering, causing 500 errors.

The fix spreads params ({...obj}) to restore a normal prototype, then
assigns properties onto the Promise to preserve synchronous access
(params.id, params.slug) for pre-Next.js 15 code. This satisfies both
constraints:
1. RSC serializer accepts objects with Object.prototype
2. Existing components can access params synchronously without await

Fixes cloudflare#241
@gagipro
Copy link
Copy Markdown
Contributor Author

gagipro commented Mar 3, 2026

/bigbonk review please

1 similar comment
@southpolesteve
Copy link
Copy Markdown
Collaborator

/bigbonk review please

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 3, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@243

commit: b9018f7

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean, well-scoped fix. The approach is correct: matchPattern() uses Object.create(null), and spreading into {...obj} restores a normal prototype so the RSC serializer accepts it. The Object.assign onto the Promise preserves backward compat for synchronous params.id access.

I verified:

  • All 6 call sites in app-dev-server.ts and 3 in metadata.tsx are updated
  • Intercepting route params also flow through makeThenableParams (via slotParams at lines 801/809)
  • The __matchConfigPattern function also uses Object.create(null) but its params are only used for redirect/rewrite destination interpolation, not RSC serialization, so it's unaffected
  • No production server or worker entry parity concerns — the App Router prod server delegates to the built RSC entry, so it inherits this fix automatically
  • searchParams (spObj) is constructed with {} (normal prototype), so makeThenableParams on it is a no-op prototype-wise but keeps the code consistent

Two minor suggestions below, neither blocking.

* Normalize null-prototype objects from matchPattern() into thenable objects.
* See app-dev-server.ts makeThenableParams() for full explanation.
*/
function makeThenableParams<T extends Record<string, unknown>>(obj: T): Promise<T> & T {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this is a private copy of the same function that exists in app-dev-server.ts. If metadata.tsx ever gets more call sites, or if the logic needs to change (e.g. adding Object.freeze for immutability), both copies need to stay in sync.

Worth considering exporting it from a shared utility module (e.g. packages/vinext/src/utils/thenable-params.ts) and importing in both files. Not blocking — the duplication is small and the function is unlikely to change frequently.

// restores a normal prototype. Object.assign onto the Promise preserves
// synchronous property access (params.id, params.slug) that existing
// components and test fixtures rely on.
function makeThenableParams(obj) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: the function in metadata.tsx is typed (<T extends Record<string, unknown>>), but this one is untyped since this file is generated code emitted as a string. That's fine — just noting for future reference that if someone wants to add type safety to the generated RSC entry, the metadata version is the canonical typed signature.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 3, 2026

Review submitted. Here's a summary:

Approved the PR. The fix is correct and well-scoped:

  • matchPattern() uses Object.create(null), producing objects without Object.prototype that the RSC serializer rejects
  • The {...obj} spread restores a normal prototype, and Object.assign onto the Promise preserves synchronous params.id access for pre-Next.js 15 code
  • All 9 call sites (6 in app-dev-server.ts, 3 in metadata.tsx) are properly updated
  • No parity concerns with prod server or Cloudflare worker entry (App Router prod delegates to the built RSC entry)
  • Intercepting route params also flow through the normalization

I left two non-blocking nits: (1) the duplicated makeThenableParams function could be extracted to a shared utility, and (2) a note that the metadata.tsx version is typed while the app-dev-server.ts version is untyped (expected, since it's generated code).

github run

@southpolesteve southpolesteve merged commit 702367d into cloudflare:main Mar 4, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RSC serialization fails with null-prototype params from matchPattern()

2 participants