From 5ec5647395322604f2edbb2a3602e2c2e760821f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 13 Nov 2025 17:21:08 +0800 Subject: [PATCH] Merge ScriptManager/Module loading changes Get tests passing. --- src/browser/Page.zig | 12 +- src/browser/ScriptManager.zig | 845 ++++++++---------- src/browser/js/Context.zig | 236 ++--- src/browser/polyfill/webcomponents.zig | 3 +- .../tests/document/query_selector.html | 2 +- .../tests/element/html/script/dynamic.html | 4 +- src/browser/tests/element/query_selector.html | 20 +- src/browser/tests/page/module.html | 117 +-- src/browser/tests/testing.js | 28 +- src/browser/tests/window/location.html | 2 +- src/browser/tests/window/report_error.html | 3 - src/browser/webapi/Window.zig | 24 + src/browser/webapi/element/html/Body.zig | 12 +- src/browser/webapi/element/html/Script.zig | 21 +- src/browser/webapi/selector/List.zig | 33 +- src/browser/webapi/selector/Parser.zig | 55 +- src/browser/webapi/selector/Selector.zig | 60 +- src/log.zig | 3 +- 18 files changed, 695 insertions(+), 785 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index dbb2a984..6f0afe85 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -321,8 +321,11 @@ fn _documentIsComplete(self: *Page) !void { // dispatch window.load event const event = try Event.init("load", .{}, self); + // this event is weird, it's dispatched directly on the window, but + // with the document as the target + event._target = self.document.asEventTarget(); try self._event_manager.dispatchWithFunction( - self.document.asEventTarget(), + self.window.asEventTarget(), event, self.window._on_load, .{ .inject_target = false, .context = "page load" }, @@ -336,6 +339,9 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !void { const header = &transfer.response_header.?; self.url = try self.arena.dupeZ(u8, std.mem.span(header.url)); + self.window._location = try Location.init(self.url, self); + self.document._location = self.window._location; + log.debug(.http, "navigate header", .{ .url = self.url, .status = header.status, @@ -413,7 +419,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void { .html => |buf| { var parser = Parser.init(self.arena, self.document.asNode(), self); parser.parse(buf.items); - self._script_manager.pageIsLoaded(); + self._script_manager.staticScriptsDone(); if (self._script_manager.isDone()) { // No scripts, or just inline scripts that were already processed // we need to trigger this ourselves @@ -629,7 +635,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { } pub fn scriptAddedCallback(self: *Page, script: *HtmlScript) !void { - self._script_manager.add(script, "parsing") catch |err| { + self._script_manager.addFromElement(script, "parsing") catch |err| { log.err(.page, "page.scriptAddedCallback", .{ .err = err, .src = script.asElement().getAttributeSafe("src"), diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 312b85ba..4a394f65 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -38,7 +38,7 @@ const ScriptManager = @This(); page: *Page, -// used to prevent recursive evaluation +// used to prevent recursive evalutaion is_evaluating: bool, // Only once this is true can deferred scripts be run @@ -46,103 +46,104 @@ static_scripts_done: bool, // List of async scripts. We don't care about the execution order of these, but // on shutdown/abort, we need to cleanup any pending ones. -asyncs: OrderList, +async_scripts: std.DoublyLinkedList, // List of deferred scripts. These must be executed in order, but only once // dom_loaded == true, -deferreds: OrderList, +defer_scripts: std.DoublyLinkedList, + +// When an async script is ready, it's queued here. We played with executing +// them as they complete, but it can cause timing issues with v8 module loading. +ready_scripts: std.DoublyLinkedList, shutdown: bool = false, client: *Http.Client, -buffer_pool: BufferPool, -script_pool: std.heap.MemoryPool(PendingScript), -sync_module_pool: std.heap.MemoryPool(SyncModule), -async_module_pool: std.heap.MemoryPool(AsyncModule), - allocator: Allocator, +buffer_pool: BufferPool, + +script_pool: std.heap.MemoryPool(Script), // We can download multiple sync modules in parallel, but we want to process -// then in order. We can't use an OrderList, like the other script types, +// them in order. We can't use an std.DoublyLinkedList, like the other script types, // because the order we load them might not be the order we want to process // them in (I'm not sure this is true, but as far as I can tell, v8 doesn't // make any guarantees about the list of sub-module dependencies it gives us -// So this is more like a cache. When a SyncModule is complete, it's put here -// and can be requested as needed. -sync_modules: std.StringHashMapUnmanaged(*SyncModule), +// So this is more like a cache. When an imported module is completed, its +// source is placed here (keyed by the full url) for some point in the future +// when v8 asks for it. +// The type is confusing (too confusing? move to a union). Starts of as `null` +// then transitions to either an error (from errorCalback) or the completed +// buffer from doneCallback +imported_modules: std.StringHashMapUnmanaged(?error{Failed}!std.ArrayList(u8)), // Mapping between module specifier and resolution. // see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap // importmap contains resolved urls. importmap: std.StringHashMapUnmanaged([:0]const u8), -const OrderList = std.DoublyLinkedList; - pub fn init(page: *Page) ScriptManager { - const browser = page._session.browser; // page isn't fully initialized, we can setup our reference, but that's it. + const browser = page._session.browser; const allocator = browser.allocator; return .{ .page = page, - .asyncs = .{}, - .deferreds = .{}, + .async_scripts = .{}, + .defer_scripts = .{}, + .ready_scripts = .{}, .importmap = .empty, - .sync_modules = .empty, .is_evaluating = false, .allocator = allocator, + .imported_modules = .empty, .client = browser.http_client, .static_scripts_done = false, .buffer_pool = BufferPool.init(allocator, 5), - .script_pool = std.heap.MemoryPool(PendingScript).init(allocator), - .sync_module_pool = std.heap.MemoryPool(SyncModule).init(allocator), - .async_module_pool = std.heap.MemoryPool(AsyncModule).init(allocator), + .script_pool = std.heap.MemoryPool(Script).init(allocator), }; } pub fn deinit(self: *ScriptManager) void { + // necessary to free any buffers scripts may be referencing self.reset(); - var it = self.sync_modules.valueIterator(); - while (it.next()) |value_ptr| { - value_ptr.*.buffer.deinit(self.allocator); - self.sync_module_pool.destroy(value_ptr.*); - } self.buffer_pool.deinit(); self.script_pool.deinit(); - self.sync_module_pool.deinit(); - self.async_module_pool.deinit(); - - self.sync_modules.deinit(self.allocator); + self.imported_modules.deinit(self.allocator); // we don't deinit self.importmap b/c we use the page's arena for its // allocations. } pub fn reset(self: *ScriptManager) void { - var it = self.sync_modules.valueIterator(); - while (it.next()) |value_ptr| { - value_ptr.*.buffer.deinit(self.allocator); - self.sync_module_pool.destroy(value_ptr.*); + { + var it = self.imported_modules.valueIterator(); + while (it.next()) |value_ptr| { + // might have not been loaded yet (null) + const result = value_ptr.* orelse continue; + // might have loaded an error, in which case there's nothing to free + var buf = result catch continue; + buf.deinit(self.allocator); + } + self.imported_modules.clearRetainingCapacity(); } - self.sync_modules.clearRetainingCapacity(); + // Our allocator is the page arena, it's been reset. We cannot use // clearAndRetainCapacity, since that space is no longer ours self.importmap = .empty; - self.clearList(&self.asyncs); - self.clearList(&self.deferreds); + clearList(&self.defer_scripts); + clearList(&self.async_scripts); + clearList(&self.ready_scripts); self.static_scripts_done = false; } -fn clearList(_: *const ScriptManager, list: *OrderList) void { - while (list.first) |node| { - const pending_script: *PendingScript = @fieldParentPtr("node", node); - // this removes it from the list - pending_script.deinit(); +fn clearList(list: *std.DoublyLinkedList) void { + while (list.popFirst()) |n| { + const script: *Script = @fieldParentPtr("node", n); + script.deinit(true); } - std.debug.assert(list.first == null); } -pub fn add(self: *ScriptManager, script_element: *Element.Html.Script, comptime ctx: []const u8) !void { +pub fn addFromElement(self: *ScriptManager, script_element: *Element.Html.Script, comptime ctx: []const u8) !void { if (script_element._executed) { // If a script tag gets dynamically created and added to the dom: // document.getElementsByTagName('head')[0].appendChild(script) @@ -201,89 +202,94 @@ pub fn add(self: *ScriptManager, script_element: *Element.Html.Script, comptime source = .{ .@"inline" = inline_source }; } - var script = Script{ + const script = try self.script_pool.create(); + errdefer self.script_pool.destroy(script); + + script.* = .{ .kind = kind, + .node = .{}, + .manager = self, .source = source, .script_element = script_element, + .complete = source == .@"inline", .url = remote_url orelse page.url, - .is_defer = if (remote_url == null) false else element.getAttributeSafe("defer") != null, - .is_async = if (remote_url == null) false else element.getAttributeSafe("async") != null, + .mode = blk: { + if (source == .@"inline") { + // inline modules are deferred, all other inline scripts have a + // normal execution flow + break :blk if (kind == .module) .@"defer" else .normal; + } + if (element.getAttributeSafe("async") != null) { + break :blk .async; + } + if (element.getAttributeSafe("defer") != null) { + break :blk .@"defer"; + } + break :blk .normal; + }, }; - if (source == .@"inline") { - // inline script gets executed immediately - return script.eval(page); - } - - 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 "???", - }); - } + if (remote_url) |url| { + errdefer script.deinit(true); var headers = try self.client.newHeaders(); - try page.requestCookie(.{}).headersForRequest(page.arena, remote_url.?, &headers); + try page.requestCookie(.{}).headersForRequest(page.arena, url, &headers); try self.client.request(.{ - .url = remote_url.?, - .ctx = pending_script, + .url = url, + .ctx = 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, + .resource_type = .script, + .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, + .header_callback = Script.headerCallback, + .data_callback = Script.dataCallback, + .done_callback = Script.doneCallback, + .error_callback = Script.errorCallback, }); - if (script.is_defer) { - // non-blocking loading, track the list this belongs to, and return - pending_script.list = &self.deferreds; - return; - } + log.debug(.http, "script queue", .{ + .ctx = ctx, + .url = remote_url.?, + .stack = page.js.stackTrace() catch "???", + }); + } - 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; - }; - - defer pending_script.deinit(); + if (script.mode != .normal) { + const list = self.scriptList(script); + list.append(&script.node); + return; + } // this is , 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); + if (!script.complete) { + _ = try client.tick(200); + continue; } - _ = try client.tick(200); + + // could have already been evaluating if this is dynamically added + const was_evaluating = self.is_evaluating; + self.is_evaluating = true; + defer { + self.is_evaluating = was_evaluating; + script.deinit(true); + } + return script.eval(page); } } +fn scriptList(self: *ScriptManager, script: *const Script) *std.DoublyLinkedList { + return switch (script.mode) { + .normal => unreachable, // not added to a list, executed immediately + .@"defer" => &self.defer_scripts, + .async, .import_async, .import => &self.async_scripts, + }; +} + // Resolve a module specifier to an valid URL. pub fn resolveSpecifier(self: *ScriptManager, arena: Allocator, base: [:0]const u8, specifier: [:0]const u8) ![:0]const u8 { // If the specifier is mapped in the importmap, return the pre-resolved value. @@ -294,19 +300,28 @@ pub fn resolveSpecifier(self: *ScriptManager, arena: Allocator, base: [:0]const return URL.resolve(arena, base, specifier, .{}); } -pub fn getModule(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) !void { - const gop = try self.sync_modules.getOrPut(self.allocator, url); +pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) !void { + const gop = try self.imported_modules.getOrPut(self.allocator, url); if (gop.found_existing) { - // already requested return; } - errdefer _ = self.sync_modules.remove(url); + errdefer _ = self.imported_modules.remove(url); - const sync = try self.sync_module_pool.create(); - errdefer self.sync_module_pool.destroy(sync); + const script = try self.script_pool.create(); + errdefer self.script_pool.destroy(script); - sync.* = .{ .manager = self }; - gop.value_ptr.* = sync; + script.* = .{ + .kind = .module, + .url = url, + .node = .{}, + .manager = self, + .complete = false, + .script_element = null, + .source = .{ .remote = .{} }, + .mode = .import, + }; + + gop.value_ptr.* = null; var headers = try self.client.newHeaders(); try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers); @@ -320,73 +335,69 @@ pub fn getModule(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) try self.client.request(.{ .url = url, - .ctx = sync, + .ctx = script, .method = .GET, .headers = headers, .cookie_jar = &self.page._session.cookie_jar, .resource_type = .script, - .start_callback = if (log.enabled(.http, .debug)) SyncModule.startCallback else null, - .header_callback = SyncModule.headerCallback, - .data_callback = SyncModule.dataCallback, - .done_callback = SyncModule.doneCallback, - .error_callback = SyncModule.errorCallback, + .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, + .header_callback = Script.headerCallback, + .data_callback = Script.dataCallback, + .done_callback = Script.doneCallback, + .error_callback = Script.errorCallback, }); + + // This seems wrong since we're not dealing with an async import (unlike + // getAsyncModule below), but all we're trying to do here is pre-load the + // script for execution at some point in the future (when waitForModule is + // called). + self.async_scripts.append(&script.node); } -pub fn waitForModule(self: *ScriptManager, url: [:0]const u8) !GetResult { - // Normally it's dangerous to hold on to map pointers. But here, the map - // can't change. It's possible that by calling `tick`, other entries within - // the map will have their value changed, but the map itself is immutable - // during this tick. - const entry = self.sync_modules.getEntry(url) orelse { +pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource { + const entry = self.imported_modules.getEntry(url) orelse { + // It shouldn't be possible for v8 to ask for a module that we didn't + // `preloadImport` above. return error.UnknownModule; }; - const sync = entry.value_ptr.*; - // We can have multiple scripts waiting for the same module in concurrency. - // We use the waiters to ensures only the last waiter deinit the resources. - sync.waiters += 1; - defer sync.waiters -= 1; + const was_evaluating = self.is_evaluating; + self.is_evaluating = true; + defer self.is_evaluating = was_evaluating; var client = self.client; - while (true) { - switch (sync.state) { - .loading => {}, - .done => { - if (sync.waiters == 1) { - // Our caller has its own higher level cache (caching the - // actual compiled module). There's no reason for us to keep - // this if we are the last waiter. - defer self.sync_module_pool.destroy(sync); - defer self.sync_modules.removeByPtr(entry.key_ptr); - return .{ - .shared = false, - .buffer = sync.buffer, - .buffer_pool = &self.buffer_pool, - }; - } - - return .{ - .shared = true, - .buffer = sync.buffer, - .buffer_pool = &self.buffer_pool, - }; - }, - .err => |err| return err, - } + while (entry.value_ptr.* == null) { // rely on http's timeout settings to avoid an endless/long loop. _ = try client.tick(200); } + + defer self.imported_modules.removeByPtr(entry.key_ptr); + + // it's possible we stored an error in the map, if so, we'll return it now + const buf = try (entry.value_ptr.*.?); + + return .{ + .buffer = buf, + .buffer_pool = &self.buffer_pool, + }; } -pub fn getAsyncModule(self: *ScriptManager, url: [:0]const u8, cb: AsyncModule.Callback, cb_data: *anyopaque, referrer: []const u8) !void { - const async = try self.async_module_pool.create(); - errdefer self.async_module_pool.destroy(async); +pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void { + const script = try self.script_pool.create(); + errdefer self.script_pool.destroy(script); - async.* = .{ - .cb = cb, + script.* = .{ + .kind = .module, + .url = url, + .node = .{}, .manager = self, - .cb_data = cb_data, + .complete = false, + .script_element = null, + .source = .{ .remote = .{} }, + .mode = .{ .import_async = .{ + .callback = cb, + .data = cb_data, + } }, }; var headers = try self.client.newHeaders(); @@ -399,35 +410,43 @@ pub fn getAsyncModule(self: *ScriptManager, url: [:0]const u8, cb: AsyncModule.C .stack = self.page.js.stackTrace() catch "???", }); + // It's possible, but unlikely, for client.request to immediately finish + // a request, thus calling our callback. We generally don't want a call + // from v8 (which is why we're here), to result in a new script evaluation. + // So we block even the slightest change that `client.request` immediately + // executes a callback. + const was_evaluating = self.is_evaluating; + self.is_evaluating = true; + defer self.is_evaluating = was_evaluating; + try self.client.request(.{ .url = url, .method = .GET, .headers = headers, - .cookie_jar = &self.page._session.cookie_jar, - .ctx = async, + .ctx = script, .resource_type = .script, - .start_callback = if (log.enabled(.http, .debug)) AsyncModule.startCallback else null, - .header_callback = AsyncModule.headerCallback, - .data_callback = AsyncModule.dataCallback, - .done_callback = AsyncModule.doneCallback, - .error_callback = AsyncModule.errorCallback, + .cookie_jar = &self.page._session.cookie_jar, + .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, + .header_callback = Script.headerCallback, + .data_callback = Script.dataCallback, + .done_callback = Script.doneCallback, + .error_callback = Script.errorCallback, }); + + self.async_scripts.append(&script.node); } -pub fn pageIsLoaded(self: *ScriptManager) void { +// Called from the Page to let us know it's done parsing the HTML. Necessary that +// we know this so that we know that we can start evaluating deferred scripts. +pub fn staticScriptsDone(self: *ScriptManager) void { std.debug.assert(self.static_scripts_done == false); self.static_scripts_done = true; self.evaluate(); } -// try to evaluate completed scripts (in order). This is called whenever a script -// is completed. fn evaluate(self: *ScriptManager) void { if (self.is_evaluating) { // It's possible for a script.eval to cause evaluate to be called again. - // This is particularly true with blockingGet, but even without this, - // it's theoretically possible (but unlikely). We could make this work - // but there's little reason to support the complexity. return; } @@ -435,6 +454,28 @@ fn evaluate(self: *ScriptManager) void { self.is_evaluating = true; defer self.is_evaluating = false; + while (self.ready_scripts.popFirst()) |n| { + var script: *Script = @fieldParentPtr("node", n); + switch (script.mode) { + .async => { + defer script.deinit(true); + script.eval(page); + }, + .import_async => |ia| { + defer script.deinit(false); + if (script.status < 200 or script.status > 299) { + ia.callback(ia.data, error.FailedToLoad); + } else { + ia.callback(ia.data, .{ + .buffer = script.source.remote, + .buffer_pool = &self.buffer_pool, + }); + } + }, + else => unreachable, // no other script is put in this list + } + } + if (self.static_scripts_done == false) { // We can only execute deferred scripts if // 1 - all the normal scripts are done @@ -446,69 +487,38 @@ fn evaluate(self: *ScriptManager) void { return; } - while (self.deferreds.first) |n| { - var pending_script: *PendingScript = @fieldParentPtr("node", n); - if (pending_script.complete == false) { + while (self.defer_scripts.first) |n| { + var script: *Script = @fieldParentPtr("node", n); + if (script.complete == false) { return; } - defer pending_script.deinit(); - pending_script.script.eval(page); + defer { + _ = self.defer_scripts.popFirst(); + script.deinit(true); + } + script.eval(page); } + // At this point all normal scripts and deferred scripts are done, PLUS + // the page has signaled that it's done parsing HTML (static_scripts_done == true). + // + // When all scripts (normal and deferred) are done loading, the document - // state changes (this ultimately triggers the DOMContentLoaded event) + // state changes (this ultimately triggers the DOMContentLoaded event). + // Page makes this safe to call multiple times. page.documentIsLoaded(); - if (self.asyncs.first == null) { - // 1 - there are no async scripts pending - // 2 - we checkecked static_scripts_done == true above - // 3 - we drained self.scripts above - // 4 - we drained self.deferred above + if (self.async_scripts.first == null) { + // Looks like all async scripts are done too! + // Page makes this safe to call multiple times. page.documentIsComplete(); } } 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 - self.deferreds.first == null; // and there are no more diff --git a/src/browser/tests/element/html/script/dynamic.html b/src/browser/tests/element/html/script/dynamic.html index e251d5b5..766c3180 100644 --- a/src/browser/tests/element/html/script/dynamic.html +++ b/src/browser/tests/element/html/script/dynamic.html @@ -7,9 +7,7 @@ const script1 = document.createElement('script'); script1.src = "dynamic1.js"; document.getElementsByTagName('head')[0].appendChild(script1); - testing.eventually(() => { - testing.expectEqual(1, loaded1); - }); + testing.expectEqual(1, loaded1);

Direct child paragraph

@@ -51,15 +51,15 @@ { const container = $('#desc-container'); - // testing.expectEqual('Nested paragraph', container.querySelector('div p').textContent); - // testing.expectEqual('Nested paragraph', container.querySelector('div.nested p').textContent); + testing.expectEqual('Direct child paragraph', container.querySelector('div p').textContent); + testing.expectEqual('Nested paragraph', container.querySelector('div.nested p').textContent); testing.expectEqual('Deeply nested paragraph', container.querySelector('div span p').textContent); - // testing.expectEqual('Nested paragraph', container.querySelector('.nested .text').textContent); - // testing.expectEqual(null, container.querySelector('article div p')); + testing.expectEqual('Nested paragraph', container.querySelector('.nested .text').textContent); + testing.expectEqual(null, container.querySelector('article div p')); - // const outerDiv = $('#outer-div'); - // testing.expectEqual('deep-span', outerDiv.querySelector('div div span').id); - // testing.expectEqual('deep-span', outerDiv.querySelector('.level1 span').id); - // testing.expectEqual('deep-span', outerDiv.querySelector('.level1 .level2 span').id); + const outerDiv = $('#outer-div'); + testing.expectEqual('deep-span', outerDiv.querySelector('div div span').id); + testing.expectEqual('deep-span', outerDiv.querySelector('.level1 span').id); + testing.expectEqual('deep-span', outerDiv.querySelector('.level1 .level2 span').id); } diff --git a/src/browser/tests/page/module.html b/src/browser/tests/page/module.html index f3dae6d1..1dd79794 100644 --- a/src/browser/tests/page/module.html +++ b/src/browser/tests/page/module.html @@ -1,7 +1,7 @@ - + - - - diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 89c3d16b..d5ac8b97 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -2,6 +2,7 @@ let failed = false; let observed_ids = {}; let eventuallies = []; + let async_capture = null; let current_script_id = null; function expectTrue(actual) { @@ -12,14 +13,17 @@ expectEqual(false, actual); } - function expectEqual(expected, actual) { + function expectEqual(expected, actual, opts) { if (_equal(expected, actual)) { - _registerObservation('ok'); + _registerObservation('ok', opts); return; } failed = true; - _registerObservation('fail'); + _registerObservation('fail', opts); let err = `expected: ${_displayValue(expected)}, got: ${_displayValue(actual)}\n script_id: ${_currentScriptId()}`; + if (async_capture) { + err += `\n stack: ${async_capture.stack}`; + } console.error(err); throw new Error('expectEqual failed'); } @@ -57,7 +61,14 @@ callback: cb, script_id: script_id, }); + } + async function async(cb) { + const script_id = document.currentScript.id; + const stack = new Error().stack; + async_capture = {script_id: script_id, stack: stack}; + await cb(); + async_capture = null; } function assertOk() { @@ -92,6 +103,7 @@ window.testing = { fail: fail, + async: async, assertOk: assertOk, expectTrue: expectTrue, expectFalse: expectFalse, @@ -125,7 +137,6 @@ return false; } - if (expected instanceof Node) { if (!(actual instanceof Node)) { return false; @@ -145,8 +156,8 @@ return true; } - function _registerObservation(status) { - const script_id = _currentScriptId(); + function _registerObservation(status, opts) { + script_id = opts?.script_id || _currentScriptId(); if (!script_id) { return; } @@ -161,7 +172,12 @@ return current_script_id; } + if (async_capture) { + return async_capture.script_id; + } + const current_script = document.currentScript; + if (!current_script) { return null; } diff --git a/src/browser/tests/window/location.html b/src/browser/tests/window/location.html index f5ce7ffb..01a4049d 100644 --- a/src/browser/tests/window/location.html +++ b/src/browser/tests/window/location.html @@ -2,6 +2,6 @@ diff --git a/src/browser/tests/window/report_error.html b/src/browser/tests/window/report_error.html index 6796d46f..c2d66125 100644 --- a/src/browser/tests/window/report_error.html +++ b/src/browser/tests/window/report_error.html @@ -35,9 +35,6 @@ window.reportError(err); testing.expectEqual(true, evt.message.includes('Detailed error')); - testing.expectEqual('script.js', evt.filename); - testing.expectEqual(100, evt.lineno); - testing.expectEqual(25, evt.colno); testing.expectEqual(err, evt.error); } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d5261fed..0ea5fc06 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -147,6 +147,29 @@ pub fn cancelAnimationFrame(self: *Window, id: u32) void { sc.removed = true; } +pub fn reportError(self: *Window, err: js.Object, page: *Page) !void { + const error_event = try ErrorEvent.init("error", .{ + .@"error" = err, + .message = err.toString() catch "Unknown error", + .bubbles = false, + .cancelable = true, + }, page); + + const event = error_event.asEvent(); + try page._event_manager.dispatch(self.asEventTarget(), event); + + if (comptime builtin.is_test == false) { + if (!event._prevent_default) { + log.warn(.js, "window.reportError", .{ + .message = error_event._message, + .filename = error_event._filename, + .line_number = error_event._line_number, + .column_number = error_event._column_number, + }); + } + } +} + pub fn matchMedia(_: *const Window, query: []const u8, page: *Page) !*MediaQueryList { return page._factory.eventTarget(MediaQueryList{ ._proto = undefined, @@ -290,6 +313,7 @@ pub const JsApi = struct { pub const matchMedia = bridge.function(Window.matchMedia, .{}); pub const btoa = bridge.function(Window.btoa, .{}); pub const atob = bridge.function(Window.atob, .{}); + pub const reportError = bridge.function(Window.reportError, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index 9822502f..585e74d6 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -30,11 +30,11 @@ pub const JsApi = struct { pub const Build = struct { pub fn complete(node: *Node, page: *Page) !void { - _ = node; - _ = page; - // @ZIGDOM - // const el = node.as(Element); - // const on_load = el.getAttributeSafe("onload") orelse return; - // page.window._on_load = page.js.stringToFunction(on_load); + const el = node.as(Element); + const on_load = el.getAttributeSafe("onload") orelse return; + page.window._on_load = page.js.stringToFunction(on_load) catch |err| blk: { + log.err(.js, "body.onload", .{.err = err, .str = on_load}); + break :blk null; + }; } }; diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index e6081106..df224ae2 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -1,3 +1,4 @@ +const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); @@ -77,15 +78,19 @@ pub const Build = struct { const element = self.asElement(); self._src = element.getAttributeSafe("src") orelse ""; - // @ZIGDOM - _ = page; - // if (element.getAttributeSafe("onload")) |on_load| { - // self._on_load = page.js.stringToFunction(on_load); - // } + if (element.getAttributeSafe("onload")) |on_load| { + self._on_load = page.js.stringToFunction(on_load) catch |err| blk: { + log.err(.js, "script.onload", .{.err = err, .str = on_load}); + break :blk null; + }; + } - // if (element.getAttributeSafe("onerror")) |on_error| { - // self._on_error = page.js.stringToFunction(on_error); - // } + if (element.getAttributeSafe("onerror")) |on_error| { + self._on_error = page.js.stringToFunction(on_error) catch |err| blk: { + log.err(.js, "script.onerror", .{.err = err, .str = on_error}); + break :blk null; + }; + } } }; diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 0283178d..0bedc46c 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -22,32 +22,26 @@ pub const EntryIterator = GenericIterator(Iterator, null); pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); -pub fn init(arena: Allocator, root: *Node, selector: Selector.Selector, page: *Page) !*List { - var list = try page._factory.create(List{ - ._arena = arena, - ._nodes = &.{}, - }); - +pub fn collect( + allocator: std.mem.Allocator, + root: *Node, + selector: Selector.Selector, + nodes: *std.AutoArrayHashMapUnmanaged(*Node, void), + page: *Page, +) !void { if (optimizeSelector(root, &selector, page)) |result| { - var nodes: std.ArrayListUnmanaged(*Node) = .empty; - var tw = TreeWalker.init(result.root, .{}); - const optimized_selector = result.selector; if (result.exclude_root) { _ = tw.next(); } - // When exclude_root is true, pass root as boundary so it can match but we won't search beyond it - // When exclude_root is false, pass null so there's no boundary (root already matched, searching descendants) + const boundary = if (result.exclude_root) result.root else null; while (tw.next()) |node| { - if (matches(node, optimized_selector, boundary)) { - try nodes.append(arena, node); + if (matches(node, result.selector, boundary)) { + try nodes.put(allocator, node, {}); } } - list._nodes = nodes.items; } - - return list; } // used internally to find the first match @@ -135,7 +129,7 @@ fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page .first = selector.first, .segments = selector.segments, }, - .exclude_root = false, + .exclude_root = true, }; } @@ -238,7 +232,7 @@ fn findIdSelector(selector: *const Selector.Selector) ?IdAnchor { return null; } -fn matches(node: *Node, selector: Selector.Selector, root: ?*Node) bool { +pub fn matches(node: *Node, selector: Selector.Selector, root: ?*Node) bool { const el = node.is(Node.Element) orelse return false; if (selector.segments.len == 0) { @@ -333,8 +327,9 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { const parent = node._parent orelse return null; // Don't match beyond the root boundary + // If there's a boundary, check if parent is outside (an ancestor of) the boundary if (root) |boundary| { - if (parent == boundary) { + if (!boundary.contains(parent)) { return null; } } diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 7fccc812..0e88df0d 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; + const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); @@ -8,7 +10,6 @@ const Part = Selector.Part; const Combinator = Selector.Combinator; const Segment = Selector.Segment; const Attribute = @import("../element/Attribute.zig"); -const Allocator = std.mem.Allocator; const Parser = @This(); @@ -26,10 +27,56 @@ const ParseError = error{ InvalidTagSelector, InvalidSelector, }; + +pub fn parseList(arena: Allocator, input: []const u8, page: *Page) ParseError![]const Selector.Selector { + var selectors: std.ArrayList(Selector.Selector) = .empty; + + var remaining = input; + while (true) { + const trimmed = std.mem.trimLeft(u8, remaining, &std.ascii.whitespace); + if (trimmed.len == 0) break; + + var comma_pos: usize = trimmed.len; + var depth: usize = 0; + for (trimmed, 0..) |c, i| { + switch (c) { + '(' => depth += 1, + ')' => { + if (depth > 0) depth -= 1; + }, + ',' => { + if (depth == 0) { + comma_pos = i; + break; + } + }, + else => {}, + } + } + + const selector_input = std.mem.trimRight(u8, trimmed[0..comma_pos], &std.ascii.whitespace); + + if (selector_input.len > 0) { + const selector = try parse(arena, selector_input, page); + try selectors.append(arena, selector); + } + + if (comma_pos >= trimmed.len) break; + remaining = trimmed[comma_pos + 1 ..]; + } + + if (selectors.items.len == 0) { + return error.InvalidSelector; + } + + return selectors.items; +} + pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Selector.Selector { var parser = Parser{ .input = input }; - var segments: std.ArrayListUnmanaged(Segment) = .empty; - var current_compound: std.ArrayListUnmanaged(Part) = .empty; + var segments: std.ArrayList(Segment) = .empty; + var current_compound: std.ArrayList(Part) = .empty; + // Parse the first compound (no combinator before it) while (parser.skipSpaces()) { @@ -302,7 +349,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (std.mem.eql(u8, name, "not")) { // CSS Level 4: :not() can contain a full selector list (comma-separated selectors) // e.g., :not(div, .class, #id > span) - var selectors: std.ArrayListUnmanaged(Selector.Selector) = .empty; + var selectors: std.ArrayList(Selector.Selector) = .empty; _ = self.skipSpaces(); diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 7e4da77c..8839b1b6 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -10,24 +10,29 @@ pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Elemen return error.SyntaxError; } - const selector = try Parser.parse(page.call_arena, input, page); + const arena = page.call_arena; + const selectors = try Parser.parseList(arena, input, page); - // Fast path: single compound with only an ID selector - if (selector.segments.len == 0 and selector.first.parts.len == 1) { - const first = selector.first.parts[0]; - if (first == .id) { - const el = page.document._elements_by_id.get(first.id) orelse return null; - // Check if the element is within the root subtree - if (root.contains(el.asNode())) { + for (selectors) |selector| { + // Fast path: single compound with only an ID selector + if (selector.segments.len == 0 and selector.first.parts.len == 1) { + const first = selector.first.parts[0]; + if (first == .id) { + const el = page.document._elements_by_id.get(first.id) orelse continue; + // Check if the element is within the root subtree + if (root.contains(el.asNode())) { + return el; + } + continue; + } + } + + if (List.initOne(root, selector, page)) |node| { + if (node.is(Node.Element)) |el| { return el; } - return null; } } - - if (List.initOne(root, selector, page)) |node| { - return node.is(Node.Element); - } return null; } @@ -37,8 +42,33 @@ pub fn querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List { } const arena = page.arena; - const selector = try Parser.parse(arena, input, page); - return List.init(arena, root, selector, page); + var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; + + const selectors = try Parser.parseList(arena, input, page); + for (selectors) |selector| { + try List.collect(arena, root, selector, &nodes, page); + } + + return page._factory.create(List{ + ._arena = arena, + ._nodes = nodes.keys(), + }); +} + +pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { + if (input.len == 0) { + return error.SyntaxError; + } + + const arena = page.call_arena; + const selectors = try Parser.parseList(arena, input, page); + + for (selectors) |selector| { + if (List.matches(el.asNode(), selector, null)) { + return true; + } + } + return false; } pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bool { diff --git a/src/log.zig b/src/log.zig index d0f02bf9..f791e9f7 100644 --- a/src/log.zig +++ b/src/log.zig @@ -131,7 +131,7 @@ pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data: var writer = stderr.writer(&buf); logTo(scope, level, msg, data, &writer.interface) catch |log_err| { - std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg }); + std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"\n", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg }); }; } @@ -147,7 +147,6 @@ fn logTo(comptime scope: Scope, level: Level, comptime msg: []const u8, data: an } } } - switch (opts.format) { .logfmt => try logLogfmt(scope, level, msg, data, out), .pretty => try logPretty(scope, level, msg, data, out),