Remove thread local

Rework node.isConnected(), this now [correctly] returns true as long as a node
is part of _a_ document (it doesn't have to be the 'main' document). This
requires changes around id lookup optimization.
This commit is contained in:
Karl Seguin
2025-12-14 16:16:54 +08:00
parent 82cd5d4bab
commit f93403d3dc
8 changed files with 335 additions and 38 deletions

View File

@@ -64,7 +64,6 @@ const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
pub threadlocal var current: *Page = undefined;
var default_url = URL{ ._raw = "about:blank" };
pub var default_location: Location = Location{ ._url = &default_url };
@@ -171,7 +170,6 @@ pub fn init(arena: Allocator, call_arena: Allocator, session: *Session) !*Page {
page.scheduler = Scheduler.init(page.arena);
try page.reset(true);
current = page;
return page;
}
@@ -794,19 +792,25 @@ pub fn domChanged(self: *Page) void {
};
}
pub fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) {
if (node.is(ShadowRoot)) |shadow_root| {
fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) {
// Walk up the tree checking for ShadowRoot and tracking the root
var current = node;
while (true) {
if (current.is(ShadowRoot)) |shadow_root| {
return &shadow_root._elements_by_id;
}
var parent = node._parent;
while (parent) |n| {
if (n.is(ShadowRoot)) |shadow_root| {
return &shadow_root._elements_by_id;
}
parent = n._parent;
const parent = current._parent orelse {
if (current._type == .document) {
return &current._type.document._elements_by_id;
}
// Detached nodes should not have IDs registered
std.debug.assert(false);
return &page.document._elements_by_id;
};
current = parent;
}
}
pub fn addElementId(self: *Page, parent: *Node, element: *Element, id: []const u8) !void {
@@ -823,9 +827,19 @@ pub fn removeElementId(self: *Page, element: *Element, id: []const u8) void {
}
pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Element {
if (node.isConnected() or node.isInShadowTree()) {
const id_map = self.getElementIdMap(node);
return id_map.get(id);
}
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{});
while (tw.next()) |el| {
const element_id = el.getAttributeSafe("id") orelse continue;
if (std.mem.eql(u8, element_id, id)) {
return el;
}
}
return null;
}
pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void {
try self._mutation_observers.append(self.arena, observer);
@@ -1509,6 +1523,8 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
}
// grab this before we null the parent
const was_connected = child.isConnected();
// Capture the ID map before disconnecting, so we can remove IDs from the correct document
const id_map = if (was_connected) self.getElementIdMap(child) else null;
child._parent = null;
child._child_link = .{};
@@ -1537,7 +1553,7 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{});
while (tw.next()) |el| {
if (el.getAttributeSafe("id")) |id| {
self.removeElementId(el, id);
_ = id_map.?.remove(id);
}
Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self);
@@ -1588,7 +1604,10 @@ const InsertNodeRelative = union(enum) {
after: *Node,
before: *Node,
};
const InsertNodeOpts = struct { child_already_connected: bool = false };
const InsertNodeOpts = struct {
child_already_connected: bool = false,
adopting_to_new_document: bool = false,
};
pub fn insertNodeRelative(self: *Page, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
return self._insertNodeRelative(false, parent, child, relative, opts);
}
@@ -1666,22 +1685,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
if (comptime from_parser) {
if (child.is(Element)) |el| {
if (el.getAttributeSafe("id")) |id| {
try self.addElementId(parent, el, id);
}
// Invoke connectedCallback for custom elements during parsing
// For main document parsing, we know nodes are connected (fast path)
// For fragment parsing (innerHTML), we need to check connectivity
if (self._parse_mode == .document or child.isConnected()) {
if (child.isConnected() or child.isInShadowTree()) {
if (el.getAttributeSafe("id")) |id| {
try self.addElementId(parent, el, id);
}
try Element.Html.Custom.invokeConnectedCallbackOnElement(true, el, self);
}
}
return;
}
if (opts.child_already_connected) {
// The child is already connected, we don't have to reconnect it
if (opts.child_already_connected and !opts.adopting_to_new_document) {
// The child is already connected in the same document, we don't have to reconnect it
return;
}

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<body></body>
<script id=basic>
{
@@ -118,3 +119,121 @@
});
}
</script>
<script id=getElementById>
{
const doc = new DOMParser().parseFromString('<div id="new-node">new-node</div>', 'text/html');
testing.expectEqual('new-node', doc.getElementById('new-node').textContent);
}
</script>
<script id=getElementById_isolationBetweenDocuments>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="shared-id">From parsed doc</div>', 'text/html');
// Create element with same ID in main document
const mainEl = document.createElement('div');
mainEl.id = 'shared-id';
mainEl.textContent = 'From main doc';
document.body.appendChild(mainEl);
// Each document should find its own element
const mainFound = document.getElementById('shared-id');
testing.expectEqual('From main doc', mainFound.textContent);
const parsedFound = doc.getElementById('shared-id');
testing.expectEqual('From parsed doc', parsedFound.textContent);
// Clean up
mainEl.remove();
}
</script>
<script id=getElementById_afterSettingId>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<div>No ID initially</div>', 'text/html');
const div = doc.querySelector('div');
// Should not find it yet
testing.expectEqual(null, doc.getElementById('new-id'));
// Set ID via JavaScript
div.id = 'new-id';
// Should now find it
const found = doc.getElementById('new-id');
testing.expectEqual(div, found);
}
</script>
<script id=getElementById_afterRemovingId>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="remove-me">Content</div>', 'text/html');
// Should find it initially
const div = doc.getElementById('remove-me');
testing.expectEqual('Content', div.textContent);
// Remove the ID
div.removeAttribute('id');
// Should not find it anymore
testing.expectEqual(null, doc.getElementById('remove-me'));
}
</script>
<script id=getElementById_afterChangingId>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="old-id">Content</div>', 'text/html');
const div = doc.querySelector('div');
// Change the ID
div.id = 'new-id';
// Should not find old ID
testing.expectEqual(null, doc.getElementById('old-id'));
// Should find new ID
const found = doc.getElementById('new-id');
testing.expectEqual(div, found);
}
</script>
<script id=getElementById_detachedFromParsedDoc>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="will-detach">Content</div>', 'text/html');
const div = doc.querySelector('div');
// Should find it while connected
testing.expectEqual(div, doc.getElementById('will-detach'));
// Remove it from the document
div.remove();
// Should not find it after removal
testing.expectEqual(null, doc.getElementById('will-detach'));
}
</script>
<script id=getElementById_multipleDocuments>
{
const parser = new DOMParser();
const doc1 = parser.parseFromString('<div id="doc1-el">Doc 1</div>', 'text/html');
const doc2 = parser.parseFromString('<div id="doc2-el">Doc 2</div>', 'text/html');
// Each document should only find its own element
testing.expectEqual('Doc 1', doc1.getElementById('doc1-el').textContent);
testing.expectEqual(null, doc1.getElementById('doc2-el'));
testing.expectEqual('Doc 2', doc2.getElementById('doc2-el').textContent);
testing.expectEqual(null, doc2.getElementById('doc1-el'));
}
</script>

