fix: Custom-element constructor parser endless recursion

In a custom element, when this.innerHTML = '....' is called, we need to be
careful to prevent endless recursion. The html5ever callback used to determine
the context element should not invoke the custom-element constructor, else we'll
enter an endless loop.

This also fixes an ungating problem added with the new HttpClient when a
waitForImport can block forever.

Both issues were see on a WooCommerce site - though the HttpClient is only
due to an earlier HttpClient refactor.
This commit is contained in:
Karl Seguin
2026-07-13 23:23:00 +08:00
parent 24d1051b1a
commit ca1e80a397
7 changed files with 136 additions and 7 deletions

View File

@@ -211,6 +211,12 @@ _customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Ele
// The constructor can access this to get the element being upgraded.
_upgrading_element: ?*Node = null,
// Set when materializing the fragment parser's context element. The element
// is never inserted into the tree so if its a custom element ,we must not run
// its constructor (else we'll end up in an endless loop if the constructor
// sets this.innerHTML = '...', which happens).
_skip_custom_element_upgrade: bool = false,
// List of custom elements that were created before their definition was registered
_undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{},

View File

@@ -829,6 +829,12 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
._definition = definition,
});
// Fragment-parse context element. It will not be inserted and
// we should not run the custom element's constructor.
if (frame._skip_custom_element_upgrade) {
return node;
}
const def = definition orelse {
const element = node.as(Element);
const custom = element.is(Element.Html.Custom).?;

View File

@@ -272,6 +272,7 @@ pub fn parseFragment(self: *Parser, html: []const u8) void {
&self.container,
self,
createElementCallback,
createContextElementCallback,
getDataCallback,
appendCallback,
parseErrorCallback,
@@ -439,6 +440,25 @@ fn createXMLElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualNa
return _createElementCallbackWithDefaultnamespace(ctx, data, qname, attributes, .xml);
}
// html5ever_parse_fragment materializes the fragment's context element through
// this dedicated callback, never through createElementCallback. The context
// element is a throwaway: html5ever only queries its name (and, for a
// <template> context, its content), it is never inserted into the tree. It
// must be built bare — running a custom element constructor here is unbounded
// recursion when the constructor sets innerHTML: that re-parse needs a context
// element of the same custom tag, reconstructing forever (stack overflow). No
// _ce_reactions window is needed: nothing can enqueue reactions on a bare
// create.
fn createContextElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator) callconv(.c) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
self.frame._skip_custom_element_upgrade = true;
defer self.frame._skip_custom_element_upgrade = false;
return self._createElementCallback(data, qname, attributes, .unknown) catch |err| {
self.err = .{ .err = err, .source = .create_element };
return null;
};
}
fn _createElementCallbackWithDefaultnamespace(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator, default_namespace: Element.Namespace) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();

View File

