Files
cloudflare__vinext/tests/dynamic.test.ts
T
Webdesign29 7d0203d421 fix: RSC compatibility for dynamic() and layout segment context (#466)
* 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.

* chore: fix formatting and update entry-templates snapshots after merge with main

* 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.

* test: add regression tests for RSC dynamic() and route handler await params

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.

* 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<P>' cast is
  safe because the RSC renderer natively supports async components, but
  TypeScript's ComponentType<P> 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.

* chore: remove unused vi import from dynamic.test.ts

* docs: correct inaccurate React.lazy/RSC comments throughout

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

---------

Co-authored-by: James <james@eli.cx>
2026-03-28 15:19:50 +00:00

204 lines
8.3 KiB
TypeScript

/**
* next/dynamic shim unit tests.
*
* Mirrors test cases from Next.js test/unit/next-dynamic.test.tsx,
* plus comprehensive coverage for vinext's dynamic() implementation:
* SSR rendering, ssr:false behavior, loading components, error
* boundaries, displayName assignment, and flushPreloads().
*/
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";
// ─── Test components ────────────────────────────────────────────────────
function Hello() {
return React.createElement("div", null, "Hello from dynamic");
}
function LoadingSpinner({ isLoading, error }: { isLoading?: boolean; error?: Error | null }) {
if (error) return React.createElement("div", null, `Error: ${error.message}`);
if (isLoading) return React.createElement("div", null, "Loading...");
return null;
}
// ─── SSR rendering ──────────────────────────────────────────────────────
describe("next/dynamic SSR", () => {
it("renders dynamically imported component on server (mirrors Next.js test)", async () => {
// Next.js test: dynamic(() => import('./fixtures/stub-components/hello'))
// Verifies that next/dynamic doesn't crash
const DynamicHello = dynamic(() => Promise.resolve({ default: Hello }));
// On server, this uses React.lazy + Suspense
// renderToString will resolve the lazy component synchronously for simple promises
expect(DynamicHello.displayName).toBe("DynamicServer");
});
it("sets correct displayName for server component", () => {
const DynamicComponent = dynamic(() => Promise.resolve({ default: Hello }));
expect(DynamicComponent.displayName).toBe("DynamicServer");
});
it("handles modules exporting bare component (no default)", async () => {
// Some dynamic imports export the component directly
const DynamicComponent = dynamic(() => Promise.resolve(Hello as any));
expect(DynamicComponent.displayName).toBe("DynamicServer");
});
});
// ─── SSR: false ─────────────────────────────────────────────────────────
describe("next/dynamic ssr: false", () => {
it("renders loading component on server when ssr: false", () => {
const DynamicNoSSR = dynamic(() => Promise.resolve({ default: Hello }), {
ssr: false,
loading: LoadingSpinner,
});
const html = ReactDOMServer.renderToString(React.createElement(DynamicNoSSR));
expect(html).toContain("Loading...");
expect(html).not.toContain("Hello from dynamic");
});
it("renders nothing on server when ssr: false and no loading", () => {
const DynamicNoSSR = dynamic(() => Promise.resolve({ default: Hello }), { ssr: false });
const html = ReactDOMServer.renderToString(React.createElement(DynamicNoSSR));
expect(html).toBe("");
});
it("sets DynamicSSRFalse displayName on server", () => {
const DynamicNoSSR = dynamic(() => Promise.resolve({ default: Hello }), { ssr: false });
expect(DynamicNoSSR.displayName).toBe("DynamicSSRFalse");
});
});
// ─── Loading component ──────────────────────────────────────────────────
describe("next/dynamic loading component", () => {
it("passes isLoading and pastDelay to loading component on SSR", () => {
let receivedProps: any = null;
function TrackingLoader(props: any) {
receivedProps = props;
return React.createElement("div", null, "tracking");
}
const DynamicWithTracking = dynamic(() => Promise.resolve({ default: Hello }), {
ssr: false,
loading: TrackingLoader,
});
ReactDOMServer.renderToString(React.createElement(DynamicWithTracking));
expect(receivedProps).toEqual({
isLoading: true,
pastDelay: true,
error: null,
});
});
});
// ─── Default options ────────────────────────────────────────────────────
describe("next/dynamic defaults", () => {
it("defaults ssr to true", () => {
const DynamicDefault = dynamic(() => Promise.resolve({ default: Hello }));
// If ssr defaults to true, we get DynamicServer, not DynamicSSRFalse
expect(DynamicDefault.displayName).toBe("DynamicServer");
});
it("handles undefined options", () => {
const DynamicNoOpts = dynamic(() => Promise.resolve({ default: Hello }), undefined);
expect(DynamicNoOpts.displayName).toBe("DynamicServer");
});
});
// ─── flushPreloads ──────────────────────────────────────────────────────
describe("flushPreloads", () => {
it("returns an empty array when no preloads queued", async () => {
const result = await flushPreloads();
expect(result).toEqual([]);
});
it("can be called multiple times safely", async () => {
await flushPreloads();
const result = await 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<unknown>)({});
// 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<unknown>)({});
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;
}
});
});