Skip to content

Preserve character data and handle void elements in innerHTML parsing - #685

Open
olavoasantos wants to merge 1 commit into
mainfrom
preserve-html-parser-data
Open

olavoasantos wants to merge 1 commit into
mainfrom
preserve-html-parser-data

Conversation

@olavoasantos

@olavoasantos olavoasantos commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Problem

The limited innerHTML parser discarded literal ampersands, did not decode character references, and stored encoded attribute values such that serialization could escape them again. It also treated HTML void elements as normal parents, nesting following content beneath them and emitting invented closing tags.

Impact

Important. Ordinary valid HTML assigned through the exposed innerHTML path could lose text data, produce a different tree, or serialize to different markup. In a worker-side Remote DOM tree, that means consumers can make decisions from content that does not match the markup they assigned.

Reproduction

const element = document.createElement('div');

element.innerHTML = '<p>Fish & chips</p><img src="logo.png"><p>After</p>';

Before this change, the literal & was dropped and the second paragraph was parsed beneath img, which was serialized with a closing tag. It now retains Fish & chips, keeps img and both paragraphs as siblings, and serializes the void element as <img src="logo.png">.

Change

  • Decode the parser's supported named and numeric character references while preserving literal ampersands and unrecognized references.
  • Decode attribute references before storing them so serialization escapes the value once.
  • Keep tokenizers local to each parse call, preventing nested innerHTML parsing from corrupting an outer parse.
  • Treat the defined HTML void-element set as childless during parsing and serialization.

This is a targeted correction to the existing limited parser, not browser-grade entity coverage or malformed-markup recovery.

Tests

Adds packages/polyfill/source/tests/inner-html-parsing.test.ts, covering literal ampersands; the supported named, decimal, and hexadecimal references; attribute round-tripping; reentrant parsing from an attribute callback; void-element sibling structure; self-closing void syntax; and closing-tag omission for every supported HTML void element.

Stack

Validation

Fresh GitHub CI on restacked head 1b7881e passes:

  • lint;
  • type-check and build;
  • unit tests with coverage;
  • bundle-size checks;
  • Playwright end-to-end tests;
  • the classified Web Platform Test suite.

@olavoasantos
olavoasantos force-pushed the preserve-html-parser-data branch from 041ee09 to f922a66 Compare September 3, 2026 15:29
@olavoasantos
olavoasantos changed the base branch from deliberate-document-cloning to normalize-dom-namespaces September 3, 2026 15:30
@olavoasantos
olavoasantos force-pushed the preserve-html-parser-data branch from f922a66 to 1b7881e Compare September 4, 2026 14:31
@olavoasantos
olavoasantos marked this pull request as ready for review September 14, 2026 18:41
@henrytao-me

Copy link
Copy Markdown
Member

The parser and reentrancy fixes check out. I found two focused corrections when comparing native Chrome 152, the namespace base, and head 1b7881e:

1. Classify void elements by namespace and exact local name

The new serialization check drops children based only on a lowercased qualified name:

const node = document.createElementNS('urn:widget', 'input');
node.append('must survive');
node.outerHTML;
  • Native and base: <input>must survive</input>.
  • This PR: <input>.

The same introduced content loss affects HTML-namespace elements created with established local names INPUT or Input; native preserves their children. Conversely, the name-only check misses a prefixed HTML void element such as h:br (that variant was already incorrect on the base).

Could we use one element-aware predicate for both parsing and serialization?

function isVoidElement(element: Element) {
  return (
    element.namespaceURI === HTML_NAMESPACE &&
    VOID_ELEMENTS.has(element.localName)
  );
}

Pass the created node during parsing and the element during serialization. createElement() already normalizes parsed HTML names, so the predicate doesn't need to lowercase established DOM names.

Please add regression coverage for foreign-namespace input/br/source, HTML-namespace INPUT/Input, and prefixed HTML h:br, alongside the existing lowercase HTML void cases.

2. Preserve unrecognized named-entity spellings

The decoder's /gi matching and toLowerCase() accept spellings that aren't HTML character references:

const element = document.createElement('div');
element.innerHTML = '<p>&aMp; &APOS;</p>';
element.textContent;
  • Native: &aMp; &APOS;.
  • This PR: & '.

Please match the exact supported named spellings and leave unknown ones literal. Include valid uppercase aliases such as &AMP; explicitly rather than accepting arbitrary casing; retain case-insensitive hexadecimal digits independently. Add text and attribute cases for these spellings.

This second issue is a gap in the new decoder, not a claim that the base handled these inputs correctly (it dropped their ampersands). Neither correction requires expanding this into a browser-grade parser.

}

export function parseHtml(html: string, contextNode: Node) {
const elementTokenizer =

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: Hoisting regexes out of hot functions is typically good for perf.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yea, hoisted the tokenizer regexes and switched the loops to matchAll() so the global regex state remains isolated during reentrant parsing.

@olavoasantos
olavoasantos force-pushed the preserve-html-parser-data branch from 1b7881e to 958e38f Compare September 16, 2026 13:57
@olavoasantos
olavoasantos force-pushed the preserve-html-parser-data branch from 958e38f to 798dec7 Compare September 16, 2026 14:08
@olavoasantos

Copy link
Copy Markdown
Contributor Author

Fixed both. Void classification now uses the element namespace and exact localName, so foreign and established mixed-case elements keep their children while prefixed HTML void elements still use their local name. Named references now match only the exact aliases we support; arbitrary casing stays literal while hexadecimal digits remain case-insensitive.

@olavoasantos
olavoasantos force-pushed the preserve-html-parser-data branch from 798dec7 to fd4de61 Compare September 16, 2026 20:13
@olavoasantos
olavoasantos removed this pull request from stack #701 September 16, 2026 20:15
@olavoasantos
olavoasantos changed the base branch from normalize-dom-namespaces to main September 16, 2026 20:15
@olavoasantos
olavoasantos added this pull request to stack #718 September 16, 2026 20:15
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.

4 participants