mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
2d035d47bd
* fix: make next/document default export a class Next.js's `next/document` default export is `class Document extends React.Component`. vinext's shim exported a function, so user `_document.tsx` files that use the class-based form (the common Next.js idiom: `class MyDocument extends Document`) ended up extending a function. React refuses to call class constructors without `new`, which caused SSR to throw "Class constructor MyDocument cannot be invoked without 'new'" — surfacing in e2e as 500 / empty responses. This is the actual root cause of the "async modules render empty pages" report (issue #1361): the Next.js `test/e2e/async-modules` fixture's `pages/_document.jsx` uses `class extends Document`, and once it crashes the whole page renders empty. Top-level await itself propagates correctly through Rolldown's static-import graph already. Adds a class-based Document shim that mirrors Next.js's signature (static `getInitialProps`, instance `render()`), plus production-build e2e coverage for pages, `_app.tsx`, `getStaticProps`, `getServerSideProps`, API routes, and class-based `_document.tsx`, all using top-level `await`. Closes #1361 Ports from Next.js: test/e2e/async-modules/index.test.ts https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/index.test.ts * fix(next/document): align Document base class with Next.js's typed contract Addresses Bonk review feedback on PR #1381: - Export DocumentContext / DocumentInitialProps types matching Next.js, so subclasses that delegate via `await Document.getInitialProps(ctx)` receive typed objects. The runtime path is still a stub (the Pages Router renderPage/defaultGetInitialProps chain is not wired up), but the signatures pin the contract. - Declare the default Document class export in next-shims.d.ts so strict-mode consumers don't see `any` for `import Document from "next/document"`. - Add a class-extends regression test in tests/document.test.ts — this is the contract that broke in issue #1361 (the original Next.js `pages/_document.jsx` uses `class extends Document`). - Switch the class generic default from `unknown` to `{}` to match Next.js's `class Document<P = {}>`. - Clarify App Router fixture comment: the upstream Next.js async-modules suite is Pages-Router-only, so this is adapted, not ported verbatim.
138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
/**
|
|
* next/document shim tests.
|
|
*
|
|
* These components render placeholder markers that the Pages Router dev-server
|
|
* replaces with real content via string substitution. The tests verify the
|
|
* contracts the dev-server depends on — not that React can render a div.
|
|
*/
|
|
import { describe, it, expect } from "vite-plus/test";
|
|
import React from "react";
|
|
import ReactDOMServer from "react-dom/server";
|
|
import Document, { Html, Head, Main, NextScript } from "../packages/vinext/src/shims/document.js";
|
|
|
|
function render(el: React.ReactElement): string {
|
|
return ReactDOMServer.renderToString(el);
|
|
}
|
|
|
|
describe("Main", () => {
|
|
it("renders the __NEXT_MAIN__ placeholder inside a #__next container", () => {
|
|
const html = render(React.createElement(Main));
|
|
// Dev-server looks for id="__next" and replaces __NEXT_MAIN__ with rendered page content
|
|
expect(html).toContain('id="__next"');
|
|
expect(html).toContain("__NEXT_MAIN__");
|
|
});
|
|
});
|
|
|
|
describe("NextScript", () => {
|
|
it("renders the __NEXT_SCRIPTS__ comment that dev-server replaces with hydration scripts", () => {
|
|
const html = render(React.createElement(NextScript));
|
|
// Dev-server replaces this HTML comment with __NEXT_DATA__ + module script tags
|
|
expect(html).toContain("<!-- __NEXT_SCRIPTS__ -->");
|
|
});
|
|
});
|
|
|
|
describe("Head", () => {
|
|
it("injects default charset and viewport meta tags", () => {
|
|
const html = render(React.createElement(Head));
|
|
expect(html).toContain('charSet="utf-8"');
|
|
expect(html).toContain('content="width=device-width, initial-scale=1"');
|
|
});
|
|
|
|
it("preserves custom children alongside defaults", () => {
|
|
const html = render(
|
|
React.createElement(Head, null, React.createElement("title", null, "My App")),
|
|
);
|
|
// Custom content rendered
|
|
expect(html).toContain("<title>My App</title>");
|
|
// Defaults still present
|
|
expect(html).toContain('charSet="utf-8"');
|
|
});
|
|
});
|
|
|
|
describe("Default Document", () => {
|
|
it("assembles all sub-components in the nesting order the dev-server expects", () => {
|
|
const html = render(React.createElement(Document));
|
|
|
|
// The dev-server does string replacement on this output.
|
|
// If the nesting order breaks, SSR output will be malformed.
|
|
const headOpen = html.indexOf("<head>");
|
|
const bodyOpen = html.indexOf("<body>");
|
|
const mainDiv = html.indexOf('id="__next"');
|
|
const placeholder = html.indexOf("__NEXT_MAIN__");
|
|
const scripts = html.indexOf("__NEXT_SCRIPTS__");
|
|
const bodyClose = html.indexOf("</body>");
|
|
|
|
// All markers must be present
|
|
expect(headOpen).toBeGreaterThan(-1);
|
|
expect(bodyOpen).toBeGreaterThan(-1);
|
|
expect(mainDiv).toBeGreaterThan(-1);
|
|
expect(placeholder).toBeGreaterThan(-1);
|
|
expect(scripts).toBeGreaterThan(-1);
|
|
|
|
// Order matters: head < body < main < placeholder < scripts < /body
|
|
expect(headOpen).toBeLessThan(bodyOpen);
|
|
expect(bodyOpen).toBeLessThan(mainDiv);
|
|
expect(mainDiv).toBeLessThan(placeholder);
|
|
expect(placeholder).toBeLessThan(scripts);
|
|
expect(scripts).toBeLessThan(bodyClose);
|
|
});
|
|
});
|
|
|
|
describe("Html", () => {
|
|
it("forwards lang prop to the root <html> element", () => {
|
|
const html = render(React.createElement(Html, { lang: "fr" }));
|
|
expect(html).toMatch(/<html[^>]*lang="fr"/);
|
|
});
|
|
|
|
it("wraps the entire document as the root element", () => {
|
|
const html = render(React.createElement(Document));
|
|
// Default Document uses Html as root — output must start with <html
|
|
expect(html).toMatch(/^<html/);
|
|
});
|
|
});
|
|
|
|
// Regression test for the contract motivating PR #1381 (issue #1361):
|
|
// user `pages/_document.tsx` files commonly use the class form
|
|
// `class MyDocument extends Document`. If the shim's default export is a
|
|
// function, the extends chain produces a class React refuses to construct
|
|
// (`Class constructor cannot be invoked without 'new'`), which 500s SSR and
|
|
// surfaces as empty pages in deploy-suite e2e tests.
|
|
//
|
|
// Ported from Next.js: test/e2e/async-modules/pages/_document.jsx
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/pages/_document.jsx
|
|
describe("Document base class", () => {
|
|
it("can be extended by a user class that React can construct", () => {
|
|
class MyDocument extends Document {
|
|
render() {
|
|
return React.createElement(
|
|
Html,
|
|
{ lang: "ja" },
|
|
React.createElement(Head),
|
|
React.createElement(
|
|
"body",
|
|
null,
|
|
React.createElement("div", { id: "doc-marker" }, "ok"),
|
|
React.createElement(Main),
|
|
React.createElement(NextScript),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
const html = render(React.createElement(MyDocument));
|
|
expect(html).toMatch(/<html[^>]*lang="ja"/);
|
|
expect(html).toContain('id="doc-marker"');
|
|
expect(html).toContain("__NEXT_MAIN__");
|
|
expect(html).toContain("__NEXT_SCRIPTS__");
|
|
});
|
|
|
|
it("exposes a static getInitialProps that resolves to a DocumentInitialProps-shaped value", async () => {
|
|
// The runtime contract: subclasses commonly delegate via
|
|
// `await Document.getInitialProps(ctx)` and destructure `html` from the
|
|
// result. The stub returns an empty html shell — the test pins the shape
|
|
// so wiring up the full Pages Router renderPage flow later doesn't
|
|
// silently regress consumers that destructure `html`.
|
|
const result = await Document.getInitialProps({});
|
|
expect(typeof result.html).toBe("string");
|
|
});
|
|
});
|