mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
d1fb2ebf53
* fix(pages): keep charset first in custom document heads * fix(pages): preserve complete head ordering parity * test(pages): assert dev document head ordering * fix(pages): preserve head order across Fast Refresh * fix(pages): align trace metadata head ordering in dev * fix(pages): match Next head manager updates * fix(pages): order unknown head tags last --------- Co-authored-by: James <james@eli.cx>
1122 lines
38 KiB
TypeScript
1122 lines
38 KiB
TypeScript
/**
|
|
* next/head shim unit tests.
|
|
*
|
|
* Mirrors test cases from Next.js test/unit/next-head-rendering.test.ts,
|
|
* plus comprehensive coverage for vinext's Head SSR collection, HTML
|
|
* generation, allowed tags, and escaping.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vite-plus/test";
|
|
import React from "react";
|
|
import ReactDOMServer from "react-dom/server";
|
|
import Head, {
|
|
resetSSRHead,
|
|
getSSRHeadHTML,
|
|
escapeAttr,
|
|
reduceHeadChildren,
|
|
setDocumentInitialHead,
|
|
_applyHeadPropsToElement,
|
|
_syncClientHead,
|
|
_clientHeadChildren,
|
|
_removeClientHeadInstance,
|
|
_updateClientHeadInstance,
|
|
isEqualHeadNode,
|
|
} from "../packages/vinext/src/shims/head.js";
|
|
|
|
// ─── SSR rendering (mirrors Next.js test/unit/next-head-rendering.test.ts) ──
|
|
|
|
describe("Rendering next/head", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("should render outside of Next.js without error", () => {
|
|
// Next.js test: renderToString(<><Head /><p>hello world</p></>)
|
|
// Verifies Head doesn't throw when used standalone
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement(Head, null),
|
|
React.createElement("p", null, "hello world"),
|
|
),
|
|
);
|
|
expect(html).toContain("hello world");
|
|
});
|
|
|
|
it("returns null (no rendered output in body)", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "My Page")),
|
|
);
|
|
// Head always returns null — elements are collected, not rendered inline
|
|
expect(html).toBe("");
|
|
});
|
|
});
|
|
|
|
// ─── SSR head collection ────────────────────────────────────────────────
|
|
|
|
describe("Head SSR collection", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("collects title element", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "My Page Title")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<title");
|
|
expect(headHtml).toContain("My Page Title");
|
|
expect(headHtml).toContain("</title>");
|
|
expect(headHtml).toContain('data-next-head=""');
|
|
});
|
|
|
|
it("collects meta elements as self-closing", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", { name: "description", content: "A test page" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<meta name="description" content="A test page"');
|
|
expect(headHtml).toContain("/>"); // self-closing
|
|
expect(headHtml).not.toContain("</meta>");
|
|
});
|
|
|
|
it("collects link elements as self-closing", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("link", { rel: "stylesheet", href: "/styles.css" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<link rel="stylesheet" href="/styles.css"');
|
|
expect(headHtml).toContain("/>"); // self-closing
|
|
});
|
|
|
|
it("collects style elements", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("style", null, "body { color: red; }")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<style");
|
|
// Text content is HTML-escaped
|
|
expect(headHtml).toContain("body { color: red; }");
|
|
});
|
|
|
|
it("collects script elements", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("script", { src: "/analytics.js", async: true }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<script src="/analytics.js" async');
|
|
expect(headHtml).toContain("</script>");
|
|
});
|
|
|
|
it("collects base element as self-closing", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("base", { href: "https://example.com/" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<base href="https://example.com/"');
|
|
expect(headHtml).toContain("/>"); // self-closing
|
|
});
|
|
|
|
it("collects noscript elements", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("noscript", null, "JavaScript is required"),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<noscript");
|
|
expect(headHtml).toContain("JavaScript is required");
|
|
expect(headHtml).toContain("</noscript>");
|
|
});
|
|
|
|
it("collects multiple head elements in order", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("title", null, "First"),
|
|
React.createElement("meta", { name: "viewport", content: "width=device-width" }),
|
|
React.createElement("link", { rel: "icon", href: "/favicon.ico" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("First");
|
|
expect(headHtml).toContain("viewport");
|
|
expect(headHtml).toContain("favicon.ico");
|
|
});
|
|
|
|
it("resets head between renders", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "Page 1")),
|
|
);
|
|
expect(getSSRHeadHTML()).toContain("Page 1");
|
|
|
|
resetSSRHead();
|
|
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("title", null, "Page 2")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("Page 2");
|
|
expect(headHtml).not.toContain("Page 1");
|
|
});
|
|
|
|
it("returns only default charset/viewport when no user head elements", () => {
|
|
// Even without any user `<Head>`, vinext emits next/head's defaultHead()
|
|
// (charset + viewport) to match Next.js's canonical head ordering.
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('<meta charset="utf-8"');
|
|
expect(headHtml).toContain('<meta name="viewport"');
|
|
// No other tags.
|
|
expect(headHtml).not.toContain("<title");
|
|
expect(headHtml).not.toContain("<link");
|
|
});
|
|
|
|
it("dedupes keyed tags across multiple Head instances and keeps the last one", () => {
|
|
// Next.js documents `key` as the dedupe mechanism for next/head tags:
|
|
// https://github.com/vercel/next.js/blob/canary/docs/02-pages/04-api-reference/01-components/head.mdx
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Original Title",
|
|
key: "og-title",
|
|
}),
|
|
),
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Updated Title",
|
|
key: "og-title",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('content="Updated Title"');
|
|
expect(headHtml).not.toContain('content="Original Title"');
|
|
expect(headHtml.match(/property="og:title"/g)).toHaveLength(1);
|
|
});
|
|
|
|
it("dedupes keyed tags across Head instances when one Head has multiple children", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Title A",
|
|
key: "og-title",
|
|
}),
|
|
React.createElement("meta", {
|
|
name: "description",
|
|
content: "Desc A",
|
|
key: "desc",
|
|
}),
|
|
),
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Title B",
|
|
key: "og-title",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('content="Title B"');
|
|
expect(headHtml).toContain('content="Desc A"');
|
|
expect(headHtml).not.toContain('content="Title A"');
|
|
expect(headHtml.match(/property="og:title"/g)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("Head reduction", () => {
|
|
it("dedupes keyed tags and keeps the last matching element", () => {
|
|
const reduced = reduceHeadChildren([
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Original Title",
|
|
key: "og-title",
|
|
}),
|
|
React.createElement("meta", {
|
|
property: "og:title",
|
|
content: "Updated Title",
|
|
key: "og-title",
|
|
}),
|
|
]);
|
|
|
|
expect(reduced).toHaveLength(1);
|
|
const dedupedMeta = reduced[0] as React.ReactElement<{ content?: string }> | undefined;
|
|
expect(dedupedMeta?.props.content).toBe("Updated Title");
|
|
});
|
|
|
|
it("dedupes meta[name] tags without explicit keys using the last value", () => {
|
|
const reduced = reduceHeadChildren([
|
|
[
|
|
React.createElement("meta", {
|
|
name: "description",
|
|
content: "Description A",
|
|
}),
|
|
React.createElement("meta", {
|
|
name: "description",
|
|
content: "Description B",
|
|
}),
|
|
],
|
|
]);
|
|
|
|
expect(reduced).toHaveLength(1);
|
|
const dedupedMeta = reduced[0] as React.ReactElement<{ content?: string }> | undefined;
|
|
expect(dedupedMeta?.props.content).toBe("Description B");
|
|
});
|
|
});
|
|
|
|
// ─── Disallowed tags ────────────────────────────────────────────────────
|
|
|
|
describe("Head disallowed tags", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("ignores <div> tag (not allowed in head)", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("div", null, "bad")),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).not.toContain("<div");
|
|
expect(headHtml).not.toContain("bad");
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it("ignores <iframe> tag (security concern)", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("iframe", { src: "https://evil.com" })),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).not.toContain("<iframe");
|
|
expect(headHtml).not.toContain("evil.com");
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it("ignores component elements (non-string type)", () => {
|
|
function CustomComponent() {
|
|
return React.createElement("meta", { name: "custom" });
|
|
}
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement(CustomComponent)),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
// Component elements are ignored because child.type is not a string
|
|
expect(headHtml).not.toContain('name="custom"');
|
|
});
|
|
|
|
it("keeps allowed tags while ignoring disallowed ones", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("title", null, "Good"),
|
|
React.createElement("div", null, "Bad"),
|
|
React.createElement("meta", { name: "good" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("Good");
|
|
expect(headHtml).toContain('name="good"');
|
|
expect(headHtml).not.toContain("<div");
|
|
warn.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ─── HTML/Attribute escaping ────────────────────────────────────────────
|
|
|
|
describe("Head escaping", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
});
|
|
|
|
it("escapes HTML in text content", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("title", null, 'Page <script>alert("xss")</script>'),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain("<script>");
|
|
expect(headHtml).not.toContain("<script>alert");
|
|
});
|
|
|
|
it("escapes HTML in attribute values", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", { name: 'test"value', content: "a<b>c&d" }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain(""");
|
|
expect(headHtml).toContain("<");
|
|
expect(headHtml).toContain("&");
|
|
});
|
|
|
|
it("renders dangerouslySetInnerHTML raw on SSR", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("script", {
|
|
dangerouslySetInnerHTML: { __html: 'console.log("hello")' },
|
|
}),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('console.log("hello")');
|
|
});
|
|
|
|
it("empty dangerouslySetInnerHTML.__html takes precedence over children on SSR", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
// oxlint-disable-next-line react/no-danger-with-children
|
|
React.createElement("style", {
|
|
dangerouslySetInnerHTML: { __html: "" },
|
|
// oxlint-disable-next-line react/no-children-prop
|
|
children: "fallback",
|
|
}),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).not.toContain("fallback");
|
|
expect(headHtml).toMatch(/<style[^>]*><\/style>/);
|
|
});
|
|
|
|
it("converts className to class attribute", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("style", { className: "critical" }, "body{}"),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain('class="critical"');
|
|
expect(headHtml).not.toContain("className");
|
|
});
|
|
|
|
it("renders boolean true attributes as bare attribute name", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("script", { src: "/app.js", async: true, defer: true }),
|
|
),
|
|
);
|
|
const headHtml = getSSRHeadHTML();
|
|
expect(headHtml).toContain(" async ");
|
|
expect(headHtml).toContain(" defer ");
|
|
});
|
|
});
|
|
|
|
describe("Head client sync", () => {
|
|
function createElementDouble() {
|
|
const attributes = new Map<string, string>();
|
|
return {
|
|
attributes,
|
|
innerHTML: "",
|
|
textContent: "",
|
|
setAttribute(name: string, value: string) {
|
|
attributes.set(name, value);
|
|
},
|
|
};
|
|
}
|
|
|
|
it("applies dangerouslySetInnerHTML to client-managed head elements", () => {
|
|
// Next.js client reference:
|
|
// packages/next/src/client/head-manager.ts reactElementToDOM()
|
|
// sets el.innerHTML from dangerouslySetInnerHTML.__html.
|
|
const element = createElementDouble();
|
|
|
|
_applyHeadPropsToElement(element, {
|
|
dangerouslySetInnerHTML: { __html: "body { color: red; }" },
|
|
});
|
|
|
|
expect(element.innerHTML).toBe("body { color: red; }");
|
|
});
|
|
|
|
it("ignores malformed dangerouslySetInnerHTML without __html key", () => {
|
|
// dangerouslySetInnerHTML: {} has no __html key, so getDangerouslySetInnerHTML
|
|
// returns undefined. The client falls through to children (matching SSR behavior).
|
|
const element = createElementDouble();
|
|
element.innerHTML = "previous";
|
|
|
|
_applyHeadPropsToElement(element, {
|
|
dangerouslySetInnerHTML: {},
|
|
});
|
|
|
|
// No valid __html and no children — content is unchanged.
|
|
expect(element.innerHTML).toBe("previous");
|
|
});
|
|
|
|
it("falls through to children when dangerouslySetInnerHTML has no __html key", () => {
|
|
const element = createElementDouble();
|
|
|
|
_applyHeadPropsToElement(element, {
|
|
dangerouslySetInnerHTML: {},
|
|
children: "fallback",
|
|
});
|
|
|
|
// Malformed dangerouslySetInnerHTML is ignored, children win.
|
|
expect(element.textContent).toBe("fallback");
|
|
});
|
|
|
|
it("empty dangerouslySetInnerHTML.__html takes precedence over children on client", () => {
|
|
const element = createElementDouble();
|
|
_applyHeadPropsToElement(element, {
|
|
children: "fallback",
|
|
dangerouslySetInnerHTML: { __html: "" },
|
|
});
|
|
expect(element.innerHTML).toBe("");
|
|
expect(element.textContent).toBe("");
|
|
});
|
|
|
|
it("prefers dangerouslySetInnerHTML over children on client-managed head elements", () => {
|
|
const element = createElementDouble();
|
|
|
|
_applyHeadPropsToElement(element, {
|
|
children: "children content",
|
|
dangerouslySetInnerHTML: { __html: "raw content" },
|
|
});
|
|
|
|
expect(element.innerHTML).toBe("raw content");
|
|
expect(element.textContent).toBe("");
|
|
});
|
|
|
|
it("sets textContent from children when dangerouslySetInnerHTML is absent", () => {
|
|
const element = createElementDouble();
|
|
_applyHeadPropsToElement(element, { children: "hello" });
|
|
expect(element.textContent).toBe("hello");
|
|
expect(element.innerHTML).toBe("");
|
|
});
|
|
|
|
it("sets textContent from array children by joining them", () => {
|
|
const element = createElementDouble();
|
|
_applyHeadPropsToElement(element, { children: ["a", "b", "c"] });
|
|
expect(element.textContent).toBe("abc");
|
|
expect(element.innerHTML).toBe("");
|
|
});
|
|
|
|
it("maps JSX attribute names to HTML form when applying to DOM", () => {
|
|
// The SSR path emits `<meta http-equiv="..." />` (hyphenated). The client
|
|
// path must use the same mapping or hydration will mismatch. setAttribute
|
|
// is case-insensitive for HTML elements, so `charSet` would lowercase to
|
|
// `charset` by coincidence — but `httpEquiv` would become `httpequiv`
|
|
// (no hyphen), not `http-equiv`.
|
|
const element = createElementDouble();
|
|
_applyHeadPropsToElement(element, {
|
|
httpEquiv: "X-UA-Compatible",
|
|
content: "IE=edge",
|
|
});
|
|
expect(element.attributes.get("http-equiv")).toBe("X-UA-Compatible");
|
|
expect(element.attributes.get("httpEquiv")).toBeUndefined();
|
|
expect(element.attributes.get("content")).toBe("IE=edge");
|
|
});
|
|
|
|
it("maps JSX charSet attribute to HTML charset when applying to DOM", () => {
|
|
const element = createElementDouble();
|
|
_applyHeadPropsToElement(element, { charSet: "utf-8" });
|
|
expect(element.attributes.get("charset")).toBe("utf-8");
|
|
expect(element.attributes.get("charSet")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── escapeAttr utility ─────────────────────────────────────────────────
|
|
|
|
describe("escapeAttr", () => {
|
|
it("escapes ampersand", () => {
|
|
expect(escapeAttr("a&b")).toBe("a&b");
|
|
});
|
|
|
|
it("escapes double quotes", () => {
|
|
expect(escapeAttr('a"b')).toBe("a"b");
|
|
});
|
|
|
|
it("escapes angle brackets", () => {
|
|
expect(escapeAttr("a<b>c")).toBe("a<b>c");
|
|
});
|
|
|
|
it("returns safe strings unchanged", () => {
|
|
expect(escapeAttr("hello world")).toBe("hello world");
|
|
});
|
|
|
|
it("escapes all special chars together", () => {
|
|
expect(escapeAttr('&"<>')).toBe("&"<>");
|
|
});
|
|
});
|
|
|
|
// ─── charset / viewport ordering (issue #1569) ─────────────────────────────
|
|
//
|
|
// Mirrors Next.js's test/e2e/next-head/index.test.ts assertion that
|
|
// `<meta charset>` is emitted first, then `<meta viewport>`, then user tags,
|
|
// all carrying `data-next-head=""`. Next.js's `defaultHead()` in
|
|
// shared/lib/head.tsx is what seeds those defaults — vinext must include the
|
|
// same defaults via getSSRHeadHTML().
|
|
|
|
describe("Head default tags (charset/viewport ordering, issue #1569)", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
setDocumentInitialHead([]);
|
|
});
|
|
|
|
it("emits charset first, then viewport, before user tags", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", { name: "test-head-1", content: "hello" }),
|
|
),
|
|
);
|
|
|
|
const html = getSSRHeadHTML();
|
|
const charsetIdx = html.indexOf('charset="utf-8"');
|
|
const viewportIdx = html.indexOf('name="viewport"');
|
|
const userIdx = html.indexOf('name="test-head-1"');
|
|
|
|
expect(charsetIdx).toBeGreaterThanOrEqual(0);
|
|
expect(viewportIdx).toBeGreaterThan(charsetIdx);
|
|
expect(userIdx).toBeGreaterThan(viewportIdx);
|
|
});
|
|
|
|
it("default tags carry data-next-head attribute", () => {
|
|
const html = getSSRHeadHTML();
|
|
// Match the segment from the charset tag through the next "/>"
|
|
const charsetMatch = html.match(/<meta charset="utf-8"[^/]*\/>/);
|
|
expect(charsetMatch).not.toBeNull();
|
|
expect(charsetMatch?.[0]).toContain('data-next-head=""');
|
|
|
|
const viewportMatch = html.match(/<meta name="viewport"[^/]*\/>/);
|
|
expect(viewportMatch).not.toBeNull();
|
|
expect(viewportMatch?.[0]).toContain('data-next-head=""');
|
|
});
|
|
|
|
it("user-supplied charset overrides the default (charSet meta-type dedupe)", () => {
|
|
// charSet is special: META_TYPES dedupe always treats it as unique-only,
|
|
// so any user-supplied charset replaces the default regardless of key.
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Head, null, React.createElement("meta", { charSet: "utf-16" })),
|
|
);
|
|
|
|
const html = getSSRHeadHTML();
|
|
expect(html).toContain('charset="utf-16"');
|
|
expect(html).not.toContain('charset="utf-8"');
|
|
});
|
|
|
|
it("user-supplied viewport with key='viewport' overrides the default", () => {
|
|
// The default viewport carries `key="viewport"`. User tags must use the
|
|
// same key to dedupe — matches Next.js's `key`-based merge semantics.
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", {
|
|
name: "viewport",
|
|
content: "width=500",
|
|
key: "viewport",
|
|
}),
|
|
),
|
|
);
|
|
|
|
const html = getSSRHeadHTML();
|
|
expect(html).toContain('content="width=500"');
|
|
expect(html).not.toContain('content="width=device-width"');
|
|
});
|
|
|
|
it("emits defaults even when no user <Head> is mounted", () => {
|
|
const html = getSSRHeadHTML();
|
|
expect(html).toContain('<meta charset="utf-8"');
|
|
expect(html).toContain('<meta name="viewport"');
|
|
});
|
|
});
|
|
|
|
// ─── _document.getInitialProps() head merge (issue #1569) ──────────────────
|
|
//
|
|
// Mirrors Next.js's test/e2e/next-head/index.test.ts assertion that head tags
|
|
// returned from a user `_document.getInitialProps()` appear in the rendered
|
|
// `<head>`. vinext's SSR pipeline forwards them to the head shim via
|
|
// setDocumentInitialHead() so they flow through the same dedupe pipeline.
|
|
|
|
describe("Head _document.getInitialProps() merge (issue #1569)", () => {
|
|
beforeEach(() => {
|
|
resetSSRHead();
|
|
setDocumentInitialHead([]);
|
|
});
|
|
|
|
it("includes meta tags returned by setDocumentInitialHead in the output", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Head,
|
|
null,
|
|
React.createElement("meta", { name: "test-head-1", content: "hello" }),
|
|
),
|
|
);
|
|
setDocumentInitialHead([
|
|
React.createElement("meta", { name: "test-head-initial-props", content: "hello" }),
|
|
]);
|
|
|
|
const html = getSSRHeadHTML();
|
|
expect(html).toContain('name="test-head-initial-props"');
|
|
expect(html).toContain('content="hello"');
|
|
// The user's next/head tag still appears.
|
|
expect(html).toContain('name="test-head-1"');
|
|
});
|
|
|
|
it("resets between renders so initial-props head does not leak", () => {
|
|
setDocumentInitialHead([
|
|
React.createElement("meta", { name: "leaked", content: "should-be-gone" }),
|
|
]);
|
|
expect(getSSRHeadHTML()).toContain('name="leaked"');
|
|
|
|
resetSSRHead();
|
|
|
|
expect(getSSRHeadHTML()).not.toContain('name="leaked"');
|
|
});
|
|
});
|
|
|
|
// ─── client hydration sync keeps defaults & order (issue #1569 / #1677) ─────
|
|
//
|
|
// Underlying issue #1569 was fixed for SSR by #1677; this exercises the client
|
|
// path, which regressed: on the client, the first <Head> mount runs
|
|
// _syncClientHead(). It must:
|
|
// 1. keep the server-rendered charset / viewport defaults (which carry
|
|
// data-next-head="") — they must not vanish after hydration, and
|
|
// 2. reconcile in place (diff via isEqualNode) rather than wipe-and-rebuild,
|
|
// so SSR head ordering is preserved and <meta charset> stays first.
|
|
// This mirrors Next.js's reduceComponents() (always seeds defaultHead()) and
|
|
// head-manager.ts updateElements() (in-place reconciliation).
|
|
|
|
describe("Head client sync (defaults + order, issue #1569 / #1677)", () => {
|
|
// Minimal DOM double — the repo has no jsdom/happy-dom. Only the surface
|
|
// _syncClientHead() touches is implemented.
|
|
class FakeElement {
|
|
attributes = new Map<string, string>();
|
|
innerHTML = "";
|
|
nonce = "";
|
|
textContent = "";
|
|
parentNode: FakeHead | null = null;
|
|
constructor(public readonly tagName: string) {}
|
|
setAttribute(name: string, value: string): void {
|
|
this.attributes.set(name, value);
|
|
if (name === "nonce") this.nonce = value;
|
|
}
|
|
getAttribute(name: string): string | null {
|
|
return this.attributes.has(name) ? this.attributes.get(name)! : null;
|
|
}
|
|
hasAttribute(name: string): boolean {
|
|
return this.attributes.has(name);
|
|
}
|
|
isEqualNode(other: unknown): boolean {
|
|
if (!(other instanceof FakeElement)) return false;
|
|
if (this.tagName.toLowerCase() !== other.tagName.toLowerCase()) return false;
|
|
if (this.attributes.size !== other.attributes.size) return false;
|
|
for (const [k, v] of this.attributes) {
|
|
if (other.attributes.get(k) !== v) return false;
|
|
}
|
|
return this.textContent === other.textContent && this.innerHTML === other.innerHTML;
|
|
}
|
|
cloneNode(): FakeElement {
|
|
const clone = new FakeElement(this.tagName);
|
|
clone.attributes = new Map(this.attributes);
|
|
clone.innerHTML = this.innerHTML;
|
|
clone.nonce = this.nonce;
|
|
clone.textContent = this.textContent;
|
|
return clone;
|
|
}
|
|
}
|
|
|
|
class FakeHead {
|
|
children: FakeElement[] = [];
|
|
appendChild(el: FakeElement): void {
|
|
el.parentNode?.removeChild(el);
|
|
el.parentNode = this;
|
|
this.children.push(el);
|
|
}
|
|
prepend(el: FakeElement): void {
|
|
el.parentNode?.removeChild(el);
|
|
el.parentNode = this;
|
|
this.children.unshift(el);
|
|
}
|
|
removeChild(el: FakeElement): void {
|
|
const idx = this.children.indexOf(el);
|
|
if (idx >= 0) this.children.splice(idx, 1);
|
|
el.parentNode = null;
|
|
}
|
|
querySelectorAll(selector: string): FakeElement[] {
|
|
if (selector !== "[data-next-head]") return [];
|
|
return this.children.filter((el) => el.hasAttribute("data-next-head"));
|
|
}
|
|
querySelector(selector: string): FakeElement | null {
|
|
if (selector !== "meta[charset]") return null;
|
|
return (
|
|
this.children.find(
|
|
(el) => el.tagName.toLowerCase() === "meta" && el.hasAttribute("charset"),
|
|
) ?? null
|
|
);
|
|
}
|
|
}
|
|
|
|
function installFakeDocument(): {
|
|
restore: () => void;
|
|
head: FakeHead;
|
|
createElement: (tag: string) => FakeElement;
|
|
} {
|
|
const head = new FakeHead();
|
|
const createElement = (tag: string) => new FakeElement(tag);
|
|
const fakeDocument = { createElement, head };
|
|
const prevDocument = (globalThis as Record<string, unknown>).document;
|
|
const prevHTMLElement = (globalThis as Record<string, unknown>).HTMLElement;
|
|
Object.defineProperty(fakeDocument, "title", {
|
|
configurable: true,
|
|
get() {
|
|
return (
|
|
head.children.find((child) => child.tagName.toLowerCase() === "title")?.textContent ?? ""
|
|
);
|
|
},
|
|
set(value: string) {
|
|
let title = head.children.find((child) => child.tagName.toLowerCase() === "title");
|
|
if (!title) {
|
|
title = createElement("title");
|
|
head.appendChild(title);
|
|
}
|
|
title.textContent = value;
|
|
},
|
|
});
|
|
(globalThis as Record<string, unknown>).document = fakeDocument;
|
|
(globalThis as Record<string, unknown>).HTMLElement = FakeElement;
|
|
return {
|
|
head,
|
|
createElement,
|
|
restore: () => {
|
|
(globalThis as Record<string, unknown>).document = prevDocument;
|
|
(globalThis as Record<string, unknown>).HTMLElement = prevHTMLElement;
|
|
},
|
|
};
|
|
}
|
|
|
|
// Build a server-rendered <head> matching what SSR emits for the example:
|
|
// charset, viewport, title (all data-next-head), followed by a modulepreload
|
|
// link that is NOT vinext-managed.
|
|
function seedServerHead(head: FakeHead, createElement: (tag: string) => FakeElement) {
|
|
const charset = createElement("meta");
|
|
charset.setAttribute("charset", "utf-8");
|
|
charset.setAttribute("data-next-head", "");
|
|
const viewport = createElement("meta");
|
|
viewport.setAttribute("name", "viewport");
|
|
viewport.setAttribute("content", "width=device-width");
|
|
viewport.setAttribute("data-next-head", "");
|
|
const title = createElement("title");
|
|
title.textContent = "Cloudflare Pages Router";
|
|
title.setAttribute("data-next-head", "");
|
|
const modulepreload = createElement("link");
|
|
modulepreload.setAttribute("rel", "modulepreload");
|
|
modulepreload.setAttribute("href", "/_next/static/index.js");
|
|
head.children.push(charset, viewport, title, modulepreload);
|
|
for (const el of head.children) el.parentNode = head;
|
|
return { charset, viewport, title, modulepreload };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
_clientHeadChildren.clear();
|
|
});
|
|
|
|
it("preserves SSR order and reuses matching nodes (no reorder, no churn)", () => {
|
|
const { head, createElement, restore } = installFakeDocument();
|
|
try {
|
|
const server = seedServerHead(head, createElement);
|
|
|
|
// The page's <Head> re-declares the same title on the client.
|
|
_clientHeadChildren.set(
|
|
Symbol("test"),
|
|
React.createElement("title", null, "Cloudflare Pages Router"),
|
|
);
|
|
_syncClientHead();
|
|
|
|
// Order is unchanged and the exact same node instances are reused.
|
|
expect(head.children).toEqual([
|
|
server.charset,
|
|
server.viewport,
|
|
server.title,
|
|
server.modulepreload,
|
|
]);
|
|
// No duplicate charset/viewport.
|
|
const metas = head.children.filter((el) => el.tagName === "meta");
|
|
expect(metas.filter((el) => el.getAttribute("charset") === "utf-8")).toHaveLength(1);
|
|
expect(metas.filter((el) => el.getAttribute("name") === "viewport")).toHaveLength(1);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("reuses nonce-bearing nodes when CSP hides the live nonce attribute", () => {
|
|
// Ported from Next.js: test/unit/is-equal-node.unit.test.ts
|
|
const { createElement, restore } = installFakeDocument();
|
|
try {
|
|
const oldTag = createElement("script");
|
|
oldTag.setAttribute("nonce", "abc123");
|
|
// Simulate Chromium/Firefox after insertion under a matching CSP.
|
|
oldTag.setAttribute("nonce", "");
|
|
oldTag.nonce = "abc123";
|
|
const newTag = createElement("script");
|
|
newTag.setAttribute("nonce", "abc123");
|
|
|
|
expect(isEqualHeadNode(oldTag as unknown as Element, newTag as unknown as Element)).toBe(
|
|
true,
|
|
);
|
|
newTag.setAttribute("nonce", "different");
|
|
expect(isEqualHeadNode(oldTag as unknown as Element, newTag as unknown as Element)).toBe(
|
|
false,
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("updates title text without moving or replacing the SSR title node", () => {
|
|
const { head, createElement, restore } = installFakeDocument();
|
|
try {
|
|
const server = seedServerHead(head, createElement);
|
|
_clientHeadChildren.set(Symbol("test"), React.createElement("title", null, "Updated title"));
|
|
|
|
_syncClientHead();
|
|
|
|
expect(head.children[2]).toBe(server.title);
|
|
expect(server.title.textContent).toBe("Updated title");
|
|
expect(head.children.filter((child) => child.tagName.toLowerCase() === "title")).toHaveLength(
|
|
1,
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("inserts changed tag types in Next.js head-manager order", () => {
|
|
const { head, createElement, restore } = installFakeDocument();
|
|
try {
|
|
seedServerHead(head, createElement);
|
|
_clientHeadChildren.set(
|
|
Symbol("test"),
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement("script", { src: "/new.js" }),
|
|
React.createElement("meta", { name: "new-meta", content: "1" }),
|
|
),
|
|
);
|
|
|
|
_syncClientHead();
|
|
|
|
const metaIndex = head.children.findIndex((tag) => tag.getAttribute("name") === "new-meta");
|
|
const scriptIndex = head.children.findIndex((tag) => tag.getAttribute("src") === "/new.js");
|
|
expect(metaIndex).toBeGreaterThan(-1);
|
|
expect(scriptIndex).toBeGreaterThan(metaIndex);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("batches replacement instances and makes the most recently updated Head win", async () => {
|
|
const { head, restore } = installFakeDocument();
|
|
const first = Symbol("first");
|
|
const second = Symbol("second");
|
|
try {
|
|
_updateClientHeadInstance(
|
|
first,
|
|
React.createElement("meta", { name: "shared", content: "first" }),
|
|
);
|
|
await Promise.resolve();
|
|
const firstNode = head.children.find((tag) => tag.getAttribute("name") === "shared");
|
|
expect(firstNode).toBeDefined();
|
|
|
|
// Removing and replacing one instance in the same effects flush must not
|
|
// delete/recreate an identical DOM node between registrations.
|
|
_removeClientHeadInstance(first);
|
|
_updateClientHeadInstance(
|
|
second,
|
|
React.createElement("meta", { name: "shared", content: "first" }),
|
|
);
|
|
await Promise.resolve();
|
|
expect(head.children.find((tag) => tag.getAttribute("name") === "shared")).toBe(firstNode);
|
|
|
|
_updateClientHeadInstance(
|
|
first,
|
|
React.createElement("meta", { name: "shared", content: "updated-first" }),
|
|
);
|
|
await Promise.resolve();
|
|
expect(
|
|
head.children.find((tag) => tag.getAttribute("name") === "shared")?.getAttribute("content"),
|
|
).toBe("updated-first");
|
|
} finally {
|
|
_clientHeadChildren.clear();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("appends genuinely new tags without disturbing existing order", () => {
|
|
const { head, createElement, restore } = installFakeDocument();
|
|
try {
|
|
const server = seedServerHead(head, createElement);
|
|
|
|
_clientHeadChildren.set(
|
|
Symbol("test"),
|
|
React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement("title", null, "Cloudflare Pages Router"),
|
|
React.createElement("meta", { name: "description", content: "hi" }),
|
|
),
|
|
);
|
|
_syncClientHead();
|
|
|
|
// Defaults + title kept in place; the new description meta is appended.
|
|
const description = head.children.find((el) => el.getAttribute("name") === "description");
|
|
expect(description).toBeDefined();
|
|
expect(head.children.indexOf(server.charset)).toBe(0);
|
|
expect(head.children.indexOf(server.viewport)).toBe(1);
|
|
expect(head.children.indexOf(server.title)).toBe(2);
|
|
// The new tag lands after the previously-existing children.
|
|
expect(head.children.indexOf(description!)).toBeGreaterThan(
|
|
head.children.indexOf(server.modulepreload),
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("removes stale vinext-managed tags that are no longer desired", () => {
|
|
const { head, createElement, restore } = installFakeDocument();
|
|
try {
|
|
seedServerHead(head, createElement);
|
|
const stale = createElement("meta");
|
|
stale.setAttribute("name", "stale");
|
|
stale.setAttribute("data-next-head", "");
|
|
head.appendChild(stale);
|
|
|
|
// Client <Head> declares only the title (defaults come from defaultHead()).
|
|
_clientHeadChildren.set(
|
|
Symbol("test"),
|
|
React.createElement("title", null, "Cloudflare Pages Router"),
|
|
);
|
|
_syncClientHead();
|
|
|
|
expect(head.children.includes(stale)).toBe(false);
|
|
// Defaults remain.
|
|
const metas = head.children.filter((el) => el.tagName === "meta");
|
|
expect(metas.some((el) => el.getAttribute("charset") === "utf-8")).toBe(true);
|
|
expect(metas.some((el) => el.getAttribute("name") === "viewport")).toBe(true);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("prepends a newly-created <meta charset> so it stays first", () => {
|
|
const { head, createElement, restore } = installFakeDocument();
|
|
try {
|
|
// <head> has a non-managed script first and no charset yet.
|
|
const script = createElement("script");
|
|
script.setAttribute("src", "/a.js");
|
|
head.appendChild(script);
|
|
|
|
_clientHeadChildren.set(Symbol("test"), null);
|
|
_syncClientHead();
|
|
|
|
// defaultHead()'s charset must be prepended ahead of the script.
|
|
expect(head.children[0]?.tagName).toBe("meta");
|
|
expect(head.children[0]?.getAttribute("charset")).toBe("utf-8");
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("lets a user key='viewport' override the default viewport on the client", () => {
|
|
// Mirrors the SSR override test (head.test.ts) on the client path: the
|
|
// default viewport carries key="viewport", so a user tag with the same key
|
|
// wins the dedupe in reduceHeadChildren — same precedence as Next.js.
|
|
const { head, restore } = installFakeDocument();
|
|
try {
|
|
_clientHeadChildren.set(
|
|
Symbol("test"),
|
|
React.createElement("meta", {
|
|
name: "viewport",
|
|
content: "width=500",
|
|
key: "viewport",
|
|
}),
|
|
);
|
|
_syncClientHead();
|
|
|
|
const viewports = head.children.filter(
|
|
(el) => el.tagName === "meta" && el.getAttribute("name") === "viewport",
|
|
);
|
|
// Exactly one viewport, and it is the user's override.
|
|
expect(viewports).toHaveLength(1);
|
|
expect(viewports[0]?.getAttribute("content")).toBe("width=500");
|
|
// The default charset is still emitted alongside the override.
|
|
expect(
|
|
head.children.some((el) => el.tagName === "meta" && el.getAttribute("charset") === "utf-8"),
|
|
).toBe(true);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("emits defaults even when the client <Head> has no children", () => {
|
|
const { head, restore } = installFakeDocument();
|
|
try {
|
|
_clientHeadChildren.set(Symbol("test"), null);
|
|
_syncClientHead();
|
|
|
|
const metas = head.children.filter((el) => el.tagName === "meta");
|
|
expect(metas.some((el) => el.getAttribute("charset") === "utf-8")).toBe(true);
|
|
expect(metas.some((el) => el.getAttribute("name") === "viewport")).toBe(true);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
});
|