Initial support for frames

Missing:

- [ ] Navigation support within frames (in fact, as-is, any navigation done
      inside a frame, will almost certainly break things
- [ ] Correct CDP support. I don't know how frames are supposed to be exposed
      to CDP. Normal navigate events? Distinct CDP frame_ids?
- [ ] Cross-origin restrictions. The interaction between frames is supposed to
      change depending on whether or not they're on the same origin
- [ ] Potentially handling src-less frames incorrectly. Might not really matter

Adds basic frame support. Initially explored adding a BrowsingContext and
embedding it in Page, with the goal of also having it embedded in a to-be
created Frame. But it turns out that 98% of Page _was_ BrowsingContext and
introducing a BrowsingContext as the primary interaction unit broke pretty much
_every_ single WebAPI. So Page was expanded:

- Added `_parent: ?*Page`, which is `null` for "root" page.
- Added `frame: ?*IFrame`, which is `null` for the "root" page. This is the
  HTMLIFrameElement for frame-pages.
- Added a _type: enum{root, frame}, which is currently only used to improve
  the logs
- Added a frames: std.ArrayList(*Page). This is a list of frames for the page.
  Note that a "frame-page" can itself haven nested frames.

Besides the above, there were 3 "big" changes.

1 - Adding frames (dynamically, parsed) has to create a new page, start
    navigation, track it (in the frames list). Part of this was just
    piggybacking off of code that handles <script>

2 - The page "load" event blocks on the frame "load" event. This cascades.
    when a page triggers it's load, it can do:
```zig
      if (self._parent) |p| {
        p.iframeLoaded(self);
      }
```
   Pages need to keep track of how many iframes they're waiting to load. When
   all iframes (and all scripts) are loaded, it can then triggers its own load
   event.

3 - Our JS execution expects 1 primary entered context (the pages). But we now
    have multiple page contexts, and we need to be in the correct one based
    on where javascript is being executed. There is no more an default entered
    context. Creating a Local.Scope enters the context, and ls.deinit() exits
    the context.
This commit is contained in:
Karl Seguin
2026-02-16 17:43:38 +08:00
parent 938cd5e136
commit 081979be3b
17 changed files with 437 additions and 114 deletions

View File

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

View File

@@ -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", .{});
}

View File

@@ -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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script>
function frame1Onload() {
window.f1_onload = true;
}
</script>
<iframe id=f1 onload="frame1Onload" src="support/sub1.html"></iframe>
<iframe id=f2 src="support/sub2.html"></iframe>
<script id="basic">
testing.eventually(() => {
testing.expectEqual(undefined, window[10]);
testing.expectEqual(window, window[0].top);
testing.expectEqual(window, window[0].parent);
testing.expectEqual(false, window === window[0]);
testing.expectEqual(window, window[1].top);
testing.expectEqual(window, window[1].parent);
testing.expectEqual(false, window === window[1]);
testing.expectEqual(false, window[0] === window[1]);
testing.expectEqual(0, $('#f1').childNodes.length);
testing.expectEqual(window[0], $('#f1').contentWindow);
testing.expectEqual(window[1], $('#f2').contentWindow);
testing.expectEqual(window[0].document, $('#f1').contentDocument);
testing.expectEqual(window[1].document, $('#f2').contentDocument);
// sibling frames share the same top
testing.expectEqual(window[0].top, window[1].top);
// child frames have no sub-frames
testing.expectEqual(0, window[0].length);
testing.expectEqual(0, window[1].length);
// self and window are self-referential on child frames
testing.expectEqual(window[0], window[0].self);
testing.expectEqual(window[0], window[0].window);
testing.expectEqual(window[1], window[1].self);
// child frame's top.parent is itself (root has no parent)
testing.expectEqual(window, window[0].top.parent);
// testing.expectEqual(true, window.sub1_loaded);
// testing.expectEqual(true, window.sub2_loaded);
});
</script>
<script id=onload>
{
let f3_load_event = false;
let f3 = document.createElement('iframe');
f3.addEventListener('load', () => {
f3_load_event = true;
});
f3.src = 'invalid'; // still fires load!
document.documentElement.appendChild(f3);
testing.eventually(() => {
testing.expectEqual(true, window.f1_onload);
testing.expectEqual(true, f3_load_event);
});
}
</script>
<script id=count>
testing.eventually(() => {
testing.expectEqual(3, window.length);
});
</script>

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<div id=div-1>sub1 div1</div>
<script>
// should not have access to the parent's JS context
window.top.sub1_loaded = window.testing == undefined;
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<div id=div-1>sub2 div1</div>
<script>
// should not have access to the parent's JS context
window.top.sub2_loaded = window.testing == undefined;
</script>

View File

@@ -220,4 +220,8 @@
return val;
});
}
if (window._lightpanda_skip_auto_assert !== true) {
window.addEventListener('load', testing.assertOk);
}
})();

View File

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

View File

@@ -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, .{});

View File

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

View File

@@ -16,15 +16,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 "";
}
};

View File

@@ -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",

View File

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