From e409b2f99f307204bdcba8b3ba09dcb26f07f295 Mon Sep 17 00:00:00 2001 From: Benjamin Favre Date: Wed, 11 Mar 2026 08:59:27 +0000 Subject: [PATCH 1/7] 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. From 54e0793e25057cfc15bfea64f0a148cdb863bae5 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 10:56:20 +0000 Subject: [PATCH 2/7] chore: fix formatting and update entry-templates snapshots after merge with main --- packages/vinext/src/shims/dynamic.ts | 19 ++++++++++++++----- .../entry-templates.test.ts.snap | 12 ++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index 8b297bbd7..51e26d99b 100644 --- a/packages/vinext/src/shims/dynamic.ts +++ b/packages/vinext/src/shims/dynamic.ts @@ -124,7 +124,11 @@ function dynamic

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

( 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

; + const Component = + "default" in mod + ? (mod as { default: ComponentType

}).default + : (mod as ComponentType

); return React.createElement(Component, props); }; AsyncServerDynamic.displayName = "DynamicAsyncServer"; @@ -186,7 +191,11 @@ function dynamic

( const fallback = LoadingComponent ? React.createElement(LoadingComponent, { isLoading: true, pastDelay: true, error: null }) : null; - return React.createElement(React.Suspense, { fallback }, React.createElement(LazyComponent, props)); + return React.createElement( + React.Suspense, + { fallback }, + React.createElement(LazyComponent, props), + ); }; ClientDynamic.displayName = "DynamicClient"; diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index e27e6d3f7..fa1acb0a0 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1839,7 +1839,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { markDynamicUsage, method, middlewareContext: _mwCtx, - params, + params: makeThenableParams(params), reportRequestError: _reportRequestError, request, revalidateSeconds, @@ -4036,7 +4036,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { markDynamicUsage, method, middlewareContext: _mwCtx, - params, + params: makeThenableParams(params), reportRequestError: _reportRequestError, request, revalidateSeconds, @@ -6239,7 +6239,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { markDynamicUsage, method, middlewareContext: _mwCtx, - params, + params: makeThenableParams(params), reportRequestError: _reportRequestError, request, revalidateSeconds, @@ -8466,7 +8466,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { markDynamicUsage, method, middlewareContext: _mwCtx, - params, + params: makeThenableParams(params), reportRequestError: _reportRequestError, request, revalidateSeconds, @@ -10667,7 +10667,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { markDynamicUsage, method, middlewareContext: _mwCtx, - params, + params: makeThenableParams(params), reportRequestError: _reportRequestError, request, revalidateSeconds, @@ -13221,7 +13221,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { markDynamicUsage, method, middlewareContext: _mwCtx, - params, + params: makeThenableParams(params), reportRequestError: _reportRequestError, request, revalidateSeconds, From cf4268148a77b9563c7e27c442e1c8716b05f1b6 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 11:50:02 +0000 Subject: [PATCH 3/7] fix: restore 'use client' in layout-segment-context to fix useSelectedLayoutSegment(s) Removing 'use client' caused LayoutSegmentProvider to run in the RSC environment where React.createContext is undefined. getLayoutSegmentContext() returned null, the provider became a no-op, and useSelectedLayoutSegments always returned [] instead of the actual segments. The 'use client' boundary is required so the RSC bundler renders this component in the SSR/browser environment where createContext works. --- .../vinext/src/shims/layout-segment-context.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/shims/layout-segment-context.tsx b/packages/vinext/src/shims/layout-segment-context.tsx index fea9c44b5..32085cb8e 100644 --- a/packages/vinext/src/shims/layout-segment-context.tsx +++ b/packages/vinext/src/shims/layout-segment-context.tsx @@ -1,10 +1,17 @@ +"use client"; + /** * Layout segment context provider. * - * 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. + * Must be "use client" so that Vite's RSC bundler renders this component in + * the SSR/browser environment where React.createContext is available. The RSC + * entry imports and renders LayoutSegmentProvider directly, but because of the + * "use client" boundary the actual execution happens on the SSR/client side + * where the context can be created and consumed by useSelectedLayoutSegment(s). + * + * Without "use client", this runs in the RSC environment where + * React.createContext is undefined, getLayoutSegmentContext() returns null, + * the provider becomes a no-op, and useSelectedLayoutSegments always returns []. * * The context is shared with navigation.ts via getLayoutSegmentContext() * to avoid creating separate contexts in different modules. From e296edf7b79ca084378a438468cf46d4306fabe4 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 11:58:46 +0000 Subject: [PATCH 4/7] test: add regression tests for RSC dynamic() and route handler await params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two regression tests for the fixes in PR #466: 1. Route handler await params (Next.js 15 async params pattern): Tests /api/catch-all/[...slugs] which uses `await params` in the handler — verifies that makeThenableParams() correctly wraps params so both await and direct property access work. 2. dynamic() in RSC (async component path): Adds a pure server component fixture that uses dynamic() without "use client". In the RSC environment React.lazy is unavailable, so dynamic() must fall back to the async component pattern. Tests that the dynamically-loaded component renders in the HTML output. --- .../dynamic/dynamic-imports/dynamic-rsc.tsx | 9 +++++++ .../dynamic/rsc-dynamic/page.tsx | 15 ++++++++++++ .../dynamic/text-dynamic-rsc.tsx | 4 ++++ tests/nextjs-compat/app-routes.test.ts | 24 +++++++++++++++++-- tests/nextjs-compat/dynamic.test.ts | 18 ++++++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/dynamic/text-dynamic-rsc.tsx diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx new file mode 100644 index 000000000..18c51ad1a --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx @@ -0,0 +1,9 @@ +// No "use client" — this is a pure React Server Component. +// Regression test for: https://github.com/cloudflare/vinext/pull/466 +// +// In the RSC environment, React.lazy is not available (react-server condition +// strips it). dynamic() must fall back to the async component pattern so that +// the RSC renderer can resolve the import natively. +import dynamic from "next/dynamic"; + +export const NextDynamicRscComponent = dynamic(() => import("../text-dynamic-rsc")); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx new file mode 100644 index 000000000..616124f94 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx @@ -0,0 +1,15 @@ +// No "use client" — this entire page is a React Server Component tree. +// Regression test for: https://github.com/cloudflare/vinext/pull/466 +// +// Verifies that dynamic() works in a pure RSC context where React.lazy is +// unavailable. The dynamic() shim falls back to an async component so that +// the RSC renderer resolves it natively instead of calling React.lazy. +import { NextDynamicRscComponent } from "../dynamic-imports/dynamic-rsc"; + +export default function RscDynamicPage() { + return ( +

+ +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic/text-dynamic-rsc.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/text-dynamic-rsc.tsx new file mode 100644 index 000000000..a89458377 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/text-dynamic-rsc.tsx @@ -0,0 +1,4 @@ +// No "use client" — this is a pure React Server Component +export default function DynamicRsc() { + return

next-dynamic dynamic on rsc

; +} diff --git a/tests/nextjs-compat/app-routes.test.ts b/tests/nextjs-compat/app-routes.test.ts index 492586187..7e8a31270 100644 --- a/tests/nextjs-compat/app-routes.test.ts +++ b/tests/nextjs-compat/app-routes.test.ts @@ -346,6 +346,28 @@ describe("Next.js compat: app-routes", () => { } }); + // ── Catch-all dynamic params (Next.js 15 async params) ────── + // Regression test for: https://github.com/cloudflare/vinext/pull/466 + // Route handlers must support `await params` (Promise<{ ... }> pattern). + // Fixture: /api/catch-all/[...slugs]/route.ts uses `await params` + // + // Next.js: 'provides params to routes with dynamic parameters' + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L84-L92 + + it("catch-all route handler supports await params (Next.js 15 async params)", async () => { + const res = await fetch(`${baseUrl}/api/catch-all/a/b/c`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.slugs).toEqual(["a", "b", "c"]); + }); + + it("catch-all route handler with hyphenated segments", async () => { + const res = await fetch(`${baseUrl}/api/catch-all/foo-bar/baz-qux`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.slugs).toEqual(["foo-bar", "baz-qux"]); + }); + // ── Documented skips ───────────────────────────────────────── // // N/A: 'statically generates correctly with no dynamic usage' @@ -379,8 +401,6 @@ describe("Next.js compat: app-routes", () => { // N/A: 'no response returned' — Tests console error inspection // // N/A: 'permanentRedirect' — Would need fixture, minor variant of redirect - // - // N/A: 'catch-all routes' — Would need fixture with [...slug] route handler // ── ISR caching (dev mode) ───────────────────────────────── // In dev mode, ISR caching is disabled. Route handlers should NOT emit diff --git a/tests/nextjs-compat/dynamic.test.ts b/tests/nextjs-compat/dynamic.test.ts index 133173ac1..01e64f235 100644 --- a/tests/nextjs-compat/dynamic.test.ts +++ b/tests/nextjs-compat/dynamic.test.ts @@ -112,6 +112,24 @@ describe("Next.js compat: next/dynamic", () => { expect(html).not.toContain("next-dynamic dynamic no ssr on client"); }); + // ── RSC (pure server component) dynamic() ──────────────────── + + // Regression test for: https://github.com/cloudflare/vinext/pull/466 + // + // In the RSC environment, React.lazy is not available (react-server condition + // exports a stripped-down React). dynamic() must fall back to the async + // component pattern so RSC can resolve it without React.lazy. + // + // Without the fix, `typeof React.lazy !== "function"` is false in RSC, + // causing dynamic() to call React.lazy which throws, and the dynamic import + // never resolves — the content is silently missing from the page. + + it("RSC: dynamic() in a pure server component renders content (no React.lazy)", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/dynamic/rsc-dynamic"); + expect(html).toContain("next-dynamic dynamic on rsc"); + expect(html).toContain('id="css-text-dynamic-rsc"'); + }); + // ── Browser-only tests (documented, not ported) ────────────── // // SKIP: 'should handle next/dynamic in hydration correctly' From e2e1d67ff07ee4a8d90a5078f4b4f9775ccfd6bd Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 14:30:59 +0000 Subject: [PATCH 5/7] fix: address bonk review comments on dynamic.ts RSC async path - Add comments explaining LoadingComponent is intentionally ignored in the RSC async fallback path (parent Suspense boundaries handle loading states; error handling defers to nearest error boundary) - Add comment explaining the 'as unknown as ComponentType

' cast is safe because the RSC renderer natively supports async components, but TypeScript's ComponentType

doesn't account for async return types - Add unit tests for the AsyncServerDynamic path by stubbing React.lazy to undefined (simulating a react-server condition without lazy). Verifies: displayName, async resolution, bare component exports, and that LoadingComponent is correctly ignored. Note: React 19.x exports React.lazy from the react-server condition, so typeof React.lazy !== 'function' does not trigger in current React. The AsyncServerDynamic path is defensive forward-compatibility code. The unit tests stub React.lazy to exercise it directly. --- packages/vinext/src/shims/dynamic.ts | 6 +++ tests/dynamic.test.ts | 74 +++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index 51e26d99b..0ef0a6926 100644 --- a/packages/vinext/src/shims/dynamic.ts +++ b/packages/vinext/src/shims/dynamic.ts @@ -143,6 +143,10 @@ function dynamic

( // natively supports async components. if (typeof React.lazy !== "function") { const AsyncServerDynamic = async (props: P) => { + // Note: LoadingComponent is not used here — in the RSC environment, + // async components suspend natively and parent boundaries + // provide loading states. Error handling also defers to the nearest + // error boundary in the component tree. const mod = await loader(); const Component = "default" in mod @@ -151,6 +155,8 @@ function dynamic

( return React.createElement(Component, props); }; AsyncServerDynamic.displayName = "DynamicAsyncServer"; + // Cast is safe: async components are natively supported by the RSC renderer, + // but TypeScript's ComponentType

doesn't account for async return types. return AsyncServerDynamic as unknown as ComponentType

; } diff --git a/tests/dynamic.test.ts b/tests/dynamic.test.ts index e0d11604e..f1412b08b 100644 --- a/tests/dynamic.test.ts +++ b/tests/dynamic.test.ts @@ -6,7 +6,7 @@ * SSR rendering, ssr:false behavior, loading components, error * boundaries, displayName assignment, and flushPreloads(). */ -import { describe, it, expect } from "vite-plus/test"; +import { describe, it, expect, vi } from "vite-plus/test"; import React from "react"; import ReactDOMServer from "react-dom/server"; import dynamic, { flushPreloads } from "../packages/vinext/src/shims/dynamic.js"; @@ -129,3 +129,75 @@ describe("flushPreloads", () => { expect(result).toEqual([]); }); }); + +// ─── RSC async component path ──────────────────────────────────────────── +// +// React 19.x exports React.lazy from the react-server condition, so the +// `typeof React.lazy !== "function"` guard does NOT trigger in current +// React. The AsyncServerDynamic path is defensive forward-compatibility +// code for hypothetical future React versions that strip lazy from RSC. +// +// We verify it here by temporarily stubbing React.lazy to undefined, +// simulating the react-server environment of older or stripped React builds. + +describe("next/dynamic RSC async component path (React.lazy unavailable)", () => { + it("returns an async component (DynamicAsyncServer) when React.lazy is not a function", () => { + const originalLazy = React.lazy; + try { + // @ts-expect-error — simulating react-server condition where lazy is absent + React.lazy = undefined; + + const DynamicRsc = dynamic(() => Promise.resolve({ default: Hello })); + expect(DynamicRsc.displayName).toBe("DynamicAsyncServer"); + } finally { + React.lazy = originalLazy; + } + }); + + it("async component resolves and renders the dynamically loaded component", async () => { + const originalLazy = React.lazy; + try { + // @ts-expect-error — simulating react-server condition where lazy is absent + React.lazy = undefined; + + const DynamicRsc = dynamic(() => Promise.resolve({ default: Hello })); + // The returned component is an async function — call it directly as RSC would + const element = await (DynamicRsc as unknown as (props: object) => Promise)({}); + // Should return a React element rendered from Hello + expect(element).toBeTruthy(); + expect((element as React.ReactElement).type).toBe(Hello); + } finally { + React.lazy = originalLazy; + } + }); + + it("async component handles modules exporting bare component (no default)", async () => { + const originalLazy = React.lazy; + try { + // @ts-expect-error — simulating react-server condition where lazy is absent + React.lazy = undefined; + + const DynamicRsc = dynamic(() => Promise.resolve(Hello as any)); + const element = await (DynamicRsc as unknown as (props: object) => Promise)({}); + expect((element as React.ReactElement).type).toBe(Hello); + } finally { + React.lazy = originalLazy; + } + }); + + it("async component ignores LoadingComponent (defers to parent Suspense boundary)", () => { + const originalLazy = React.lazy; + try { + // @ts-expect-error — simulating react-server condition where lazy is absent + React.lazy = undefined; + + // LoadingComponent is passed but should be silently ignored in RSC path + const DynamicRsc = dynamic(() => Promise.resolve({ default: Hello }), { + loading: LoadingSpinner, + }); + expect(DynamicRsc.displayName).toBe("DynamicAsyncServer"); + } finally { + React.lazy = originalLazy; + } + }); +}); From a4102759794ca06c968563affafad0a14534fb96 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 14:46:22 +0000 Subject: [PATCH 6/7] chore: remove unused vi import from dynamic.test.ts --- tests/dynamic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dynamic.test.ts b/tests/dynamic.test.ts index f1412b08b..5d55b3db7 100644 --- a/tests/dynamic.test.ts +++ b/tests/dynamic.test.ts @@ -6,7 +6,7 @@ * SSR rendering, ssr:false behavior, loading components, error * boundaries, displayName assignment, and flushPreloads(). */ -import { describe, it, expect, vi } from "vite-plus/test"; +import { describe, it, expect } from "vite-plus/test"; import React from "react"; import ReactDOMServer from "react-dom/server"; import dynamic, { flushPreloads } from "../packages/vinext/src/shims/dynamic.js"; From 5bedf20df02654b89dd7fb50823e38ce49b8abe5 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 14:53:46 +0000 Subject: [PATCH 7/7] docs: correct inaccurate React.lazy/RSC comments throughout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 19.x exports React.lazy from the react-server condition, so the typeof React.lazy !== 'function' guard does not trigger in current React. The AsyncServerDynamic path is defensive forward-compatibility code only — it does not execute today. Update all comments that incorrectly claimed React.lazy is unavailable in the react-server environment: - dynamic.ts: top-level doc comment and guard comment - dynamic-rsc.tsx fixture: comment explaining the RSC path - rsc-dynamic/page.tsx fixture: explain which path actually executes - nextjs-compat/dynamic.test.ts: clarify that the integration test exercises LazyServer + Suspense, not AsyncServerDynamic --- packages/vinext/src/shims/dynamic.ts | 12 +++++++----- .../dynamic/dynamic-imports/dynamic-rsc.tsx | 8 +++++--- .../app/nextjs-compat/dynamic/rsc-dynamic/page.tsx | 7 ++++--- tests/nextjs-compat/dynamic.test.ts | 14 ++++++-------- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index 0ef0a6926..068377048 100644 --- a/packages/vinext/src/shims/dynamic.ts +++ b/packages/vinext/src/shims/dynamic.ts @@ -6,7 +6,9 @@ * 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 + * - RSC: Uses React.lazy + Suspense (available in React 19.x react-server). + * Falls back to async component pattern if a future React version + * strips lazy from react-server. * - SSR: React.lazy + Suspense (renderToReadableStream suspends) * - Client: React.lazy + Suspense (standard code splitting) * @@ -137,10 +139,10 @@ function dynamic

( // SSR-enabled path if (isServer) { - // 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. + // Defensive fallback: if a future React version strips React.lazy from the + // react-server condition, fall back to an async component pattern. + // In React 19.x, React.lazy IS available in react-server, so this branch + // does not execute — it exists for forward compatibility only. if (typeof React.lazy !== "function") { const AsyncServerDynamic = async (props: P) => { // Note: LoadingComponent is not used here — in the RSC environment, diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx index 18c51ad1a..b7697d9ff 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx @@ -1,9 +1,11 @@ // No "use client" — this is a pure React Server Component. // Regression test for: https://github.com/cloudflare/vinext/pull/466 // -// In the RSC environment, React.lazy is not available (react-server condition -// strips it). dynamic() must fall back to the async component pattern so that -// the RSC renderer can resolve the import natively. +// In the RSC environment, React.lazy may not be available in future React +// versions (the react-server condition could strip it). dynamic() has a +// defensive fallback to an async component pattern for that scenario. +// In React 19.x, React.lazy IS available in react-server, so this uses +// the standard LazyServer + Suspense path. import dynamic from "next/dynamic"; export const NextDynamicRscComponent = dynamic(() => import("../text-dynamic-rsc")); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx index 616124f94..e691579c3 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx @@ -1,9 +1,10 @@ // No "use client" — this entire page is a React Server Component tree. // Regression test for: https://github.com/cloudflare/vinext/pull/466 // -// Verifies that dynamic() works in a pure RSC context where React.lazy is -// unavailable. The dynamic() shim falls back to an async component so that -// the RSC renderer resolves it natively instead of calling React.lazy. +// Verifies that dynamic() works in a pure RSC context. Currently React.lazy +// is available in react-server, so the standard lazy path handles this. +// The async fallback path (for future React versions that strip lazy from +// react-server) is tested in tests/dynamic.test.ts via React.lazy stubbing. import { NextDynamicRscComponent } from "../dynamic-imports/dynamic-rsc"; export default function RscDynamicPage() { diff --git a/tests/nextjs-compat/dynamic.test.ts b/tests/nextjs-compat/dynamic.test.ts index 01e64f235..52b47776f 100644 --- a/tests/nextjs-compat/dynamic.test.ts +++ b/tests/nextjs-compat/dynamic.test.ts @@ -116,15 +116,13 @@ describe("Next.js compat: next/dynamic", () => { // Regression test for: https://github.com/cloudflare/vinext/pull/466 // - // In the RSC environment, React.lazy is not available (react-server condition - // exports a stripped-down React). dynamic() must fall back to the async - // component pattern so RSC can resolve it without React.lazy. - // - // Without the fix, `typeof React.lazy !== "function"` is false in RSC, - // causing dynamic() to call React.lazy which throws, and the dynamic import - // never resolves — the content is silently missing from the page. + // Verifies that dynamic() works when called from a pure server component. + // In React 19.x, React.lazy IS available in the react-server condition, + // so this exercises the standard LazyServer + Suspense path in RSC. + // The AsyncServerDynamic fallback (for hypothetical future React versions + // that strip lazy) is covered by unit tests in tests/dynamic.test.ts. - it("RSC: dynamic() in a pure server component renders content (no React.lazy)", async () => { + it("RSC: dynamic() in a pure server component renders content", async () => { const { html } = await fetchHtml(baseUrl, "/nextjs-compat/dynamic/rsc-dynamic"); expect(html).toContain("next-dynamic dynamic on rsc"); expect(html).toContain('id="css-text-dynamic-rsc"');