fix: RSC compatibility for dynamic() and layout segment context#466
fix: RSC compatibility for dynamic() and layout segment context#466benfavre wants to merge 1 commit intocloudflare:mainfrom
Conversation
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.
|
/bigbonk review |
commit: |
There was a problem hiding this comment.
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>; |
There was a problem hiding this comment.
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.
| 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) => { |
There was a problem hiding this comment.
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:
| 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) => { |
There was a problem hiding this comment.
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.
|
Review posted successfully. Here's a summary of the feedback: Review SummaryThe PR addresses three real issues with RSC compatibility and the changes are well-scoped. All three fixes are correct:
Inline comments:
Testing gap:
|
Problem
Three related issues prevent
next/dynamicand layout segment context from working in React Server Component environments:1.
dynamic.tscrashes in RSC withReact.lazy is not a functionThe
"use client"directive forcesnext/dynamicinto a client component boundary, butdynamic()should work in server components too. When the RSC entry imports it, thereact-servercondition exports a stripped-down React that does not includelazy,Suspense,useState, oruseEffect. The destructured imports fail silently (they becomeundefined), andlazy(...)crashes at runtime.2.
layout-segment-context.tsxbreaks RSC renderingThe
"use client"directive creates a client component boundary. The RSC entry directly importsLayoutSegmentProvider, so this boundary interferes with the server rendering pipeline.getLayoutSegmentContext()already returnsnullwhenReact.createContextis 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
paramsto be async (Promises). Route handlers thatawait paramscrash when params is a plain object frommatchPattern(). The RSC entry already hasmakeThenableParams()for this purpose but wasn't using it for route handler params.Solution
dynamic.ts
"use client"directivelazy,Suspense,useState,useEffect) to namespace access (React.lazy,React.Suspense, etc.) — these areundefinedunderreact-servercondition but accessed safely via runtime checkstypeof React.lazy !== "function", use async server component pattern instead (RSC renderer natively supports async components)layout-segment-context.tsx
"use client"directiveapp-rsc-entry.ts
paramswithmakeThenableParams()soawait paramsworks correctlyChanges
packages/vinext/src/shims/dynamic.ts"use client", add RSC async path, useReact.*namespacepackages/vinext/src/shims/layout-segment-context.tsx"use client", update docpackages/vinext/src/entries/app-rsc-entry.ts{ params }→{ params: makeThenableParams(params) }Reproduction
Risk
Low-medium.
React.lazyis unavailable). SSR and client paths are functionally identical — only the import style changed from destructured to namespace.getLayoutSegmentContext()already returnsnullin RSC; removing"use client"just avoids an unnecessary component boundary.makeThenableParams()is already used for page params in the same file; this extends it to route handlers.