From a1a7919f74a0bebef1d1fa30a2d091a5ed5a8ce5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 13 Mar 2026 19:05:23 +0800 Subject: [PATCH] Better script handling. Dynamic scripts have script.async == true by default (we handled this correctly in the ScriptManager, but we didn't return the right value when .async was accessed). Inline scripts only consider direct children, not the entire tree. Empty inline scripts are executed at a later time if text is inserted into them --- src/browser/Page.zig | 20 +++++- src/browser/ScriptManager.zig | 15 ++++- .../tests/element/html/script/async_text.html | 61 +++++++++++++++++++ src/browser/webapi/CData.zig | 9 ++- src/browser/webapi/Node.zig | 13 ++++ src/browser/webapi/element/html/Script.zig | 12 +++- 6 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 src/browser/tests/element/html/script/async_text.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index f0c914ad..0599bab5 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1010,6 +1010,14 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele return; } + if (comptime from_parser) { + // parser-inserted scripts have force-async set to false, but only if + // they have src or non-empty content + if (script._src.len > 0 or script.asNode().firstChild() != null) { + script._force_async = false; + } + } + self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| { log.err(.page, "page.scriptAddedCallback", .{ .err = err, @@ -2643,6 +2651,8 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } } + const parent_is_connected = parent.isConnected(); + // Tri-state behavior for mutations: // 1. from_parser=true, parse_mode=document -> no mutations (initial document parse) // 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions) @@ -2658,6 +2668,15 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod // When the parser adds the node, nodeIsReady is only called when the // nodeComplete() callback is executed. try self.nodeIsReady(false, child); + + // Check if text was added to a script that hasn't started yet. + if (child._type == .cdata and parent_is_connected) { + if (parent.is(Element.Html.Script)) |script| { + if (!script._executed) { + try self.nodeIsReady(false, parent); + } + } + } } // Notify mutation observers about childList change @@ -2696,7 +2715,6 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } const parent_in_shadow = parent.is(ShadowRoot) != null or parent.isInShadowTree(); - const parent_is_connected = parent.isConnected(); if (!parent_in_shadow and !parent_is_connected) { return; diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 6f55f43b..2baeef8d 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -159,7 +159,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e // + + + + + + + + + diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 4fb6de6f..4e806c85 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -151,8 +151,13 @@ pub fn asNode(self: *CData) *Node { pub fn is(self: *CData, comptime T: type) ?*T { inline for (@typeInfo(Type).@"union".fields) |f| { - if (f.type == T and @field(Type, f.name) == self._type) { - return &@field(self._type, f.name); + if (@field(Type, f.name) == self._type) { + if (f.type == T) { + return &@field(self._type, f.name); + } + if (f.type == *T) { + return @field(self._type, f.name); + } } } return null; diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 3673a1e7..2eca4047 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -285,6 +285,19 @@ pub fn getTextContentAlloc(self: *Node, allocator: Allocator) error{WriteFailed} return data[0 .. data.len - 1 :0]; } +/// Returns the "child text content" which is the concatenation of the data +/// of all the Text node children of the node, in tree order. +/// This differs from textContent which includes all descendant text. +/// See: https://dom.spec.whatwg.org/#concept-child-text-content +pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void { + var it = self.childrenIterator(); + while (it.next()) |child| { + if (child.is(CData.Text)) |text| { + try writer.writeAll(text._proto._data.str()); + } + } +} + pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { switch (self._type) { .element => |el| { diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index ad3d9ef7..00860d5f 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -31,6 +31,8 @@ const Script = @This(); _proto: *HtmlElement, _src: []const u8 = "", _executed: bool = false, +// dynamic scripts are forced to be async by default +_force_async: bool = true, pub fn asElement(self: *Script) *Element { return self._proto._proto; @@ -83,10 +85,11 @@ pub fn setCharset(self: *Script, value: []const u8, page: *Page) !void { } pub fn getAsync(self: *const Script) bool { - return self.asConstElement().getAttributeSafe(comptime .wrap("async")) != null; + return self._force_async or self.asConstElement().getAttributeSafe(comptime .wrap("async")) != null; } pub fn setAsync(self: *Script, value: bool, page: *Page) !void { + self._force_async = false; if (value) { try self.asElement().setAttributeSafe(comptime .wrap("async"), .wrap(""), page); } else { @@ -136,7 +139,12 @@ pub const JsApi = struct { try self.asNode().getTextContent(&buf.writer); return buf.written(); } - pub const text = bridge.accessor(_innerText, Script.setInnerText, .{}); + pub const text = bridge.accessor(_text, Script.setInnerText, .{}); + fn _text(self: *Script, page: *const Page) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try self.asNode().getChildTextContent(&buf.writer); + return buf.written(); + } }; pub const Build = struct {