diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 640d6bf5..f4b8f7b7 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -495,12 +495,15 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null; // Look up the inline handler for this target - const element = switch (target._type) { - .node => |n| n.is(Element) orelse return null, + const html_element = switch (target._type) { + .node => |n| n.is(Element.Html) orelse return null, else => return null, }; - return self.page.getAttrListener(element, handler_type); + return html_element.getAttributeFunction(handler_type, self.page) catch |err| { + log.warn(.event, "inline html callback", .{ .type = handler_type, .err = err }); + return null; + }; } fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { diff --git a/src/browser/Page.zig b/src/browser/Page.zig index a562c65b..964233ad 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -216,16 +216,30 @@ _arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct { count: usize, }) else void) = if (IS_DEBUG) .empty else {}, +parent: ?*Page, window: *Window, document: *Document, +iframe: ?*Element.Html.IFrame = null, +frames: std.ArrayList(*Page) = .{}, +frames_sorted: bool = true, // DOM version used to invalidate cached state of "live" collections version: usize = 0, -_req_id: u32 = 0, + +// This is maybe not great. It's a counter on the number of events that we're +// waiting on before triggering the "load" event. Essentially, we need all +// synchronous scripts and all iframes to be loaded. Scripts are handled by the +// ScriptManager, so all scripts just count as 1 pending load. +_pending_loads: u32, + +_parent_notified: if (IS_DEBUG) bool else void = if (IS_DEBUG) false else {}, + +_type: enum { root, frame }, // only used for logs right now +_req_id: ?u32 = null, _navigated_options: ?NavigatedOpts = null, -pub fn init(self: *Page, id: u32, session: *Session) !void { +pub fn init(self: *Page, id: u32, session: *Session, parent: ?*Page) !void { if (comptime IS_DEBUG) { log.debug(.page, "page.init", .{}); } @@ -246,6 +260,7 @@ pub fn init(self: *Page, id: u32, session: *Session) !void { self.* = .{ .id = id, .js = undefined, + .parent = parent, .arena = page_arena, .document = document, .window = undefined, @@ -253,28 +268,41 @@ pub fn init(self: *Page, id: u32, session: *Session) !void { .call_arena = call_arena, ._session = session, ._factory = factory, + ._pending_loads = 1, // always 1 for the ScriptManager + ._type = if (parent == null) .root else .frame, ._script_manager = undefined, ._event_manager = EventManager.init(page_arena, self), }; + var screen: *Screen = undefined; + var visual_viewport: *VisualViewport = undefined; + if (parent) |p| { + screen = p.window._screen; + visual_viewport = p.window._visual_viewport; + } else { + screen = try factory.eventTarget(Screen{ + ._proto = undefined, + ._orientation = null, + }); + visual_viewport = try factory.eventTarget(VisualViewport{ + ._proto = undefined, + }); + } + self.window = try factory.eventTarget(Window{ + ._page = self, ._proto = undefined, ._document = self.document, ._location = &default_location, ._performance = Performance.init(), - ._screen = try factory.eventTarget(Screen{ - ._proto = undefined, - ._orientation = null, - }), - ._visual_viewport = try factory.eventTarget(VisualViewport{ - ._proto = undefined, - }), + ._screen = screen, + ._visual_viewport = visual_viewport, }); self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); errdefer self._script_manager.deinit(); - self.js = try browser.env.createContext(self, true); + self.js = try browser.env.createContext(self); errdefer self.js.deinit(); if (comptime builtin.is_test == false) { @@ -290,8 +318,12 @@ pub fn init(self: *Page, id: u32, session: *Session) !void { } pub fn deinit(self: *Page) void { + for (self.frames.items) |frame| { + frame.deinit(); + } + if (comptime IS_DEBUG) { - log.debug(.page, "page.deinit", .{ .url = self.url }); + log.debug(.page, "page.deinit", .{ .url = self.url, .type = self._type }); // Uncomment if you want slab statistics to print. // const stats = self._factory._slab.getStats(self.arena) catch unreachable; @@ -311,7 +343,7 @@ pub fn deinit(self: *Page) void { var it = self._arena_pool_leak_track.valueIterator(); while (it.next()) |value_ptr| { if (value_ptr.count > 0) { - log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner }); + log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type }); } } } @@ -380,7 +412,7 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void { if (comptime IS_DEBUG) { const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; if (found.count != 1) { - log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count }); + log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type }); return; } found.count = 0; @@ -405,6 +437,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi .reason = opts.reason, .body = opts.body != null, .req_id = req_id, + .type = self._type, }); // if the url is about:blank, we load an empty HTML document in the @@ -501,7 +534,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi .done_callback = pageDoneCallback, .error_callback = pageErrorCallback, }) catch |err| { - log.err(.page, "navigate request", .{ .url = self.url, .err = err }); + log.err(.page, "navigate request", .{ .url = self.url, .err = err, .type = self._type }); return err; }; } @@ -536,6 +569,7 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp .url = resolved_url, .reason = opts.reason, .target = resolved_url, + .type = self._type, }); self._session.browser.http_client.abort(); @@ -584,7 +618,7 @@ pub fn documentIsLoaded(self: *Page) void { self._load_state = .load; self.document._ready_state = .interactive; self._documentIsLoaded() catch |err| { - log.err(.page, "document is loaded", .{ .err = err }); + log.err(.page, "document is loaded", .{ .err = err, .type = self._type }); }; } @@ -597,6 +631,38 @@ pub fn _documentIsLoaded(self: *Page) !void { ); } +pub fn scriptsCompletedLoading(self: *Page) void { + self.pendingLoadCompleted(); +} + +pub fn iframeCompletedLoading(self: *Page, iframe: *Element.Html.IFrame) void { + blk: { + var ls: JS.Local.Scope = undefined; + self.js.localScope(&ls); + defer ls.deinit(); + + const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| { + log.err(.page, "iframe event init", .{ .err = err }); + break :blk; + }; + defer if (!event._v8_handoff) event.deinit(false); + self._event_manager.dispatch(iframe.asNode().asEventTarget(), event) catch |err| { + log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src }); + }; + } + self.pendingLoadCompleted(); +} + +fn pendingLoadCompleted(self: *Page) void { + const pending_loads = self._pending_loads; + if (pending_loads == 1) { + self._pending_loads = 0; + self.documentIsComplete(); + } else { + self._pending_loads = pending_loads - 1; + } +} + pub fn documentIsComplete(self: *Page) void { if (self._load_state == .complete) { // Ideally, documentIsComplete would only be called once, but with @@ -616,7 +682,7 @@ pub fn documentIsComplete(self: *Page) void { self._load_state = .complete; self._documentIsComplete() catch |err| { - log.err(.page, "document is complete", .{ .err = err }); + log.err(.page, "document is complete", .{ .err = err, .type = self._type }); }; if (IS_DEBUG) { @@ -670,6 +736,19 @@ fn _documentIsComplete(self: *Page) !void { ls.toLocal(self.window._on_pageshow), .{ .context = "page show" }, ); + + self.notifyParentLoadComplete(); +} + +fn notifyParentLoadComplete(self: *Page) void { + if (comptime IS_DEBUG) { + std.debug.assert(self._parent_notified == false); + self._parent_notified = true; + } + + if (self.parent) |p| { + p.iframeCompletedLoading(self.iframe.?); + } } fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { @@ -687,6 +766,7 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { .url = self.url, .status = header.status, .content_type = header.contentType(), + .type = self._type, }); } @@ -707,7 +787,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { } orelse .unknown; if (comptime IS_DEBUG) { - log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len }); + log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type }); } switch (mime.content_type) { @@ -751,18 +831,19 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { } fn pageDoneCallback(ctx: *anyopaque) !void { + var self: *Page = @ptrCast(@alignCast(ctx)); + if (comptime IS_DEBUG) { - log.debug(.page, "navigate done", .{}); + log.debug(.page, "navigate done", .{ .type = self._type }); } - var self: *Page = @ptrCast(@alignCast(ctx)); self.clearTransferArena(); //We need to handle different navigation types differently. try self._session.navigation.commitNavigation(self); defer if (comptime IS_DEBUG) { - log.debug(.page, "page.load.complete", .{ .url = self.url }); + log.debug(.page, "page.load.complete", .{ .url = self.url, .type = self._type }); }; const parse_arena = try self.getArena(.{ .debug = "Page.parse" }); @@ -831,7 +912,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void { } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { - log.err(.page, "navigate failed", .{ .err = err }); + log.err(.page, "navigate failed", .{ .err = err, .type = self._type }); var self: *Page = @ptrCast(@alignCast(ctx)); self._parse_state = .{ .err = err }; @@ -839,7 +920,7 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { // In case of error, we want to complete the page with a custom HTML // containing the error. pageDoneCallback(ctx) catch |e| { - log.err(.browser, "pageErrorCallback", .{ .err = e }); + log.err(.browser, "pageErrorCallback", .{ .err = e, .type = self._type }); return; }; } @@ -871,7 +952,7 @@ pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { // to run this through more real-world sites and see if we need // to expand the switch (err) to have more customized logs for // specific messages. - log.err(.browser, "page wait", .{ .err = err }); + log.err(.browser, "page wait", .{ .err = err, .type = self._type }); }, } return .done; @@ -879,6 +960,10 @@ pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { } fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { + if (comptime IS_DEBUG) { + std.debug.assert(self._type == .root); + } + var timer = try std.time.Timer.start(); var ms_remaining = wait_ms; @@ -1102,10 +1187,73 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele log.err(.page, "page.scriptAddedCallback", .{ .err = err, .src = script.asElement().getAttributeSafe(comptime .wrap("src")), + .type = self._type, }); }; } +pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { + if (self.isGoingAway()) { + // if we're planning on navigating to another page, don't load this iframe + return; + } + if (iframe._executed) { + return; + } + + const src = try iframe.getSrc(self); + if (src.len == 0) { + return; + } + + iframe._executed = true; + const page_frame = try self.arena.create(Page); + try Page.init(page_frame, self._session, self); + + self._pending_loads += 1; + page_frame.iframe = iframe; + iframe._content_window = page_frame.window; + + page_frame.navigate(src, .{}) catch |err| { + log.warn(.page, "iframe navigate failure", .{ .url = src, .err = err }); + self._pending_loads -= 1; + iframe._content_window = null; + page_frame.deinit(); + return error.IFrameLoadError; + }; + + // window[N] is based on document order. For now we'll just append the frame + // at the end of our list and set frames_sorted == false. window.getFrame + // will check this flag to decide if it needs to sort the frames or not. + // But, we can optimize this a bit. Since we expect frames to often be + // added in document order, we can do a quick check to see whether the list + // is sorted or not. + try self.frames.append(self.arena, page_frame); + + const frames_len = self.frames.items.len; + if (frames_len == 1) { + // this is the only frame, it must be sorted. + return; + } + + if (self.frames_sorted == false) { + // the list already wasn't sorted, it still isn't + return; + } + + // So we added a frame into a sorted list. If this frame is sorted relative + // to the last frame, it's still sorted + const iframe_a = self.frames.items[frames_len - 2].iframe.?; + const iframe_b = self.frames.items[frames_len - 1].iframe.?; + + if (iframe_a.asNode().compareDocumentPosition(iframe_b.asNode()) & 0x04 == 0) { + // if b followed a, then & 0x04 = 0x04 + // but since we got 0, it means b does not follow a, and thus our list + // is no longer sorted. + self.frames_sorted = false; + } +} + pub fn domChanged(self: *Page) void { self.version += 1; @@ -1115,7 +1263,7 @@ pub fn domChanged(self: *Page) void { self._intersection_check_scheduled = true; self.js.queueIntersectionChecks() catch |err| { - log.err(.page, "page.schedIntersectChecks", .{ .err = err }); + log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); }; } @@ -1206,6 +1354,7 @@ pub fn setAttrListener( log.debug(.event, "Page.setAttrListener", .{ .element = element, .listener_type = listener_type, + .type = self._type, }); } @@ -1216,18 +1365,6 @@ pub fn setAttrListener( gop.value_ptr.* = listener_callback; } -/// Returns the inline event listener by an element and listener type. -pub fn getAttrListener( - self: *const Page, - element: *Element, - listener_type: GlobalEventHandler, -) ?JS.Function.Global { - return self._element_attr_listeners.get(.{ - .target = element.asEventTarget(), - .handler = listener_type, - }); -} - pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void { return self._performance_observers.append(self.arena, observer); } @@ -1247,7 +1384,7 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void for (self._performance_observers.items) |observer| { if (observer.interested(entry)) { observer._entries.append(self.arena, entry) catch |err| { - log.err(.page, "notifyPerformanceObservers", .{ .err = err }); + log.err(.page, "notifyPerformanceObservers", .{ .err = err, .type = self._type }); }; } } @@ -1342,7 +1479,7 @@ pub fn performScheduledIntersectionChecks(self: *Page) void { } self._intersection_check_scheduled = false; self.checkIntersections() catch |err| { - log.err(.page, "page.schedIntersectChecks", .{ .err = err }); + log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); }; } @@ -1358,7 +1495,7 @@ pub fn deliverIntersections(self: *Page) void { i -= 1; const observer = self._intersection_observers.items[i]; observer.deliverEntries(self) catch |err| { - log.err(.page, "page.deliverIntersections", .{ .err = err }); + log.err(.page, "page.deliverIntersections", .{ .err = err, .type = self._type }); }; } } @@ -1376,7 +1513,7 @@ pub fn deliverMutations(self: *Page) void { }; if (self._mutation_delivery_depth > 100) { - log.err(.page, "page.MutationLimit", .{}); + log.err(.page, "page.MutationLimit", .{ .type = self._type }); self._mutation_delivery_depth = 0; return; } @@ -1385,7 +1522,7 @@ pub fn deliverMutations(self: *Page) void { while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.deliverRecords(self) catch |err| { - log.err(.page, "page.deliverMutations", .{ .err = err }); + log.err(.page, "page.deliverMutations", .{ .err = err, .type = self._type }); }; } } @@ -1403,7 +1540,7 @@ pub fn deliverSlotchangeEvents(self: *Page) void { var i: usize = 0; var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| { - log.err(.page, "deliverSlotchange.append", .{ .err = err }); + log.err(.page, "deliverSlotchange.append", .{ .err = err, .type = self._type }); return; }; @@ -1416,14 +1553,14 @@ pub fn deliverSlotchangeEvents(self: *Page) void { for (slots) |slot| { const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| { - log.err(.page, "deliverSlotchange.init", .{ .err = err }); + log.err(.page, "deliverSlotchange.init", .{ .err = err, .type = self._type }); continue; }; defer if (!event._v8_handoff) event.deinit(false); const target = slot.asNode().asEventTarget(); _ = target.dispatchEvent(event, self) catch |err| { - log.err(.page, "deliverSlotchange.dispatch", .{ .err = err }); + log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type }); }; } } @@ -1478,7 +1615,7 @@ pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void { // called from the parser when the node and all its children have been added pub fn nodeComplete(self: *Page, node: *Node) !void { Node.Build.call(node, "complete", .{ node, self }) catch |err| { - log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err }); + log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); return err; }; return self.nodeIsReady(true, node); @@ -2147,7 +2284,6 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const }, else => {}, } - const tag_name = try String.init(self.arena, name, .{}); // Check if this is a custom element (must have hyphen for HTML namespace) @@ -2178,7 +2314,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const var caught: JS.TryCatch.Caught = undefined; _ = ls.toLocal(def.constructor).newInstance(&caught) catch |err| { - log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught }); + log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught, .type = self._type }); return node; }; @@ -2236,7 +2372,7 @@ fn createHtmlElementT(self: *Page, comptime E: type, namespace: Element.Namespac const node = element.asNode(); if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) { @call(.auto, @field(E.Build, "created"), .{ node, self }) catch |err| { - log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err }); + log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); return err; }; } @@ -2646,7 +2782,7 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod pub fn attributeChange(self: *Page, element: *Element, name: String, value: String, old_value: ?String) void { _ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| { - log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err }); + log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type }); }; Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self); @@ -2655,7 +2791,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyAttributeChange(element, name, old_value, self) catch |err| { - log.err(.page, "attributeChange.notifyObserver", .{ .err = err }); + log.err(.page, "attributeChange.notifyObserver", .{ .err = err, .type = self._type }); }; } @@ -2672,7 +2808,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: String) void { _ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| { - log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err }); + log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type }); }; Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self); @@ -2681,7 +2817,7 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyAttributeChange(element, name, old_value, self) catch |err| { - log.err(.page, "attributeRemove.notifyObserver", .{ .err = err }); + log.err(.page, "attributeRemove.notifyObserver", .{ .err = err, .type = self._type }); }; } @@ -2698,11 +2834,11 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void { self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| { - log.err(.page, "signalSlotChange.put", .{ .err = err }); + log.err(.page, "signalSlotChange.put", .{ .err = err, .type = self._type }); return; }; self.scheduleSlotchangeDelivery() catch |err| { - log.err(.page, "signalSlotChange.schedule", .{ .err = err }); + log.err(.page, "signalSlotChange.schedule", .{ .err = err, .type = self._type }); }; } @@ -2742,7 +2878,7 @@ fn updateElementAssignedSlot(self: *Page, element: *Element) void { // Recursively search through the shadow root for a matching slot if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| { self._element_assigned_slots.put(self.arena, element, slot) catch |err| { - log.err(.page, "updateElementAssignedSlot.put", .{ .err = err }); + log.err(.page, "updateElementAssignedSlot.put", .{ .err = err, .type = self._type }); }; } } @@ -2789,7 +2925,7 @@ pub fn characterDataChange( while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyCharacterDataChange(target, old_value, self) catch |err| { - log.err(.page, "cdataChange.notifyObserver", .{ .err = err }); + log.err(.page, "cdataChange.notifyObserver", .{ .err = err, .type = self._type }); }; } } @@ -2816,7 +2952,7 @@ pub fn childListChange( while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| { - log.err(.page, "childListChange.notifyObserver", .{ .err = err }); + log.err(.page, "childListChange.notifyObserver", .{ .err = err, .type = self._type }); }; } } @@ -2867,7 +3003,17 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { } self.scriptAddedCallback(from_parser, script) catch |err| { - log.err(.page, "page.nodeIsReady", .{ .err = err }); + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type }); + return err; + }; + } else if (node.is(Element.Html.IFrame)) |iframe| { + if ((comptime from_parser == false) and iframe._src.len == 0) { + // iframe was added via JavaScript, but without a src + return; + } + + self.iframeAddedCallback(iframe) catch |err| { + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type }); return err; }; } @@ -3015,6 +3161,7 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { .node = target, .x = x, .y = y, + .type = self._type, }); } const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{ @@ -3049,12 +3196,12 @@ pub fn handleClick(self: *Page, target: *Node) !void { // Check target attribute - don't navigate if opening in new window/tab const target_val = anchor.getTarget(); if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) { - log.warn(.not_implemented, "a.target", .{}); + log.warn(.not_implemented, "a.target", .{ .type = self._type }); return; } if (try element.hasAttribute(comptime .wrap("download"), self)) { - log.warn(.browser, "a.download", .{}); + log.warn(.browser, "a.download", .{ .type = self._type }); return; } @@ -3091,6 +3238,7 @@ pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { .url = self.url, .node = element, .key = keyboard_event._key, + .type = self._type, }); } try self._event_manager.dispatch(element.asEventTarget(), event); @@ -3258,6 +3406,10 @@ test "WebApi: Page" { try testing.htmlRunner("page", .{}); } +test "WebApi: Frames" { + try testing.htmlRunner("frames", .{}); +} + test "WebApi: Integration" { try testing.htmlRunner("integration", .{}); } diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 570ef743..19e20825 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -83,6 +83,10 @@ imported_modules: std.StringHashMapUnmanaged(ImportedModule), // importmap contains resolved urls. importmap: std.StringHashMapUnmanaged([:0]const u8), +// have we notified the page that all scripts are loaded (used to fire the "load" +// event). +page_notified_of_completion: bool, + pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager { return .{ .page = page, @@ -96,6 +100,7 @@ pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) Script .client = http_client, .static_scripts_done = false, .buffer_pool = BufferPool.init(allocator, 5), + .page_notified_of_completion = false, .script_pool = std.heap.MemoryPool(Script).init(allocator), }; } @@ -570,10 +575,9 @@ fn evaluate(self: *ScriptManager) void { // Page makes this safe to call multiple times. page.documentIsLoaded(); - if (self.async_scripts.first == null) { - // Looks like all async scripts are done too! - // Page makes this safe to call multiple times. - page.documentIsComplete(); + if (self.async_scripts.first == null and self.page_notified_of_completion == false) { + self.page_notified_of_completion = true; + page.scriptsCompletedLoading(); } } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 2d0f87ce..0b9228bd 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -105,13 +105,12 @@ pub fn deinit(self: *Session) void { pub fn createPage(self: *Session) !*Page { lp.assert(self.page == null, "Session.createPage - page not null", .{}); - self.page = @as(Page, undefined); - const page = &self.page.?; - const id = self.page_id_gen +% 1; self.page_id_gen = id; - try Page.init(page, id, self); + self.page = @as(Page, undefined); + const page = &self.page.?; + try Page.init(page, id, self, null); // Creates a new NavigationEventTarget for this page. try self.navigation.onNewPage(page); @@ -150,13 +149,14 @@ pub fn replacePage(self: *Session) !*Page { var current = self.page.?; const page_id = current.id; + const parent = current._parent; current.deinit(); self.browser.env.memoryPressureNotification(.moderate); self.page = @as(Page, undefined); const page = &self.page.?; - try Page.init(page, page_id, self); + try Page.init(page, page_id, self, parent); return page; } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index de9c4849..115b6156 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -47,9 +47,6 @@ isolate: js.Isolate, // from this, and we can free it when the context is done. handle: v8.Global, -// True if the context is auto-entered, -entered: bool, - cpu_profiler: ?*v8.CpuProfiler = null, heap_profiler: ?*v8.HeapProfiler = null, @@ -247,11 +244,6 @@ pub fn deinit(self: *Context) void { v8.v8__Global__Reset(global); } } - - if (self.entered) { - v8.v8__Context__Exit(@ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle))); - } - v8.v8__Global__Reset(&self.handle); } @@ -333,11 +325,14 @@ pub fn localScope(self: *Context, ls: *js.Local.Scope) void { const isolate = self.isolate; js.HandleScope.init(&ls.handle_scope, isolate); + const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)); + v8.v8__Context__Enter(local_v8_context); + // TODO: add and init ls.hs for the handlescope ls.local = .{ .ctx = self, - .handle = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)), .isolate = isolate, + .handle = local_v8_context, .call_arena = self.call_arena, }; } @@ -364,7 +359,6 @@ pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.G extra = "(e)"; } const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0); - const js_val = try ls.local.compileAndRun(full, null); if (!js_val.isFunction()) { return error.StringFunctionError; diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index ef0cc1f6..c51c055e 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -175,8 +175,23 @@ pub fn init(app: *App, opts: InitOpts) !Env { .data = null, .flags = v8.kOnlyInterceptStrings | v8.kNonMasking, }); + // I don' 100% understand this. We actually set this up in the snapshot, + // but for the global instance, it doesn't work. SetIndexedHandler and + // SetNamedHandler are set on the Instance template, and that's the key + // difference. The context has its own global instance, so we need to set + // these back up directly on it. There might be a better way to do this. + v8.v8__ObjectTemplate__SetIndexedHandler(global_template_local, &.{ + .getter = Window.JsApi.index.getter, + .setter = null, + .query = null, + .deleter = null, + .enumerator = null, + .definer = null, + .descriptor = null, + .data = null, + .flags = 0, + }); v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal); - private_symbols = PrivateSymbols.init(isolate_handle); } @@ -225,7 +240,7 @@ pub fn deinit(self: *Env) void { allocator.destroy(self.isolate_params); } -pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { +pub fn createContext(self: *Env, page: *Page) !*Context { const context_arena = try self.app.arena_pool.acquire(); errdefer self.app.arena_pool.release(context_arena); @@ -264,13 +279,6 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { var global_global: v8.Global = undefined; v8.v8__Global__New(isolate.handle, global_obj, &global_global); - if (enter) { - v8.v8__Context__Enter(v8_context); - } - errdefer if (enter) { - v8.v8__Context__Exit(v8_context); - }; - const context_id = self.context_id; self.context_id = context_id + 1; @@ -279,7 +287,6 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { .env = self, .page = page, .id = context_id, - .entered = enter, .isolate = isolate, .arena = context_arena, .handle = context_global, diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index e9d9d9d9..e786c176 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -1326,6 +1326,7 @@ pub const Scope = struct { handle_scope: js.HandleScope, pub fn deinit(self: *Scope) void { + v8.v8__Context__Exit(self.local.handle); self.handle_scope.deinit(); } diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html new file mode 100644 index 00000000..00403f3a --- /dev/null +++ b/src/browser/tests/frames/frames.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + diff --git a/src/browser/tests/frames/support/sub1.html b/src/browser/tests/frames/support/sub1.html new file mode 100644 index 00000000..f6b8ec4b --- /dev/null +++ b/src/browser/tests/frames/support/sub1.html @@ -0,0 +1,6 @@ + +
sub1 div1
+ diff --git a/src/browser/tests/frames/support/sub2.html b/src/browser/tests/frames/support/sub2.html new file mode 100644 index 00000000..ca1aaa21 --- /dev/null +++ b/src/browser/tests/frames/support/sub2.html @@ -0,0 +1,7 @@ + +
sub2 div1
+ + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 62c8473f..314fc001 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -220,4 +220,8 @@ return val; }); } + + if (window._lightpanda_skip_auto_assert !== true) { + window.addEventListener('load', testing.assertOk); + } })(); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 124da937..5bdfa5fb 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -679,7 +679,7 @@ pub fn normalize(self: *Node, page: *Page) !void { return self._normalize(page.call_arena, &buffer, page); } -pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError }!*Node { +pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError, IFrameLoadError }!*Node { const deep = deep_ orelse false; switch (self._type) { .cdata => |cd| { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d339d60b..80694dfa 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -52,6 +52,7 @@ const Allocator = std.mem.Allocator; const Window = @This(); _proto: *EventTarget, +_page: *Page, _document: *Document, _css: CSS = .init, _crypto: Crypto = .init, @@ -96,6 +97,21 @@ pub fn getWindow(self: *Window) *Window { return self; } +pub fn getTop(self: *Window) *Window { + var p = self._page; + while (p.parent) |parent| { + p = parent; + } + return p.window; +} + +pub fn getParent(self: *Window) *Window { + if (self._page.parent) |p| { + return p.window; + } + return self; +} + pub fn getDocument(self: *Window) *Document { return self._document; } @@ -388,23 +404,31 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { return decoded; } -pub fn getFrame(_: *Window, _: usize) !?*Window { - // TODO return the iframe's window. - return null; +pub fn getFrame(self: *Window, idx: usize) !?*Window { + const page = self._page; + const frames = page.frames.items; + if (idx >= frames.len) { + return null; + } + + if (page.frames_sorted == false) { + std.mem.sort(*Page, frames, {}, struct { + fn lessThan(_: void, a: *Page, b: *Page) bool { + const iframe_a = a.iframe orelse return false; + const iframe_b = b.iframe orelse return true; + + const pos = iframe_a.asNode().compareDocumentPosition(iframe_b.asNode()); + // Return true if a precedes b (a should come before b in sorted order) + return (pos & 0x04) != 0; // FOLLOWING bit: b follows a + } + }.lessThan); + page.frames_sorted = true; + } + return frames[idx].window; } pub fn getFramesLength(self: *const Window) u32 { - const TreeWalker = @import("TreeWalker.zig"); - var walker = TreeWalker.Full.init(self._document.asNode(), .{}); - - var ln: u32 = 0; - while (walker.next()) |node| { - if (node.is(Element.Html.IFrame) != null) { - ln += 1; - } - } - - return ln; + return @intCast(self._page.frames.items.len); } pub fn getScrollX(self: *const Window) u32 { @@ -716,10 +740,10 @@ pub const JsApi = struct { pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } }); - pub const top = bridge.accessor(Window.getWindow, null, .{}); + pub const top = bridge.accessor(Window.getTop, null, .{}); pub const self = bridge.accessor(Window.getWindow, null, .{}); pub const window = bridge.accessor(Window.getWindow, null, .{}); - pub const parent = bridge.accessor(Window.getWindow, null, .{}); + pub const parent = bridge.accessor(Window.getParent, null, .{}); pub const navigator = bridge.accessor(Window.getNavigator, null, .{}); pub const screen = bridge.accessor(Window.getScreen, null, .{}); pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{}); diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 5f2cb867..8f7ebe95 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -342,6 +342,7 @@ pub fn click(self: *HtmlElement, page: *Page) !void { try page._event_manager.dispatch(self.asEventTarget(), event); } +<<<<<<< HEAD // TODO: Per spec, hidden is a tristate: true | false | "until-found". // We only support boolean for now; "until-found" would need bridge union support. pub fn getHidden(self: *HtmlElement) bool { @@ -373,13 +374,14 @@ pub fn setTabIndex(self: *HtmlElement, value: i32, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("tabindex"), .wrap(str), page); } -fn getAttributeFunction( + +pub fn getAttributeFunction( self: *HtmlElement, listener_type: GlobalEventHandler, page: *Page, ) !?js.Function.Global { const element = self.asElement(); - if (page.getAttrListener(element, listener_type)) |cached| { + if (page._element_attr_listeners.get(.{ .target = element.asEventTarget(), .handler = listener_type })) |cached| { return cached; } diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 4aa65df0..7d4d183f 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -16,15 +16,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Window = @import("../../Window.zig"); +const Document = @import("../../Document.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const URL = @import("../../URL.zig"); const IFrame = @This(); _proto: *HtmlElement, +_src: []const u8 = "", +_executed: bool = false, +_content_window: ?*Window = null, pub fn asElement(self: *IFrame) *Element { return self._proto._proto; @@ -33,8 +39,27 @@ pub fn asNode(self: *IFrame) *Node { return self.asElement().asNode(); } -pub fn getContentWindow(_: *const IFrame, page: *Page) *Window { - return page.window; +pub fn getContentWindow(self: *const IFrame) ?*Window { + return self._content_window; +} + +pub fn getContentDocument(self: *const IFrame) ?*Document { + const window = self._content_window orelse return null; + return window._document; +} + +pub fn getSrc(self: *const IFrame, page: *Page) ![:0]const u8 { + if (self._src.len == 0) return ""; + return try URL.resolve(page.call_arena, page.base(), self._src, .{}); +} + +pub fn setSrc(self: *IFrame, src: []const u8, page: *Page) !void { + const element = self.asElement(); + try element.setAttributeSafe(comptime .wrap("src"), .wrap(src), page); + self._src = element.getAttributeSafe(comptime .wrap("src")) orelse unreachable; + if (element.asNode().isConnected()) { + try page.iframeAddedCallback(self); + } } pub const JsApi = struct { @@ -46,5 +71,15 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{}); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); + pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); +}; + +pub const Build = struct { + pub fn complete(node: *Node, _: *Page) !void { + const self = node.as(IFrame); + const element = self.asElement(); + self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; + } }; diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index f7a3dd88..ae83ad17 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -763,7 +763,7 @@ const IsolatedWorld = struct { // Currently we have only 1 page/frame and thus also only 1 state in the isolate world. pub fn createContext(self: *IsolatedWorld, page: *Page) !*js.Context { if (self.context == null) { - self.context = try self.browser.env.createContext(page, false); + self.context = try self.browser.env.createContext(page); } else { log.warn(.cdp, "not implemented", .{ .feature = "createContext: Not implemented second isolated context creation", diff --git a/src/testing.zig b/src/testing.zig index b79eacd4..62ec8870 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -414,6 +414,15 @@ fn runWebApiTest(test_file: [:0]const u8) !void { try_catch.init(&ls.local); defer try_catch.deinit(); + // by default, on load, testing.js will call testing.assertOk(). This makes our + // tests work well in a browser. But, for our test runner, we disable that + // and call it explicitly. This gives us better error messages. + ls.local.eval("window._lightpanda_skip_auto_assert = true;", "auto_assert") catch |err| { + const caught = try_catch.caughtOrError(arena_allocator, err); + std.debug.print("disable auto assert failure\nError: {f}\n", .{caught}); + return err; + }; + try page.navigate(url, .{}); _ = test_session.wait(2000);