Skip to content

fix: RSC compatibility for dynamic() and layout segment context#466

Open
benfavre wants to merge 1 commit intocloudflare:mainfrom
benfavre:fix/rsc-dynamic-layout-segment
Open

fix: RSC compatibility for dynamic() and layout segment context#466
benfavre wants to merge 1 commit intocloudflare:mainfrom
benfavre:fix/rsc-dynamic-layout-segment

Conversation

@benfavre
Copy link
Contributor

Problem

Three related issues prevent next/dynamic and layout segment context from working in React Server Component environments:

1. dynamic.ts crashes in RSC with React.lazy is not a function

The "use client" directive forces next/dynamic into a client component boundary, but dynamic() should work in server components too. When the RSC entry imports it, the react-server condition exports a stripped-down React that does not include lazy, Suspense, useState, or useEffect. The destructured imports fail silently (they become undefined), and lazy(...) crashes at runtime.

2. layout-segment-context.tsx breaks RSC rendering

The "use client" directive creates a client component boundary. The RSC entry directly imports LayoutSegmentProvider, so this boundary interferes with the server rendering pipeline. getLayoutSegmentContext() already returns null when React.createContext is unavailable, and the provider falls back to passing children through unchanged — the "use client" directive is unnecessary.

3. Route handler params not wrapped as thenable

Next.js 15+ changed route handler params to be async (Promises). Route handlers that await params crash when params is a plain object from matchPattern(). The RSC entry already has makeThenableParams() for this purpose but wasn't using it for route handler params.

Solution

dynamic.ts

  • Removed "use client" directive
  • Changed destructured imports (lazy, Suspense, useState, useEffect) to namespace access (React.lazy, React.Suspense, etc.) — these are undefined under react-server condition but accessed safely via runtime checks
  • Added RSC path: when typeof React.lazy !== "function", use async server component pattern instead (RSC renderer natively supports async components)

layout-segment-context.tsx

  • Removed "use client" directive
  • Updated doc comment to explain the graceful fallback behavior

app-rsc-entry.ts

  • Wrapped route handler params with makeThenableParams() so await params works correctly

Changes

File Change
packages/vinext/src/shims/dynamic.ts Remove "use client", add RSC async path, use React.* namespace
packages/vinext/src/shims/layout-segment-context.tsx Remove "use client", update doc
packages/vinext/src/entries/app-rsc-entry.ts { params }{ params: makeThenableParams(params) }

Reproduction

// page.tsx (Server Component)
import dynamic from "next/dynamic";

// This crashes because dynamic.ts has "use client" and React.lazy
// is not available in the RSC environment
const Chart = dynamic(() => import("./Chart"), { ssr: false });

export default function Page() {
  return <Chart />;
}
// app/api/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params; // Crashes: params is a plain object, not a Promise
  return Response.json({ id });
}

Risk

Low-medium.

  • dynamic.ts: The RSC path is additive (only activates when React.lazy is unavailable). SSR and client paths are functionally identical — only the import style changed from destructured to namespace.
  • layout-segment-context.tsx: getLayoutSegmentContext() already returns null in RSC; removing "use client" just avoids an unnecessary component boundary.
  • app-rsc-entry.ts: makeThenableParams() is already used for page params in the same file; this extends it to route handlers.

Three related fixes for React Server Component environments:

1. **dynamic.ts: Remove "use client" and add RSC async path**

   The `"use client"` directive forced `next/dynamic` into a client
   component boundary, but `dynamic()` should work in server components
   too. In the RSC environment, `React.lazy` is not available (the
   `react-server` condition exports a stripped-down React). Added a
   runtime check: when `React.lazy` is not a function, use an async
   server component pattern instead (the RSC renderer natively supports
   async components).

   Also switched from destructured imports (`lazy`, `Suspense`,
   `useState`, `useEffect`) to `React.lazy`, `React.Suspense`, etc.
   to avoid importing names that don't exist under the `react-server`
   condition.

2. **layout-segment-context.tsx: Remove "use client"**

   This module is imported directly by the RSC entry. The `"use client"`
   directive created a client component boundary that breaks the RSC
   rendering pipeline. `getLayoutSegmentContext()` already returns
   `null` when `React.createContext` is unavailable (RSC), and the
   `LayoutSegmentProvider` gracefully falls back to passing children
   through unchanged.

3. **app-rsc-entry.ts: Wrap route handler params with makeThenableParams**

   Next.js 15+ changed route handler params to be async (Promises).
   Route handlers that `await params` crash when params is a plain
   object. `makeThenableParams()` wraps the object so it's both a
   Promise and has synchronous property access.
@james-elicx
Copy link
Collaborator

/bigbonk review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

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

