From e409b2f99f307204bdcba8b3ba09dcb26f07f295 Mon Sep 17 00:00:00 2001 From: Benjamin Favre Date: Wed, 11 Mar 2026 08:59:27 +0000 Subject: [PATCH] fix: RSC compatibility for dynamic() and layout segment context 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. --- packages/vinext/src/entries/app-rsc-entry.ts | 2 +- packages/vinext/src/shims/dynamic.ts | 49 +++++++++++++------ .../src/shims/layout-segment-context.tsx | 9 ++-- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 5d43c20f7..72c404048 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1957,7 +1957,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { - const response = await handlerFn(request, { params }); + const response = await handlerFn(request, { params: makeThenableParams(params) }); const dynamicUsedInHandler = consumeDynamicUsage(); // Apply Cache-Control from route segment config (export const revalidate = N). diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index cffb35b84..8b297bbd7 100644 --- a/packages/vinext/src/shims/dynamic.ts +++ b/packages/vinext/src/shims/dynamic.ts @@ -1,4 +1,3 @@ -"use client"; /** * next/dynamic shim * @@ -6,12 +5,17 @@ * renderToReadableStream suspends until the dynamically-imported component is * available. On the client, also uses React.lazy for code splitting. * + * Works in RSC, SSR, and client environments: + * - RSC: React.lazy is not available, so we use an async component pattern + * - SSR: React.lazy + Suspense (renderToReadableStream suspends) + * - Client: React.lazy + Suspense (standard code splitting) + * * Supports: * - dynamic(() => import('./Component')) * - dynamic(() => import('./Component'), { loading: () => }) * - dynamic(() => import('./Component'), { ssr: false }) */ -import React, { lazy, Suspense, type ComponentType, useState, useEffect } from "react"; +import React, { type ComponentType } from "react"; interface DynamicOptions { loading?: ComponentType<{ error?: Error | null; isLoading?: boolean; pastDelay?: boolean }>; @@ -90,7 +94,7 @@ function dynamic

( // ssr: false — render nothing on the server, lazy-load on client if (!ssr) { if (isServer) { - // On the server, just render the loading state or nothing + // On the server (SSR or RSC), just render the loading state or nothing const SSRFalse = (_props: P) => { return LoadingComponent ? React.createElement(LoadingComponent, { isLoading: true, pastDelay: true, error: null }) @@ -101,15 +105,15 @@ function dynamic

( } // Client: use lazy with Suspense - const LazyComponent = lazy(async () => { + const LazyComponent = React.lazy(async () => { const mod = await loader(); if ("default" in mod) return mod as { default: ComponentType

}; return { default: mod as ComponentType

}; }); const ClientSSRFalse = (props: P) => { - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); if (!mounted) { return LoadingComponent @@ -120,7 +124,7 @@ function dynamic

( const fallback = LoadingComponent ? React.createElement(LoadingComponent, { isLoading: true, pastDelay: true, error: null }) : null; - return React.createElement(Suspense, { fallback }, React.createElement(LazyComponent, props)); + return React.createElement(React.Suspense, { fallback }, React.createElement(LazyComponent, props)); }; ClientSSRFalse.displayName = "DynamicClientSSRFalse"; @@ -129,12 +133,25 @@ function dynamic

( // SSR-enabled path if (isServer) { - // Use React.lazy so that renderToReadableStream can suspend until the - // dynamically-imported component is available. The previous eager-load - // pattern relied on flushPreloads() being called before rendering, which - // works for the Pages Router but not the App Router where client modules - // are loaded lazily during RSC stream deserialization (issue #75). - const LazyServer = lazy(async () => { + // In RSC environment, React.lazy is not available (react-server condition + // exports a stripped-down React without lazy/useState/useEffect). + // Use an async server component pattern instead — the RSC renderer + // natively supports async components. + if (typeof React.lazy !== "function") { + const AsyncServerDynamic = async (props: P) => { + const mod = await loader(); + const Component = "default" in mod + ? (mod as { default: ComponentType

}).default + : mod as ComponentType

; + return React.createElement(Component, props); + }; + AsyncServerDynamic.displayName = "DynamicAsyncServer"; + return AsyncServerDynamic as unknown as ComponentType

; + } + + // SSR path: Use React.lazy so that renderToReadableStream can suspend + // until the dynamically-imported component is available. + const LazyServer = React.lazy(async () => { const mod = await loader(); if ("default" in mod) return mod as { default: ComponentType

}; return { default: mod as ComponentType

}; @@ -151,7 +168,7 @@ function dynamic

( const content = ErrorBoundary ? React.createElement(ErrorBoundary, { fallback: LoadingComponent }, lazyElement) : lazyElement; - return React.createElement(Suspense, { fallback }, content); + return React.createElement(React.Suspense, { fallback }, content); }; ServerDynamic.displayName = "DynamicServer"; @@ -159,7 +176,7 @@ function dynamic

( } // Client path: standard React.lazy with Suspense - const LazyComponent = lazy(async () => { + const LazyComponent = React.lazy(async () => { const mod = await loader(); if ("default" in mod) return mod as { default: ComponentType

}; return { default: mod as ComponentType

}; @@ -169,7 +186,7 @@ function dynamic

( const fallback = LoadingComponent ? React.createElement(LoadingComponent, { isLoading: true, pastDelay: true, error: null }) : null; - return React.createElement(Suspense, { fallback }, React.createElement(LazyComponent, props)); + return React.createElement(React.Suspense, { fallback }, React.createElement(LazyComponent, props)); }; ClientDynamic.displayName = "DynamicClient"; diff --git a/packages/vinext/src/shims/layout-segment-context.tsx b/packages/vinext/src/shims/layout-segment-context.tsx index b710a6277..fea9c44b5 100644 --- a/packages/vinext/src/shims/layout-segment-context.tsx +++ b/packages/vinext/src/shims/layout-segment-context.tsx @@ -1,11 +1,10 @@ -"use client"; - /** * Layout segment context provider. * - * This is a "use client" module because it needs React's createContext - * and useContext, which are NOT available in the react-server condition. - * The RSC entry renders this as a client component boundary. + * Does NOT use "use client" — this module is imported directly by the RSC + * entry which runs in the react-server condition. getLayoutSegmentContext() + * returns null when React.createContext is unavailable (RSC), and the + * provider gracefully falls back to passing children through unchanged. * * The context is shared with navigation.ts via getLayoutSegmentContext() * to avoid creating separate contexts in different modules.