diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index e24c79607..ca4f13888 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2139,7 +2139,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { markDynamicUsage, method, middlewareContext: _mwCtx, - params, + params: makeThenableParams(params), reportRequestError: _reportRequestError, request, revalidateSeconds, diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index cffb35b84..068377048 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,19 @@ * 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: 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) + * * 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 +96,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 +107,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 +126,11 @@ 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 +139,32 @@ 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 () => { + // 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, + // 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 + ? (mod as { default: ComponentType

}).default + : (mod as ComponentType

); + 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

; + } + + // 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 +181,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 +189,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 +199,11 @@ 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..32085cb8e 100644 --- a/packages/vinext/src/shims/layout-segment-context.tsx +++ b/packages/vinext/src/shims/layout-segment-context.tsx @@ -3,9 +3,15 @@ /** * 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. + * 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. 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, diff --git a/tests/dynamic.test.ts b/tests/dynamic.test.ts index e0d11604e..5d55b3db7 100644 --- a/tests/dynamic.test.ts +++ b/tests/dynamic.test.ts @@ -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; + } + }); +}); 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..b7697d9ff --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/dynamic-imports/dynamic-rsc.tsx @@ -0,0 +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 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 new file mode 100644 index 000000000..e691579c3 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic/rsc-dynamic/page.tsx @@ -0,0 +1,16 @@ +// 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. 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() { + 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..52b47776f 100644 --- a/tests/nextjs-compat/dynamic.test.ts +++ b/tests/nextjs-compat/dynamic.test.ts @@ -112,6 +112,22 @@ 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 + // + // 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", 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'