mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
258 lines
11 KiB
TypeScript
258 lines
11 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 { renderToReadableStream } from "react-dom/server.edge";
|
|
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;
|
|
}
|
|
|
|
async function renderDynamicToHtml(component: React.ComponentType) {
|
|
const stream = await renderToReadableStream(React.createElement(component));
|
|
await stream.allReady;
|
|
return new Response(stream).text();
|
|
}
|
|
|
|
// ─── 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");
|
|
});
|
|
|
|
it("accepts a direct loader promise", async () => {
|
|
// Ported from Next.js: test/development/basic/next-dynamic/pages/dynamic/ssr.js
|
|
// https://github.com/vercel/next.js/blob/canary/test/development/basic/next-dynamic/pages/dynamic/ssr.js
|
|
const DynamicComponent = dynamic(Promise.resolve({ default: Hello }));
|
|
|
|
await expect(renderDynamicToHtml(DynamicComponent)).resolves.toContain("Hello from dynamic");
|
|
});
|
|
|
|
it("accepts an options object with loader", async () => {
|
|
// Ported from Next.js: test/development/basic/next-dynamic/pages/dynamic/head.js
|
|
// https://github.com/vercel/next.js/blob/canary/test/development/basic/next-dynamic/pages/dynamic/head.js
|
|
const DynamicComponent = dynamic({
|
|
loader: () => Promise.resolve({ default: Hello }),
|
|
});
|
|
|
|
await expect(renderDynamicToHtml(DynamicComponent)).resolves.toContain("Hello from dynamic");
|
|
});
|
|
});
|
|
|
|
// ─── 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 the loading UI on the server with pastDelay:true so it matches the client first render (issue 1967)", () => {
|
|
// App Router always renders the loading fallback with pastDelay=true on BOTH
|
|
// server and client. A loading component that branches on pastDelay (the
|
|
// documented `if (!pastDelay) return null` pattern) must therefore render the
|
|
// same thing on the server as it does on the client's first/pre-mount render —
|
|
// otherwise hydration mismatches (issue 1967).
|
|
const LoadingAfterDelay = ({ pastDelay }: { pastDelay?: boolean }) =>
|
|
pastDelay ? React.createElement("div", null, "Delayed loading") : null;
|
|
const DynamicNoSSR = dynamic(() => Promise.resolve({ default: Hello }), {
|
|
ssr: false,
|
|
loading: LoadingAfterDelay,
|
|
});
|
|
|
|
const serverHtml = ReactDOMServer.renderToStaticMarkup(React.createElement(DynamicNoSSR));
|
|
// Canonical client first-render output: the loading component rendered with
|
|
// pastDelay:true, which is exactly what ClientSSRFalse emits pre-mount via
|
|
// createDynamicLoadingProps().
|
|
const clientFirstRenderHtml = ReactDOMServer.renderToStaticMarkup(
|
|
React.createElement(LoadingAfterDelay, { pastDelay: true }),
|
|
);
|
|
|
|
expect(serverHtml).toContain("Delayed loading");
|
|
expect(serverHtml).toBe(clientFirstRenderHtml);
|
|
});
|
|
|
|
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 the App Router loading props (pastDelay:true) 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));
|
|
|
|
// pastDelay is true on the server to match the client's first render and the
|
|
// Next.js App Router contract (issue 1967).
|
|
expect(receivedProps).toEqual({
|
|
isLoading: true,
|
|
pastDelay: true,
|
|
error: null,
|
|
timedOut: false,
|
|
retry: expect.any(Function),
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── 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 there is no separate preload work", 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;
|
|
}
|
|
});
|
|
});
|