commit: fec0b4d

Copy link
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.

Review: RSC compatibility for dynamic() and layout segment context

Overall this is a solid PR that addresses real issues. The three changes are coherent and well-scoped. A few observations and one suggestion below.

Summary

dynamic.ts — The approach is sound. Removing "use client" and switching from destructured imports to React.* namespace access is the correct fix for RSC compatibility. The runtime typeof React.lazy !== "function" check is a clean way to detect the react-server environment without build-time coupling. The async server component fallback is the right pattern for RSC.

layout-segment-context.tsx — Correct. The module already has a graceful fallback when createContext is unavailable. The "use client" directive was creating an unnecessary component boundary in the RSC entry's module graph. Since the RSC entry directly imports LayoutSegmentProvider, removing the directive lets it run in-process rather than forcing a client reference indirection.

app-rsc-entry.ts — Clear and correct. makeThenableParams() is already used for page params on the same file; extending it to route handlers is the obvious fix for await params in route handlers.

Testing gap

The existing tests in tests/dynamic.test.ts and tests/shims.test.ts run in Node.js where React.lazy is available, so none of them exercise the new AsyncServerDynamic path. Consider adding a test that mocks the RSC environment (e.g., temporarily stubs React.lazy to undefined) to verify the async server component fallback works correctly.

return React.createElement(Component, props);
};
AsyncServerDynamic.displayName = "DynamicAsyncServer";
return AsyncServerDynamic as unknown as ComponentType<P>;
Copy link
Contributor

Choose a reason for hiding this comment

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

The as unknown as ComponentType<P> cast is needed because AsyncServerDynamic is an async function (returns Promise<ReactElement>) which doesn't match the synchronous ComponentType<P> signature. This is fine because the RSC renderer natively supports async components — but it's worth a brief comment explaining why the cast is safe, since it looks suspicious at first glance.

Suggested change
return AsyncServerDynamic as unknown as ComponentType<P>;
// Cast is safe: async components are natively supported by the RSC renderer,
// but TypeScript's ComponentType<P> doesn't account for async return types.
return AsyncServerDynamic as unknown as ComponentType<P>;

// Use an async server component pattern instead — the RSC renderer
// natively supports async components.
if (typeof React.lazy !== "function") {
const AsyncServerDynamic = async (props: P) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

The LoadingComponent option is silently ignored in the RSC async path. This is likely the correct behavior (the RSC stream suspends natively on the async component, and any <Suspense> boundary higher in the tree provides the loading fallback). But it's a behavioral difference from the SSR path where LoadingComponent is rendered as the <Suspense> fallback.

Worth a comment noting this is intentional:

Suggested change
const AsyncServerDynamic = async (props: P) => {
const AsyncServerDynamic = async (props: P) => {
// Note: LoadingComponent is not used here — in the RSC environment,
// async components suspend natively and parent <Suspense> boundaries
// provide loading states.

// Use an async server component pattern instead — the RSC renderer
// natively supports async components.
if (typeof React.lazy !== "function") {
const AsyncServerDynamic = async (props: P) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: This async function doesn't have error handling. If loader() rejects, the error propagates uncaught through the RSC renderer. In the SSR path, getDynamicErrorBoundary() wraps the lazy component so loader failures render the LoadingComponent with the error. The RSC path skips this because React.Component (needed for the error boundary class) is also unavailable under react-server. This is acceptable — RSC errors will be caught by the nearest error boundary in the component tree — but worth being aware of.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 11, 2026

Review posted successfully. Here's a summary of the feedback:

Review Summary

The PR addresses three real issues with RSC compatibility and the changes are well-scoped. All three fixes are correct:

  1. dynamic.ts — Removing "use client" and using React.* namespace access is the right approach. The typeof React.lazy !== "function" runtime check cleanly detects the react-server environment. The async server component fallback is the correct RSC pattern.

  2. layout-segment-context.tsx — The "use client" directive was unnecessary since the module already gracefully falls back when createContext is unavailable in RSC.

  3. app-rsc-entry.ts — Wrapping route handler params with makeThenableParams() is the obvious fix, consistent with how page params are already handled.

Inline comments:

  • Suggested adding a comment on the as unknown as ComponentType<P> cast explaining why it's safe (async components are natively supported by RSC)
  • Noted that LoadingComponent is silently ignored in the RSC async path — suggested a comment documenting this is intentional
  • Flagged that the RSC async path lacks error handling for loader rejections (acceptable since RSC errors propagate to the nearest error boundary, but worth being aware of)

Testing gap:

  • None of the existing tests exercise the new AsyncServerDynamic path since they run in Node.js where React.lazy is available. Suggested adding a test that mocks the RSC environment.

github run

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.

2 participants