Making JavaScript-heavy pages behave like pages

Lightpanda Browser’s next boundary is inside the document: how parsed bytes become a DOM that scripts can observe, mutate, and continue to drive. The focus is the lifecycle connecting html5ever callbacks, DOM construction, script scheduling, V8 contexts, origins, and event-loop work. Understanding that lifecycle lets you separate “the JavaScript engine ran” from the stronger claim that a dynamic page behaved like a browser page.

The parser builds a live object graph

Lightpanda uses the Rust html5ever parser through a Zig wrapper. The important design choice is what happens at the boundary. The parser does not build an isolated tree that is handed to the browser after parsing finishes. It calls back into Lightpanda to create elements, append nodes and text, add attributes, handle comments and doctypes, reparent children, and attach declarative shadow roots. Those callbacks create the native DOM objects that JavaScript will later see.

That makes parsing eventful. When the parser inserts an element, the browser may need to recognize a custom element, queue a reaction, or start work associated with a script, iframe, image, or stylesheet. When html5ever needs foster parenting or a node is moved, the wrapper must update the native tree without losing the parser’s insertion semantics. The parser and DOM are therefore two cooperating state machines: one decides what the markup means, and the other owns the objects and side effects that make that meaning observable.

The same integration supports more than full-document navigation. Lightpanda has separate paths for fragments, XML, and streaming input. A fragment used by innerHTML should not behave exactly like a document navigation. A context element can affect how the fragment is tokenized, while a document without a browsing context has different custom-element rules. Declarative shadow roots also need a controlled insertion path so their contents become a shadow tree rather than ordinary light DOM.

Streaming parsing exposes the hardest case: JavaScript can call document.write() while the parser is already processing input. A non-reentrant parser cannot safely call itself recursively at that point. Lightpanda queues the new bytes and lets the active feed loop consume them at the correct insertion point. That preserves the parser’s internal state while still allowing the page to affect its own input, a behavior that a static “parse first, execute later” model would miss.

The wrapper also polls for V8 termination during append work. Parsing an unexpectedly large or self-expanding input cannot rely on V8 to interrupt every native callback immediately. The parser periodically checks whether execution should stop and records termination rather than continuing to build an unbounded tree.

This callback model also explains why DOM construction can trigger work before parsing has finished. A custom element may react when it is inserted. A script element may initiate a fetch or execute inline code. An iframe may create a child frame whose load contributes to the parent’s pending work. A fragment inserted through innerHTML may need different script and custom-element behavior from a document loaded by navigation.