Skip to content

fix(head): preserve dangerous html during client sync#1180

Merged
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/head-dangerous-innerhtml
May 13, 2026
Merged

fix(head): preserve dangerous html during client sync#1180
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/head-dangerous-innerhtml

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Field Detail
Goal Keep next/head raw HTML content stable after vinext client head sync runs.
Core change Apply dangerouslySetInnerHTML.__html to client-created head nodes instead of silently dropping it.
Boundary Pages Router next/head client DOM projection. SSR escaping and tag filtering are unchanged.
Primary files packages/vinext/src/shims/head.ts, tests/head.test.ts
Expected impact Apps that use inline <style>, <script>, or <noscript> content in next/head no longer lose that content after hydration or client navigation.

Why

next/head owns a client projection of the current head state. For parity, that projection needs to preserve the same developer-authored raw content that SSR accepted, subject to the same explicit dangerouslySetInnerHTML opt-in.

Area Principle / invariant What this PR changes
Client head sync Client-created head nodes should represent the reduced <Head> elements, including raw content. Sets innerHTML from dangerouslySetInnerHTML.__html when the prop is present.
Content precedence Next.js treats dangerouslySetInnerHTML as taking precedence over children. Mirrors that precedence in vinext's client helper.
Safety boundary The risk boundary is the developer's explicit React raw HTML opt-in, not a client-only silent drop. Keeps existing SSR raw HTML behavior and attribute-name validation.

What changed

Scenario Before After
<Head><style dangerouslySetInnerHTML={{ __html }} /></Head> after client sync The managed <style> node was recreated empty. The managed <style> node receives innerHTML = __html.
dangerouslySetInnerHTML={{}} The prior node content could be removed and replaced by an empty node implicitly. The client helper explicitly sets empty innerHTML, matching Next.js.
Both children and dangerouslySetInnerHTML Behavior depended on vinext's prop loop rather than Next.js precedence. Raw HTML wins over children.
Maintainer review path
  1. packages/vinext/src/shims/head.ts validates the new helper and its use from syncClientHead().
  2. tests/head.test.ts covers the client projection behavior without adding a DOM test dependency.
Validation
  • vp test run tests/head.test.ts
  • vp test run tests/head.test.ts tests/shims.test.ts
  • vp check

The first head test run was red before the implementation because _applyHeadPropsToElement did not exist. After implementation, the focused regression tests and broader shim coverage passed.

Risk / compatibility
  • Public API impact: no documented public API change. _applyHeadPropsToElement is exported only to keep the client projection unit-testable in this Node test environment.
  • Runtime impact: limited to client-created next/head nodes.
  • SSR impact: existing SSR raw HTML behavior and closing-tag escaping remain in place.
  • Compatibility: aligns vinext with Next.js client head manager behavior.
Non-goals
  • This does not change which tags are allowed in <Head>.
  • This does not alter SSR escaping for script or style closing tags.
  • This does not add next/script behavior to next/head.

References

Reference Why it matters
Next.js reactElementToDOM() sets el.innerHTML from dangerouslySetInnerHTML.__html Confirms the intended client-side semantics.
Next.js skips dangerouslySetInnerHTML in the generic attribute setter Shows raw content is applied as content, not as an attribute.
Next.js Head docs note head contents are client-managed and cleared on unmount Documents the client-managed head surface affected by this fix.

Client-managed next/head tags currently drop dangerouslySetInnerHTML when hydration or navigation rebuilds vinext-owned head nodes. That diverges from Next.js and can leave SSR-injected script, style, or noscript content empty after the client projection runs.

The client path treated raw HTML as unsafe even though the SSR path already accepts the developer opt-in. Reuse one typed prop application helper for client head nodes and set innerHTML from dangerouslySetInnerHTML with the same precedence as Next.js.

Add regression coverage for client raw HTML application, empty __html fallback, and precedence over children.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1180

commit: b566fe2

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 12, 2026 04:28
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix — the old code was silently dropping dangerouslySetInnerHTML content on client sync, which broke inline <style> and <script> in next/head after hydration. The extracted _applyHeadPropsToElement is clean, the test double approach is sensible, and the Next.js references check out.

Two minor observations below, one of which is worth addressing.

if (dangerouslySetInnerHTML) {
domEl.innerHTML = getDangerouslySetInnerHTML(dangerouslySetInnerHTML) ?? "";
} else if (typeof props.children === "string") {
domEl.textContent = props.children;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next.js's reactElementToDOM also handles array children by joining them:

el.textContent =
  typeof children === 'string'
    ? children
    : Array.isArray(children)
      ? children.join('')
      : ''

vinext only handles the string case here (and in the old inlined code). This isn't a regression from this PR — the old code had the same gap — but since you're extracting this into a named helper it's a good time to close it for parity.

Suggested change
domEl.textContent = props.children;
} else if (typeof props.children === "string") {
domEl.textContent = props.children;
} else if (Array.isArray(props.children)) {
domEl.textContent = props.children.join("");
}

