Merge pull request #2237 from lightpanda-io/window_open

window.open
This commit is contained in:
Karl Seguin
2026-04-29 18:32:58 +08:00
committed by GitHub
5 changed files with 396 additions and 4 deletions

View File

@@ -534,6 +534,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
} else if (self.parent) |parent| {
self.origin = parent.origin;
} else if (self.window._opener) |opener| {
self.origin = opener._frame.origin;
} else {
self.origin = null;
}
@@ -1338,6 +1340,69 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
}
}
const OpenPopupOpts = struct {
url: []const u8,
name: []const u8,
opener: ?*Window,
};
// Create a new top-level browsing context as a sibling of the root frame.
// The popup shares the Page's arena, factory, and identity map, but has no
// parent and is not attached to the frame tree — it lives in page.popups.
pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
const page = self._page;
const session = self._session;
const resolved_url: [:0]const u8 = blk: {
if (opts.url.len == 0) {
break :blk "about:blank";
}
if (std.mem.eql(u8, opts.url, "about:blank")) {
break :blk "about:blank";
}
const frame_base = base_blk: {
var frame = self;
while (true) {
const maybe_base = frame.base();
if (!std.mem.eql(u8, maybe_base, "about:blank")) {
break :base_blk maybe_base;
}
frame = frame.parent orelse break :base_blk "";
}
};
break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .always_dupe = true, .encoding = self.charset });
};
const popup = try page.frame_arena.create(Frame);
errdefer page.frame_arena.destroy(popup);
const frame_id = session.nextFrameId();
try Frame.init(popup, frame_id, page, null);
errdefer popup.deinit(true);
popup.window._opener = opts.opener;
if (opts.name.len > 0 and
!std.ascii.eqlIgnoreCase(opts.name, "_blank") and
!std.ascii.eqlIgnoreCase(opts.name, "_self") and
!std.ascii.eqlIgnoreCase(opts.name, "_parent") and
!std.ascii.eqlIgnoreCase(opts.name, "_top"))
{
popup.window._name = try page.frame_arena.dupe(u8, opts.name);
}
const popup_index = page.popups.items.len;
try page.popups.append(page.frame_arena, popup);
// not impossible that navigate adds popups, so remove by index
errdefer _ = page.popups.swapRemove(popup_index);
popup.navigate(resolved_url, .{ .reason = .script }) catch |err| {
log.warn(.frame, "popup navigate failure", .{ .url = resolved_url, .err = err });
return err;
};
return popup;
}
pub fn domChanged(self: *Frame) void {
self.version += 1;
@@ -3527,7 +3592,7 @@ pub const QueuedNavigation = struct {
/// to the appropriateFrame to navigate.
/// Returns null if the target is "_blank" (which would open a new window/tab).
/// Note: Callers should handle empty target separately (for owner document resolution).
fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame {
pub fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame {
if (std.ascii.eqlIgnoreCase(target_name, "_self")) {
return self;
}

View File

@@ -88,6 +88,14 @@ queued_queued_navigation: std.ArrayList(*Frame) = .empty,
// The root Frame of this Page. Non-optional — a Page always has a root frame.
frame: Frame,
// Popup Frames opened by window.open. They are top-level browsing contexts
// (parent == null, no iframe element) but share this Page's factory, arena,
// and identity map.
// Their lifetime is bound to the Page: on Page.deinit they
// are torn down. TODO: this is far from correct. An new window shouldn't be tied
// to the original page like this.
popups: std.ArrayList(*Frame) = .empty,
// Initialize a Page and its root Frame.
pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena");
@@ -107,6 +115,11 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
// Tear down the Page and its root Frame. Equivalent to the old
// Session.removePage + Session.resetFrameResources.
pub fn deinit(self: *Page, abort_http: bool) void {
for (self.popups.items) |popup| {
popup.deinit(abort_http);
}
self.popups = .empty;
self.frame.deinit(abort_http);
const session = self.session;
@@ -217,6 +230,16 @@ pub fn findFrameByFrameId(self: *Page, frame_id: u32) ?*Frame {
return findFrameBy(&self.frame, "_frame_id", frame_id);
}
// Returns the popup Frame registered under `name`, or null.
pub fn findPopupByName(self: *Page, name: []const u8) ?*Frame {
for (self.popups.items) |popup| {
if (std.mem.eql(u8, popup.window._name, name)) {
return popup;
}
}
return null;
}
pub fn findFrameByLoaderId(self: *Page, loader_id: u32) ?*Frame {
return findFrameBy(&self.frame, "_loader_id", loader_id);
}

View File

@@ -281,6 +281,13 @@ pub fn processQueuedNavigation(self: *Session) !void {
}
fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void {
// Popups live on the Page as top-level browsing contexts without a
// parent or iframe element. Their re-navigation path is simpler than
// iframes — no parent bookkeeping to patch.
if (frame.parent == null and frame.iframe == null) {
return self.processPopupNavigation(frame, qn);
}
lp.assert(frame.parent != null, "root queued navigation", .{});
const iframe = frame.iframe.?;
@@ -331,6 +338,45 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
};
}
// Re-navigates a popup Frame in place. The Frame pointer stays stable
// (scripts in the opener may hold a cached Window ref — though the Window
// object inside is replaced, matching how iframes behave on navigation).
fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void {
frame._queued_navigation = null;
defer self.releaseArena(qn.arena);
// Preserve popup identity fields. _name lives in the Page arena and
// survives Frame.deinit; _opener is just a pointer.
const saved_name = frame.window._name;
const saved_opener = frame.window._opener;
const frame_id = frame._frame_id;
const page = self.currentPage().?;
frame.deinit(true);
frame.* = undefined;
errdefer {
// If re-init fails, drop from popups so we don't leave a corpse.
for (page.popups.items, 0..) |p, i| {
if (p == frame) {
_ = page.popups.swapRemove(i);
break;
}
}
}
try Frame.init(frame, frame_id, page, null);
errdefer frame.deinit(true);
frame.window._name = saved_name;
frame.window._opener = saved_opener;
frame.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued popup navigation error", .{ .err = err });
return err;
};
}
fn processRootQueuedNavigation(self: *Session) !void {
const current_frame = &self.page.?.frame;
const frame_id = current_frame._frame_id;

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<script id=basic>
{
// window.open returns a Window-like object.
const w = window.open('about:blank');
testing.expectEqual(true, w != null);
testing.expectEqual(false, w.closed);
// The popup is a top-level browsing context.
testing.expectEqual(w, w.self);
testing.expectEqual(w, w.window);
testing.expectEqual(w, w.top);
testing.expectEqual(w, w.parent);
// Opener points back at us.
testing.expectEqual(window, w.opener);
w.close();
testing.expectEqual(true, w.closed);
}
</script>
<script id=main_opener_is_null>
testing.expectEqual(null, window.opener);
</script>
<script id=about_blank_default>
{
// No-arg open defaults to about:blank.
const w = window.open();
testing.expectEqual(true, w != null);
testing.expectEqual(false, w.closed);
w.close();
}
</script>
<script id=noopener>
{
// noopener returns null.
const w = window.open('about:blank', '_blank', 'noopener');
testing.expectEqual(null, w);
}
</script>
<script id=noreferrer>
{
// noreferrer also returns null (and implies noopener).
const w = window.open('about:blank', '_blank', 'noreferrer');
testing.expectEqual(null, w);
}
</script>
<script id=named_reuse>
{
// Second open with the same name reuses the popup — same Window identity.
const a = window.open('about:blank', 'myPopup');
testing.expectEqual('myPopup', a.name);
const b = window.open('about:blank', 'myPopup');
testing.expectEqual(a, b);
a.close();
}
</script>
<script id=close_noop_on_main>
// close() on a non-popup window is a no-op.
window.close();
testing.expectEqual(false, window.closed);
</script>
<script id=double_close>
{
const w = window.open('about:blank');
w.close();
testing.expectEqual(true, w.closed);
// Second close is safe.
w.close();
testing.expectEqual(true, w.closed);
}
</script>
<script id=name_setter>
{
const w = window.open('about:blank');
testing.expectEqual('', w.name);
w.name = 'renamed';
testing.expectEqual('renamed', w.name);
// Now it can be looked up by name.
const again = window.open('about:blank', 'renamed');
testing.expectEqual(w, again);
w.close();
}
</script>
<script id=close_drops_opener>
{
// Closing a popup that was opened from main shouldn't touch window.opener.
const w = window.open('about:blank', 'outer');
testing.expectEqual(window, w.opener);
w.close();
testing.expectEqual(true, w.closed);
testing.expectEqual(null, window.opener);
}
</script>

View File

@@ -96,6 +96,18 @@ _scroll_pos: struct {
// A cross origin wrapper for this window
_cross_origin_wrapper: CrossOriginWindow,
// The Window that called window.open to create this one. Null for the root
// window, for noopener popups, and cleared if the opener is torn down while
// we're still alive. Only valid if `!_opener.?._closed`.
_opener: ?*Window = null,
// True after our Frame has been deinit'd by window.close. Many things on the
// window become invalid once this is true.
_closed: bool = false,
// Popup name (owned by page.arena)
_name: []const u8 = "",
pub fn asEventTarget(self: *Window) *EventTarget {
return self._proto;
}
@@ -112,6 +124,25 @@ pub fn getWindow(self: *Window) *Window {
return self;
}
pub fn getOpener(self: *Window, frame: *Frame) ?Access {
const opener = self._opener orelse return null;
if (opener._closed) return null;
return Access.init(frame.window, opener);
}
pub fn getClosed(self: *const Window) bool {
return self._closed;
}
pub fn getName(self: *const Window) []const u8 {
return self._name;
}
pub fn setName(self: *Window, name: []const u8, frame: *Frame) !void {
// Store in the Page's frame arena so the slice outlives any call_arena.
self._name = try frame.arena.dupe(u8, name);
}
pub fn getTop(self: *Window, frame: *Frame) Access {
var p = self._frame;
while (p.parent) |parent| {
@@ -425,6 +456,108 @@ pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]
return CSSStyleProperties.init(element, true, frame);
}
// window.open(url?, target?, features?) — v1 scope:
// * Always creates a new popup Frame on the Page (sibling to the root).
// * Honors `noopener` / `noreferrer` tokens in `features` (opener=null,
// return value=null). Geometry (width, height, ...) ignored.
// * `target` values `_self` / `_parent` / `_top` navigate the current frame.
// Any other value is treated as a popup name; reusing a live name
// navigates the existing popup instead of spawning a new one.
// * `url` empty or missing opens about:blank.
pub fn open(self: *Window, url_: ?[]const u8, target_: ?[]const u8, features_: ?[]const u8, frame: *Frame) !?Access {
const raw_url = url_ orelse "";
const target = target_ orelse "";
const features = features_ orelse "";
const no_opener = hasFeatureToken(features, "noopener") or hasFeatureToken(features, "noreferrer");
// _self / _parent / _top navigate the current browsing context.
if (std.ascii.eqlIgnoreCase(target, "_self") or
std.ascii.eqlIgnoreCase(target, "_parent") or
std.ascii.eqlIgnoreCase(target, "_top"))
{
const nav_target = frame.resolveTargetFrame(target) orelse frame;
const nav_url = if (raw_url.len == 0) "about:blank" else raw_url;
try frame.scheduleNavigation(nav_url, .{
.reason = .script,
.kind = .{ .push = null },
}, .{ .script = nav_target });
if (no_opener) {
return null;
}
return Access.init(frame.window, nav_target.window);
}
const page = frame._page;
// Name-based reuse: if a popup with this name already exists, reuse it.
// `_blank` is reserved and never reuses.
const is_named = target.len > 0 and !std.ascii.eqlIgnoreCase(target, "_blank");
if (is_named) {
if (page.findPopupByName(target)) |existing| {
if (raw_url.len > 0) {
try existing.scheduleNavigation(raw_url, .{
.reason = .script,
.kind = .{ .push = null },
}, .{ .script = existing });
}
if (no_opener) {
return null;
}
return Access.init(frame.window, existing.window);
}
}
// Spawn a new popup Frame as a sibling of the root.
const popup = try frame.openPopup(.{
.url = raw_url,
.name = target,
.opener = if (no_opener) null else self,
});
if (no_opener) {
return null;
}
return Access.init(frame.window, popup.window);
}
pub fn close(self: *Window) void {
if (self._closed) {
return;
}
// Per spec, close() is only honored on script-opened windows. That
// maps exactly to membership in page.popups.
const frame = self._frame;
const page = frame._page;
var popup_index: usize = 0;
while (popup_index < page.popups.items.len) : (popup_index += 1) {
if (page.popups.items[popup_index] == frame) {
break;
}
} else return;
self._closed = true;
// Any live Window holding us as its opener must drop the reference —
// our Frame is about to go away, and a stale _frame deref on their
// side would crash.
for (page.popups.items) |popup| {
if (popup.window._opener == self) {
popup.window._opener = null;
}
}
if (page.frame.window._opener == self) {
page.frame.window._opener = null;
}
_ = page.popups.swapRemove(popup_index);
frame.deinit(true);
}
pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, frame: *Frame) !void {
// For now, we ignore targetOrigin checking and just dispatch the message
// In a full implementation, we would validate the origin
@@ -834,6 +967,20 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global {
};
}
// Checks whether a window.open features string contains a token, matched
// case-insensitively on whole-token boundaries (comma or whitespace separated).
// The features syntax is legacy and loose; the only tokens we interpret are
// noopener and noreferrer.
fn hasFeatureToken(features: []const u8, token: []const u8) bool {
var it = std.mem.tokenizeAny(u8, features, " \t\r\n,");
while (it.next()) |raw| {
// Trim a trailing =value if present — we only need the key.
const key = if (std.mem.indexOfScalarPos(u8, raw, 0, '=')) |eq| raw[0..eq] else raw;
if (std.ascii.eqlIgnoreCase(key, token)) return true;
}
return false;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Window);
@@ -913,9 +1060,11 @@ pub const JsApi = struct {
pub const innerHeight = bridge.property(1080, .{ .template = false });
pub const devicePixelRatio = bridge.property(1, .{ .template = false });
// This should return a window-like object in specific conditions. Would be
// pretty complicated to properly support I think.
pub const opener = bridge.property(null, .{ .template = false });
pub const opener = bridge.accessor(Window.getOpener, null, .{});
pub const closed = bridge.accessor(Window.getClosed, null, .{});
pub const name = bridge.accessor(Window.getName, Window.setName, .{});
pub const open = bridge.function(Window.open, .{});
pub const close = bridge.function(Window.close, .{});
pub const alert = bridge.function(struct {
fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void {