View File

@@ -17,6 +17,13 @@
<template id="empty"></template>
<script id=ids>
testing.expectEqual(null, document.querySelector('#inner1'));
testing.expectEqual(null, document.getElementById('inner1'));
testing.expectEqual('First', document.getElementById('nested').content.getElementById('inner1').textContent);
testing.expectEqual('First', document.getElementById('nested').content.querySelector('#inner1').textContent);
</script>
<script id=content_property>
{
const template = $('#basic');
@@ -205,4 +212,4 @@
// NOT the DocumentFragment, so it should be empty
testing.expectEqual('', template.textContent);
}
</script>
</script> -->

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body>
<div id="test-content">
<p id="p1">Connected paragraph</p>
</div>
<script id=isConnected_elementsInMainDocument>
{
const p1 = document.getElementById('p1');
testing.expectEqual(true, p1.isConnected);
const body = document.body;
testing.expectEqual(true, body.isConnected);
const html = document.documentElement;
testing.expectEqual(true, html.isConnected);
}
</script>
<script id=isConnected_detachedElement>
{
const div = document.createElement('div');
testing.expectEqual(false, div.isConnected);
// Even if it has content, still not connected
div.textContent = 'Hello';
testing.expectEqual(false, div.isConnected);
}
</script>
<script id=isConnected_afterAppending>
{
const div = document.createElement('div');
testing.expectEqual(false, div.isConnected);
document.body.appendChild(div);
testing.expectEqual(true, div.isConnected);
// Remove it
div.remove();
testing.expectEqual(false, div.isConnected);
}
</script>
<script id=isConnected_nestedDetachedElements>
{
const parent = document.createElement('div');
const child = document.createElement('span');
parent.appendChild(child);
// Neither should be connected
testing.expectEqual(false, parent.isConnected);
testing.expectEqual(false, child.isConnected);
// Add parent to document
document.body.appendChild(parent);
// Now both should be connected
testing.expectEqual(true, parent.isConnected);
testing.expectEqual(true, child.isConnected);
}
</script>
<script id=isConnected_parsedDocument>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="parsed">Parsed content</div>', 'text/html');
// Use querySelector instead of getElementById for now
const div = doc.querySelector('div');
// CRITICAL: Elements in a parsed document should be connected to their own document
testing.expectEqual(true, div.isConnected);
const body = doc.body;
testing.expectEqual(true, body.isConnected);
}
</script>
<script id=isConnected_documentNode>
{
// The document itself should be connected
testing.expectEqual(true, document.isConnected);
// A parsed document should also be connected (to itself)
const parser = new DOMParser();
const doc = parser.parseFromString('<div>test</div>', 'text/html');
testing.expectEqual(true, doc.isConnected);
}
</script>
<script id=isConnected_removedFromParsedDoc>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="test">Content</div>', 'text/html');
// Use querySelector instead of getElementById for now
const div = doc.querySelector('div');
testing.expectEqual(true, div.isConnected);
// Remove it from the parsed document
div.remove();
testing.expectEqual(false, div.isConnected);
}
</script>
</body>