Comment thread packages/vinext/src/shims/head.ts Outdated
): void {
const dangerouslySetInnerHTML = props.dangerouslySetInnerHTML;

if (dangerouslySetInnerHTML) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truthiness check: if (dangerouslySetInnerHTML) is falsy for 0, "", NaN, etc., but the real concern here is null and undefined (the only realistic "absent" values for this prop). Since getDangerouslySetInnerHTML already validates the shape, the truthiness gate is fine in practice. Just noting it for posterity — no change needed.

Comment thread tests/head.test.ts

expect(element.innerHTML).toBe("raw content");
expect(element.textContent).toBe("");
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice coverage. One more edge case worth adding: a test where _applyHeadPropsToElement receives only children (no dangerouslySetInnerHTML) to confirm the textContent path works and that the extracted helper didn't regress the common case. Something like:

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

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review posted. Here's a summary of the feedback:

Overall: Good fix. The old code silently dropped dangerouslySetInnerHTML content during client head sync, breaking inline <style> and <script> tags in next/head after hydration. The extracted _applyHeadPropsToElement helper is clean, the test double approach avoids a DOM dependency, and the Next.js references are accurate.

Comments posted:

  1. head.ts:299 (suggestion) — Next.js's reactElementToDOM also handles array children by joining them. The old code had the same gap, but since this is being extracted into a named helper, it's a good time to add Array.isArray(children) handling for parity.

  2. head.ts:296 (informational) — Noted that the truthiness check on dangerouslySetInnerHTML is fine in practice since getDangerouslySetInnerHTML validates the shape. No change needed.

  3. tests/head.test.ts:476 (suggestion) — Recommended adding a test for the children-only path (no dangerouslySetInnerHTML) to confirm the extracted helper didn't regress the common case.

github run

Address review feedback:
- Handle Array.isArray(children) in _applyHeadPropsToElement by joining
  array elements, matching Next.js reactElementToDOM behavior
- Add test for children-only path (no dangerouslySetInnerHTML) to ensure
  the extracted helper didn't regress the common case
- Add test for array children joining
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR — correctly addresses the silent dropping of dangerouslySetInnerHTML during client head sync. The extracted helper is clean, test coverage is solid, the previous review feedback has been addressed (array children, children-only test), and all CI is green.

One real issue: there is an SSR/client behavior divergence for dangerouslySetInnerHTML: { __html: "" } (empty string). See inline comment.

Comment thread packages/vinext/src/shims/head.ts Outdated
// SECURITY NOTE: This injects raw HTML. Developers must never pass
// unsanitized user input here — it is a stored XSS vector.
const html = getDangerouslySetInnerHTML(value);
if (html) innerHTML = html;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSR/client parity gap with empty __html.

SSR (this line): if (html) innerHTML = html — when __html is "", getDangerouslySetInnerHTML returns "", but if ("") is falsy, so SSR skips the assignment and falls through to children.

Client (line 296): if (dangerouslySetInnerHTML) — the prop value { __html: "" } is an object (truthy), so the client enters the branch and sets innerHTML = "", which takes precedence over children.

This means <style dangerouslySetInnerHTML={{ __html: '' }}>fallback</style> would render fallback on SSR but empty on the client.

Next.js treats the presence of dangerouslySetInnerHTML as taking precedence regardless of __html value. This should be:

Suggested change
if (html) innerHTML = html;
const html = getDangerouslySetInnerHTML(value);
if (html != null) innerHTML = html;

This makes both paths consistent: if dangerouslySetInnerHTML is present with a valid __html (including empty string), it wins over children.

Comment thread packages/vinext/src/shims/head.ts Outdated
const dangerouslySetInnerHTML = props.dangerouslySetInnerHTML;

if (dangerouslySetInnerHTML) {
domEl.innerHTML = getDangerouslySetInnerHTML(dangerouslySetInnerHTML) ?? "";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this line is subtly correct but worth a comment. getDangerouslySetInnerHTML returns undefined for non-object values or missing __html, and the ?? "" fallback ensures innerHTML is set to empty string even for malformed props like dangerouslySetInnerHTML: { __html: 42 }. That's the right defensive behavior. No change needed, just noting it.

/** Tags whose content is raw text — closing-tag sequences must be escaped during SSR. */
const RAW_CONTENT_TAGS = new Set(["script", "style"]);

type HeadDOMElement = Pick<HTMLElement, "innerHTML" | "setAttribute" | "textContent">;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the Pick<HTMLElement, ...> type is a nice lightweight contract for testability. Worth noting this is intentionally narrow — it doesn't include removeAttribute or style, which is fine for head elements but would need expanding if this helper were reused elsewhere.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review posted. Here's a summary:

Overall: Good fix. The old code silently dropped dangerouslySetInnerHTML content during client head sync, breaking inline <style> and <script> in next/head after hydration. The extracted _applyHeadPropsToElement helper is clean, previous review feedback (array children, children-only test) has been addressed, and all CI is green.

One real issue found:

  • head.ts:226 — SSR/client parity gap with empty __html: The SSR path uses if (html) which is falsy for "", so { __html: "" } falls through to children on SSR. But the client path uses if (dangerouslySetInnerHTML) which is truthy for the object { __html: "" }, so it takes precedence over children on the client. This creates a server/client mismatch. Suggested fix: change if (html) to if (html != null) so both paths consistently let dangerouslySetInnerHTML win when present.

Two informational notes:

  • head.ts:297 — The ?? "" fallback on getDangerouslySetInnerHTML is correct defensive behavior for malformed props.
  • head.ts:67 — The narrow Pick<HTMLElement, ...> type is a nice testability contract.

github run

…rHTML

Address second review: dangerouslySetInnerHTML: { __html: '' } was
handled inconsistently between SSR and client. The SSR path used a
truthiness check (if (html)) which skipped empty strings, falling
through to children. The client path checked the prop object (truthy),
so empty __html correctly took precedence.

Fix: restructure headChildToHTML to check dangerouslySetInnerHTML before
children using != null, making both paths consistent with Next.js
semantics where dangerouslySetInnerHTML always wins when present.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix. The core change is correct — the old code explicitly skipped dangerouslySetInnerHTML on the client path with a // skip for safety comment, which silently broke inline <style> and <script> content in next/head after hydration. The PR correctly aligns with Next.js's reactElementToDOM() behavior.

The extracted _applyHeadPropsToElement helper is clean, the test double approach avoids a DOM dependency, and the SSR/client parity fix for empty __html (using != null on SSR, object truthiness on client) looks correct. Tests pass.

One remaining issue to address below.

// SECURITY NOTE: This injects raw HTML. Developers must never pass
// unsanitized user input here — it is a stored XSS vector.
innerHTML = rawHtml;
} else if (typeof props.children === "string") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSR/client parity gap: the SSR path only handles string children here, but the client path (_applyHeadPropsToElement at line 306) also handles Array.isArray(children) by joining them. If a <Head> child has array children (e.g. React.createElement("title", null, "Hello ", name)), the SSR path silently drops the content while the client path renders it.

This isn't a regression from this PR (the old SSR code had the same gap), but since you're already fixing SSR/client parity in this PR, it's worth closing the loop:

Suggested change
} else if (typeof props.children === "string") {
} else if (typeof props.children === "string") {
innerHTML = escapeHTML(props.children);
} else if (Array.isArray(props.children)) {
innerHTML = escapeHTML(props.children.join(""));
}

