Merge pull request #1811 from lightpanda-io/script_handling
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled

Better script handling.
This commit is contained in:
Karl Seguin
2026-03-13 21:40:19 +08:00
committed by GitHub
6 changed files with 123 additions and 7 deletions

View File

@@ -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;

View File

@@ -159,7 +159,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
// <script> has already been processed.
return;
}
script_element._executed = true;
const element = script_element.asElement();
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
@@ -204,10 +203,22 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
source = .{ .remote = .{} };
}
} else {
const inline_source = try element.asNode().getTextContentAlloc(page.arena);
var buf = std.Io.Writer.Allocating.init(page.arena);
try element.asNode().getChildTextContent(&buf.writer);
try buf.writer.writeByte(0);
const data = buf.written();
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
if (inline_source.len == 0) {
// we haven't set script_element._executed = true yet, which is good.
// If content is appended to the script, we will execute it then.
return;
}
source = .{ .@"inline" = inline_source };
}
// Only set _executed (already-started) when we actually have content to execute
script_element._executed = true;
const script = try self.script_pool.create();
errdefer self.script_pool.destroy(script);

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<script id=force_async>
{
// Dynamically created scripts have async=true by default
let s = document.createElement('script');
testing.expectEqual(true, s.async);
// Setting async=false clears the force async flag and removes attribute
s.async = false;
testing.expectEqual(false, s.async);
testing.expectEqual(false, s.hasAttribute('async'));
// Setting async=true adds the attribute
s.async = true;
testing.expectEqual(true, s.async);
testing.expectEqual(true, s.hasAttribute('async'));
}
</script>
<script></script>
<script id=empty>
{
// Empty parser-inserted script should have async=true (force async retained)
let scripts = document.getElementsByTagName('script');
let emptyScript = scripts[scripts.length - 2];
testing.expectEqual(true, emptyScript.async);
}
</script>
<script id=text_content>
{
let s = document.createElement('script');
s.appendChild(document.createComment('COMMENT'));
s.appendChild(document.createTextNode(' TEXT '));
s.appendChild(document.createProcessingInstruction('P', 'I'));
let a = s.appendChild(document.createElement('a'));
a.appendChild(document.createTextNode('ELEMENT'));
// script.text should return only direct Text node children
testing.expectEqual(' TEXT ', s.text);
// script.textContent should return all descendant text
testing.expectEqual(' TEXT ELEMENT', s.textContent);
}
</script>
<script id=lazy_inline>
{
// Empty script in DOM, then append text - should execute
window.lazyScriptRan = false;
let s = document.createElement('script');
document.head.appendChild(s);
// Script is in DOM but empty, so not yet executed
testing.expectEqual(false, window.lazyScriptRan);
// Append text node with code
s.appendChild(document.createTextNode('window.lazyScriptRan = true;'));
// Now it should have executed
testing.expectEqual(true, window.lazyScriptRan);
}
</script>

View File

@@ -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;

View File

@@ -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| {

View File

@@ -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 {