Files
cloudflare__vinext/tests/document.test.ts
T
James Anderson 2d035d47bd fix: await top-level await module imports (#1381)
* 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.
2026-05-21 12:03:33 +01:00

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");
});
});