Scripts now properly block rendering

Re-enabled CDP tests

Fixed more tests
This commit is contained in:
Karl Seguin
2025-10-29 16:37:11 +08:00
parent 1a04ebce35
commit fb9cce747d
14 changed files with 175 additions and 205 deletions

View File

@@ -130,16 +130,6 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
if (event._bubbles == false) {
event._event_phase = .at_target;
const target_et = target.asEventTarget();
if (self.lookup.getPtr(@intFromPtr(target_et))) |list| {
try self.dispatchPhase(list, target_et, event, null);
}
event._event_phase = .none;
return;
}
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
@@ -150,7 +140,8 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
path_len += 1;
}
// Even though the window isn't part of the DOM, events bubble to it
// Even though the window isn't part of the DOM, events always propagate
// through it in the capture phase
if (path_len < path_buffer.len) {
path_buffer[path_len] = self.page.window.asEventTarget();
path_len += 1;
@@ -159,6 +150,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
const path = path_buffer[0..path_len];
// Phase 1: Capturing phase (root → target, excluding target)
// This happens for all events, regardless of bubbling
event._event_phase = .capturing_phase;
var i: usize = path_len;
while (i > 1) {
@@ -173,6 +165,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
}
}
// Phase 2: At target
event._event_phase = .at_target;
const target_et = target.asEventTarget();
if (self.lookup.getPtr(@intFromPtr(target_et))) |list| {
@@ -183,12 +176,16 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
}
}
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (self.lookup.getPtr(@intFromPtr(current_target))) |list| {
try self.dispatchPhase(list, current_target, event, false);
if (event._stop_propagation) {
break;
// Phase 3: Bubbling phase (target → root, excluding target)
// This only happens if the event bubbles
if (event._bubbles) {
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (self.lookup.getPtr(@intFromPtr(current_target))) |list| {
try self.dispatchPhase(list, current_target, event, false);
if (event._stop_propagation) {
break;
}
}
}
}

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const js = @import("js/js.zig");
const log = @import("../log.zig");
@@ -31,11 +32,13 @@ const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator;
const ArrayListUnmanaged = std.ArrayListUnmanaged;
const IS_DEBUG = builtin.mode == .Debug;
const ScriptManager = @This();
page: *Page,
// used to prevent recursive evalutaion
// used to prevent recursive evaluation
is_evaluating: bool,
// Only once this is true can deferred scripts be run
@@ -45,9 +48,6 @@ static_scripts_done: bool,
// on shutdown/abort, we need to cleanup any pending ones.
asyncs: OrderList,
// Normal scripts (non-deferred & non-async). These must be executed in order
scripts: OrderList,
// List of deferred scripts. These must be executed in order, but only once
// dom_loaded == true,
deferreds: OrderList,
@@ -85,7 +85,6 @@ pub fn init(page: *Page) ScriptManager {
return .{
.page = page,
.asyncs = .{},
.scripts = .{},
.deferreds = .{},
.importmap = .empty,
.sync_modules = .empty,
@@ -130,7 +129,6 @@ pub fn reset(self: *ScriptManager) void {
self.importmap = .empty;
self.clearList(&self.asyncs);
self.clearList(&self.scripts);
self.clearList(&self.deferreds);
self.static_scripts_done = false;
}
@@ -212,57 +210,78 @@ pub fn add(self: *ScriptManager, script_element: *Element.Html.Script, comptime
.is_async = if (remote_url == null) false else element.getAttributeSafe("async") != null,
};
if (source == .@"inline" and self.scripts.first == null) {
// inline script with no pending scripts, execute it immediately.
// (if there is a pending script, then we cannot execute this immediately
// as it needs to be executed in order)
if (source == .@"inline") {
// inline script gets executed immediately
return script.eval(page);
}
const pending_script = try self.script_pool.create();
errdefer self.script_pool.destroy(pending_script);
pending_script.* = .{
.script = script,
.complete = false,
.manager = self,
.node = .{},
const pending_script = blk: {
// Done in a block this way so that, if something fails in this block
// it's cleaned up with these errdefers
// BUT, if we need to load/execute the script immediately, cleanup/lifetimes
// become the responsibility of the outer block.
const pending_script = try self.script_pool.create();
errdefer self.script_pool.destroy(pending_script);
pending_script.* = .{
.script = script,
.complete = false,
.manager = self,
.node = .{},
};
errdefer pending_script.deinit();
if (comptime IS_DEBUG) {
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url.?,
.stack = page.js.stackTrace() catch "???",
});
}
var headers = try self.client.newHeaders();
try page.requestCookie(.{}).headersForRequest(page.arena, remote_url.?, &headers);
try self.client.request(.{
.url = remote_url.?,
.ctx = pending_script,
.method = .GET,
.headers = headers,
.resource_type = .script,
.cookie_jar = &page._session.cookie_jar,
.start_callback = if (log.enabled(.http, .debug)) startCallback else null,
.header_callback = headerCallback,
.data_callback = dataCallback,
.done_callback = doneCallback,
.error_callback = errorCallback,
});
if (script.is_defer) {
// non-blocking loading, track the list this belongs to, and return
pending_script.list = &self.deferreds;
return;
}
if (script.is_async) {
// non-blocking loading, track the list this belongs to, and return
pending_script.list = &self.asyncs;
return;
}
break :blk pending_script;
};
if (source == .@"inline") {
// if we're here, it means that we have pending scripts (i.e. self.scripts
// is not empty). Because the script is inline, it's complete/ready, but
// we need to process them in order
pending_script.complete = true;
self.scripts.append(&pending_script.node);
return;
} else {
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url.?,
.stack = page.js.stackTrace() catch "???",
});
defer pending_script.deinit();
// this is <script src="..."></script>, it needs to block the caller
// until it's evaluated
var client = self.client;
while (true) {
if (pending_script.complete) {
return pending_script.script.eval(page);
}
_ = try client.tick(200);
}
pending_script.getList().append(&pending_script.node);
errdefer pending_script.deinit();
var headers = try self.client.newHeaders();
try page.requestCookie(.{}).headersForRequest(page.arena, remote_url.?, &headers);
try self.client.request(.{
.url = remote_url.?,
.ctx = pending_script,
.method = .GET,
.headers = headers,
.resource_type = .script,
.cookie_jar = &page._session.cookie_jar,
.start_callback = if (log.enabled(.http, .debug)) startCallback else null,
.header_callback = headerCallback,
.data_callback = dataCallback,
.done_callback = doneCallback,
.error_callback = errorCallback,
});
}
// Resolve a module specifier to an valid URL.
@@ -394,6 +413,7 @@ pub fn getAsyncModule(self: *ScriptManager, url: [:0]const u8, cb: AsyncModule.C
.error_callback = AsyncModule.errorCallback,
});
}
pub fn pageIsLoaded(self: *ScriptManager) void {
std.debug.assert(self.static_scripts_done == false);
self.static_scripts_done = true;
@@ -415,15 +435,6 @@ fn evaluate(self: *ScriptManager) void {
self.is_evaluating = true;
defer self.is_evaluating = false;
while (self.scripts.first) |n| {
var pending_script: *PendingScript = @fieldParentPtr("node", n);
if (pending_script.complete == false) {
return;
}
defer pending_script.deinit();
pending_script.script.eval(page);
}
if (self.static_scripts_done == false) {
// We can only execute deferred scripts if
// 1 - all the normal scripts are done
@@ -460,7 +471,6 @@ fn evaluate(self: *ScriptManager) void {
pub fn isDone(self: *const ScriptManager) bool {
return self.asyncs.first == null and // there are no more async scripts
self.static_scripts_done and // and we've finished parsing the HTML to queue all <scripts>
self.scripts.first == null and // and there are no more <script src=> to wait for
self.deferreds.first == null; // and there are no more <script defer src=> to wait for
}
@@ -536,12 +546,13 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
// A script which is pending execution.
// It could be pending because:
// (a) we're still downloading its content or
// (b) this is a non-async script that has to be executed in order
// (b) it's a deferred script which has to be executed in order
pub const PendingScript = struct {
script: Script,
complete: bool,
node: OrderList.Node,
manager: *ScriptManager,
list: ?*std.DoublyLinkedList = null,
fn deinit(self: *PendingScript) void {
const script = &self.script;
@@ -550,14 +561,11 @@ pub const PendingScript = struct {
if (script.source == .remote) {
manager.buffer_pool.release(script.source.remote);
}
self.getList().remove(&self.node);
}
fn remove(self: *PendingScript) void {
if (self.node) |*node| {
self.getList().remove(node);
self.node = null;
if (self.list) |list| {
list.remove(&self.node);
}
manager.script_pool.destroy(self);
}
fn startCallback(self: *PendingScript, transfer: *Http.Transfer) !void {
@@ -614,6 +622,7 @@ pub const PendingScript = struct {
manager.evaluate();
return;
}
// async script can be evaluated immediately
self.script.eval(manager.page);
self.deinit();
@@ -634,23 +643,6 @@ pub const PendingScript = struct {
manager.evaluate();
}
fn getList(self: *const PendingScript) *OrderList {
// When a script has both the async and defer flag set, it should be
// treated as async. Async is newer, so some websites use both so that
// if async isn't known, it'll fallback to defer.
const script = &self.script;
if (script.is_async) {
return &self.manager.asyncs;
}
if (script.is_defer) {
return &self.manager.deferreds;
}
return &self.manager.scripts;
}
};
const Script = struct {

View File

@@ -571,7 +571,9 @@ pub fn mapZigInstanceToJs(self: *Context, js_obj_: ?v8.Object, value: anytype) !
return self.mapZigInstanceToJs(js_obj_, heap);
},
.pointer => |ptr| {
const gop = try self.identity_map.getOrPut(arena, @intFromPtr(value));
const resolved = resolveValue(value);
const gop = try self.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
if (gop.found_existing) {
// we've seen this instance before, return the same
// PersistentObject.
@@ -579,8 +581,6 @@ pub fn mapZigInstanceToJs(self: *Context, js_obj_: ?v8.Object, value: anytype) !
}
const isolate = self.isolate;
const resolved = resolveValue(value);
// Sometimes we're creating a new v8.Object, like when
// we're returning a value from a function. In those cases
// we have to get the object template, and we can get an object
@@ -1874,11 +1874,6 @@ fn zigJsonToJs(isolate: v8.Isolate, v8_context: v8.Context, value: std.json.Valu
}
}
pub fn getGlobalThis(self: *Context) js.This {
const js_global = self.v8_context.getGlobal();
return .{ .obj = .{ .js_obj = js_global, .context = self } };
}
// == Misc ==
// An interface for types that want to have their jsDeinit function to be
// called when the call context ends

View File

@@ -241,7 +241,11 @@ pub fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.Funct
bridge.Function => {
const function_template = v8.FunctionTemplate.initCallback(isolate, value.func);
const js_name: v8.Name = v8.String.initUtf8(isolate, name).toName();
template_proto.set(js_name, function_template, v8.PropertyAttribute.None);
if (value.static) {
template.set(js_name, function_template, v8.PropertyAttribute.None);
} else {
template_proto.set(js_name, function_template, v8.PropertyAttribute.None);
}
},
bridge.Indexed => {
const configuration = v8.IndexedPropertyHandlerConfiguration{

View File

@@ -85,6 +85,7 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool, global_cal
defer temp_scope.deinit();
const js_global = v8.FunctionTemplate.initDefault(isolate);
js_global.setClassName(v8.String.initUtf8(isolate, "Window"));
Env.attachClass(@TypeOf(page.window.*).JsApi, isolate, js_global);
const global_template = js_global.getInstanceTemplate();
@@ -205,38 +206,6 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool, global_cal
// }
// }
// @ZIGDOM
// Primitive attributes are set directly on the FunctionTemplate
// when we setup the environment. But we cannot set more complex
// types (v8 will crash).
//
// Plus, just to create more complex types, we always need a
// context, i.e. an Array has to have a Context to exist.
//
// As far as I can tell, getting the FunctionTemplate's object
// and setting values directly on it, for each context, is the
// way to do this.
// inline for (JsApis, 0..) |Jsapi, i| {
// inline for (@typeInfo(Struct).@"struct".decls) |declaration| {
// const name = declaration.name;
// if (comptime name[0] == '_') {
// const value = @field(Struct, name);
// if (comptime js.isComplexAttributeType(@typeInfo(@TypeOf(value)))) {
// const js_obj = templates[i].getFunction(v8_context).toObject();
// const js_name = v8.String.initUtf8(isolate, name[1..]).toName();
// const js_val = try context.zigValueToJs(value);
// if (!js_obj.setValue(v8_context, js_name, js_val)) {
// log.fatal(.app, "set class attribute", .{
// .@"struct" = @typeName(Struct),
// .name = name,
// });
// }
// }
// }
// }
// }
try context.setupGlobal();
return context;
}

View File

@@ -105,6 +105,7 @@ pub const Constructor = struct {
};
pub const Function = struct {
static: bool,
func: *const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void,
const Opts = struct {
@@ -115,27 +116,30 @@ pub const Function = struct {
};
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Function {
return .{ .func = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
defer caller.deinit();
return .{
.static = opts.static,
.func = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
defer caller.deinit();
if (comptime opts.static) {
caller.function(T, func, info, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, func, info, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
if (comptime opts.static) {
caller.function(T, func, info, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, func, info, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
}
}
}.wrap };
}.wrap,
};
}
};

View File

@@ -498,5 +498,5 @@ pub export fn v8_inspector__Client__IMPL__descriptionForValueSubtype(
test "TaggedAnyOpaque" {
// If we grow this, fine, but it should be a conscious decision
try std.testing.expectEqual(16, @sizeOf(TaggedAnyOpaque));
try std.testing.expectEqual(24, @sizeOf(TaggedAnyOpaque));
}

View File

@@ -10,7 +10,7 @@
<script id=document>
testing.expectEqual(null, document.parentNode);
testing.expectEqual(undefined, document.getCurrentScript);
testing.expectEqual("http://localhost:9601/src/browser/tests/document/document.html", document.URL);
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/document/document.html", document.URL);
</script>
<script id=headAndbody>

View File

@@ -117,7 +117,7 @@
parent.dispatchEvent(new Event('dupe'));
testing.expectEqual(101, parent_calls);
<!-- </script> -->
</script>
<div id=parent4><div id=child4></div></div>
<script id=stpoPropagation>
@@ -264,11 +264,14 @@
<div id=child9></div>
<script id=nonBubblingNoCapture>
// Test that non-bubbling events don't trigger capture phase
// Test that non-bubbling events still have capture phase but no bubble phase
let non_bubble_calls = [];
const child9 = $('#child9');
window.addEventListener('nobubble', () => non_bubble_calls.push('window-capture'), true);
window.addEventListener('nobubble', (e) => {
non_bubble_calls.push('window-capture');
testing.expectEqual(Event.CAPTURING_PHASE, e.eventPhase);
}, true);
window.addEventListener('nobubble', () => non_bubble_calls.push('window-bubble'), false);
child9.addEventListener('nobubble', (e) => {
non_bubble_calls.push('child');
@@ -277,7 +280,8 @@
child9.dispatchEvent(new Event('nobubble', {bubbles: false}));
// Should only call child listener, not window
testing.expectEqual(1, non_bubble_calls.length);
testing.expectEqual('child', non_bubble_calls[0]);
// Should call window-capture (during capture phase) and child, but NOT window-bubble
testing.expectEqual(2, non_bubble_calls.length);
testing.expectEqual('window-capture', non_bubble_calls[0]);
testing.expectEqual('child', non_bubble_calls[1]);
</script>

View File

@@ -20,8 +20,8 @@
<script id=urlSearchParams>
const inputs = [
[["over", "9000!!"], ["abc", 123], ["key1", ""], ["key2", ""]],
{over: "9000!!", abc: 123, key1: "", key2: ""},
// @ZIGDOM [["over", "9000!!"], ["abc", 123], ["key1", ""], ["key2", ""]],
// @ZIGDOM {over: "9000!!", abc: 123, key1: "", key2: ""},
"over=9000!!&abc=123&key1&key2=",
"?over=9000!!&abc=123&key1&key2=",
]
@@ -73,7 +73,7 @@
}
</script>
<script id=encoding>
x<script id=encoding>
{
const usp = new URLSearchParams('key=hello%20world&special=%21%40%23%24&plus=a+b');
testing.expectEqual('hello world', usp.get('key'));
@@ -351,4 +351,4 @@
testing.expectEqual('a=2&a=4&b=1&b=3&c=5', usp.toString());
testing.expectEqual(['a', 'a', 'b', 'b', 'c'], Array.from(usp.keys()));
}
</script>
</script> -->

View File

@@ -56,6 +56,15 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback: js.Fun
return page._event_manager.remove(self, typ, callback, use_capture);
}
pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
return switch (self._type) {
.node => |n| n.format(writer),
.window => writer.writeAll("<window>"),
.xhr => writer.writeAll("<XMLHttpRequestEventTarget>"),
.abort_signal => writer.writeAll("<abort_signal>"),
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(EventTarget);

View File

@@ -9,7 +9,7 @@ pub fn registerTypes() []const type {
}
pub const Jar = @import("cookie.zig").Jar;
pub const Cookie =@import("cookie.zig").Cookie;
pub const Cookie = @import("cookie.zig").Cookie;
pub const Shed = struct {
_origins: std.StringHashMapUnmanaged(*Bucket) = .empty,

View File

@@ -117,11 +117,12 @@ const TestContext = struct {
bc.session_id = sid;
}
if (opts.html) |html| {
if (bc.session_id == null) bc.session_id = "SID-X";
const page = try bc.session.createPage();
page.window.document = (try Document.init(html)).doc;
}
// @ZIGDOM
// if (opts.html) |html| {
// if (bc.session_id == null) bc.session_id = "SID-X";
// const page = try bc.session.createPage();
// page.window._document = (try Document.init(html)).doc;
// }
return bc;
}

View File

@@ -422,9 +422,8 @@ test {
const log = @import("log.zig");
const TestHTTPServer = @import("TestHTTPServer.zig");
// @ZIGDOM-CDP
// const Server = @import("Server.zig");
// var test_cdp_server: ?Server = null;
const Server = @import("Server.zig");
var test_cdp_server: ?Server = null;
var test_http_server: ?TestHTTPServer = null;
test "tests:beforeAll" {
@@ -446,12 +445,10 @@ test "tests:beforeAll" {
var wg: std.Thread.WaitGroup = .{};
wg.startMany(2);
// @ZIGDOM-CDP
// {
// const thread = try std.Thread.spawn(.{}, serveCDP, .{&wg});
// thread.detach();
// }
wg.finish(); // @ZIGDOM-CDP REMOVE
{
const thread = try std.Thread.spawn(.{}, serveCDP, .{&wg});
thread.detach();
}
test_http_server = TestHTTPServer.init(testHTTPHandler);
{
@@ -465,10 +462,9 @@ test "tests:beforeAll" {
}
test "tests:afterAll" {
// @ZIGDOM-CDP
// if (test_cdp_server) |*server| {
// server.deinit();
// }
if (test_cdp_server) |*server| {
server.deinit();
}
if (test_http_server) |*server| {
server.deinit();
}
@@ -477,20 +473,19 @@ test "tests:afterAll" {
test_app.deinit();
}
// @ZIGDOM-CDP
// fn serveCDP(wg: *std.Thread.WaitGroup) !void {
// const address = try std.net.Address.parseIp("127.0.0.1", 9583);
// test_cdp_server = try Server.init(test_app, address);
fn serveCDP(wg: *std.Thread.WaitGroup) !void {
const address = try std.net.Address.parseIp("127.0.0.1", 9583);
test_cdp_server = try Server.init(test_app, address);
// var server = try Server.init(test_app, address);
// defer server.deinit();
// wg.finish();
var server = try Server.init(test_app, address);
defer server.deinit();
wg.finish();
// test_cdp_server.?.run(address, 5) catch |err| {
// std.debug.print("CDP server error: {}", .{err});
// return err;
// };
// }
test_cdp_server.?.run(address, 5) catch |err| {
std.debug.print("CDP server error: {}", .{err});
return err;
};
}
fn testHTTPHandler(req: *std.http.Server.Request) !void {
const path = req.head.target;