mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
7d0203d421
* 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>
204 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|