View File

@@ -470,4 +470,3 @@
testing.expectEqual(null, iterator.nextNode());
}
</script>

View File

@@ -1,3 +1,4 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id=container></div>

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="target-container">
<p id="reference-node">
I am the original reference node.
</p>
</div>
<script id=owner>
const parser = new DOMParser();
const newDoc = parser.parseFromString('<div id="new-node"><p>Hey</p><span>Marked</span></div>', 'text/html');
const newNode = newDoc.getElementById('new-node');
const parent = $('#target-container');
const referenceNode = $('#reference-node');
parent.insertBefore(newNode, referenceNode);
const k = $('#new-node');
const ptag = k.querySelector('p');
const spanTag = k.querySelector('span');
const anotherDoc = parser.parseFromString('<div id="another-new-node"></div>', 'text/html');
const anotherNewNode = anotherDoc.getElementById('another-new-node');
testing.expectEqual('[object HTMLDivElement]', parent.appendChild(anotherNewNode).toString());
testing.expectEqual(newNode.ownerDocument, parent.ownerDocument);
testing.expectEqual(anotherNewNode.ownerDocument, parent.ownerDocument);
testing.expectEqual('P', newNode.firstChild.nodeName);
testing.expectEqual(parent.ownerDocument, ptag.ownerDocument);
testing.expectEqual(parent.ownerDocument, spanTag.ownerDocument);
testing.expectEqual(true, parent.contains(newNode));
testing.expectEqual(true, parent.contains(anotherNewNode));
testing.expectEqual(false, anotherDoc.contains(anotherNewNode));
testing.expectEqual(false, newDoc.contains(newNode));
</script>

View File

@@ -202,6 +202,10 @@ pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {
// then we can remove + add a bit more efficiently (we don't have to fully
// disconnect then reconnect)
const child_connected = child.isConnected();
// Check if we're adopting the node to a different document
const child_root = child.getRootNode(null);
const parent_root = self.getRootNode(null);
const adopting_to_new_document = child_connected and child_root != parent_root;
if (child._parent) |parent| {
// we can signal removeNode that the child will remain connected
@@ -209,7 +213,10 @@ pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {
page.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() });
}
try page.appendNode(self, child, .{ .child_already_connected = child_connected });
try page.appendNode(self, child, .{
.child_already_connected = child_connected,
.adopting_to_new_document = adopting_to_new_document,
});
return child;
}
@@ -319,19 +326,14 @@ pub fn isInShadowTree(self: *Node) bool {
}
pub fn isConnected(self: *const Node) bool {
const target = Page.current.document.asNode();
if (self == target) {
return true;
// Walk up to find the root node
var root = self;
while (root._parent) |parent| {
root = parent;
}
var node = self._parent;
while (node) |n| {
if (n == target) {
return true;
}
node = n._parent;
}
return false;
// A node is connected if its root is a document
return root._type == .document;
}
const GetRootNodeOpts = struct {
@@ -432,6 +434,10 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page
}
const child_already_connected = new_node.isConnected();
// Check if we're adopting the node to a different document
const child_root = new_node.getRootNode(null);
const parent_root = self.getRootNode(null);
const adopting_to_new_document = child_already_connected and child_root != parent_root;
page.domChanged();
const will_be_reconnected = self.isConnected();
@@ -443,7 +449,10 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page
self,
new_node,
.{ .before = ref_node },
.{ .child_already_connected = child_already_connected },
.{
.child_already_connected = child_already_connected,
.adopting_to_new_document = adopting_to_new_document,
},
);
return new_node;