@@ -75,6 +75,7 @@ pub extern "c" fn html5ever_parse_fragment(
doc: *anyopaque,
ctx: *anyopaque,
createElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
createContextElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
elemNameCallback: *const fn (node_ref: *anyopaque) callconv(.c) *anyopaque,
appendCallback: *const fn (ctx: *anyopaque, parent_ref: *anyopaque, NodeOrText) callconv(.c) void,
parseErrorCallback: *const fn (ctx: *anyopaque, StringSlice) callconv(.c) void,

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="constructor_sets_innerhtml">
{
// A custom element whose constructor sets innerHTML. The innerHTML
// setter fragment-parses with this element as the parsing context.
// html5ever's parse_fragment builds a throwaway context element of the
// same tag via our create_element callback; if we run its constructor
// it sets innerHTML again -> re-parses -> rebuilds the context element
// -> constructs forever (stack overflow). The context element must be
// built bare, so the constructor runs exactly once.
let calls = 0;
class CeCtorInnerHtml extends HTMLElement {
constructor() {
super();
calls += 1;
this.innerHTML = '<span>hi</span>';
}
}
customElements.define('ce-ctor-innerhtml', CeCtorInnerHtml);
const el = document.createElement('ce-ctor-innerhtml');
testing.expectEqual(1, calls);
testing.expectEqual('<span>hi</span>', el.innerHTML);
}
</script>
<script id="innerhtml_on_custom_element_host">
{
// Setting innerHTML on an existing custom-element instance parses with
// that element as context. The throwaway context element html5ever
// builds must NOT invoke the constructor a second time.
let calls = 0;
class CeHost extends HTMLElement {
constructor() {
super();
calls += 1;
}
}
customElements.define('ce-host', CeHost);
const host = document.createElement('ce-host');
testing.expectEqual(1, calls);
host.innerHTML = '<b>x</b>';
testing.expectEqual(1, calls);
testing.expectEqual('<b>x</b>', host.innerHTML);
}
</script>
<script id="fragment_children_still_upgrade">
{
// Only the context element is skipped: real custom elements that appear
// *inside* the parsed fragment must still be constructed.
let calls = 0;
class CeChild extends HTMLElement {
constructor() {
super();
calls += 1;
}
}
customElements.define('ce-child', CeChild);
const container = document.createElement('div');
container.innerHTML = '<ce-child></ce-child><ce-child></ce-child>';
testing.expectEqual(2, calls);
testing.expectEqual(2, container.querySelectorAll('ce-child').length);
}
</script>

View File

@@ -29,9 +29,10 @@ use std::os::raw::{c_uchar, c_void};
use types::*;
use encoding_rs::Encoding;
use html5ever::driver::parse_fragment_for_element;
use html5ever::interface::tree_builder::QuirksMode;
use html5ever::tendril::{StrTendril, TendrilSink};
use html5ever::{ns, parse_document, parse_fragment, LocalName, ParseOpts, Parser, QualName};
use html5ever::{ns, parse_document, LocalName, ParseOpts, Parser, QualName};
#[no_mangle]
pub extern "C" fn html5ever_parse_document(
@@ -466,6 +467,7 @@ pub extern "C" fn html5ever_parse_fragment(
document: Ref,
ctx: Ref,
create_element_callback: CreateElementCallback,
create_context_element_callback: CreateElementCallback,
get_data_callback: GetDataCallback,
append_callback: AppendCallback,
parse_error_callback: ParseErrorCallback,
@@ -537,12 +539,27 @@ pub extern "C" fn html5ever_parse_fragment(
..Default::default()
};
parse_fragment(
let context_qname = QualName::new(None, ns!(html), context_local);
let context_data = arena.alloc(sink::ElementData {
qname: context_qname.clone(),
mathml_annotation_xml_integration_point: false,
});
let mut context_attrs = CAttributeIterator { vec: vec![], pos: 0 };
let context_elem = unsafe {
(create_context_element_callback)(
ctx,
context_data as *mut _ as *mut c_void,
CQualName::create(&context_qname),
&mut context_attrs as *mut _ as *mut c_void,
)
};
parse_fragment_for_element(
sink,
opts,
QualName::new(None, ns!(html), context_local),
vec![], // attributes
false, // context_element_allows_scripting
context_elem,
false, // context_element_allows_scripting
None, // form_element
)
.from_utf8()
.one(bytes);

View File

@@ -647,14 +647,24 @@ fn settleConns(self: *Client) !void {
fn dispatchCompleted(self: *Client, mode: DrainMode) void {
if (mode == .all) {
if (comptime IS_DEBUG) {
// .all never inside a syncRequest, so nothing can be gating requests.
std.debug.assert(self.blocking_requests.count() == 0);
}
// Called from the top; not inside a syncRequest. There cannot be a
// blocking request, so this is the time to process any gated requests.
}
// blocking_requests.count() is always true on tick(.all) but can also
// happen mid .sync_wait. This is important to do. During blocking, we gated
// every request. When the blocking requested completed, it only unblocked
// it's frames request.
if (self.blocking_requests.count() == 0) {
var node = self.gated_queue.last;
while (node) |n| {
node = n.prev;
const transfer: *Transfer = @fieldParentPtr("_queue_node", n);
if (mode == .sync_wait and self.isGated(transfer)) {
// still gated: a document is never delivered on a sync pump
continue;
}
transfer._gated = false;
self.gated_queue.remove(n);
self.dispatch_queue.prepend(n);