Comment thread packages/vinext/src/shims/head.ts Outdated
): void {
const dangerouslySetInnerHTML = props.dangerouslySetInnerHTML;

if (dangerouslySetInnerHTML) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This truthiness check works correctly for the { __html: "" } case (the object is truthy even when __html is empty), but it would incorrectly enter this branch for any truthy non-object value like dangerouslySetInnerHTML: "not an object" or dangerouslySetInnerHTML: 42. In that case getDangerouslySetInnerHTML returns undefined, the ?? "" kicks in, and the element's innerHTML is wiped — silently discarding both the malformed prop and any children.

Using getDangerouslySetInnerHTML as the gatekeeper (matching the SSR path's pattern) would be more robust:

Suggested change
if (dangerouslySetInnerHTML) {
const rawHtml = getDangerouslySetInnerHTML(props.dangerouslySetInnerHTML);
if (rawHtml != null) {
domEl.innerHTML = rawHtml;

This makes SSR and client use the exact same entry point and condition shape, and avoids the silent content wipe for malformed props.

Comment thread tests/head.test.ts Outdated
expect(element.innerHTML).toBe("body { color: red; }");
});

it("uses an empty string for missing dangerouslySetInnerHTML.__html on the client", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good defensive test. Worth noting: this test documents that dangerouslySetInnerHTML: {} (missing __html key entirely) wipes innerHTML to empty. That's a reasonable choice, but it's different from what the SSR path does — SSR's getDangerouslySetInnerHTML({}) returns undefined, so rawHtml != null is false, and SSR falls through to children.

So <style dangerouslySetInnerHTML={{}} children="fallback" /> would render fallback on SSR but empty on client. This is the same class of SSR/client divergence the previous review flagged — using getDangerouslySetInnerHTML as the gatekeeper in _applyHeadPropsToElement (per my other comment) would fix this edge case too.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review posted. Here's a summary:

Overall: Good fix. The old code explicitly skipped dangerouslySetInnerHTML on the client path, silently breaking inline <style> and <script> content in next/head after hydration. The extracted _applyHeadPropsToElement helper is clean and the test coverage is solid.

Three comments posted:

  1. head.ts:226 (suggestion) — SSR/client parity gap for array children. The client path handles Array.isArray(children) by joining, but the SSR path only handles string children. Suggested adding the array case to SSR for consistency.

  2. head.ts:302 (suggestion) — The truthiness check if (dangerouslySetInnerHTML) should be replaced with getDangerouslySetInnerHTML as the gatekeeper (matching the SSR path's pattern). The current code silently wipes innerHTML for malformed props like dangerouslySetInnerHTML: "not an object" and also creates a SSR/client divergence for dangerouslySetInnerHTML: {} (missing __html key).

  3. tests/head.test.ts:471 (informational) — Noted that the dangerouslySetInnerHTML: {} test documents behavior that diverges from SSR. Using getDangerouslySetInnerHTML as the gatekeeper (per the suggestion above) would fix this edge case.

github run

…erHTML

Address third review round:
- SSR: add Array.isArray(children) handling to headChildToHTML, matching
  the client path for cases like createElement('title', null, 'A', 'B')
- Client: replace truthiness check with getDangerouslySetInnerHTML as
  the gatekeeper (matching SSR pattern). This fixes silent content wipes
  for malformed props like dangerouslySetInnerHTML: 'string' or
  dangerouslySetInnerHTML: {} (missing __html key)
- Both paths now use the exact same entry point and condition shape:
  getDangerouslySetInnerHTML() != null
- Update tests: malformed dangerouslySetInnerHTML: {} now correctly
  falls through to children on both SSR and client
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean fix. The old code explicitly dropped dangerouslySetInnerHTML on the client path with // skip for safety, which silently broke inline <style> and <script> content in next/head after hydration. The new code correctly aligns with Next.js's reactElementToDOM() behavior.

Previous review feedback has been addressed: array children, children-only tests, SSR/client parity for empty __html, and using getDangerouslySetInnerHTML as the gatekeeper on both paths.

The extracted _applyHeadPropsToElement helper mirrors the structure of Next.js's reactElementToDOM closely — same precedence logic (dangerouslySetInnerHTML > string children > array children), same content-setting approach (innerHTML vs textContent). The vinext version is slightly more robust than Next.js's since getDangerouslySetInnerHTML validates the __html shape rather than using a bare truthiness check on the object.

Tests are thorough and cover the key edge cases. All 38 head tests and 858 shims tests pass.

Two minor observations below — neither is blocking.

/** Tags whose content is raw text — closing-tag sequences must be escaped during SSR. */
const RAW_CONTENT_TAGS = new Set(["script", "style"]);

type HeadDOMElement = Pick<HTMLElement, "innerHTML" | "setAttribute" | "textContent">;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The type name HeadDOMElement is a bit generic — someone reading it might expect it to represent a <head> element rather than "an element that lives inside <head>". Something like HeadChildDOMElement or ManagedHeadElement would communicate the intent more clearly. Not blocking.

function getDangerouslySetInnerHTML(value: unknown): string | undefined {
if (typeof value !== "object" || value === null) return undefined;

const html = Reflect.get(value, "__html");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Micro-nit: Reflect.get(value, "__html") works but is an unusual choice when a plain property access would do. The Reflect API is typically used when you need to forward a receiver or when working with Proxy traps. Since value is already validated as a non-null object, a simple (value as Record<string, unknown>).__html would be more conventional and marginally faster (no Reflect dispatch). Not blocking — the current code is correct.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 13, 2026

Review submitted. Here's a summary:

Approved. The fix correctly addresses the bug where dangerouslySetInnerHTML content was silently dropped during client head sync, breaking inline <style> and <script> in next/head after hydration. The implementation aligns with Next.js's reactElementToDOM(), all previous review feedback has been addressed, and tests pass cleanly (38 head tests + 858 shims tests).

Two non-blocking nits posted:

  1. HeadDOMElement type name could be more specific (e.g. HeadChildDOMElement) to avoid ambiguity with the <head> element itself.
  2. Reflect.get(value, "__html") in getDangerouslySetInnerHTML is unconventional when a plain property access would suffice.

github run

@james-elicx james-elicx merged commit ee261e3 into cloudflare:main May 13, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants