From d5807bf8a0cfa0ad50c71c2e43cc6e041ebbd7ab Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 08:05:24 +0800 Subject: [PATCH 01/61] mem: Switch Element's _type to a bare tag Follows https://github.com/lightpanda-io/browser/pull/3104. Also applies to Html and Svg. A div chain shrinks by 24 bytes. --- src/browser/Factory.zig | 2 +- src/browser/dump.zig | 2 +- src/browser/frame/user_input.zig | 18 +- src/browser/js/Local.zig | 10 +- src/browser/parser/Parser.zig | 2 +- src/browser/webapi/Element.zig | 519 +++++++++--------- src/browser/webapi/collections/node_live.zig | 2 +- .../webapi/css/CSSStyleDeclaration.zig | 8 +- src/browser/webapi/element/Html.zig | 258 ++++++--- src/browser/webapi/element/Svg.zig | 120 ++-- src/browser/webapi/element/html/Label.zig | 2 +- src/browser/webapi/element/html/Media.zig | 42 +- src/browser/webapi/element/popover.zig | 2 +- src/browser/webapi/element/svg/Geometry.zig | 66 ++- .../webapi/element/svg/GradientElement.zig | 36 +- src/browser/webapi/element/svg/Graphics.zig | 80 ++- .../webapi/element/svg/TextContent.zig | 40 +- .../webapi/element/svg/TextPositioning.zig | 36 +- 18 files changed, 767 insertions(+), 478 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 151cab9d7..109a03fc4 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -474,7 +474,7 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO const svg_ptr = chain.get(i); svg_ptr.* = .{ ._tag_name = try String.init(self._arena, tag_name, .{}), - ._type = unionInit(Element.Svg.Type, chain.get(i + 1)), + ._type = typeInit(Element.Svg, chain.get(i + 1)), }; setProto(svg_ptr, chain.get(i - 1)); } else { diff --git a/src/browser/dump.zig b/src/browser/dump.zig index c2db77472..89c35423b 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -261,7 +261,7 @@ fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, frame: *Fram fn isVoidElement(el: *const Node.Element) bool { return switch (el._type) { - .html => |html| switch (html._type) { + .html => switch (el.subtype(Node.Element.Html)._type) { .br, .hr, .img, .input, .link, .meta => true, else => false, }, diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 385919b5b..153d8c0db 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -208,7 +208,7 @@ fn hasClickActivationBehavior(node: *Node) bool { return switch (html_element._type) { .anchor => element.getAttributeSafe(comptime .wrap("href")) != null, .input, .button, .select, .textarea, .label => true, - .generic => |generic| generic._tag == .summary, + .generic => html_element.subtype(Element.Html.Generic)._tag == .summary, else => false, }; } @@ -328,7 +328,8 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { const html_element = element.is(Element.Html) orelse return; switch (html_element._type) { - .anchor => |anchor| { + .anchor => { + const anchor = html_element.subtype(Element.Html.Anchor); const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; if (href.len == 0) { return; @@ -363,7 +364,8 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { .kind = .{ .push = null }, }, .{ .anchor = target_frame }); }, - .input => |input| { + .input => { + const input = html_element.subtype(Element.Html.Input); try element.focus(frame); // Per HTML §4.10.18.6.4 "Image Button state (type=image)", clicking an // image button submits its form. The form-data set already gets the @@ -373,14 +375,16 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { return frame.submitForm(element, input.getForm(frame), .{}); } }, - .button => |button| { + .button => { + const button = html_element.subtype(Element.Html.Button); try element.focus(frame); if (std.mem.eql(u8, button.getType(), "submit")) { return frame.submitForm(element, button.getForm(frame), .{}); } }, .select, .textarea => try element.focus(frame), - .label => |label| { + .label => { + const label = html_element.subtype(Element.Html.Label); // Per HTML §4.10.4 "The label element", a label's activation // behavior is to run the synthetic click activation steps on the // labeled control. Mirrors Chrome's HTMLLabelElement::DefaultEventHandler. @@ -388,8 +392,8 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { const control_html = control.is(Element.Html) orelse return; try control_html.click(frame); }, - .generic => |generic| { - switch (generic._tag) { + .generic => { + switch (html_element.subtype(Element.Html.Generic)._tag) { .summary => { const parent_el = target.parentElement() orelse return; const details = parent_el.is(Element.Html.Details) orelse return; diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index e84766246..0d65665c2 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -1267,7 +1267,15 @@ pub fn resolveValue(value: anytype) Resolved { // (e.g. CData); the type maps the tag to the member's type. if (comptime @typeInfo(@TypeOf(value._type)) == .@"enum" and @hasDecl(T, "Subtype")) { switch (value._type) { - inline else => |tag| return resolveValue(value.subtype(T.Subtype(tag))), + inline else => |tag| { + const S = T.Subtype(tag); + if (S == T) { + // A tag can map to the type itself (e.g. Media.generic); + // the value is already the most specific type. + return resolveT(T, value); + } + return resolveValue(value.subtype(S)); + }, } } diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 1de032884..c8d9ac364 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -600,7 +600,7 @@ fn getTemplateContentsCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque { const element = node.as(Element); - const template = element._type.html.is(Element.Html.Template) orelse unreachable; + const template = element.subtype(Element.Html).is(Element.Html.Template) orelse unreachable; const content_node = template.getContent().asNode(); // Create a ParsedNode wrapper for the content DocumentFragment diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 180addb36..4cdf07cc2 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -115,15 +115,35 @@ _attributes: Attribute.List = .{}, // work to resolve the proto). _proto_canary: if (lp.IS_DEBUG) *Node else void = undefined, -pub const Type = union(enum) { - html: *Html, - svg: *Svg, +pub const Type = enum(u8) { + html, + svg, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .html => Html, + .svg => Svg, + }; +} + +pub fn subtype(self: *const Element, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Element); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Element, comptime T: type) ?*T { const type_name = @typeName(T); switch (self._type) { - .html => |el| { + .html => { + const el = self.subtype(Html); if (T == Html) { return el; } @@ -131,7 +151,8 @@ pub fn is(self: *Element, comptime T: type) ?*T { return el.is(T); } }, - .svg => |svg| { + .svg => { + const svg = self.subtype(Svg); if (T == Svg) { return svg; } @@ -178,177 +199,183 @@ pub fn isEqualNode(self: *Element, other: *Element) bool { pub fn getTagNameLower(self: *const Element) []const u8 { switch (self._type) { - .html => |he| switch (he._type) { - .custom => |ce| { - @branchHint(.unlikely); - return ce._tag_name.str(); - }, - else => return switch (he._type) { - .anchor => "a", - .area => "area", - .base => "base", - .body => "body", - .br => "br", - .button => "button", - .canvas => "canvas", - .custom => |e| e._tag_name.str(), - .data => "data", - .datalist => "datalist", - .details => "details", - .dialog => "dialog", - .directory => "dir", - .div => "div", - .dl => "dl", - .embed => "embed", - .fieldset => "fieldset", - .font => "font", - .frameset => "frameset", - .form => "form", - .generic => |e| e._tag_name.str(), - .heading => |e| e._tag_name.str(), - .head => "head", - .html => "html", - .hr => "hr", - .iframe => "iframe", - .img => "img", - .input => "input", - .label => "label", - .legend => "legend", - .li => "li", - .link => "link", - .map => "map", - .marquee => "marquee", - .media => |m| switch (m._type) { - .audio => "audio", - .video => "video", - .generic => "media", + .html => { + const he = self.subtype(Html); + switch (he._type) { + .custom => { + @branchHint(.unlikely); + return he.subtype(Html.Custom)._tag_name.str(); }, - .meta => "meta", - .meter => "meter", - .mod => |e| e._tag_name.str(), - .object => "object", - .ol => "ol", - .optgroup => "optgroup", - .option => "option", - .output => "output", - .p => "p", - .picture => "picture", - .param => "param", - .pre => "pre", - .progress => "progress", - .quote => |e| e._tag_name.str(), - .script => "script", - .select => "select", - .slot => "slot", - .source => "source", - .span => "span", - .style => "style", - .table => "table", - .table_caption => "caption", - .table_cell => |e| e._tag_name.str(), - .table_col => |e| e._tag_name.str(), - .table_row => "tr", - .table_section => |e| e._tag_name.str(), - .template => "template", - .textarea => "textarea", - .time => "time", - .title => "title", - .track => "track", - .ul => "ul", - .unknown => |e| e._tag_name.str(), - }, + else => return switch (he._type) { + .anchor => "a", + .area => "area", + .base => "base", + .body => "body", + .br => "br", + .button => "button", + .canvas => "canvas", + .custom => he.subtype(Html.Custom)._tag_name.str(), + .data => "data", + .datalist => "datalist", + .details => "details", + .dialog => "dialog", + .directory => "dir", + .div => "div", + .dl => "dl", + .embed => "embed", + .fieldset => "fieldset", + .font => "font", + .frameset => "frameset", + .form => "form", + .generic => he.subtype(Html.Generic)._tag_name.str(), + .heading => he.subtype(Html.Heading)._tag_name.str(), + .head => "head", + .html => "html", + .hr => "hr", + .iframe => "iframe", + .img => "img", + .input => "input", + .label => "label", + .legend => "legend", + .li => "li", + .link => "link", + .map => "map", + .marquee => "marquee", + .media => switch (he.subtype(Html.Media)._type) { + .audio => "audio", + .video => "video", + .generic => "media", + }, + .meta => "meta", + .meter => "meter", + .mod => he.subtype(Html.Mod)._tag_name.str(), + .object => "object", + .ol => "ol", + .optgroup => "optgroup", + .option => "option", + .output => "output", + .p => "p", + .picture => "picture", + .param => "param", + .pre => "pre", + .progress => "progress", + .quote => he.subtype(Html.Quote)._tag_name.str(), + .script => "script", + .select => "select", + .slot => "slot", + .source => "source", + .span => "span", + .style => "style", + .table => "table", + .table_caption => "caption", + .table_cell => he.subtype(Html.TableCell)._tag_name.str(), + .table_col => he.subtype(Html.TableCol)._tag_name.str(), + .table_row => "tr", + .table_section => he.subtype(Html.TableSection)._tag_name.str(), + .template => "template", + .textarea => "textarea", + .time => "time", + .title => "title", + .track => "track", + .ul => "ul", + .unknown => he.subtype(Html.Unknown)._tag_name.str(), + }, + } }, - .svg => |svg| return svg._tag_name.str(), + .svg => return self.subtype(Svg)._tag_name.str(), } } pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { return switch (self._type) { - .html => |he| switch (he._type) { - .anchor => "A", - .area => "AREA", - .base => "BASE", - .body => "BODY", - .br => "BR", - .button => "BUTTON", - .canvas => "CANVAS", - .custom => |e| upperTagName(&e._tag_name, buf), - .data => "DATA", - .datalist => "DATALIST", - .details => "DETAILS", - .dialog => "DIALOG", - .directory => "DIR", - .div => "DIV", - .dl => "DL", - .embed => "EMBED", - .fieldset => "FIELDSET", - .font => "FONT", - .frameset => "FRAMESET", - .form => "FORM", - .generic => |e| upperTagName(&e._tag_name, buf), - .heading => |e| upperTagName(&e._tag_name, buf), - .head => "HEAD", - .html => "HTML", - .hr => "HR", - .iframe => "IFRAME", - .img => "IMG", - .input => "INPUT", - .label => "LABEL", - .legend => "LEGEND", - .li => "LI", - .link => "LINK", - .map => "MAP", - .marquee => "MARQUEE", - .meta => "META", - .media => |m| switch (m._type) { - .audio => "AUDIO", - .video => "VIDEO", - .generic => "MEDIA", - }, - .meter => "METER", - .mod => |e| upperTagName(&e._tag_name, buf), - .object => "OBJECT", - .ol => "OL", - .optgroup => "OPTGROUP", - .option => "OPTION", - .output => "OUTPUT", - .p => "P", - .picture => "PICTURE", - .param => "PARAM", - .pre => "PRE", - .progress => "PROGRESS", - .quote => |e| upperTagName(&e._tag_name, buf), - .script => "SCRIPT", - .select => "SELECT", - .slot => "SLOT", - .source => "SOURCE", - .span => "SPAN", - .style => "STYLE", - .table => "TABLE", - .table_caption => "CAPTION", - .table_cell => |e| upperTagName(&e._tag_name, buf), - .table_col => |e| upperTagName(&e._tag_name, buf), - .table_row => "TR", - .table_section => |e| upperTagName(&e._tag_name, buf), - .template => "TEMPLATE", - .textarea => "TEXTAREA", - .time => "TIME", - .title => "TITLE", - .track => "TRACK", - .ul => "UL", - .unknown => |e| switch (self._namespace) { - .html => upperTagName(&e._tag_name, buf), - .svg, .xml, .mathml, .unknown, .null => e._tag_name.str(), - }, + .html => blk: { + const he = self.subtype(Html); + break :blk switch (he._type) { + .anchor => "A", + .area => "AREA", + .base => "BASE", + .body => "BODY", + .br => "BR", + .button => "BUTTON", + .canvas => "CANVAS", + .custom => upperTagName(&he.subtype(Html.Custom)._tag_name, buf), + .data => "DATA", + .datalist => "DATALIST", + .details => "DETAILS", + .dialog => "DIALOG", + .directory => "DIR", + .div => "DIV", + .dl => "DL", + .embed => "EMBED", + .fieldset => "FIELDSET", + .font => "FONT", + .frameset => "FRAMESET", + .form => "FORM", + .generic => upperTagName(&he.subtype(Html.Generic)._tag_name, buf), + .heading => upperTagName(&he.subtype(Html.Heading)._tag_name, buf), + .head => "HEAD", + .html => "HTML", + .hr => "HR", + .iframe => "IFRAME", + .img => "IMG", + .input => "INPUT", + .label => "LABEL", + .legend => "LEGEND", + .li => "LI", + .link => "LINK", + .map => "MAP", + .marquee => "MARQUEE", + .meta => "META", + .media => switch (he.subtype(Html.Media)._type) { + .audio => "AUDIO", + .video => "VIDEO", + .generic => "MEDIA", + }, + .meter => "METER", + .mod => upperTagName(&he.subtype(Html.Mod)._tag_name, buf), + .object => "OBJECT", + .ol => "OL", + .optgroup => "OPTGROUP", + .option => "OPTION", + .output => "OUTPUT", + .p => "P", + .picture => "PICTURE", + .param => "PARAM", + .pre => "PRE", + .progress => "PROGRESS", + .quote => upperTagName(&he.subtype(Html.Quote)._tag_name, buf), + .script => "SCRIPT", + .select => "SELECT", + .slot => "SLOT", + .source => "SOURCE", + .span => "SPAN", + .style => "STYLE", + .table => "TABLE", + .table_caption => "CAPTION", + .table_cell => upperTagName(&he.subtype(Html.TableCell)._tag_name, buf), + .table_col => upperTagName(&he.subtype(Html.TableCol)._tag_name, buf), + .table_row => "TR", + .table_section => upperTagName(&he.subtype(Html.TableSection)._tag_name, buf), + .template => "TEMPLATE", + .textarea => "TEXTAREA", + .time => "TIME", + .title => "TITLE", + .track => "TRACK", + .ul => "UL", + .unknown => switch (self._namespace) { + .html => upperTagName(&he.subtype(Html.Unknown)._tag_name, buf), + .svg, .xml, .mathml, .unknown, .null => he.subtype(Html.Unknown)._tag_name.str(), + }, + }; }, - .svg => |svg| svg._tag_name.str(), + .svg => self.subtype(Svg)._tag_name.str(), }; } pub fn getTagNameDump(self: *const Element) []const u8 { switch (self._type) { .html => return self.getTagNameLower(), - .svg => |svg| return svg._tag_name.str(), + .svg => return self.subtype(Svg)._tag_name.str(), } } @@ -1916,81 +1943,84 @@ fn upperTagName(tag_name: *String, buf: []u8) []const u8 { pub fn getTag(self: *const Element) Tag { return switch (self._type) { - .html => |he| switch (he._type) { - .anchor => .anchor, - .area => .area, - .base => .base, - .div => .div, - .dl => .dl, - .embed => .embed, - .form => .form, - .p => .p, - .custom => .custom, - .data => .data, - .datalist => .datalist, - .details => .details, - .dialog => .dialog, - .directory => .directory, - .iframe => .iframe, - .img => .img, - .br => .br, - .button => .button, - .canvas => .canvas, - .fieldset => .fieldset, - .font => .font, - .frameset => .frameset, - .heading => |h| h._tag, - .label => .label, - .legend => .legend, - .li => .li, - .map => .map, - .marquee => .marquee, - .ul => .ul, - .ol => .ol, - .object => .object, - .optgroup => .optgroup, - .output => .output, - .picture => .picture, - .param => .param, - .pre => .pre, - .generic => |g| g._tag, - .media => |m| switch (m._type) { - .audio => .audio, - .video => .video, - .generic => .media, - }, - .meter => .meter, - .mod => |m| m._tag, - .progress => .progress, - .quote => |q| q._tag, - .script => .script, - .select => .select, - .slot => .slot, - .source => .source, - .span => .span, - .option => .option, - .table => .table, - .table_caption => .caption, - .table_cell => |tc| tc._tag, - .table_col => |tc| tc._tag, - .table_row => .tr, - .table_section => |ts| ts._tag, - .template => .template, - .textarea => .textarea, - .time => .time, - .track => .track, - .input => .input, - .link => .link, - .meta => .meta, - .hr => .hr, - .style => .style, - .title => .title, - .body => .body, - .html => .html, - .head => .head, - .unknown => .unknown, + .html => blk: { + const he = self.subtype(Html); + break :blk switch (he._type) { + .anchor => .anchor, + .area => .area, + .base => .base, + .div => .div, + .dl => .dl, + .embed => .embed, + .form => .form, + .p => .p, + .custom => .custom, + .data => .data, + .datalist => .datalist, + .details => .details, + .dialog => .dialog, + .directory => .directory, + .iframe => .iframe, + .img => .img, + .br => .br, + .button => .button, + .canvas => .canvas, + .fieldset => .fieldset, + .font => .font, + .frameset => .frameset, + .heading => he.subtype(Html.Heading)._tag, + .label => .label, + .legend => .legend, + .li => .li, + .map => .map, + .marquee => .marquee, + .ul => .ul, + .ol => .ol, + .object => .object, + .optgroup => .optgroup, + .output => .output, + .picture => .picture, + .param => .param, + .pre => .pre, + .generic => he.subtype(Html.Generic)._tag, + .media => switch (he.subtype(Html.Media)._type) { + .audio => .audio, + .video => .video, + .generic => .media, + }, + .meter => .meter, + .mod => he.subtype(Html.Mod)._tag, + .progress => .progress, + .quote => he.subtype(Html.Quote)._tag, + .script => .script, + .select => .select, + .slot => .slot, + .source => .source, + .span => .span, + .option => .option, + .table => .table, + .table_caption => .caption, + .table_cell => he.subtype(Html.TableCell)._tag, + .table_col => he.subtype(Html.TableCol)._tag, + .table_row => .tr, + .table_section => he.subtype(Html.TableSection)._tag, + .template => .template, + .textarea => .textarea, + .time => .time, + .track => .track, + .input => .input, + .link => .link, + .meta => .meta, + .hr => .hr, + .style => .style, + .title => .title, + .body => .body, + .html => .html, + .head => .head, + .unknown => .unknown, + }; }, - .svg => |se| se.getTag(), + .svg => self.subtype(Svg).getTag(), }; } @@ -2353,22 +2383,21 @@ pub const Build = struct { // Calls `func_name` with `args` on the most specific type where it is // implement. This could be on the Element itself. pub fn call(self: *const Element, comptime func_name: []const u8, args: anytype) !bool { - inline for (@typeInfo(Element.Type).@"union".fields) |f| { - if (@field(Element.Type, f.name) == self._type) { - // The inner type implements this function. Call it and we're done. - const S = reflect.Struct(f.type); + switch (self._type) { + inline else => |tag| { + const S = Subtype(tag); if (@hasDecl(S, "Build")) { + // The inner type has its own "call" method. Defer to it. if (@hasDecl(S.Build, "call")) { - const sub = @field(self._type, f.name); - return S.Build.call(sub, func_name, args); + return S.Build.call(self.subtype(S), func_name, args); } // The inner type implements this function. Call it and we're done. - if (@hasDecl(f.type, func_name)) { - return @call(.auto, @field(f.type, func_name), args); + if (@hasDecl(S, func_name)) { + return @call(.auto, @field(S, func_name), args); } } - } + }, } if (@hasDecl(Element.Build, func_name)) { diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index c96f9b179..884aefaf6 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -407,7 +407,7 @@ pub fn NodeLive(comptime mode: Mode) type { fn isFormControl(el: *Element) bool { if (el._type != .html) return false; - const html = el._type.html; + const html = el.subtype(Element.Html); return switch (html._type) { .input, .button, .select, .textarea => true, else => false, diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 80121f58d..862c89ff2 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -802,8 +802,8 @@ fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, name: String) []con fn getDefaultDisplay(element: *const Element) []const u8 { switch (element._type) { - .html => |html| { - return switch (html._type) { + .html => { + return switch (element.subtype(Element.Html)._type) { .anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline", .body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .frameset, .legend, .map, .marquee, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block", .generic, .custom, .unknown, .data => blk: { @@ -835,8 +835,8 @@ fn isInlineTag(tag_name: []const u8) bool { fn getDefaultColor(element: *const Element) []const u8 { switch (element._type) { - .html => |html| { - return switch (html._type) { + .html => { + return switch (element.subtype(Element.Html)._type) { .anchor => "rgb(0, 0, 238)", // blue else => "rgb(0, 0, 0)", }; diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 64e96684d..d8d6d0574 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -130,87 +130,169 @@ pub fn upgradeConstruct(frame: *Frame) !*Element { return node.is(Element) orelse return error.TypeError; } -pub const Type = union(enum) { - anchor: *Anchor, - area: *Area, - base: *Base, - body: *Body, - br: *BR, - button: *Button, - canvas: *Canvas, - custom: *Custom, - data: *Data, - datalist: *DataList, - details: *Details, - dialog: *Dialog, - directory: *Directory, - div: *Div, - dl: *DList, - embed: *Embed, - fieldset: *FieldSet, - font: *Font, - form: *Form, - frameset: *FrameSet, - generic: *Generic, - heading: *Heading, - head: *Head, - html: *Html, - hr: *HR, - img: *Image, - iframe: *IFrame, - input: *Input, - label: *Label, - legend: *Legend, - li: *LI, - link: *Link, - map: *Map, - marquee: *Marquee, - media: *Media, - meta: *Meta, - meter: *Meter, - mod: *Mod, - object: *Object, - ol: *OL, - optgroup: *OptGroup, - option: *Option, - output: *Output, - p: *Paragraph, - picture: *Picture, - param: *Param, - pre: *Pre, - progress: *Progress, - quote: *Quote, - script: *Script, - select: *Select, - slot: *Slot, - source: *Source, - span: *Span, - style: *Style, - table: *Table, - table_caption: *TableCaption, - table_cell: *TableCell, - table_col: *TableCol, - table_row: *TableRow, - table_section: *TableSection, - template: *Template, - textarea: *TextArea, - time: *Time, - title: *Title, - track: *Track, - ul: *UL, - unknown: *Unknown, +pub const Type = enum(u8) { + anchor, + area, + base, + body, + br, + button, + canvas, + custom, + data, + datalist, + details, + dialog, + directory, + div, + dl, + embed, + fieldset, + font, + form, + frameset, + generic, + heading, + head, + html, + hr, + img, + iframe, + input, + label, + legend, + li, + link, + map, + marquee, + media, + meta, + meter, + mod, + object, + ol, + optgroup, + option, + output, + p, + picture, + param, + pre, + progress, + quote, + script, + select, + slot, + source, + span, + style, + table, + table_caption, + table_cell, + table_col, + table_row, + table_section, + template, + textarea, + time, + title, + track, + ul, + unknown, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .anchor => Anchor, + .area => Area, + .base => Base, + .body => Body, + .br => BR, + .button => Button, + .canvas => Canvas, + .custom => Custom, + .data => Data, + .datalist => DataList, + .details => Details, + .dialog => Dialog, + .directory => Directory, + .div => Div, + .dl => DList, + .embed => Embed, + .fieldset => FieldSet, + .font => Font, + .form => Form, + .frameset => FrameSet, + .generic => Generic, + .heading => Heading, + .head => Head, + .html => Html, + .hr => HR, + .img => Image, + .iframe => IFrame, + .input => Input, + .label => Label, + .legend => Legend, + .li => LI, + .link => Link, + .map => Map, + .marquee => Marquee, + .media => Media, + .meta => Meta, + .meter => Meter, + .mod => Mod, + .object => Object, + .ol => OL, + .optgroup => OptGroup, + .option => Option, + .output => Output, + .p => Paragraph, + .picture => Picture, + .param => Param, + .pre => Pre, + .progress => Progress, + .quote => Quote, + .script => Script, + .select => Select, + .slot => Slot, + .source => Source, + .span => Span, + .style => Style, + .table => Table, + .table_caption => TableCaption, + .table_cell => TableCell, + .table_col => TableCol, + .table_row => TableRow, + .table_section => TableSection, + .template => Template, + .textarea => TextArea, + .time => Time, + .title => Title, + .track => Track, + .ul => UL, + .unknown => Unknown, + }; +} + +pub fn subtype(self: *const HtmlElement, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, HtmlElement); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *HtmlElement, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |f| { - if (@field(Type, f.name) == self._type) { - if (f.type == T) { - return &@field(self._type, f.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - if (f.type == *T) { - return @field(self._type, f.name); - } - } + }, } return null; } @@ -304,8 +386,8 @@ pub fn insertAdjacentHTML( pub fn click(self: *HtmlElement, frame: *Frame) !void { switch (self._type) { - inline .button, .input, .textarea, .select => |i| { - if (i.getDisabled()) { + inline .button, .input, .textarea, .select => |tag| { + if (self.subtype(Subtype(tag)).getDisabled()) { return; } }, @@ -330,8 +412,8 @@ pub fn click(self: *HtmlElement, frame: *Frame) !void { if (event._prevent_default == false) { // toggle the popover_target const explicit: ?*Element = switch (self._type) { - .button => |b| b._popover_target, - .input => |i| i._popover_target, + .button => self.subtype(Button)._popover_target, + .input => self.subtype(Input)._popover_target, else => null, }; try popover.runInvokerActivation(self, explicit, frame); @@ -543,7 +625,7 @@ fn setAttributeListener( ) !void { if (comptime lp.IS_DEBUG) { log.debug(.event, "Html.setAttributeListener", .{ - .type = std.meta.activeTag(self._type), + .type = self._type, .listener_type = listener_type, }); } @@ -1492,14 +1574,14 @@ fn collectInnerText(self: *HtmlElement, state: *InnerTextState) std.Io.Writer.Er const e = child.subtype(Node.Element); switch (e._type) { .svg => {}, - .html => |he| { + .html => { const tag = e.getTag(); switch (child_filter) { .none => {}, .select => if (tag != .option and tag != .optgroup) continue, .optgroup => if (tag != .option) continue, } - try handleChildElement(he, tag, state, &saw_cell, &saw_row); + try handleChildElement(e.subtype(HtmlElement), tag, state, &saw_cell, &saw_row); }, } }, @@ -1870,17 +1952,17 @@ pub const Build = struct { // Calls `func_name` with `args` on the most specific type where it is // implement. This could be on the HtmlElement itself. pub fn call(self: *const HtmlElement, comptime func_name: []const u8, args: anytype) !bool { - inline for (@typeInfo(HtmlElement.Type).@"union".fields) |f| { - if (@field(HtmlElement.Type, f.name) == self._type) { + switch (self._type) { + inline else => |tag| { + const S = Subtype(tag); // The inner type implements this function. Call it and we're done. - const S = reflect.Struct(f.type); if (@hasDecl(S, "Build")) { if (@hasDecl(S.Build, func_name)) { try @call(.auto, @field(S.Build, func_name), args); return true; } } - } + }, } if (@hasDecl(HtmlElement.Build, func_name)) { diff --git a/src/browser/webapi/element/Svg.zig b/src/browser/webapi/element/Svg.zig index a7a39cfe3..39960ec0b 100644 --- a/src/browser/webapi/element/Svg.zig +++ b/src/browser/webapi/element/Svg.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); @@ -47,64 +48,99 @@ _type: Type, _tag_name: String, // Svg elements are case-preserving _proto_canary: if (lp.IS_DEBUG) *Element else void = undefined, -pub const Type = union(enum) { - graphics: *Graphics, - view: *View, - title: *Title, - desc: *Desc, - metadata: *Metadata, - gradient: *GradientElement, - clip_path: *ClipPath, - marker: *Marker, - mask: *Mask, - pattern: *Pattern, - stop: *Stop, - generic: *Generic, +pub const Type = enum(u8) { + graphics, + view, + title, + desc, + metadata, + gradient, + clip_path, + marker, + mask, + pattern, + stop, + generic, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .graphics => Graphics, + .view => View, + .title => Title, + .desc => Desc, + .metadata => Metadata, + .gradient => GradientElement, + .clip_path => ClipPath, + .marker => Marker, + .mask => Mask, + .pattern => Pattern, + .stop => Stop, + .generic => Generic, + }; +} + +pub fn subtype(self: *const Svg, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Svg); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Svg, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) { - return @field(self._type, field.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } if (self._type == .graphics) { - return self._type.graphics.is(T); + return self.subtype(Graphics).is(T); } if (self._type == .gradient) { - return self._type.gradient.is(T); + return self.subtype(GradientElement).is(T); } return null; } pub fn getTag(self: *const Svg) Element.Tag { return switch (self._type) { - .graphics => |g| switch (g._type) { - .svg => .svg, - .g => .g, - // No dedicated Element.Tag values; tag-name matching falls back - // to _tag_name, like it does for generic SVG elements. - .a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown, - .text_content => |content| switch (content._type) { - .positioning => |positioning| switch (positioning._type) { - .text => .text, - .tspan => .unknown, + .graphics => blk: { + const g = self.subtype(Graphics); + break :blk switch (g._type) { + .svg => .svg, + .g => .g, + // No dedicated Element.Tag values; tag-name matching falls back + // to _tag_name, like it does for generic SVG elements. + .a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown, + .text_content => tc: { + const content = g.subtype(Graphics.TextContent); + break :tc switch (content._type) { + .positioning => switch (content.subtype(Graphics.TextContent.TextPositioning)._type) { + .text => .text, + .tspan => .unknown, + }, + .text_path => .unknown, + }; }, - .text_path => .unknown, - }, - .geometry => |geo| switch (geo._type) { - .rect => .rect, - .circle => .circle, - .ellipse => .ellipse, - .line => .line, - .path => .path, - .polygon => .polygon, - .polyline => .polyline, - }, + .geometry => switch (g.subtype(Graphics.Geometry)._type) { + .rect => .rect, + .circle => .circle, + .ellipse => .ellipse, + .line => .line, + .path => .path, + .polygon => .polygon, + .polyline => .polyline, + }, + }; }, - .generic => |g| g._tag, + .generic => self.subtype(Generic)._tag, .title => .title, .view, .desc, .metadata, .gradient, .clip_path, .marker, .mask, .pattern, .stop => .unknown, }; diff --git a/src/browser/webapi/element/html/Label.zig b/src/browser/webapi/element/html/Label.zig index 7616c0e2c..fd4da498f 100644 --- a/src/browser/webapi/element/html/Label.zig +++ b/src/browser/webapi/element/html/Label.zig @@ -49,7 +49,7 @@ fn isLabelable(el: *Element) bool { const html = el.is(HtmlElement) orelse return false; return switch (html._type) { .button, .meter, .output, .progress, .select, .textarea => true, - .input => |input| input._input_type != .hidden, + .input => html.subtype(HtmlElement.Input)._input_type != .hidden, else => false, }; } diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index 8e3af790c..823dcee38 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -49,12 +49,34 @@ pub const NetworkState = enum(u16) { NETWORK_NO_SOURCE = 3, }; -pub const Type = union(enum) { +pub const Type = enum(u8) { generic, - audio: *Audio, - video: *Video, + audio, + video, }; +// `.generic` maps to Media itself: a bare chain ends at Media, so the +// tag has no chain member of its own. +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .generic => Media, + .audio => Audio, + .video => Video, + }; +} + +pub fn subtype(self: *const Media, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Media); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + _type: Type, _proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _paused: bool = true, @@ -77,18 +99,10 @@ pub fn asNode(self: *Media) *Node { } pub fn is(self: *Media, comptime T: type) ?*T { - const type_name = @typeName(T); switch (self._type) { - .audio => |a| { - if (T == *Audio) return a; - if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Audio")) { - return a; - } - }, - .video => |v| { - if (T == *Video) return v; - if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Video")) { - return v; + inline .audio, .video => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } }, .generic => {}, diff --git a/src/browser/webapi/element/popover.zig b/src/browser/webapi/element/popover.zig index 68c490bc4..4912473de 100644 --- a/src/browser/webapi/element/popover.zig +++ b/src/browser/webapi/element/popover.zig @@ -207,7 +207,7 @@ pub fn invokerTarget(invoker: *Node, explicit: ?*Element, frame: *Frame) ?*Eleme pub fn runInvokerActivation(invoker: *HtmlElement, explicit: ?*Element, frame: *Frame) !void { switch (invoker._type) { .button => {}, - .input => |input| switch (input._input_type) { + .input => switch (invoker.subtype(HtmlElement.Input)._input_type) { .button, .submit, .reset, .image => {}, else => return, // not an invoker }, diff --git a/src/browser/webapi/element/svg/Geometry.zig b/src/browser/webapi/element/svg/Geometry.zig index 7df09b2ce..5f54155da 100644 --- a/src/browser/webapi/element/svg/Geometry.zig +++ b/src/browser/webapi/element/svg/Geometry.zig @@ -45,23 +45,47 @@ pub const Proto = Graphics; _type: Type, _proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, -pub const Type = union(enum) { - rect: *Rect, - circle: *Circle, - ellipse: *Ellipse, - line: *Line, - path: *Path, - polygon: *Polygon, - polyline: *Polyline, +pub const Type = enum(u8) { + rect, + circle, + ellipse, + line, + path, + polygon, + polyline, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .rect => Rect, + .circle => Circle, + .ellipse => Ellipse, + .line => Line, + .path => Path, + .polygon => Polygon, + .polyline => Polyline, + }; +} + +pub fn subtype(self: *const Geometry, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Geometry); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Geometry, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |f| { - if (@field(Type, f.name) == self._type) { - if (f.type == *T) { - return @field(self._type, f.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } return null; } @@ -107,16 +131,16 @@ pub fn getPointAtLength(self: *Geometry, distance: f64, frame: *Frame) !*DOMPoin pub fn buildPath(self: *Geometry, frame: *Frame) !PathData.Path { return switch (self._type) { - .rect => |rect| buildRect(rect, frame), - .circle => |circle| buildCircle(circle, frame), - .ellipse => |ellipse| buildEllipse(ellipse, frame), - .line => |line| buildLine(line, frame), - .path => |path| PathData.parse( - path.asElement().getAttributeSafe(comptime .wrap("d")) orelse "", + .rect => buildRect(self.subtype(Rect), frame), + .circle => buildCircle(self.subtype(Circle), frame), + .ellipse => buildEllipse(self.subtype(Ellipse), frame), + .line => buildLine(self.subtype(Line), frame), + .path => PathData.parse( + self.subtype(Path).asElement().getAttributeSafe(comptime .wrap("d")) orelse "", frame.local_arena, ), - .polygon => |polygon| buildPoints(try polygon.getPoints(frame), true, frame), - .polyline => |polyline| buildPoints(try polyline.getPoints(frame), false, frame), + .polygon => buildPoints(try self.subtype(Polygon).getPoints(frame), true, frame), + .polyline => buildPoints(try self.subtype(Polyline).getPoints(frame), false, frame), }; } diff --git a/src/browser/webapi/element/svg/GradientElement.zig b/src/browser/webapi/element/svg/GradientElement.zig index c63d460df..26eabb90f 100644 --- a/src/browser/webapi/element/svg/GradientElement.zig +++ b/src/browser/webapi/element/svg/GradientElement.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -39,18 +40,37 @@ pub const Proto = Svg; _type: Type, _proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, -pub const Type = union(enum) { - linear: *LinearGradient, - radial: *RadialGradient, +pub const Type = enum(u8) { + linear, + radial, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .linear => LinearGradient, + .radial => RadialGradient, + }; +} + +pub fn subtype(self: *const GradientElement, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, GradientElement); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *GradientElement, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) { - return @field(self._type, field.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } return null; } diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig index 8668ba3b1..e4b4f9d05 100644 --- a/src/browser/webapi/element/svg/Graphics.zig +++ b/src/browser/webapi/element/svg/Graphics.zig @@ -49,33 +49,61 @@ pub const Proto = SvgElement; _type: Type, _proto_canary: if (lp.IS_DEBUG) *SvgElement else void = undefined, -pub const Type = union(enum) { - svg: *Svg, - g: *G, - a: *A, - use: *Use, - image: *Image, - defs: *Defs, - symbol: *Symbol, - switch_element: *Switch, - foreign_object: *ForeignObject, - text_content: *TextContent, - geometry: *Geometry, +pub const Type = enum(u8) { + svg, + g, + a, + use, + image, + defs, + symbol, + switch_element, + foreign_object, + text_content, + geometry, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .svg => Svg, + .g => G, + .a => A, + .use => Use, + .image => Image, + .defs => Defs, + .symbol => Symbol, + .switch_element => Switch, + .foreign_object => ForeignObject, + .text_content => TextContent, + .geometry => Geometry, + }; +} + +pub fn subtype(self: *const Graphics, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Graphics); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Graphics, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |f| { - if (@field(Type, f.name) == self._type) { - if (f.type == *T) { - return @field(self._type, f.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } if (self._type == .geometry) { - return self._type.geometry.is(T); + return self.subtype(Geometry).is(T); } if (self._type == .text_content) { - return self._type.text_content.is(T); + return self.subtype(TextContent).is(T); } return null; } @@ -108,12 +136,12 @@ pub const JsApi = struct { pub fn getBBox(self: *Graphics, frame: *Frame) !*DOMRect { var bounds: PathData.Bounds = .{}; switch (self._type) { - .geometry => |geometry| { - var path = try geometry.buildPath(frame); + .geometry => { + var path = try self.subtype(Geometry).buildPath(frame); defer path.deinit(frame.local_arena); bounds = path.bounds(.{}); }, - .foreign_object => |foreign_object| bounds = try foreign_object.getBounds(frame), + .foreign_object => bounds = try self.subtype(ForeignObject).getBounds(frame), .g, .a, .svg => try accumulateChildren(self, .{}, &bounds, frame), .defs, .symbol, .switch_element, .use, .image, .text_content => {}, } @@ -156,13 +184,13 @@ fn accumulateChildren(parent: *Graphics, matrix: PathData.Matrix, bounds: *PathD const child_matrix = parent_matrix.multiply(transformMatrix(element)); switch (graphics._type) { - .geometry => |geometry| { - var path = try geometry.buildPath(frame); + .geometry => { + var path = try graphics.subtype(Geometry).buildPath(frame); defer path.deinit(frame.local_arena); bounds.merge(path.bounds(child_matrix)); }, - .foreign_object => |foreign_object| { - const child_bounds = try foreign_object.getBounds(frame); + .foreign_object => { + const child_bounds = try graphics.subtype(ForeignObject).getBounds(frame); if (!child_bounds.isEmpty()) { var foreign_path: PathData.Path = .{}; defer foreign_path.deinit(frame.local_arena); diff --git a/src/browser/webapi/element/svg/TextContent.zig b/src/browser/webapi/element/svg/TextContent.zig index 665453aca..4f8683fda 100644 --- a/src/browser/webapi/element/svg/TextContent.zig +++ b/src/browser/webapi/element/svg/TextContent.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -39,18 +40,39 @@ pub const Proto = Graphics; _type: Type, _proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, -pub const Type = union(enum) { - positioning: *TextPositioning, - text_path: *TextPath, +pub const Type = enum(u8) { + positioning, + text_path, }; -pub fn is(self: *TextContent, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) return @field(self._type, field.name); - } +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .positioning => TextPositioning, + .text_path => TextPath, + }; +} + +pub fn subtype(self: *const TextContent, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, TextContent); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); } - if (self._type == .positioning) return self._type.positioning.is(T); + return sub; +} + +pub fn is(self: *TextContent, comptime T: type) ?*T { + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); + } + }, + } + if (self._type == .positioning) return self.subtype(TextPositioning).is(T); return null; } diff --git a/src/browser/webapi/element/svg/TextPositioning.zig b/src/browser/webapi/element/svg/TextPositioning.zig index 91052aed8..61567a4ea 100644 --- a/src/browser/webapi/element/svg/TextPositioning.zig +++ b/src/browser/webapi/element/svg/TextPositioning.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); @@ -34,16 +35,37 @@ pub const Proto = TextContent; _type: Type, _proto_canary: if (lp.IS_DEBUG) *TextContent else void = undefined, -pub const Type = union(enum) { - text: *Text, - tspan: *TSpan, +pub const Type = enum(u8) { + text, + tspan, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .text => Text, + .tspan => TSpan, + }; +} + +pub fn subtype(self: *const TextPositioning, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, TextPositioning); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *TextPositioning, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) return @field(self._type, field.name); - } + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); + } + }, } return null; } From 93ca18036b2f3062a6c60d35e56eee65682ea007 Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Mon, 3 Aug 2026 23:03:17 -0400 Subject: [PATCH 02/61] Prevent V8 re-entry after requested termination Assisted-By: devx/e3d65847-fba6-490f-8cd2-6b88cf889c6f --- src/browser/js/Env.zig | 10 ++++------ src/browser/js/Function.zig | 24 ++++++++++++------------ src/browser/js/Script.zig | 6 +++--- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index d56a2d8a8..c240cb7de 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -544,13 +544,11 @@ pub fn dumpMemoryStats(self: *Env) void { } pub fn isExecutionTerminating(self: *const Env) bool { - return v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle); + return v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle) or self.terminatePending(); } -// Whether a forcible terminate has been requested (and not yet cleared by -// cancelTerminate). Unlike isExecutionTerminating, this is our own sticky -// flag, so it stays true after V8 consumes the terminate on the JSEntry -// unwind. Callers about to enter a fresh eval use it to refuse to run. +// Our sticky terminate flag remains true after V8 consumes the request while +// unwinding a JSEntry. isExecutionTerminating includes it to block fresh work. pub fn terminatePending(self: *const Env) bool { return self.terminate_requested.load(.acquire); } @@ -632,7 +630,7 @@ pub fn cancelTerminate(self: *Env) void { pub fn performIsolateMicrotasks(self: *Env) void { self.terminate_mutex.lockUncancelable(lp.io); defer self.terminate_mutex.unlock(lp.io); - if (v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle)) return; + if (self.isExecutionTerminating()) return; v8.v8__Isolate__PerformMicrotaskCheckpoint(self.isolate.handle); } diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index c0d8c7b1d..f9067922e 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -66,7 +66,7 @@ pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Objec } // See _tryCallWithThis for why a pending termination blocks V8 entry. - if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) { + if (local.ctx.env.isExecutionTerminating()) { return error.ExecutionTerminated; } @@ -77,7 +77,7 @@ pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Objec // This creates a new instance using this Function as a constructor. // const c_args = @as(?[*]const ?*c.Value, @ptrCast(&.{})); const handle = v8.v8__Function__NewInstance(self.handle, local.handle, 0, null) orelse { - if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) { + if (local.ctx.env.isExecutionTerminating()) { return error.ExecutionTerminated; } caught.* = try_catch.caughtOrError(local.call_arena, error.Unknown); @@ -158,7 +158,7 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args // A pending termination (watchdog / CDP-disconnect kill) must not be // followed by another V8 entry. Callers must treat ExecutionTerminated as // stop running JS and unwind". - if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) { + if (local.ctx.env.isExecutionTerminating()) { return error.ExecutionTerminated; } @@ -212,7 +212,7 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args defer try_catch.deinit(); const handle = v8.v8__Function__Call(self.handle, local.handle, js_this.handle, @as(c_int, @intCast(js_args.len)), c_args) orelse { - if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) { + if (local.ctx.env.isExecutionTerminating()) { // Terminated mid-call, not a JS throw: no rethrow, no reporting. return error.ExecutionTerminated; } @@ -265,7 +265,7 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global { } const testing = @import("../../testing.zig"); -test "Function: termination is classified and blocks re-entry" { +test "Function: requested termination is classified and blocks re-entry" { const frame = try testing.createFrame(); defer testing.test_session.closeAllPages(); @@ -286,24 +286,18 @@ test "Function: termination is classified and blocks re-entry" { probe_err: ?anyerror = null, fn kill(self: *@This()) void { - self.env.terminate(); + self.env.requestTerminate(); } fn probed(self: *@This()) void { self.probe_ran = true; } - // Runs at call depth >= 1: the killed inner call must leave the - // termination pending, and the follow-up call must refuse to enter V8 - // (running it would silently clear the pending termination). fn nested(self: *@This()) void { var caught: js.TryCatch.Caught = undefined; _ = self.f_kill.?.tryCall(void, .{}, &caught) catch |err| { self.kill_err = err; }; - _ = self.f_probe.?.tryCall(void, .{}, &caught) catch |err| { - self.probe_err = err; - }; } }; var state = State{ .env = env }; @@ -322,6 +316,12 @@ test "Function: termination is classified and blocks re-entry" { var caught: js.TryCatch.Caught = undefined; try testing.expectError(error.ExecutionTerminated, driver_fn.tryCall(void, .{nested_cb}, &caught)); try testing.expectEqual(error.ExecutionTerminated, state.kill_err.?); + try testing.expectEqual(true, env.terminatePending()); + try testing.expectEqual(false, v8.v8__Isolate__IsExecutionTerminating(env.isolate.handle)); + + _ = state.f_probe.?.tryCall(void, .{}, &caught) catch |err| { + state.probe_err = err; + }; try testing.expectEqual(error.ExecutionTerminated, state.probe_err.?); try testing.expectEqual(false, state.probe_ran); diff --git a/src/browser/js/Script.zig b/src/browser/js/Script.zig index 94404851e..08b28ef95 100644 --- a/src/browser/js/Script.zig +++ b/src/browser/js/Script.zig @@ -32,12 +32,12 @@ handle: *const v8.Script, pub fn run(self: Script) !js.Value { // See Function._tryCallWithThis for why a pending termination blocks V8 // entry and is distinct from a JS throw. - const isolate_handle = self.local.isolate.handle; - if (v8.v8__Isolate__IsExecutionTerminating(isolate_handle)) { + const env = self.local.ctx.env; + if (env.isExecutionTerminating()) { return error.ExecutionTerminated; } const result = v8.v8__Script__Run(self.handle, self.local.handle) orelse { - if (v8.v8__Isolate__IsExecutionTerminating(isolate_handle)) { + if (env.isExecutionTerminating()) { return error.ExecutionTerminated; } return error.JsException; From caaf53dc9f7c2ae818c0add244a9a922a419f660 Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Mon, 3 Aug 2026 11:06:31 -0400 Subject: [PATCH 03/61] Fix Playwright CDP session interception routing Assisted-By: devx/e284dddf-2391-42ce-9dff-5ce418d0ab2f --- src/cdp/CDP.zig | 40 ++++++++-- src/cdp/domains/fetch.zig | 57 ++++++++++++-- src/cdp/domains/target.zig | 152 +++++++++++++++++++++++++++++-------- src/network/HttpClient.zig | 53 +++++++++++++ 4 files changed, 256 insertions(+), 46 deletions(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 2ed72c51b..603124f58 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -440,15 +440,28 @@ fn dispatchCommand(command: *Command, method: []const u8) !void { return error.UnknownDomain; } -fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool { +pub fn resolveSessionId(self: *const CDP, input_session_id: []const u8) ?[]const u8 { if (self.browser_session_id) |browser_session_id| { if (std.mem.eql(u8, browser_session_id, input_session_id)) { - return true; + return browser_session_id; } } - const browser_context = &(self.browser_context orelse return false); - const session_id = browser_context.session_id orelse return false; - return std.mem.eql(u8, session_id, input_session_id); + const browser_context = &(self.browser_context orelse return null); + if (browser_context.session_id) |session_id| { + if (std.mem.eql(u8, session_id, input_session_id)) { + return session_id; + } + } + for (browser_context.attached_sessions.items) |session| { + if (std.mem.eql(u8, session.id, input_session_id)) { + return session.id; + } + } + return null; +} + +fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool { + return self.resolveSessionId(input_session_id) != null; } pub fn createBrowserContext(self: *CDP) ![]const u8 { @@ -508,6 +521,10 @@ pub const BrowserContext = struct { id: u32, }; + pub const AttachedSession = struct { + id: []const u8, + parent_id: ?[]const u8, + }; id: []const u8, cdp: *CDP, @@ -550,6 +567,7 @@ pub const BrowserContext = struct { // if we get a request with a sessionId that doesn't match the current one // we should reject it. session_id: ?[]const u8, + attached_sessions: std.ArrayList(AttachedSession) = .empty, security_origin: []const u8, page_life_cycle_events: bool, @@ -572,6 +590,7 @@ pub const BrowserContext = struct { extra_headers: std.ArrayList(http.Header) = .empty, intercept_state: InterceptState, + fetch_session_id: ?[]const u8 = null, // When network is enabled, we'll capture the transfer.id -> body // This is awfully memory intensive, but our underlying http client and @@ -820,17 +839,26 @@ pub const BrowserContext = struct { self.notification.unregister(.http_request_served_from_cache, self); } - pub fn fetchEnable(self: *BrowserContext, authRequests: bool) !void { + pub fn fetchEnable(self: *BrowserContext, authRequests: bool, session_id: []const u8) !void { self.fetchDisable(); //in case of multiple calls + self.fetch_session_id = session_id; try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept); if (authRequests) { try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired); } } + pub fn fetchDisableForSession(self: *BrowserContext, session_id: []const u8) void { + const active_session_id = self.fetch_session_id orelse return; + if (std.mem.eql(u8, active_session_id, session_id)) { + self.fetchDisable(); + } + } + pub fn fetchDisable(self: *BrowserContext) void { self.notification.unregister(.http_request_intercept, self); self.notification.unregister(.http_request_auth_required, self); + self.fetch_session_id = null; } pub fn lifecycleEventsEnable(self: *BrowserContext) !void { diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index c32bf0d03..5d09752b5 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -143,9 +143,16 @@ const ErrorReason = enum { BlockedByResponse, }; +fn commandSessionId(cmd: *CDP.Command, bc: *CDP.BrowserContext) ![]const u8 { + if (cmd.input.session_id) |session_id| { + return cmd.cdp.resolveSessionId(session_id) orelse error.UnknownSessionId; + } + return bc.session_id orelse error.UnknownSessionId; +} + fn disable(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - bc.fetchDisable(); + bc.fetchDisableForSession(try commandSessionId(cmd, bc)); return cmd.sendResult(null, .{}); } @@ -157,7 +164,7 @@ fn enable(cmd: *CDP.Command) !void { } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - try bc.fetchEnable(params.handleAuthRequests); + try bc.fetchEnable(params.handleAuthRequests, try commandSessionId(cmd, bc)); return cmd.sendResult(null, .{}); } @@ -189,9 +196,8 @@ fn arePatternsSupported(patterns: []RequestPattern) bool { } pub fn requestIntercept(bc: *CDP.BrowserContext, intercept: *const Notification.RequestIntercept) !void { - // detachTarget could be called, in which case, we still have a frame doing - // things, but no session. - const session_id = bc.session_id orelse return; + // The session that enabled Fetch owns its interception events. + const session_id = bc.fetch_session_id orelse return; // We keep it around to wait for modifications to the request. // TODO: What to do when receiving replies for a previous frame's requests? @@ -413,9 +419,7 @@ fn failRequest(cmd: *CDP.Command) !void { } pub fn requestAuthRequired(bc: *CDP.BrowserContext, intercept: *const Notification.RequestAuthRequired) !void { - // detachTarget could be called, in which case, we still have a frame doing - // things, but no session. - const session_id = bc.session_id orelse return; + const session_id = bc.fetch_session_id orelse return; // We keep it around to wait for modifications to the request. // NOTE: we assume whomever created the request created it with a lifetime of the Page. @@ -459,3 +463,40 @@ fn idFromRequestId(request_id: []const u8) !u32 { } return std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams; } + +const testing = @import("../testing.zig"); + +test "cdp.Fetch: interception events belong to the enabling session" { + var ctx = try testing.context(); + defer ctx.deinit(); + const bc = try ctx.loadBrowserContext(.{ + .session_id = "SID-PRIMARY", + .target_id = "TID-000000000B".*, + }); + try bc.attached_sessions.append(bc.arena, .{ + .id = "SID-AUX", + .parent_id = null, + }); + + try ctx.processMessage(.{ + .id = 1, + .method = "Fetch.enable", + .sessionId = "SID-AUX", + }); + try testing.expect(std.mem.eql(u8, "SID-AUX", bc.fetch_session_id.?)); + try ctx.expectSentResult(null, .{ .id = 1, .session_id = "SID-AUX" }); + + try ctx.processMessage(.{ + .id = 2, + .method = "Fetch.disable", + .sessionId = "SID-PRIMARY", + }); + try testing.expect(std.mem.eql(u8, "SID-AUX", bc.fetch_session_id.?)); + + try ctx.processMessage(.{ + .id = 3, + .method = "Fetch.disable", + .sessionId = "SID-AUX", + }); + try testing.expectEqual(null, bc.fetch_session_id); +} diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index 703cd3d5f..ac976b9ce 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -246,9 +246,27 @@ fn attachToTarget(cmd: *CDP.Command) !void { return error.UnknownTargetId; } - try doAttachtoTarget(cmd, target_id); + const parent_id = if (cmd.input.session_id) |session_id| + cmd.cdp.resolveSessionId(session_id) orelse return error.UnknownSessionId + else + null; + const session_id = try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next()); + try bc.attached_sessions.append(bc.arena, .{ + .id = session_id, + .parent_id = parent_id, + }); - return cmd.sendResult(.{ .sessionId = bc.session_id }, .{}); + try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ + .sessionId = session_id, + .targetInfo = TargetInfo{ + .targetId = target_id, + .title = bc.getTitle() orelse "", + .url = bc.getURL() orelse "about:blank", + .browserContextId = bc.id, + }, + }, .{ .session_id = parent_id }); + + return cmd.sendResult(.{ .sessionId = session_id }, .{}); } fn attachToBrowserTarget(cmd: *CDP.Command) !void { @@ -290,8 +308,22 @@ fn closeTarget(cmd: *CDP.Command) !void { try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); + for (bc.attached_sessions.items) |session| { + bc.fetchDisableForSession(session.id); + try cmd.sendEvent("Inspector.detached", .{ + .reason = "Render process gone.", + }, .{ .session_id = session.id }); + try cmd.sendEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = session.id, + .reason = "Render process gone.", + }, .{ .session_id = session.parent_id }); + } + bc.attached_sessions.clearRetainingCapacity(); + // could be null, created but never attached if (bc.session_id) |session_id| { + bc.fetchDisableForSession(session_id); // Inspector.detached event try cmd.sendEvent("Inspector.detached", .{ .reason = "Render process gone.", @@ -386,8 +418,33 @@ fn sendMessageToTarget(cmd: *CDP.Command) !void { } fn detachFromTarget(cmd: *CDP.Command) !void { + const Params = struct { + sessionId: ?[]const u8 = null, + targetId: ?[]const u8 = null, + }; + const params = (try cmd.params(Params)) orelse Params{}; + if (cmd.browser_context) |bc| { + if (params.sessionId) |requested_session_id| { + for (bc.attached_sessions.items, 0..) |session, index| { + if (!std.mem.eql(u8, session.id, requested_session_id)) continue; + + _ = bc.attached_sessions.orderedRemove(index); + bc.fetchDisableForSession(session.id); + try cmd.sendEvent("Target.detachedFromTarget", .{ + .sessionId = session.id, + }, .{ .session_id = session.parent_id }); + return cmd.sendResult(null, .{}); + } + + const session_id = bc.session_id orelse return error.UnknownSessionId; + if (!std.mem.eql(u8, session_id, requested_session_id)) { + return error.UnknownSessionId; + } + } + if (bc.session_id) |session_id| { + bc.fetchDisableForSession(session_id); try cmd.sendEvent("Target.detachedFromTarget", .{ .sessionId = session_id, }, .{}); @@ -418,6 +475,7 @@ fn setAutoAttach(cmd: *CDP.Command) !void { // detach from all currently attached targets. if (cmd.browser_context) |bc| { if (bc.session_id) |session_id| { + bc.fetchDisableForSession(session_id); try cmd.sendEvent("Target.detachedFromTarget", .{ .sessionId = session_id, }, .{}); @@ -465,7 +523,9 @@ fn setAutoAttach(cmd: *CDP.Command) !void { fn doAttachtoTarget(cmd: *CDP.Command, target_id: []const u8) !void { const bc = cmd.browser_context.?; - const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next(); + const session_id = bc.session_id orelse blk: { + break :blk try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next()); + }; if (bc.session_id == null) { // extra_headers should not be kept on a new frame or tab, @@ -747,12 +807,50 @@ test "cdp.target: attachToTarget" { { try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } }); - const session_id = bc.session_id.?; + const session_id = bc.attached_sessions.items[0].id; try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = session_id, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); + try testing.expectEqual(null, bc.session_id); } } +test "cdp.target: auxiliary session is unique and routed through its parent" { + var ctx = try testing.context(); + defer ctx.deinit(); + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-9", + .session_id = "SID-PRIMARY", + .target_id = "TID-000000000B".*, + }); + + try ctx.processMessage(.{ .id = 1, .method = "Target.attachToBrowserTarget" }); + try ctx.processMessage(.{ + .id = 2, + .method = "Target.attachToTarget", + .sessionId = "BSID-1", + .params = .{ .targetId = "TID-000000000B" }, + }); + + const session_id = bc.attached_sessions.items[0].id; + try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?)); + try ctx.expectSentEvent("Target.attachedToTarget", .{ + .sessionId = session_id, + .targetInfo = .{ + .url = "about:blank", + .title = "", + .attached = true, + .type = "page", + .canAccessOpener = false, + .browserContextId = "BID-9", + .targetId = "TID-000000000B", + }, + }, .{ .index = 2, .session_id = "BSID-1" }); + try ctx.expectSentResult(.{ .sessionId = session_id }, .{ + .id = 2, + .session_id = "BSID-1", + }); +} + test "cdp.target: getTargetInfo" { var ctx = try testing.context(); defer ctx.deinit(); @@ -814,7 +912,7 @@ test "cdp.target: issue#474: attach to just created target" { try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); - const session_id = bc.session_id.?; + const session_id = bc.attached_sessions.items[0].id; try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); } } @@ -823,23 +921,19 @@ test "cdp.target: detachFromTarget" { var ctx = try testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); - { - try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); - try testing.expectEqual(true, bc.target_id != null); - try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); + try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); + try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); + const session_id = bc.attached_sessions.items[0].id; - try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); - const session_id = bc.session_id.?; - try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); + try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .sessionId = session_id } }); - try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .targetId = bc.target_id.? } }); - try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); - try testing.expectEqual(null, bc.session_id); - try ctx.expectSentResult(null, .{ .id = 12 }); + try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); + try testing.expectEqual(0, bc.attached_sessions.items.len); + try ctx.expectSentResult(null, .{ .id = 12 }); - try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); - try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 13 }); - } + try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); + try testing.expect(!std.mem.eql(u8, session_id, bc.attached_sessions.items[0].id)); + try ctx.expectSentResult(.{ .sessionId = bc.attached_sessions.items[0].id }, .{ .id = 13 }); } test "cdp.target: detachFromTarget without session" { @@ -858,19 +952,13 @@ test "cdp.target: setAutoAttach false sends detachedFromTarget" { var ctx = try testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); - { - try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); - try testing.expectEqual(true, bc.target_id != null); - try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); + try ctx.processMessage(.{ .id = 10, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } }); + try ctx.processMessage(.{ .id = 11, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); + const session_id = bc.session_id.?; - try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); - const session_id = bc.session_id.?; - try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); + try ctx.processMessage(.{ .id = 12, .method = "Target.setAutoAttach", .params = .{ .autoAttach = false, .waitForDebuggerOnStart = false } }); - // setAutoAttach false should fire detachedFromTarget event - try ctx.processMessage(.{ .id = 12, .method = "Target.setAutoAttach", .params = .{ .autoAttach = false, .waitForDebuggerOnStart = false } }); - try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); - try testing.expectEqual(null, bc.session_id); - try ctx.expectSentResult(null, .{ .id = 12 }); - } + try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); + try testing.expectEqual(null, bc.session_id); + try ctx.expectSentResult(null, .{ .id = 12 }); } diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 32155136b..dcca1bddd 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1453,6 +1453,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T if (msg.conn.getResponseHeader("location", 0)) |location| switch (transfer.req.redirect) { .follow => { try transfer.handleRedirect(location.value); + transfer.restoreInterceptHeaders(); if (self.isUrlBlocked(transfer.req.url, transfer.req.internal)) { log.warn(.http, "blocked url", .{ .url = transfer.req.url }); @@ -1464,6 +1465,23 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T if (!transfer.req.internal) lp.metrics.http_redirects.incr(); + if (self.serve_mode) { + var wait_for_interception = false; + transfer.req.notification.dispatch(.http_request_intercept, &.{ + .transfer = transfer, + .wait_for_interception = &wait_for_interception, + }); + if (wait_for_interception) { + self.removeConn(msg.conn); + transfer._conn = null; + transfer.reset(); + transfer.state = .created; + self.intercepted += 1; + transfer.park(.intercept_request); + return false; + } + } + const conn = transfer._conn.?; try self.handles.remove(conn); @@ -1913,6 +1931,9 @@ pub const Transfer = struct { _tries: u8 = 0, _redirect_count: u8 = 0, + // Fetch.continueRequest header overrides apply to one network hop. + _intercept_original_headers: ?[]const RequestHeader = null, + // Linked into client.pending_queue while .queued; reused to link the // retired transfer into client.graveyard (deinit unlinks it from the // pending queue first, so the node is always free by then). @@ -2510,6 +2531,13 @@ pub const Transfer = struct { } } + fn restoreInterceptHeaders(self: *Transfer) void { + const headers = self._intercept_original_headers orelse return; + self.req_headers.clearRetainingCapacity(); + self.req_headers.appendSliceAssumeCapacity(headers); + self._intercept_original_headers = null; + } + pub fn reset(self: *Transfer) void { // Note: do NOT reset _auth_challenge or _redirect_count here. They // span retries — _auth_challenge tells makeRequest whether to use @@ -2709,6 +2737,8 @@ pub const Transfer = struct { // CDP Fetch.continueRequest: the intercepting client supplies the // complete header set, replacing whatever the request carried. pub fn replaceRequestHeaders(self: *Transfer, headers: []const http.Header) !void { + lp.assert(self._intercept_original_headers == null, "Transfer.replaceRequestHeaders", .{ .id = self.id }); + self._intercept_original_headers = try self.arena.allocator().dupe(RequestHeader, self.req_headers.items); self.req_headers.clearRetainingCapacity(); try self.seedHeaders(); for (headers) |hdr| { @@ -3140,6 +3170,29 @@ test "HttpClient: isFetchInterceptionMethod rejects unrelated methods" { try testing.expect(!isFetchInterceptionMethod("Fetch.continueRequest ")); } +test "HttpClient: Fetch header overrides restore after one hop" { + const original = [_]Transfer.RequestHeader{ + .{ .name = "User-Agent", .value = "original" }, + .{ .name = "X-Original", .value = "yes" }, + }; + var overridden = [_]Transfer.RequestHeader{ + .{ .name = "User-Agent", .value = "override" }, + .{ .name = "X-Override", .value = "yes" }, + }; + + var transfer: Transfer = undefined; + transfer.req_headers = .{ .items = &overridden, .capacity = overridden.len }; + transfer._intercept_original_headers = &original; + transfer.restoreInterceptHeaders(); + + try testing.expectEqual(2, transfer.req_headers.items.len); + try testing.expectString("User-Agent", transfer.req_headers.items[0].name); + try testing.expectString("original", transfer.req_headers.items[0].value); + try testing.expectString("X-Original", transfer.req_headers.items[1].name); + try testing.expectString("yes", transfer.req_headers.items[1].value); + try testing.expectEqual(null, transfer._intercept_original_headers); +} + test "HttpClient: allowDuringSyncWait allows ping/close/disconnect" { const test_arena = try testing.test_app.arena_pool.acquire(.tiny, "HttpClient test"); defer test_arena.release(); From cc6fbec77805d4cef8caa7356413c008a1cef4d7 Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Mon, 3 Aug 2026 11:14:41 -0400 Subject: [PATCH 04/61] Add Playwright CDP redirect regression Assisted-By: devx/e284dddf-2391-42ce-9dff-5ce418d0ab2f --- .github/scripts/playwright-cdp-redirect.mjs | 140 ++++++++++++++++++++ .github/workflows/e2e-test.yml | 23 ++++ 2 files changed, 163 insertions(+) create mode 100644 .github/scripts/playwright-cdp-redirect.mjs diff --git a/.github/scripts/playwright-cdp-redirect.mjs b/.github/scripts/playwright-cdp-redirect.mjs new file mode 100644 index 000000000..7e3e76edb --- /dev/null +++ b/.github/scripts/playwright-cdp-redirect.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer as createHttpServer } from "node:http"; +import { createServer as createTcpServer } from "node:net"; +import { once } from "node:events"; + +import { chromium } from "playwright-core"; + +const binary = process.argv[2]; +if (!binary) throw new Error("usage: node playwright-cdp-redirect.mjs "); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(server.address().port); + }); + }); +} + +async function reservePort() { + const server = createTcpServer(); + const port = await listen(server); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +function closeServer(server) { + return new Promise((resolve) => server.close(resolve)); +} + +async function waitForLightpanda(child, port) { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Lightpanda startup timeout")), 5_000); + const onData = (chunk) => { + const line = chunk.toString(); + if (!line.includes("server running") || !line.includes(`127.0.0.1:${port}`)) return; + clearTimeout(timeout); + child.off("error", onError); + child.off("exit", onExit); + resolve(); + }; + const onError = (error) => { + clearTimeout(timeout); + reject(error); + }; + const onExit = (code) => { + clearTimeout(timeout); + reject(new Error(`Lightpanda exited during startup (${code})`)); + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("error", onError); + child.once("exit", onExit); + }); +} + +const destinationRequests = []; +const destination = createHttpServer((request, response) => { + destinationRequests.push(request.headers); + response.end("ok"); +}); +const destinationPort = await listen(destination); +const destinationUrl = `http://127.0.0.1:${destinationPort}/destination`; + +const initialRequests = []; +const initial = createHttpServer((request, response) => { + initialRequests.push(request.headers); + response.writeHead(302, { location: destinationUrl }); + response.end(); +}); +const initialPort = await listen(initial); +const initialUrl = `http://127.0.0.1:${initialPort}/start`; + +const cdpPort = await reservePort(); +const child = spawn(binary, [ + "serve", + "--host", + "127.0.0.1", + "--port", + String(cdpPort), + "--log-level", + "info", +], { + env: { ...process.env, LIGHTPANDA_DISABLE_TELEMETRY: "true" }, +}); + +let browser; +try { + await waitForLightpanda(child, cdpPort); + browser = await chromium.connectOverCDP(`ws://127.0.0.1:${cdpPort}`); + const context = await browser.newContext(); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + const pausedUrls = []; + const continuationErrors = []; + + session.on("Fetch.requestPaused", async (event) => { + if (event.request.url !== initialUrl && event.request.url !== destinationUrl) return; + pausedUrls.push(event.request.url); + const params = { requestId: event.requestId }; + if (event.request.url === initialUrl) { + params.headers = Object.entries({ + ...event.request.headers, + "x-single-hop-probe": "initial", + }).map(([name, value]) => ({ name, value: String(value) })); + } + try { + await session.send("Fetch.continueRequest", params); + } catch (error) { + continuationErrors.push(error); + } + }); + + await session.send("Fetch.enable", { + patterns: [{ urlPattern: "*", requestStage: "Request" }], + }); + const response = await page.goto(initialUrl, { + waitUntil: "domcontentloaded", + timeout: 6_000, + }); + + assert.equal(response.status(), 200); + assert.deepEqual(pausedUrls, [initialUrl, destinationUrl]); + assert.equal(continuationErrors.length, 0); + assert.equal(initialRequests.length, 1); + assert.equal(initialRequests[0]["x-single-hop-probe"], "initial"); + assert.equal(destinationRequests.length, 1); + assert.equal(destinationRequests[0]["x-single-hop-probe"], undefined); + + console.log("Playwright auxiliary CDP redirect interception passed"); +} finally { + await browser?.close().catch(() => {}); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + await Promise.race([once(child, "exit"), new Promise((resolve) => setTimeout(resolve, 2_000))]); + } + await Promise.all([closeServer(initial), closeServer(destination)]); +} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 246e760d4..99f68a23d 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -189,6 +189,29 @@ jobs: kill `cat LPD.pid` while kill -0 `cat LPD.pid` 2>/dev/null; do sleep 1; done + playwright-cdp-session: + name: playwright-cdp-session + needs: + - zig-build-release + + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - run: npm install --no-save --no-package-lock playwright-core@1.58.2 + + - name: download lightpanda release + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: lightpanda-build-release + + - run: chmod a+x ./lightpanda + + - name: run auxiliary CDP redirect interception + run: node .github/scripts/playwright-cdp-redirect.mjs ./lightpanda + wba-test: name: wba-test needs: zig-build-release From 83e47447b5b026a24cd5de61afd769af6f265428 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 12:18:31 +0800 Subject: [PATCH 05/61] perf: Reduce memory pressure notification to v8 Only notify v8 of memory pressure when (a) there's memory to claim and (b) there are dead context. Also clean up code that relied on undefined behavior which might have left local scopes un-freed and caused a v8 leak. --- src/browser/Runner.zig | 5 ++-- src/browser/js/Env.zig | 14 +++++++++++ src/browser/webapi/element/html/Custom.zig | 15 +++++------ src/cdp/domains/dom.zig | 29 +++++++++++----------- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index f07b748f6..26f95c28f 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -118,10 +118,9 @@ fn _wait(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa const timer: std.Io.Timestamp = .now(io, .boot); // Periodic V8 GC hint during long waits. V8 is otherwise only nudged on - // session/page teardown (Browser.zig, Page.zig), so a page that stays + // session/page teardown (Session.zig, Page.zig), so a page that stays // alive for seconds while running heavy JS accumulates wrappers and - // external-ref'd Zig allocations V8 has no reason to drop. `.moderate` - // speeds up incremental GC without stalling the tick. + // external-ref'd Zig allocations V8 has no reason to drop. const gc_hint_period_ns: u64 = std.time.ns_per_s * 5; var gc_hint_timer: std.Io.Timestamp = .now(io, .boot); diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index d56a2d8a8..bed60b669 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -41,6 +41,12 @@ const Allocator = std.mem.Allocator; const MAX_CONTEXTS = if (lp.build_config.wpt_extensions) 8192 else 128; +const GC_HINT_FLOOR = 16 * 1024 * 1024; + +// Seems like V8 keeps 2 internal contexts, so this is really 3 frame/workers +// we need dead before triggering a GC. +const GC_HINT_MIN_DEAD_CONTEXTS = 5; + fn initClassIds() void { inline for (JsApis, 0..) |JsApi, i| { JsApi.Meta.class_id = i; @@ -515,7 +521,15 @@ pub fn runIdleTasks(self: *const Env) void { // The level indicates the aggressivity of the GC required: // moderate speeds up incremental GC // critical runs one full GC +// Skips if there's little to reclaim AND not enough dead contexts. pub fn memoryPressureNotification(self: *Env, level: Isolate.MemoryPressureLevel) void { + const stats = self.isolate.getHeapStatistics(); + if (stats.number_of_native_contexts < self.contexts.items.len + GC_HINT_MIN_DEAD_CONTEXTS) { + return; + } + if (stats.used_heap_size + stats.external_memory < GC_HINT_FLOOR) { + return; + } var handle_scope: js.HandleScope = undefined; handle_scope.init(self.isolate); defer handle_scope.deinit(); diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 3cccc27c6..fa117b565 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -265,17 +265,18 @@ pub fn checkAndAttachBuiltIn(element: *Element, frame: *Frame) !void { // (2) called from both V8 callbacks (Local exists) and parser (no Local). // Prefer either: requiring *const js.Local parameter, OR always creating // Local.Scope upfront. - var ls: ?js.Local.Scope = null; - var local = blk: { + var ls: js.Local.Scope = undefined; + var ls_open = false; + const local = blk: { if (frame.js.local) |l| { break :blk l; } - ls = undefined; - frame.js.localScope(&ls.?); - break :blk &ls.?.local; + frame.js.localScope(&ls); + ls_open = true; + break :blk &ls.local; }; - defer if (ls) |*_ls| { - _ls.deinit(); + defer if (ls_open) { + ls.deinit(); }; var caught: js.TryCatch.Caught = undefined; diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 3b0505142..99bb8260f 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -346,32 +346,33 @@ fn resolveNode(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const frame = bc.mainFrame() orelse return error.FrameNotLoaded; - var ls: ?js.Local.Scope = null; - defer if (ls) |*_ls| { - _ls.deinit(); + var ls: js.Local.Scope = undefined; + var ls_open = false; + defer if (ls_open) { + ls.deinit(); }; if (params.executionContextId) |context_id| blk: { - ls = undefined; - frame.js.localScope(&ls.?); - if (ls.?.local.debugContextId() == context_id) { + frame.js.localScope(&ls); + ls_open = true; + if (ls.local.debugContextId() == context_id) { break :blk; } // not the default scope, check the other ones for (bc.isolated_worlds.items) |isolated_world| { - ls.?.deinit(); - ls = null; + ls.deinit(); + ls_open = false; const ctx = (isolated_world.context orelse return error.ContextNotFound); - ls = undefined; - ctx.localScope(&ls.?); - if (ls.?.local.debugContextId() == context_id) { + ctx.localScope(&ls); + ls_open = true; + if (ls.local.debugContextId() == context_id) { break :blk; } } else return error.ContextNotFound; } else { - ls = undefined; - frame.js.localScope(&ls.?); + frame.js.localScope(&ls); + ls_open = true; } const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; @@ -380,7 +381,7 @@ fn resolveNode(cmd: *CDP.Command) !void { // node._node is a *DOMNode we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement // So we use the Node.Union when retrieve the value from the environment const remote_object = try bc.inspector_session.getRemoteObject( - &ls.?.local, + &ls.local, params.objectGroup orelse "", node.dom, ); From 6e72c43fa2bb4e570432a18326dcfdaa817556aa Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 12:55:26 +0800 Subject: [PATCH 06/61] remove dead context guard...trying to pass CI regression tests --- src/browser/js/Env.zig | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index bed60b669..53b9b4c88 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -43,10 +43,6 @@ const MAX_CONTEXTS = if (lp.build_config.wpt_extensions) 8192 else 128; const GC_HINT_FLOOR = 16 * 1024 * 1024; -// Seems like V8 keeps 2 internal contexts, so this is really 3 frame/workers -// we need dead before triggering a GC. -const GC_HINT_MIN_DEAD_CONTEXTS = 5; - fn initClassIds() void { inline for (JsApis, 0..) |JsApi, i| { JsApi.Meta.class_id = i; @@ -518,15 +514,9 @@ pub fn runIdleTasks(self: *const Env) void { // a Context, it's managed by the garbage collector. We use the // `memoryPressureNotification` call on the isolate to encourage v8 to free // any contexts which have been freed. -// The level indicates the aggressivity of the GC required: -// moderate speeds up incremental GC -// critical runs one full GC -// Skips if there's little to reclaim AND not enough dead contexts. +// Skips if there's little to reclaim pub fn memoryPressureNotification(self: *Env, level: Isolate.MemoryPressureLevel) void { const stats = self.isolate.getHeapStatistics(); - if (stats.number_of_native_contexts < self.contexts.items.len + GC_HINT_MIN_DEAD_CONTEXTS) { - return; - } if (stats.used_heap_size + stats.external_memory < GC_HINT_FLOOR) { return; } From aa22b7feea4245216e8a3a5b7d6ae738fbedfb35 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 14:27:15 +0800 Subject: [PATCH 07/61] cdp: avoid creating duplicate isolated worlds If a driver asks to create an isolated world that already exists, don't create it, return the existing one. --- src/cdp/CDP.zig | 12 ++++++++++++ src/cdp/domains/page.zig | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 2ed72c51b..7c197eb16 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -714,6 +714,18 @@ pub const BrowserContext = struct { } pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld { + // The name is the world's identity (matching Chrome). Clients re-issue + // this call after every navigation; appending a duplicate each time + // would grow the per-page context count without bound. + for (self.isolated_worlds.items) |world| { + if (std.mem.eql(u8, world.name, world_name)) { + if (world.grant_universal_access != grant_universal_access) { + log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access }); + } + return world; + } + } + const browser = &self.cdp.browser; const arena = try browser.arena_pool.acquire(.small, "IsolatedWorld"); errdefer arena.release(); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 8215990ee..9b334c43f 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -244,6 +244,17 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); + + // An existing world already has a live, inspector-registered context for + // the current document: return its id without re-registering. + if (world.context) |js_context| { + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + const context_id = bc.inspector_session.inspector.getContextId(&ls.local); + return cmd.sendResult(.{ .executionContextId = context_id }, .{}); + } + const frame = bc.mainFrame() orelse return error.FrameNotLoaded; const js_context = try world.createContext(frame); @@ -1127,6 +1138,36 @@ test "cdp.frame: getFrameTree" { } } +test "cdp.frame: createIsolatedWorld is idempotent per name" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); + + try ctx.processMessage(.{ .id = 20, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = "FID-000000000X", + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try testing.expectEqual(1, bc.isolated_worlds.items.len); + const world_context = bc.isolated_worlds.items[0].context.?; + + try ctx.processMessage(.{ .id = 21, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = "FID-000000000X", + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try testing.expectEqual(1, bc.isolated_worlds.items.len); + try testing.expectEqual(world_context, bc.isolated_worlds.items[0].context.?); + + try ctx.processMessage(.{ .id = 22, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = "FID-000000000X", + .worldName = "other", + .grantUniveralAccess = true, + } }); + try testing.expectEqual(2, bc.isolated_worlds.items.len); +} + test "cdp.frame: child frame metadata" { var ctx = try testing.context(); defer ctx.deinit(); From a2c4aa00202dcac9c58ae2172b1b8bcd67df32c5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 14:30:52 +0800 Subject: [PATCH 08/61] lower gc limit just for the CI... --- src/browser/js/Env.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 53b9b4c88..8aa223003 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -41,7 +41,7 @@ const Allocator = std.mem.Allocator; const MAX_CONTEXTS = if (lp.build_config.wpt_extensions) 8192 else 128; -const GC_HINT_FLOOR = 16 * 1024 * 1024; +const GC_HINT_FLOOR = 4 * 1024 * 1024; fn initClassIds() void { inline for (JsApis, 0..) |JsApi, i| { From 78400059160445fede1c9449151dc27efc4ba5b9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 15:06:17 +0800 Subject: [PATCH 09/61] lower gc limit just for the CI... --- src/browser/js/Env.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 8aa223003..9619ef23f 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -41,7 +41,7 @@ const Allocator = std.mem.Allocator; const MAX_CONTEXTS = if (lp.build_config.wpt_extensions) 8192 else 128; -const GC_HINT_FLOOR = 4 * 1024 * 1024; +const GC_HINT_FLOOR = 2 * 1024 * 1024; fn initClassIds() void { inline for (JsApis, 0..) |JsApi, i| { From a71bf101d6938e68c0bf7714f147f4e44bed3d8d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 17:09:05 +0800 Subject: [PATCH 10/61] http: start implementation correct referrer policy Referrer header is now set based on the computation of the frame url, the target url, and the referrer policy which is parsed from the response header and/or a --- src/browser/Frame.zig | 40 ++-- src/browser/referrer.zig | 222 +++++++++++++++++++++++ src/browser/webapi/Document.zig | 16 +- src/browser/webapi/element/html/Meta.zig | 18 ++ 4 files changed, 274 insertions(+), 22 deletions(-) create mode 100644 src/browser/referrer.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ea0651489..d9f2042b9 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -34,6 +34,7 @@ const h5e = @import("parser/html5ever.zig"); const CustomElementReactions = @import("CustomElementReactions.zig"); const URL = @import("URL.zig"); +const referrer = @import("referrer.zig"); const Blob = @import("webapi/Blob.zig"); const FileList = @import("webapi/FileList.zig"); const Node = @import("webapi/Node.zig"); @@ -331,6 +332,9 @@ _navigated_options: ?NavigatedOpts = null, _http_status: ?u16 = null, _http_headers: std.ArrayList(HttpHeader) = .empty, +_referrer: ?[]const u8 = null, +referrer_policy: referrer.Policy = .default, + pub const HttpHeader = struct { name: []const u8, value: []const u8, @@ -593,8 +597,9 @@ pub fn httpMetadata(self: *const Frame) HttpMetadata { // Add common headers for a request: // * referer pub fn headersForRequest(self: *Frame, transfer: *HttpClient.Transfer) !void { - if (std.mem.startsWith(u8, self.url, "http")) { - try transfer.addHeader("Referer", self.url, .{}); + const arena = transfer.arena.allocator(); + if (try referrer.compute(arena, self.referrer_policy, self.url, transfer.req.url)) |ref| { + try transfer.addHeader("Referer", ref, .{}); } } @@ -742,6 +747,9 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo self._http_status = null; self._http_headers = .empty; + self._referrer = null; + self.referrer_policy = .default; + self.url = blk: { if (URL.isCompleteHTTPUrl(request_url)) { break :blk try self.arena.dupeZ(u8, request_url); @@ -793,6 +801,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo } if (opts.referer) |ref| { try transfer.addHeader("Referer", ref, .{}); + self._referrer = try self.arena.dupe(u8, ref); } } @@ -951,20 +960,15 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url // Capture the originating frame's URL as the Referer for this // navigation. The originator's frame may be torn down before navigate() - // runs (processRootQueuedNavigation rebuilds the Page in-place), so dup - // into the QueuedNavigation arena which outlives that tear-down. + // runs (processRootQueuedNavigation rebuilds the Page in-place), so + // allocate from the QueuedNavigation arena which outlives that tear-down. var nav_opts = opts; if (std.mem.startsWith(u8, originator.url, "http")) { - // The same dup feeds two purposes: Referer header (subject to - // Referrer-Policy in the future) and SameSite computation (which - // must use the real initiator regardless of policy). We share the - // same allocation for both. - const dup = try arena.dupeZ(u8, originator.url); if (nav_opts.referer == null) { - nav_opts.referer = dup; + nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, originator.url, resolved_url); } if (nav_opts.initiator_url == null) { - nav_opts.initiator_url = dup; + nav_opts.initiator_url = try arena.dupeZ(u8, originator.url); } } if (nav_opts.initiator_origin == null) { @@ -1274,6 +1278,11 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer. .name = try self.arena.dupe(u8, hdr.name), .value = try self.arena.dupe(u8, hdr.value), }); + if (std.ascii.eqlIgnoreCase(hdr.name, "referrer-policy")) { + if (referrer.parseHeader(hdr.value)) |rp| { + self.referrer_policy = rp; + } + } } if (self._navigated_options) |no| { @@ -1843,13 +1852,14 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { const was_sorted = self.child_frames_sorted; self.child_frames_sorted = false; - // Iframe's initial src request carries the parent's URL as Referer and - // as the SameSite initiator. Parent frame outlives this navigate() - // call, so the slice is safe. + // Iframe's initial src request carries the parent's URL as Referer + // (subject to the parent's Referrer-Policy) and as the SameSite + // initiator. Parent frame outlives this navigate() call, so the slice + // is safe; navigate dupes what it keeps. const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, self.url, "http")) self.url else null; new_frame.navigate(url, .{ .reason = .initialFrameNavigation, - .referer = parent_url, + .referer = try referrer.compute(self.call_arena, self.referrer_policy, self.url, url), .initiator_url = parent_url, .initiator_origin = self.origin, }) catch |err| { diff --git a/src/browser/referrer.zig b/src/browser/referrer.zig new file mode 100644 index 000000000..0e3250441 --- /dev/null +++ b/src/browser/referrer.zig @@ -0,0 +1,222 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Referrer Policy: https://www.w3.org/TR/referrer-policy/ +const std = @import("std"); +const URL = @import("URL.zig"); + +const Allocator = std.mem.Allocator; + +pub const Policy = enum { + no_referrer, + no_referrer_when_downgrade, + origin, + origin_when_cross_origin, + same_origin, + strict_origin, + strict_origin_when_cross_origin, + unsafe_url, + + pub const default: Policy = .strict_origin_when_cross_origin; +}; + +pub fn parse(value: []const u8) ?Policy { + const map = std.StaticStringMapWithEql(Policy, staticStringMapEqlAsciiIgnoreCase).initComptime(.{ + .{ "no-referrer", Policy.no_referrer }, + .{ "no-referrer-when-downgrade", Policy.no_referrer_when_downgrade }, + .{ "origin", Policy.origin }, + .{ "origin-when-cross-origin", Policy.origin_when_cross_origin }, + .{ "same-origin", Policy.same_origin }, + .{ "strict-origin", Policy.strict_origin }, + .{ "strict-origin-when-cross-origin", Policy.strict_origin_when_cross_origin }, + .{ "unsafe-url", Policy.unsafe_url }, + }); + return map.get(value); +} + +pub fn parseHeader(value: []const u8) ?Policy { + var policy: ?Policy = null; + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |token| { + if (parse(std.mem.trim(u8, token, " \t"))) |p| { + policy = p; + } + } + return policy; +} + +pub fn parseMeta(value: []const u8) ?Policy { + if (parse(value)) |p| { + return p; + } + // legacy values + const map = std.StaticStringMapWithEql(Policy, staticStringMapEqlAsciiIgnoreCase).initComptime(.{ + .{ "never", Policy.no_referrer }, + .{ "always", Policy.unsafe_url }, + .{ "origin-when-crossorigin", Policy.origin_when_cross_origin }, + .{ "default", Policy.default }, + }); + return map.get(value); +} + +// returns the value to send as the Referrer header based on the target_url +// and the given policy +pub fn compute(arena: Allocator, policy: Policy, referrer_url: [:0]const u8, target_url: [:0]const u8) !?[]const u8 { + if (policy == .no_referrer) { + return null; + } + + const referrer_origin = (try URL.getOrigin(arena, referrer_url)) orelse { + // blob:, data: ... don't set get a referrer + return null; + }; + + const same_origin = blk: { + const target_origin = (try URL.getOrigin(arena, target_url)) orelse break :blk false; + break :blk std.ascii.eqlIgnoreCase(referrer_origin, target_origin); + }; + const downgrade = URL.isSecure(referrer_url) and !URL.isSecure(target_url); + + const full = switch (policy) { + .no_referrer => unreachable, + .unsafe_url => true, + .origin => false, + .no_referrer_when_downgrade => if (downgrade) return null else true, + .same_origin => if (same_origin) true else return null, + .origin_when_cross_origin => same_origin, + .strict_origin => if (downgrade) return null else false, + .strict_origin_when_cross_origin => if (same_origin) true else if (downgrade) return null else false, + }; + + if (full) { + // Serializing through origin + path + query strips credentials and + // the fragment, and normalizes away default ports. + const value = try std.fmt.allocPrint(arena, "{s}{s}{s}", .{ + referrer_origin, + URL.getPathname(referrer_url), + URL.getSearch(referrer_url), + }); + + if (value.len <= 4096) { + // spec limit, if it's more than this, we falllback to the origin + return value; + } + } + return try std.fmt.allocPrint(arena, "{s}/", .{referrer_origin}); +} + +fn staticStringMapEqlAsciiIgnoreCase(a: []const u8, b: []const u8) bool { + for (a, b) |a_c, b_c| { + if (std.ascii.toLower(a_c) != std.ascii.toLower(b_c)) { + return false; + } + } + return true; +} + +const testing = @import("../testing.zig"); +test "referrer: parse" { + try testing.expectEqual(Policy.no_referrer, parse("no-referrer")); + try testing.expectEqual(Policy.unsafe_url, parse("Unsafe-URL")); + try testing.expectEqual(null, parse("")); + try testing.expectEqual(null, parse("never")); + try testing.expectEqual(null, parse("no-referrer ")); + + try testing.expectEqual(null, parseHeader("")); + try testing.expectEqual(null, parseHeader("nope")); + try testing.expectEqual(Policy.origin, parseHeader("origin")); + try testing.expectEqual(Policy.same_origin, parseHeader("origin, same-origin")); + try testing.expectEqual(Policy.origin, parseHeader("origin, garbage")); + try testing.expectEqual(Policy.same_origin, parseHeader(" origin ,\tsame-origin ")); + + try testing.expectEqual(Policy.no_referrer, parseMeta("never")); + try testing.expectEqual(Policy.unsafe_url, parseMeta("always")); + try testing.expectEqual(Policy.origin_when_cross_origin, parseMeta("origin-when-crossorigin")); + try testing.expectEqual(Policy.strict_origin_when_cross_origin, parseMeta("default")); + try testing.expectEqual(Policy.origin, parseMeta("origin")); + try testing.expectEqual(null, parseMeta("garbage")); +} + +test "referrer: compute" { + const Case = struct { + policy: Policy, + referrer: [:0]const u8, + target: [:0]const u8, + expected: ?[]const u8, + }; + + const cases = [_]Case{ + .{ .policy = .no_referrer, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = null }, + + .{ .policy = .unsafe_url, .referrer = "http://a.com/p?q=1#frag", .target = "https://b.com/", .expected = "http://a.com/p?q=1" }, + .{ .policy = .unsafe_url, .referrer = "https://a.com/p", .target = "http://b.com/", .expected = "https://a.com/p" }, + .{ .policy = .unsafe_url, .referrer = "https://user:pass@a.com/p", .target = "http://b.com/", .expected = "https://a.com/p" }, + .{ .policy = .unsafe_url, .referrer = "https://a.com:443/p", .target = "http://b.com/", .expected = "https://a.com/p" }, + .{ .policy = .unsafe_url, .referrer = "http://a.com", .target = "http://b.com/", .expected = "http://a.com/" }, + + .{ .policy = .origin, .referrer = "http://a.com:8000/p?q=1", .target = "http://a.com:8000/x", .expected = "http://a.com:8000/" }, + + .{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = "http://a.com/p" }, + .{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = null }, + .{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "https://a.com/x", .expected = null }, + + .{ .policy = .origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = "http://a.com/p" }, + .{ .policy = .origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" }, + + .{ .policy = .strict_origin, .referrer = "https://a.com/p", .target = "http://a.com/x", .expected = null }, + .{ .policy = .strict_origin, .referrer = "https://a.com/p", .target = "https://b.com/x", .expected = "https://a.com/" }, + .{ .policy = .strict_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" }, + + .{ .policy = .no_referrer_when_downgrade, .referrer = "https://a.com/p", .target = "http://b.com/x", .expected = null }, + .{ .policy = .no_referrer_when_downgrade, .referrer = "https://a.com/p", .target = "https://b.com/x", .expected = "https://a.com/p" }, + .{ .policy = .no_referrer_when_downgrade, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/p" }, + + .{ .policy = .strict_origin_when_cross_origin, .referrer = "http://a.com/p?q=1", .target = "http://a.com/x", .expected = "http://a.com/p?q=1" }, + .{ .policy = .strict_origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" }, + .{ .policy = .strict_origin_when_cross_origin, .referrer = "https://a.com/p", .target = "http://b.com/x", .expected = null }, + .{ .policy = .strict_origin_when_cross_origin, .referrer = "https://a.com/p", .target = "http://a.com/x", .expected = null }, + + // no referrer from non-http(s) documents + .{ .policy = .unsafe_url, .referrer = "about:blank", .target = "http://b.com/x", .expected = null }, + .{ .policy = .unsafe_url, .referrer = "data:text/html,x", .target = "http://b.com/x", .expected = null }, + }; + + for (cases) |case| { + const actual = try compute(testing.arena_allocator, case.policy, case.referrer, case.target); + if (case.expected) |expected| { + try testing.expectEqual(expected, actual orelse return error.UnexpectedNull); + } else { + try testing.expectEqual(null, actual); + } + } +} + +test "referrer: compute caps at 4096 bytes" { + const path = "/" ++ ("a" ** 4096); + const url = "http://a.com" ++ path; + // over the cap: falls back to the origin form + try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .unsafe_url, url, "http://b.com/x")).?); + try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .no_referrer_when_downgrade, url, "http://a.com/x")).?); + + // exactly at the cap: sent in full + const at_cap = "http://a.com/" ++ ("a" ** (4096 - "http://a.com/".len)); + try testing.expectEqual(at_cap, (try compute(testing.arena_allocator, .unsafe_url, at_cap, "http://b.com/x")).?); + + // origin-only policies are unaffected by the referrer's length + try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .origin, url, "http://b.com/x")).?); +} diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 78e2a3f83..e5e0b5e6e 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -212,6 +212,11 @@ pub fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 { }); } +pub fn getReferrer(self: *const Document) []const u8 { + const frame = self._frame orelse return ""; + return frame._referrer orelse ""; +} + pub fn getCharset(self: *const Document) []const u8 { if (self._charset) |charset| { return charset; @@ -1588,15 +1593,12 @@ pub const JsApi = struct { pub const hasFocus = bridge.function(Document.hasFocus, .{}); pub const prerendering = bridge.property(false, .{ .template = false }); - pub const characterSet = bridge.accessor(getCharacterSet, null, .{}); - pub const charset = bridge.accessor(getCharacterSet, null, .{}); - pub const inputEncoding = bridge.accessor(getCharacterSet, null, .{}); + pub const characterSet = bridge.accessor(Document.getCharset, null, .{}); + pub const charset = bridge.accessor(Document.getCharset, null, .{}); + pub const inputEncoding = bridge.accessor(Document.getCharset, null, .{}); pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{}); pub const lastModified = bridge.accessor(Document.getLastModified, null, .{}); - fn getCharacterSet(self: *const Document) []const u8 { - return self.getCharset(); - } - pub const referrer = bridge.property("", .{ .template = false }); + pub const referrer = bridge.accessor(Document.getReferrer, null, .{}); // Generates a getter/setter pair backed by the frame's attribute-listener // map, like onclick above, for other document event handler properties. diff --git a/src/browser/webapi/element/html/Meta.zig b/src/browser/webapi/element/html/Meta.zig index ea5baee1a..7c869c290 100644 --- a/src/browser/webapi/element/html/Meta.zig +++ b/src/browser/webapi/element/html/Meta.zig @@ -16,11 +16,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); + const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const referrer = @import("../../../referrer.zig"); const Meta = @This(); @@ -78,6 +81,21 @@ pub fn setScheme(self: *Meta, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("scheme"), .wrap(value), frame); } +pub const Build = struct { + // sets the document's referrer policy. + pub fn created(node: *Node, frame: *Frame) !void { + const el = node.as(Element); + const name = el.getAttributeSafe(comptime .wrap("name")) orelse return; + if (std.ascii.eqlIgnoreCase(name, "referrer") == false) { + return; + } + const content = el.getAttributeSafe(comptime .wrap("content")) orelse return; + if (referrer.parseMeta(content)) |rp| { + frame.referrer_policy = rp; + } + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(MetaElement); From 714e6095ed61b0e06faab43062fc303d5324cde7 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 17:13:03 +0800 Subject: [PATCH 11/61] lower gc limit just for the CI... --- src/browser/js/Env.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 9619ef23f..f02110a5f 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -41,7 +41,7 @@ const Allocator = std.mem.Allocator; const MAX_CONTEXTS = if (lp.build_config.wpt_extensions) 8192 else 128; -const GC_HINT_FLOOR = 2 * 1024 * 1024; +const GC_HINT_FLOOR = 1 * 1024 * 1024; fn initClassIds() void { inline for (JsApis, 0..) |JsApi, i| { From d4642d8afcfac2e1d0b41c71bb2637b1b2a03fd8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 17:37:07 +0800 Subject: [PATCH 12/61] http: redirect even on missing close_notify We already had special handling for BoringSSL's RecvError on improperly closed TLS connection. This moves the handling up, so that redirect handling is covered by it too. --- src/network/HttpClient.zig | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 32155136b..312f0001e 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1447,7 +1447,19 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T // Handle redirects: reuse the same connection to preserve TCP state. // A redirect status without a Location header is not a redirect, it's a // final response and falls through so its body is delivered. - if (effective_err == null) { + // When the server closes the TLS connection without a close_notify alert, + // BoringSSL reports RecvError. If we already received valid HTTP headers, + // this is a normal end-of-body (the connection closure signals the end + // of the response per HTTP/1.1 when there is no Content-Length). + // We must check this before endTransfer, which may reset the easy handle. + const is_conn_close_recv = blk: { + const err = effective_err orelse break :blk false; + if (err != error.RecvError) break :blk false; + const hdr = msg.conn.getResponseHeader("connection", 0) orelse break :blk true; + break :blk std.ascii.eqlIgnoreCase(hdr.value, "close"); + }; + + if (effective_err == null or is_conn_close_recv) { const status = try msg.conn.getResponseCode(); if (isRedirectStatus(status)) { if (msg.conn.getResponseHeader("location", 0)) |location| switch (transfer.req.redirect) { @@ -1502,18 +1514,6 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T // transfer's arena, release the conn, and buffer the events — user // callbacks run later, from dispatch(), never from here. - // When the server closes the TLS onnection without a close_notify alert, - // BoringSSL reports RecvError. If we already received valid HTTP headers, - // this is a normal end-of-body (the connection closure signals the end - // of the response per HTTP/1.1 when there is no Content-Length). - // We must check this before endTransfer, which may reset the easy handle. - const is_conn_close_recv = blk: { - const err = effective_err orelse break :blk false; - if (err != error.RecvError) break :blk false; - const hdr = msg.conn.getResponseHeader("connection", 0) orelse break :blk true; - break :blk std.ascii.eqlIgnoreCase(hdr.value, "close"); - }; - if (effective_err != null and !is_conn_close_recv) { self.removeConn(msg.conn); transfer._conn = null; From c86d969ce0c74f64c0a1cd1584258aff82d1b333 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 17:44:05 +0800 Subject: [PATCH 13/61] mem: prefer ensureTotalCapacityPrecise to reduce memory usage When we know the precise final length, prefer ensureTotalCapacityPrecise over ensureTotalCapacity. The latter goes through `growCapacity` which will allocate ~1.5x padding. --- src/browser/Mime.zig | 2 +- src/browser/ScriptManagerBase.zig | 2 +- src/browser/URL.zig | 2 +- src/browser/tools.zig | 2 +- src/browser/webapi/HTMLDocument.zig | 2 +- src/browser/webapi/KeyValueList.zig | 2 +- src/browser/webapi/SharedWorkerGlobalScope.zig | 2 +- src/browser/webapi/Worker.zig | 2 +- src/browser/webapi/element/Attribute.zig | 2 +- src/browser/webapi/element/DOMStringMap.zig | 4 ++-- src/browser/webapi/net/Fetch.zig | 2 +- src/browser/webapi/net/WebSocket.zig | 2 +- src/browser/webapi/net/XMLHttpRequest.zig | 2 +- src/browser/webapi/svg/reflected_list.zig | 2 +- src/cdp/CDP.zig | 2 +- src/network/HttpClient.zig | 4 ++-- src/network/RobotsGate.zig | 2 +- src/script/Schema.zig | 2 +- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 7bd941ab1..9e7e0197b 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -550,7 +550,7 @@ pub fn serialize(arena: Allocator, input: []const u8) ![]const u8 { // The serialized output is the input length plus quoting overhead; reserve // the input length so the common (no-escape) case appends without growing. - try out.ensureTotalCapacity(arena, trimmed.len); + try out.ensureTotalCapacityPrecise(arena, trimmed.len); // Lowercased names already emitted, for first-wins dedupe. var seen: std.ArrayList([]const u8) = .empty; diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index f7fc1263c..1f0db9669 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -760,7 +760,7 @@ pub const Script = struct { var buffer: std.ArrayList(u8) = .empty; if (content_length) |cl| { - try buffer.ensureTotalCapacity(self.sourceAllocator(), cl); + try buffer.ensureTotalCapacityPrecise(self.sourceAllocator(), cl); } self.source = .{ .remote = buffer }; return .proceed; diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 94d53d8df..54a555490 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -579,7 +579,7 @@ pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []cons var buf: std.ArrayList(u8) = .empty; // the most space well need is the url + ('?' or '&') + the query_string + null terminator - try buf.ensureTotalCapacity(arena, url.len + 2 + query_string.len); + try buf.ensureTotalCapacityPrecise(arena, url.len + 2 + query_string.len); buf.appendSliceAssumeCapacity(url); if (std.mem.indexOfScalar(u8, url, '?')) |index| { diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 73f2ee8bb..abdcf097b 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -2170,7 +2170,7 @@ pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) err // before its full match is found, leaking a suffix into the recording. const Pair = struct { name: []const u8, value: []const u8 }; var pairs: std.ArrayList(Pair) = .empty; - try pairs.ensureTotalCapacity(arena, env_names.len); + try pairs.ensureTotalCapacityPrecise(arena, env_names.len); for (env_names) |name| { const value = lookupLpEnv(name) orelse continue; if (value.len < 4) continue; diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index 1ad5814c8..304982f94 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -119,7 +119,7 @@ pub fn getTitle(self: *HTMLDocument, frame: *Frame) ![]const u8 { var started = false; var in_whitespace = false; var result: std.ArrayList(u8) = .empty; - try result.ensureTotalCapacity(frame.local_arena, text.len); + try result.ensureTotalCapacityPrecise(frame.local_arena, text.len); for (text) |c| { const is_ascii_ws = c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '\x0C'; diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index 363e7330e..8956ff0e1 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -100,7 +100,7 @@ pub fn init() KeyValueList { } pub fn ensureTotalCapacity(self: *KeyValueList, allocator: Allocator, n: usize) !void { - return self._entries.ensureTotalCapacity(allocator, n); + return self._entries.ensureTotalCapacityPrecise(allocator, n); } pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 { diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index afa2eec6c..65ef9f1d2 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -212,7 +212,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult { } if (transfer.getContentLength()) |cl| { - try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl); + try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl); } return .proceed; diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 2a572d986..049bb8064 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -170,7 +170,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult { } if (transfer.getContentLength()) |cl| { - try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl); + try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl); } return .proceed; diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 12b30ce75..c5486490b 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -353,7 +353,7 @@ pub const List = struct { pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 { var arr: std.ArrayList([]const u8) = .empty; - try arr.ensureTotalCapacity(allocator, self._len); + try arr.ensureTotalCapacityPrecise(allocator, self._len); for (self.entries()) |*e| { arr.appendAssumeCapacity(e.name()); } diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig index 09066ef29..9bb125449 100644 --- a/src/browser/webapi/element/DOMStringMap.zig +++ b/src/browser/webapi/element/DOMStringMap.zig @@ -81,7 +81,7 @@ fn camelToKebab(arena: Allocator, camel: String) !String { // Fallback: allocate for longer strings var result: std.ArrayList(u8) = .empty; - try result.ensureTotalCapacity(arena, output_len); + try result.ensureTotalCapacityPrecise(arena, output_len); result.appendSliceAssumeCapacity("data-"); for (camel_str, 0..) |c, i| { @@ -110,7 +110,7 @@ fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 { const data_part = kebab[5..]; // Skip "data-" var result: std.ArrayList(u8) = .empty; - try result.ensureTotalCapacity(arena, data_part.len); + try result.ensureTotalCapacityPrecise(arena, data_part.len); var i: usize = 0; while (i < data_part.len) : (i += 1) { diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 0832c1aa8..0e58aef76 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -152,7 +152,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { const arena = self._response._arena; if (transfer.getContentLength()) |cl| { - try self._buf.ensureTotalCapacity(arena.allocator(), cl); + try self._buf.ensureTotalCapacityPrecise(arena.allocator(), cl); } const res = self._response; diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index f119bc386..aa594ad6d 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -901,7 +901,7 @@ fn _receivedDataCallback(conn: *http.Connection, data: []const u8) !void { if (meta.len > self._http_client.max_response_size) { return error.MessageTooLarge; } - try self._recv_buffer.ensureTotalCapacity(self._arena.allocator(), meta.len); + try self._recv_buffer.ensureTotalCapacityPrecise(self._arena.allocator(), meta.len); } try self._recv_buffer.appendSlice(self._arena.allocator(), data); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 75275b429..b55e925d9 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -541,7 +541,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { self._response_status = transfer.responseStatus().?; if (transfer.getContentLength()) |cl| { self._response_len = cl; - try self._response_data.ensureTotalCapacity(self._arena.allocator(), cl); + try self._response_data.ensureTotalCapacityPrecise(self._arena.allocator(), cl); } self._response_url = try self._arena.dupeZ(u8, transfer.req.url); diff --git a/src/browser/webapi/svg/reflected_list.zig b/src/browser/webapi/svg/reflected_list.zig index 9138ccdc4..d375d6c74 100644 --- a/src/browser/webapi/svg/reflected_list.zig +++ b/src/browser/webapi/svg/reflected_list.zig @@ -175,7 +175,7 @@ pub fn Mixin(comptime List: type, comptime Item: type, comptime hooks: anytype) self._snapshot.clearRetainingCapacity(); try self._snapshot.appendSlice(frame.arena, raw); try retireAll(self, frame); - try self._items.ensureTotalCapacity(frame.arena, parsed.items.len); + try self._items.ensureTotalCapacityPrecise(frame.arena, parsed.items.len); for (parsed.items) |item| { self._items.appendAssumeCapacity(item); hooks.attach(self, item); diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 2ed72c51b..c17734118 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -1110,7 +1110,7 @@ pub const BrowserContext = struct { const message_len = msg.len + session_id.len + 1 + field.len + 10; var buf: std.ArrayList(u8) = .empty; - buf.ensureTotalCapacity(allocator, message_len) catch |err| { + buf.ensureTotalCapacityPrecise(allocator, message_len) catch |err| { log.err(.cdp, "inspector buffer", .{ .err = err }); return; }; diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 32155136b..f1a8b5c2c 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1071,7 +1071,7 @@ const SyncContext = struct { lp.assert(transfer.responseStatus() != null, "HttpClient.SyncRequest.headerCallback", .{ .value = transfer.responseStatus() }); self.status = transfer.responseStatus().?; if (transfer.getContentLength()) |cl| { - try self.body.ensureTotalCapacity(try self.bodyAllocator(cl), cl); + try self.body.ensureTotalCapacityPrecise(try self.bodyAllocator(cl), cl); } return .proceed; } @@ -2761,7 +2761,7 @@ pub const Transfer = struct { res.callback_error = error.ResponseTooLarge; return http.writefunc_error; } - res.buffer.ensureTotalCapacity(transfer.arena.allocator(), cl) catch {}; + res.buffer.ensureTotalCapacityPrecise(transfer.arena.allocator(), cl) catch {}; } } diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index a4d025c54..552132a89 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -214,7 +214,7 @@ const RobotsContext = struct { } lp.metrics.robots_status.incr(http.statusCategory(self.status)); if (transfer.getContentLength()) |cl| { - try self.buffer.ensureTotalCapacity(self.arena.allocator(), cl); + try self.buffer.ensureTotalCapacityPrecise(self.arena.allocator(), cl); } return .proceed; } diff --git a/src/script/Schema.zig b/src/script/Schema.zig index c81c9dc01..8439ba52d 100644 --- a/src/script/Schema.zig +++ b/src/script/Schema.zig @@ -482,7 +482,7 @@ fn enumValuesOf(arena: std.mem.Allocator, value: std.json.Value) ![]const []cons fn jsonStringArray(arena: std.mem.Allocator, value: std.json.Value) ![]const []const u8 { if (value != .array) return &.{}; var out: std.ArrayList([]const u8) = .empty; - try out.ensureTotalCapacity(arena, value.array.items.len); + try out.ensureTotalCapacityPrecise(arena, value.array.items.len); for (value.array.items) |item| { if (item != .string) continue; out.appendAssumeCapacity(item.string); From b90515616fcb0746a76e387dc2ff97ce09291c4a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 17:49:38 +0800 Subject: [PATCH 14/61] chore: remove test error log from expected error --- src/storage/sqlite/Sqlite.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/storage/sqlite/Sqlite.zig b/src/storage/sqlite/Sqlite.zig index 325d6f0eb..8754c0e32 100644 --- a/src/storage/sqlite/Sqlite.zig +++ b/src/storage/sqlite/Sqlite.zig @@ -658,6 +658,8 @@ test "Sqlite: Migrations - basic" { } test "Sqlite: Migrations - removed migration" { + testing.expectLog(&.{.storage}); + var conn = try Sqlite.Conn.open(":memory:"); defer conn.close(); From 9e05cf37d11c750e294f5d527ff50f946202e01b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 18:50:22 +0800 Subject: [PATCH 15/61] render: Fallback to contentWidth when clientWidth isn't explicit amivoice.com appends to a child until that child reports a specific clientWidth. To be correct, we'd need to know the render mode of the node (e.g. flexbox modes overflows, etc...). Without that, we have limited options. If the width is explicitly set, we use that (as before), but if it isn't, rather than defaulting we use the much more expensive contentWidth. Also applies to height. --- src/browser/tests/element/position.html | 40 +++++++++++++- src/browser/webapi/Element.zig | 69 +++++++++++++++++++------ 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index 3022384c9..2657f326b 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -53,7 +53,8 @@ box.appendChild(document.createElement('span')); testing.expectTrue(box.scrollWidth > oneChild); - testing.expectTrue(box.scrollWidth > box.clientWidth); + // An unsized box shrink-wraps: clientWidth tracks the same content sum. + testing.expectEqual(box.scrollWidth, box.clientWidth); // Text contributes nothing, however long. Estimating a run from its length // would need a per-character advance that tracks font-size, and would report @@ -133,6 +134,40 @@ } + + diff --git a/src/browser/tests/custom_elements/create_element_post_conditions.html b/src/browser/tests/custom_elements/create_element_post_conditions.html new file mode 100644 index 000000000..c4f1477ad --- /dev/null +++ b/src/browser/tests/custom_elements/create_element_post_conditions.html @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + From 13d08d394d8c04f037b4dec99017e1d10e7ac20c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 5 Aug 2026 20:51:22 +0800 Subject: [PATCH 28/61] webapi: Improve getComputedStyle with pseudo 1. Stop logging not_implemented for :before, :after - they are used a lot and the log is just noise 2. Change the computed style identity map key from element -> (element, pseudo_type). This ensures that the same element+pseudo correctly gets the same CSSStyleProperties, and it avoids needlessly creating more objects for a repeated element/pseudo pair. --- src/browser/Frame.zig | 7 ++++--- src/browser/tests/element/styles.html | 11 +++++++++++ src/browser/webapi/Element.zig | 24 ++++++++++++++++++++++++ src/browser/webapi/Window.zig | 17 ++++++++--------- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index d9f2042b9..07233e382 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -136,9 +136,10 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri // that actually access these features via JavaScript, saving 24 bytes per element. _element_styles: Element.StyleLookup = .empty, // Computed-style views handed out by window.getComputedStyle. The computed -// variant is a stateless lazy view, so one per element suffices — and Chrome -// returns the same object for repeated calls, so identity is also conformance. -_element_computed_styles: Element.StyleLookup = .empty, +// variant is a stateless lazy view, so one per (element, pseudo-element) +// suffices — and Chrome returns the same object for repeated calls, so +// identity is also conformance. +_element_computed_styles: Element.ComputedStyleLookup = .empty, _element_datasets: Element.DatasetLookup = .empty, _element_class_lists: Element.ClassListLookup = .empty, _element_rel_lists: Element.RelListLookup = .empty, diff --git a/src/browser/tests/element/styles.html b/src/browser/tests/element/styles.html index 7418d0ef3..740a06dc3 100644 --- a/src/browser/tests/element/styles.html +++ b/src/browser/tests/element/styles.html @@ -224,6 +224,17 @@ div.style.setProperty('text-transform', 'lowercase'); testing.expectEqual('lowercase', window.getComputedStyle(div).getPropertyValue('text-transform')); + // Pseudo-elements get their own cache entry, keyed per (element, pseudo); + // the legacy single-colon form maps to the same entry as the double-colon + // one, and a pseudoElt without a leading colon is ignored (CSSOM). + const before = window.getComputedStyle(div, ':before'); + testing.expectTrue(before === window.getComputedStyle(div, '::before')); + testing.expectTrue(before === window.getComputedStyle(div, ':BEFORE')); + testing.expectFalse(before === cs); + testing.expectFalse(before === window.getComputedStyle(div, '::after')); + testing.expectTrue(cs === window.getComputedStyle(div, 'before')); + testing.expectTrue(cs === window.getComputedStyle(div, '')); + // A normal declaration must not override an earlier !important one, and the // computed and inline (el.style) paths must agree on the resolved value. const impDiv = document.createElement('div'); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index ce12dbeda..ca101bc05 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -51,6 +51,30 @@ pub const Proto = Node; pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap); pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties); +pub const ComputedStyleLookup = std.AutoHashMapUnmanaged(ComputedStyleKey, *CSSStyleProperties); + +pub const ComputedStyleKey = struct { + element: *Element, + pseudo: PseudoElement, +}; + +pub const PseudoElement = enum { + none, + before, + after, + other, + + pub fn parse(pseudo: []const u8) PseudoElement { + if (pseudo.len == 0 or pseudo[0] != ':') { + return .none; + } + const name = if (std.mem.startsWith(u8, pseudo, "::")) pseudo[2..] else pseudo[1..]; + if (std.ascii.eqlIgnoreCase(name, "before")) return .before; + if (std.ascii.eqlIgnoreCase(name, "after")) return .after; + return .other; + } +}; + pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 4181ffbbd..f248f1282 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -661,16 +661,15 @@ pub fn matchMedia(_: *const Window, query: []const u8, frame: *Frame) !*MediaQue } pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]const u8, frame: *Frame) !*CSSStyleProperties { - if (pseudo_element) |pe| { - if (pe.len != 0) { - log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pe }); - // Chrome hands out a distinct object per pseudo-element, so these - // can't share the per-element cache entry. - return CSSStyleProperties.init(element, true, frame); - } - } - const gop = try frame._element_computed_styles.getOrPut(frame.arena, element); + // :before/:after get their own cache entry and no warning: our answer + // (the element's own computed style) is a reasonable default for the + // common probes + const pseudo = Element.PseudoElement.parse(pseudo_element orelse ""); + const gop = try frame._element_computed_styles.getOrPut(frame.arena, .{ .element = element, .pseudo = pseudo }); if (!gop.found_existing) { + if (pseudo == .other) { + log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pseudo_element.? }); + } gop.value_ptr.* = try CSSStyleProperties.init(element, true, frame); } return gop.value_ptr.*; From 7c89a462eb1198539b7bc51cb08c6d4327490d3b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 5 Aug 2026 12:26:49 +0800 Subject: [PATCH 29/61] cdp: send request information (RI) notifications on redirect Extracted from https://github.com/lightpanda-io/browser/pull/3122. Sends RI for redirect. Also, on a continueRequest which does redirect, restores the original headers (continueRequest's headers are only valid for a single request). To make this work in all drivers, CDP now decouples the transfer_id from the intercept_id. Each unique request gets a distinct intercept_id which is managed in CDP (with a intercept_id -> transfer_id mapping). --- src/cdp/domains/fetch.zig | 79 +++++++++++++++++++++++--------------- src/network/HttpClient.zig | 65 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index 5d09752b5..c94ccb277 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -50,15 +50,15 @@ pub fn processMessage(cmd: *CDP.Command) !void { } } -// Stored in CDP. Holds *transfer ids* (not *Transfer pointers) of paused -// transfers waiting for CDP continueRequest/fulfillRequest/failRequest/ -// continueWithAuth. Anyone resolving an entry must look the transfer up via -// `Client.findTransfer(id)` — if the transfer has been destroyed out-of-band -// (e.g. frame shutdown), the lookup returns null and the CDP command should -// no-op rather than UAF. +// Stored in CDP. Maps intercept ids to *transfer ids* (not *Transfer +// pointers) of paused transfers waiting for CDP continueRequest/ +// fulfillRequest/failRequest/continueWithAuth. Anyone resolving an entry must +// look the transfer up via `Client.findTransfer(id)` — if the transfer has been +// destroyed out-of-band (e.g. frame shutdown), the lookup returns null. pub const InterceptState = struct { allocator: Allocator, - waiting: std.AutoArrayHashMapUnmanaged(u32, void), + next_id: u32 = 1, + waiting: std.AutoArrayHashMapUnmanaged(u32, u32), pub fn init(allocator: Allocator) !InterceptState { return .{ @@ -67,17 +67,17 @@ pub const InterceptState = struct { }; } - pub fn empty(self: *const InterceptState) bool { - return self.waiting.count() == 0; + pub fn put(self: *InterceptState, transfer_id: u32) !u32 { + const intercept_id = self.next_id; + try self.waiting.put(self.allocator, intercept_id, transfer_id); + self.next_id +%= 1; + return intercept_id; } - pub fn put(self: *InterceptState, transfer_id: u32) !void { - return self.waiting.put(self.allocator, transfer_id, {}); - } - - // Returns true if the id was present and removed, false otherwise. - pub fn remove(self: *InterceptState, transfer_id: u32) bool { - return self.waiting.swapRemove(transfer_id); + // Returns the transfer id if the intercept id was present and removed. + pub fn remove(self: *InterceptState, intercept_id: u32) ?u32 { + const entry = self.waiting.fetchSwapRemove(intercept_id) orelse return null; + return entry.value; } pub fn deinit(self: *InterceptState) void { @@ -85,7 +85,7 @@ pub const InterceptState = struct { } pub fn pendingIntercepts(self: *const InterceptState) []u32 { - return self.waiting.keys(); + return self.waiting.values(); } }; @@ -203,11 +203,11 @@ pub fn requestIntercept(bc: *CDP.BrowserContext, intercept: *const Notification. // TODO: What to do when receiving replies for a previous frame's requests? const transfer = intercept.transfer; - try bc.intercept_state.put(transfer.id); - errdefer _ = bc.intercept_state.remove(transfer.id); + const intercept_id = try bc.intercept_state.put(transfer.id); + errdefer _ = bc.intercept_state.remove(intercept_id); try bc.cdp.sendEvent("Fetch.requestPaused", .{ - .requestId = &id.toInterceptId(transfer.id), + .requestId = &id.toInterceptId(intercept_id), .frameId = &id.toFrameId(transfer.req.frame_id), .request = network.RequestWriter.init(transfer), .resourceType = transfer.req.resource_type.string(), @@ -241,9 +241,9 @@ fn continueRequest(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; // Transfer may have been destroyed out-of-band between pause and now // (e.g. frame shutdown). Treat as a no-op rather than an error — the CDP // client's view of "this request still exists" is just stale. @@ -304,9 +304,9 @@ fn continueWithAuth(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; const transfer = client.findTransfer(transfer_id) orelse { log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "auth" }); return cmd.sendResult(null, .{}); @@ -362,9 +362,9 @@ fn fulfillRequest(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; const transfer = client.findTransfer(transfer_id) orelse { log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "fulfill" }); return cmd.sendResult(null, .{}); @@ -399,9 +399,9 @@ fn failRequest(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; const transfer = client.findTransfer(transfer_id) orelse { log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "fail" }); return cmd.sendResult(null, .{}); @@ -426,14 +426,14 @@ pub fn requestAuthRequired(bc: *CDP.BrowserContext, intercept: *const Notificati // TODO: What to do when receiving replies for a previous frame's requests? const transfer = intercept.transfer; - try bc.intercept_state.put(transfer.id); - errdefer _ = bc.intercept_state.remove(transfer.id); + const intercept_id = try bc.intercept_state.put(transfer.id); + errdefer _ = bc.intercept_state.remove(intercept_id); const request = &transfer.req; const challenge = transfer._auth_challenge orelse return error.NullAuthChallenge; try bc.cdp.sendEvent("Fetch.authRequired", .{ - .requestId = &id.toInterceptId(transfer.id), + .requestId = &id.toInterceptId(intercept_id), .frameId = &id.toFrameId(request.frame_id), .request = network.RequestWriter.init(transfer), .resourceType = request.resource_type.string(), @@ -465,7 +465,6 @@ fn idFromRequestId(request_id: []const u8) !u32 { } const testing = @import("../testing.zig"); - test "cdp.Fetch: interception events belong to the enabling session" { var ctx = try testing.context(); defer ctx.deinit(); @@ -500,3 +499,19 @@ test "cdp.Fetch: interception events belong to the enabling session" { }); try testing.expectEqual(null, bc.fetch_session_id); } + +test "cdp.Fetch: InterceptState issues a fresh id per pause" { + var state = try InterceptState.init(testing.allocator); + defer state.deinit(); + + const first = try state.put(7); + const second = try state.put(7); + try testing.expectEqual(false, first == second); + try testing.expectEqual(2, state.pendingIntercepts().len); + try testing.expectEqual(7, state.pendingIntercepts()[0]); + + try testing.expectEqual(7, state.remove(first).?); + try testing.expectEqual(null, state.remove(first)); + try testing.expectEqual(7, state.remove(second).?); + try testing.expectEqual(null, state.remove(second)); +} diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index f88f9f6bc..bdf981bc8 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1465,6 +1465,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T if (msg.conn.getResponseHeader("location", 0)) |location| switch (transfer.req.redirect) { .follow => { try transfer.handleRedirect(location.value); + transfer.restoreInterceptHeaders(); if (self.isUrlBlocked(transfer.req.url, transfer.req.internal)) { log.warn(.http, "blocked url", .{ .url = transfer.req.url }); @@ -1476,6 +1477,28 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T if (!transfer.req.internal) lp.metrics.http_redirects.incr(); + if (self.serve_mode) { // e.g. cdp + var wait_for_interception = false; + transfer.req.notification.dispatch(.http_request_intercept, &.{ + .transfer = transfer, + .wait_for_interception = &wait_for_interception, + }); + + if (wait_for_interception) { + transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer }); + + // Same shape as the auth-interception park above: + // give up the connection, wait for the CDP client. + self.removeConn(msg.conn); + transfer._conn = null; + transfer.reset(); + transfer.state = .created; + self.intercepted += 1; + transfer.park(.intercept_request); + return false; + } + } + const conn = transfer._conn.?; try self.handles.remove(conn); @@ -1913,6 +1936,10 @@ pub const Transfer = struct { _tries: u8 = 0, _redirect_count: u8 = 0, + // Fetch.continueRequest header overrides apply to a single network hop. We + // need to restore (and hence capture) the original headers. + _intercept_original_headers: ?[]const RequestHeader = null, + // Linked into client.pending_queue while .queued; reused to link the // retired transfer into client.graveyard (deinit unlinks it from the // pending queue first, so the node is always free by then). @@ -2709,6 +2736,8 @@ pub const Transfer = struct { // CDP Fetch.continueRequest: the intercepting client supplies the // complete header set, replacing whatever the request carried. pub fn replaceRequestHeaders(self: *Transfer, headers: []const http.Header) !void { + lp.assert(self._intercept_original_headers == null, "Transfer.replaceRequestHeaders", .{ .id = self.id }); + self._intercept_original_headers = try self.arena.allocator().dupe(RequestHeader, self.req_headers.items); self.req_headers.clearRetainingCapacity(); try self.seedHeaders(); for (headers) |hdr| { @@ -2716,6 +2745,15 @@ pub const Transfer = struct { } } + fn restoreInterceptHeaders(self: *Transfer) void { + const headers = self._intercept_original_headers orelse return; + self.req_headers.clearRetainingCapacity(); + // _intercept_original_headers.items.len MIGHT be larger than self.req_headers.items.len + // but the capacity never shrank from when _intercept_original_headers WAS req_headers. + self.req_headers.appendSliceAssumeCapacity(headers); + self._intercept_original_headers = null; + } + // abortAuthChallenge is called when an auth challenge interception is // abort. We don't call self.releaseConn here b/c it has been done // before interception process. @@ -3373,6 +3411,33 @@ test "HttpClient: Transfer.setHeader replaces by case-insensitive name" { try testing.expectEqual("yes", headers[2].value); } +test "HttpClient: Fetch header overrides restore after one hop" { + const original = [_]Transfer.RequestHeader{ + .{ .name = "User-Agent", .value = "original" }, + .{ .name = "X-Original", .value = "yes" }, + }; + var overridden = [_]Transfer.RequestHeader{ + .{ .name = "User-Agent", .value = "override" }, + .{ .name = "X-Override", .value = "yes" }, + }; + + var transfer: Transfer = undefined; + transfer.req_headers = .{ .items = &overridden, .capacity = overridden.len }; + transfer._intercept_original_headers = &original; + transfer.restoreInterceptHeaders(); + + try testing.expectEqual(2, transfer.req_headers.items.len); + try testing.expectEqual("User-Agent", transfer.req_headers.items[0].name); + try testing.expectEqual("original", transfer.req_headers.items[0].value); + try testing.expectEqual("X-Original", transfer.req_headers.items[1].name); + try testing.expectEqual("yes", transfer.req_headers.items[1].value); + try testing.expectEqual(null, transfer._intercept_original_headers); + + // idempotent once restored + transfer.restoreInterceptHeaders(); + try testing.expectEqual(2, transfer.req_headers.items.len); +} + test "HttpClient: fulfillIntercepted survives a done_callback that tears down the owner" { // Regression: the fulfilled response's done_callback runs JS which // navigates / closes the page, re-entrantly killing the transfer From 58cfe511ac561aa0a828246b5dfb1cfc1bf012cb Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 6 Aug 2026 09:59:19 +0800 Subject: [PATCH 30/61] render: CSS width/height are now children-content aware This takes https://github.com/lightpanda-io/browser/pull/3137 and applies is to the width/height CSS values (e.g. CSSStyleCSSStyle.getPropertyValue("width")) to provide a consistent width/height view of an element. Fixes slow rendering of https://inchurch.com.br --- src/browser/tests/element/position.html | 32 +++++++++++++++++++ src/browser/webapi/Element.zig | 16 +++++++--- .../webapi/css/CSSStyleDeclaration.zig | 16 ++++++---- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index 2657f326b..2d653d46c 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -168,6 +168,38 @@ } + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 94bc0502f..36ab863e0 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -181,7 +181,9 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, frame: *Frame return Custom.checkAndAttachBuiltIn(element, frame); }; - if (custom._definition != null) return; + if (custom._definition != null or custom._upgrade_failed) { + return; + } const name = custom._tag_name.str(); const definition = self._definitions.get(name) orelse return; @@ -198,19 +200,49 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio const node = custom.asNode(); const prev_upgrading = frame._upgrading_element; + const prev_consumed = frame._upgrading_consumed; frame._upgrading_element = node; - defer frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = false; + defer { + frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = prev_consumed; + } var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); defer ls.deinit(); - var caught: js.TryCatch.Caught = .{}; - _ = ls.toLocal(definition.constructor).newInstance(&caught) catch |err| { - log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err, .caught = caught }); + const local = &ls.local; + var try_catch: js.TryCatch = undefined; + try_catch.init(local); + defer try_catch.deinit(); + + const object = ls.toLocal(definition.constructor).newInstanceThrow() catch |err| { + if (err == error.ExecutionTerminated) { + custom._definition = null; + return err; + } + log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err }); + upgradeFailed(custom); + if (try_catch.exceptionValue()) |exc| { + frame.window.reportError(exc, frame) catch {}; + } return error.CustomElementUpgradeFailed; }; + const same = if (object.toZig(*Node)) |result| result == node else |_| false; + if (!same) { + // the construction result must be the element being upgraded. + log.warn(.js, "custom element upgrade", .{ .name = definition.name, .reason = "constructor returned another value" }); + upgradeFailed(custom); + const exc: js.Value = .{ + .local = local, + .handle = local.isolate.createTypeError("custom element constructor must return the upgraded element"), + }; + frame.window.reportError(exc, frame) catch {}; + return error.CustomElementUpgradeFailed; + } + // Enqueue attributeChangedCallback for existing observed attributes const element = custom.asElement(); for (element.attributeEntries()) |*attr| { @@ -227,6 +259,11 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio } } +fn upgradeFailed(custom: *Custom) void { + custom._definition = null; + custom._upgrade_failed = true; +} + fn validateName(name: []const u8) !void { if (name.len == 0) { return error.SyntaxError; @@ -288,6 +325,6 @@ pub const JsApi = struct { const testing = @import("../../testing.zig"); test "WebApi: CustomElementRegistry" { - testing.expectLog(&.{ .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js }); + testing.expectLog(&.{ .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js }); try testing.htmlRunner("custom_elements", .{}); } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f248f1282..14ccb86f2 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -639,6 +639,7 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void { // We still dispatch so that addEventListener('error', ...) listeners fire. try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "window.reportError", + .run_microtasks = false, }); if (comptime lp.IS_TEST == false) { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index d8d6d0574..1dc08f973 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -116,6 +116,10 @@ _proto_canary: if (lp.IS_DEBUG) *Element else void = undefined, // which custom element class was invoked; look it up in the registry. pub fn construct(new_target: js.Function, frame: *Frame) !*Element { if (frame._upgrading_element) |node| { + if (frame._upgrading_consumed) { + return error.TypeError; + } + frame._upgrading_consumed = true; return node.is(Element) orelse return error.IllegalConstructor; } return Frame.node_factory.constructCustomElement(frame, new_target); @@ -127,6 +131,10 @@ pub fn construct(new_target: js.Function, frame: *Frame) !*Element { // constructors routed here. pub fn upgradeConstruct(frame: *Frame) !*Element { const node = frame._upgrading_element orelse return error.TypeError; + if (frame._upgrading_consumed) { + return error.TypeError; + } + frame._upgrading_consumed = true; return node.is(Element) orelse return error.TypeError; } diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index c6ed76973..9777ef45e 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -40,6 +40,7 @@ _tag_name: String, _definition: ?*CustomElementDefinition, _connected_callback_invoked: bool = false, _disconnected_callback_invoked: bool = false, +_upgrade_failed: bool = false, // a failed upgrade is never retried pub fn asElement(self: *Custom) *Element { return self._proto.asElement(); @@ -61,6 +62,9 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E if (element.is(Custom)) |custom| { // Upgrade if a definition exists but isn't yet attached if (custom._definition == null) { + if (custom._upgrade_failed) { + return; + } const name = custom._tag_name.str(); if (frame.window._custom_elements._definitions.get(name)) |definition| { const CustomElementRegistry = @import("../../CustomElementRegistry.zig"); @@ -256,9 +260,14 @@ pub fn checkAndAttachBuiltIn(element: *Element, frame: *Frame) !void { // Invoke constructor const prev_upgrading = frame._upgrading_element; + const prev_consumed = frame._upgrading_consumed; const node = element.asNode(); frame._upgrading_element = node; - defer frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = false; + defer { + frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = prev_consumed; + } // PERFORMANCE OPTIMIZATION: This pattern is discouraged in general code. // Used here because: (1) multiple early returns before needing Local, From 63a42839890bb89a32861f9217866120bbe40179 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Aug 2026 11:45:50 +0800 Subject: [PATCH 48/61] reset arena after use, verify user input --- src/cdp/CDP.zig | 4 +++- src/cdp/domains/network.zig | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 660096936..8aa2d3aa3 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -1008,11 +1008,13 @@ pub const BrowserContext = struct { try self.captured_requests.put(self.frame_arena, key, owned_body); } } + defer self.resetNotificationArena(); try @import("domains/network.zig").httpRequestStart(self.notification_arena, self, msg); } pub fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); + defer self.resetNotificationArena(); try @import("domains/fetch.zig").requestIntercept(self.notification_arena, self, msg); } @@ -1089,7 +1091,7 @@ pub const BrowserContext = struct { const key = keyFromTransfer(msg.transfer); const resp = self.captured_responses.getPtr(key) orelse lp.assert(false, "onHttpResponseData missing captured response", .{}); - return resp.data.appendSlice(arena, msg.data); + return resp.data.appendSliceAssumeCapacitySlice(arena, msg.data); } pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void { diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index dce037291..d8e334ac9 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -659,6 +659,10 @@ fn securityState(url: [:0]const u8) []const u8 { } fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedKey { + if (request_id.len < 4) { + return error.InvalidParams; + } + const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams; return if (std.mem.startsWith(u8, request_id, "LID-")) From d35aa909596f0b144ae6466c78990df4e9816bf4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Aug 2026 12:00:05 +0800 Subject: [PATCH 49/61] fix build --- src/cdp/CDP.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 8aa2d3aa3..9399a7821 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -1091,7 +1091,7 @@ pub const BrowserContext = struct { const key = keyFromTransfer(msg.transfer); const resp = self.captured_responses.getPtr(key) orelse lp.assert(false, "onHttpResponseData missing captured response", .{}); - return resp.data.appendSliceAssumeCapacitySlice(arena, msg.data); + return resp.data.appendSlice(arena, msg.data); } pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void { From bf02e41919dfb4ecb45cdd4295cb63fb8b22958b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 7 Aug 2026 08:36:50 +0200 Subject: [PATCH 50/61] runner: advance idle notifications outside the wait-condition loop checkIdleNotifications was only called for conditions still pending, but an idle notification needs a check 500ms+ after the condition first held (Frame.IdleNotification). On a quiet page the same tick both starts that hold and resolves the condition via is_done, so nothing advanced the state machine for the rest of the wait. The CDP pump waits in 1s slices (CDP.pageWait), so Page.lifecycleEvent networkIdle/networkAlmostIdle was starved until a later slice built a fresh condition. That event is what puppeteer's networkidle0/networkidle2 and playwright's networkidle block on. Measured with puppeteer against lightpanda serve, goto(waitUntil: networkidle0) on example.com, median of 5: 2001ms before, 595ms after (the page loads in ~100ms, so ~600ms is the floor: load plus the 500ms hold). Every pre-fix run landed within 1999-2195ms - the stall is quantized to whole pump slices, not network variance. An ad-heavy page is unaffected either way (3196ms vs 2859ms, overlapping ranges): pending timers keep is_done false, so its condition never resolved early to begin with. Non-CDP waits are deliberately unchanged: waitForFrame(.networkidle) on a quiet page still resolves immediately via is_done rather than serving the 500ms hold. Agent/MCP waitForState and fetch --wait-until networkidle want "settled now", not chrome's lifecycle heuristic. --- src/browser/Runner.zig | 44 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 26f95c28f..25ab29217 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -230,6 +230,18 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa const network_idle = activity.idle(); const is_done = browser.hasMacrotasks() == false and network_idle; + // Outside the condition loop: it skips resolved conditions, but an idle + // notification needs a check 500ms+ after the hold starts, and on a quiet + // page one tick both starts the hold and resolves the condition. Before + // it, so `.networkidle` conditions read fresh state. + var page_index: usize = 0; + while (page_index < session.pages.items.len) : (page_index += 1) { + // Indexed: notifyNetworkIdle dispatches to listeners. + const page = session.pages.items[page_index]; + if (page.replacement != null) continue; // frozen; the replacement is live + page.frame.checkIdleNotifications(total_http_activity); + } + // _we_ have nothing to run, but v8 is working on background tasks. We'll // wait for them. Don't do this for CDP, since new CDP messages can always // come in at any time. @@ -272,8 +284,6 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa } }, .html, .complete => { - frame.checkIdleNotifications(total_http_activity); - const met = switch (condition.until) { .done => is_done, .domcontentloaded => frame._load_state == .load or frame._load_state == .complete, @@ -296,6 +306,8 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa } } + // Always taken for is_cdp and every exit returns .ok, so _tick never yields + // .done to the CDP pump: _wait's .done/is_cdp arm is dormant. if ((comptime is_cdp) or want_http_tick) { const ms_to_next_task = blk: { if (has_runnable_page == false) { @@ -543,3 +555,31 @@ test "Runner: lazy iframe does not delay the load event" { try testing.expectEqual(true, lazy_child._load_state == .complete); try testing.expectEqual(true, lazy_child._parent_notified); } + +test "Runner: idle notifications advance past a resolved condition" { + const page = try testing.pageTest("runner/runner1.html", .{}); + defer page.close(); + + const frame = page.frame().?; + + // What a quiet page looks like one tick in: the hold has started, and the + // same tick resolved the wait condition. Seeded past the 500ms hold so the + // test doesn't spend it. + const held_since = lp.datetime.milliTimestamp(.boot) -| 600; + frame._notified_network_idle = .{ .triggered = held_since }; + frame._notified_network_almost_idle = .{ .triggered = held_since }; + + var conditions = [_]WaitCondition{.{ + .frame_id = page.frame_id, + .until = .done, + .status = .complete, + }}; + + // is_cdp mirrors CDP.pageWait, which keeps ticking after the condition + // resolves. + var runner = page.session.runner(.{}); + _ = try runner._wait(true, 50, &conditions); + + try testing.expectEqual(true, frame._notified_network_idle == .done); + try testing.expectEqual(true, frame._notified_network_almost_idle == .done); +} From abc6f1ef76f4f83342012d82848268da922b61d0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Aug 2026 16:17:09 +0800 Subject: [PATCH 51/61] http: recalculate Referer header on each redirect On a redirect, recalculate the Referer header based on the new target. Also, allow SVG anchors to be clicked (doesn't seem related, but it came up in referrer-policy WPT tests). --- src/browser/Frame.zig | 15 +++++ src/browser/frame/user_input.zig | 89 +++++++++++++++++----------- src/browser/tests/frames/target.html | 41 +++++++++++++ src/browser/tests/net/fetch.html | 17 ++++++ src/network/HttpClient.zig | 54 ++++++++++++++++- src/testing.zig | 22 +++++++ 6 files changed, 204 insertions(+), 34 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index c046fc98b..538802d64 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -601,6 +601,7 @@ pub fn headersForRequest(self: *Frame, transfer: *HttpClient.Transfer) !void { const arena = transfer.arena.allocator(); if (try referrer.compute(arena, self.referrer_policy, self.url, transfer.req.url)) |ref| { try transfer.addHeader("Referer", ref, .{}); + transfer.req.referrer_policy = self.referrer_policy; } } @@ -803,6 +804,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo if (opts.referer) |ref| { try transfer.addHeader("Referer", ref, .{}); self._referrer = try self.arena.dupe(u8, ref); + transfer.req.referrer_policy = opts.referrer_policy; } } @@ -967,6 +969,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url if (std.mem.startsWith(u8, originator.url, "http")) { if (nav_opts.referer == null) { nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, originator.url, resolved_url); + nav_opts.referrer_policy = originator.referrer_policy; } if (nav_opts.initiator_url == null) { nav_opts.initiator_url = try arena.dupeZ(u8, originator.url); @@ -1255,6 +1258,13 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer. no.body = null; no.header = null; } + + // The Referer may have been recomputed at each hop; document.referrer + // reports what the final request actually sent. + self._referrer = if (transfer.findRequestHeader("referer")) |ref| + try self.arena.dupe(u8, ref) + else + null; } // Init new location. @@ -1861,6 +1871,7 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { new_frame.navigate(url, .{ .reason = .initialFrameNavigation, .referer = try referrer.compute(self.call_arena, self.referrer_policy, self.url, url), + .referrer_policy = self.referrer_policy, .initiator_url = parent_url, .initiator_origin = self.origin, }) catch |err| { @@ -3117,6 +3128,10 @@ pub const NavigateOpts = struct { // anchor click / form submit / location.href navigations carry a Referer. // null on CDP Page.navigate (address-bar) and Page.reload — matches Chrome. referer: ?[]const u8 = null, + // The originating frame's policy, paired with `referer` so redirect hops + // can recompute the header. null (e.g. a CDP-supplied referrer) leaves + // the Referer untouched across redirects. + referrer_policy: ?referrer.Policy = null, // The URL of the document that initiated this navigation, used as the // "site for cookies" when computing SameSite. Distinct from `referer` // because a Referrer-Policy can suppress the Referer header without diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 153d8c0db..0dbcd9297 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -204,7 +204,14 @@ fn deltaToScroll(d: f64) i32 { // implements. fn hasClickActivationBehavior(node: *Node) bool { const element = node.is(Element) orelse return false; - const html_element = element.is(Element.Html) orelse return false; + + const html_element = element.is(Element.Html) orelse { + if (element.is(Element.Svg.Graphics.A) != null) { + return svgAnchorHref(element) != null; + } + return false; + }; + return switch (html_element._type) { .anchor => element.getAttributeSafe(comptime .wrap("href")) != null, .input, .button, .select, .textarea, .label => true, @@ -213,6 +220,11 @@ fn hasClickActivationBehavior(node: *Node) bool { }; } +// SVG 2 links via `href`; xlink:href is the deprecated SVG 1.1 spelling. +fn svgAnchorHref(element: *Element) ?[]const u8 { + return element.getAttributeSafe(comptime .wrap("href")) orelse element.getAttributeSafe(comptime .wrap("xlink:href")); +} + // Clicks on editable content are for editing: they don't activate the // element or any enclosing link. // "contenteditable" is 15 bytes — past the comptime SSO limit — so the @@ -325,44 +337,20 @@ const JavascriptUrlTask = struct { pub fn handleClick(frame: *Frame, target: *Node) !void { // TODO: Also support elements when implement const element = target.is(Element) orelse return; + + if (element.is(Element.Svg.Graphics.A) != null) { + const href = svgAnchorHref(element) orelse return; + const target_name = element.getAttributeSafe(comptime .wrap("target")) orelse ""; + return followLink(frame, target, element, href, target_name); + } + const html_element = element.is(Element.Html) orelse return; switch (html_element._type) { .anchor => { const anchor = html_element.subtype(Element.Html.Anchor); const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; - if (href.len == 0) { - return; - } - - if (std.mem.startsWith(u8, href, "javascript:")) { - // Navigating to a javascript: URL evaluates the script in the - // node's frame as a queued task. (A string completion value - // would replace the document; we ignore results.) - return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]); - } - - if (try element.hasAttribute(comptime .wrap("download"), frame)) { - log.warn(.browser, "a.download", .{ .type = frame._type, .url = frame.url }); - return; - } - - const target_frame = blk: { - const target_name = anchor.getTarget(); - if (target_name.len == 0) { - break :blk target.ownerFrame(frame); - } - break :blk frame.resolveTargetFrame(target_name) orelse { - log.warn(.not_implemented, "target", .{ .type = frame._type, .url = frame.url, .target = target_name }); - return; - }; - }; - - try element.focus(frame); - try frame.scheduleNavigation(href, .{ - .reason = .script, - .kind = .{ .push = null }, - }, .{ .anchor = target_frame }); + return followLink(frame, target, element, href, anchor.getTarget()); }, .input => { const input = html_element.subtype(Element.Html.Input); @@ -414,6 +402,41 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { } } +// Follow a link on activation. Shared by HTML and SVG . +fn followLink(frame: *Frame, target: *Node, element: *Element, href: []const u8, target_name: []const u8) !void { + if (href.len == 0) { + return; + } + + if (std.mem.startsWith(u8, href, "javascript:")) { + // Navigating to a javascript: URL evaluates the script in the + // node's frame as a queued task. (A string completion value + // would replace the document; we ignore results.) + return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]); + } + + if (try element.hasAttribute(comptime .wrap("download"), frame)) { + log.warn(.browser, "a.download", .{ .type = frame._type, .url = frame.url }); + return; + } + + const target_frame = blk: { + if (target_name.len == 0) { + break :blk target.ownerFrame(frame); + } + break :blk frame.resolveTargetFrame(target_name) orelse { + log.warn(.not_implemented, "target", .{ .type = frame._type, .url = frame.url, .target = target_name }); + return; + }; + }; + + try element.focus(frame); + try frame.scheduleNavigation(href, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .{ .anchor = target_frame }); +} + pub fn triggerKeyboard(frame: *Frame, keyboard_event: *KeyboardEvent) !void { const event = keyboard_event.asEvent(); // Dispatch to the effective active element. When nothing is explicitly diff --git a/src/browser/tests/frames/target.html b/src/browser/tests/frames/target.html index 924e0e590..a44a47cb3 100644 --- a/src/browser/tests/frames/target.html +++ b/src/browser/tests/frames/target.html @@ -126,3 +126,44 @@ }); } + + + + + svg link + + + + + + + svg 1.1 link + + + diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index fdd13dbb2..ef676af35 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -451,3 +451,20 @@ }); } + + + diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index bdf981bc8..90a2c4eab 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -26,6 +26,7 @@ const Notification = @import("../Notification.zig"); const CDP = @import("../cdp/CDP.zig"); const Watchdog = @import("../Watchdog.zig"); const URL = @import("../browser/URL.zig"); +const referrer = @import("../browser/referrer.zig"); const WebSocket = @import("../browser/webapi/net/WebSocket.zig"); const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; @@ -1464,8 +1465,8 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T if (isRedirectStatus(status)) { if (msg.conn.getResponseHeader("location", 0)) |location| switch (transfer.req.redirect) { .follow => { - try transfer.handleRedirect(location.value); transfer.restoreInterceptHeaders(); + try transfer.handleRedirect(location.value); if (self.isUrlBlocked(transfer.req.url, transfer.req.internal)) { log.warn(.http, "blocked url", .{ .url = transfer.req.url }); @@ -1657,6 +1658,7 @@ pub const Request = struct { cookie_origin: [:0]const u8, resource_type: ResourceType, redirect: RedirectMode = .follow, + referrer_policy: ?referrer.Policy = null, credentials: ?[:0]const u8 = null, notification: *Notification, timeout_ms: u32 = 0, @@ -2604,6 +2606,21 @@ pub const Transfer = struct { } } + // A Referrer-Policy header on a redirect response applies to the + // remaining hops. + if (req.referrer_policy != null) { + // referrer-policy is re-applied on every hop. + var i: usize = 0; + while (conn.getResponseHeader("referrer-policy", i)) |hdr| : (i += 1) { + if (referrer.parseHeader(hdr.value)) |policy| { + req.referrer_policy = policy; + } + if (i >= hdr.amount) { + break; + } + } + } + // base_url and location are owned by curl; applyRedirectTarget resolves a // fresh arena-owned copy that gets stored in transfer.req.url. const base_url = try conn.getEffectiveUrl(); @@ -2651,6 +2668,21 @@ pub const Transfer = struct { req.method = .GET; req.body = null; } + + if (req.referrer_policy) |policy| { + // Referer header was applied based on the original target. It + // needs to be updated based on the redirect target. A redirect can + // only strip it (full -> origin -> none), so we can use whatever + // value we have now as the base + if (transfer.findRequestHeader("referer")) |current| { + const alloc = arena.allocator(); + if (try referrer.compute(alloc, policy, try alloc.dupeZ(u8, current), req.url)) |value| { + try transfer.setHeader("Referer", value, .{}); + } else { + transfer.removeHeader("Referer"); + } + } + } } fn detectAuthChallenge(transfer: *Transfer, conn: *const http.Connection) void { @@ -2702,6 +2734,26 @@ pub const Transfer = struct { }); } + pub fn findRequestHeader(self: *const Transfer, name: []const u8) ?[]const u8 { + for (self.req_headers.items) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, name)) { + return hdr.value; + } + } + return null; + } + + fn removeHeader(self: *Transfer, name: []const u8) void { + var i: usize = 0; + while (i < self.req_headers.items.len) { + if (std.ascii.eqlIgnoreCase(self.req_headers.items[i].name, name)) { + _ = self.req_headers.orderedRemove(i); + continue; + } + i += 1; + } + } + // Adds, replacing every existing header with the same case-insensitive name pub fn setHeader(self: *Transfer, name: []const u8, value: []const u8, opts: HeaderOpts) !void { var found = false; diff --git a/src/testing.zig b/src/testing.zig index ce3b15460..b62a5a327 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -1003,6 +1003,28 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/redirect_same_echo_referer")) { + // Same-origin 302 to /echo_referer: the full Referer must survive the hop. + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/echo_referer" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/redirect_cross_echo_referer")) { + // 302 to /echo_referer on the localhost alias — a cross-origin hop, so + // the Referer must be recomputed at the redirect (stripped to origin + // under the default policy) rather than re-sent in full. + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "http://localhost:9582/echo_referer" }, + }, + }); + } + if (std.mem.eql(u8, path, "/redirect_to_echo")) { // 302 to /echo_method. Used by the Page.reload-after-redirect test to // confirm a POST→302→GET chain doesn't replay POST on reload. From f80aa2882cab9d60c4f620dfa2ec59a28bf1d8b9 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 7 Aug 2026 07:54:51 -0700 Subject: [PATCH 52/61] use a better query for evictOverflow --- src/network/cache/SqliteCache.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/network/cache/SqliteCache.zig b/src/network/cache/SqliteCache.zig index 763dd2122..13203a62d 100644 --- a/src/network/cache/SqliteCache.zig +++ b/src/network/cache/SqliteCache.zig @@ -140,8 +140,10 @@ fn evictOverflow(self: *SqliteCache, conn: Conn) !void { try conn.exec( \\ delete from cache - \\ where url not in ( - \\ select url from cache order by stored_at desc limit $1 + \\ where rowid in ( + \\ select rowid from cache + \\ order by stored_at desc + \\ limit -1 offset $1 \\ ) , .{@as(i64, @intCast(limit))}); } From 32364a26dad93ed0c25de5de80ac6d8a20e33b69 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 7 Aug 2026 07:55:44 -0700 Subject: [PATCH 53/61] use proper default on http_cache_entry_limit option --- src/Config.zig | 4 ++-- src/network/cache/SqliteCache.zig | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 8bdb18396..fc74e4b23 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -191,7 +191,7 @@ const CommonOptions = .{ .{ .name = "log_filter_scopes", .type = log.FilterRule, .multiple = true, .validator = logFilterScopesValidator }, .{ .name = "user_agent_suffix", .type = ?[]const u8 }, .{ .name = "http_cache_dir", .type = ?[]const u8 }, - .{ .name = "http_cache_entry_limit", .type = ?u32 }, + .{ .name = "http_cache_entry_limit", .type = ?u32, .default = 1000 }, .{ .name = "web_bot_auth_key_file", .type = ?[]const u8 }, .{ .name = "web_bot_auth_keyid", .type = ?[]const u8 }, .{ .name = "web_bot_auth_domain", .type = ?[]const u8 }, @@ -634,7 +634,7 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 { pub fn httpCacheEntryLimit(self: *const Config) u32 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_entry_limit orelse 1000, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_entry_limit.?, else => 1000, }; } diff --git a/src/network/cache/SqliteCache.zig b/src/network/cache/SqliteCache.zig index 13203a62d..45322e45e 100644 --- a/src/network/cache/SqliteCache.zig +++ b/src/network/cache/SqliteCache.zig @@ -126,7 +126,7 @@ pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath, entry_limit: u3 try conn.exec("pragma foreign_keys=on", .{}); } - log.info(.cache, "sqlite cache initialized", .{ .path = path, .version = version }); + log.info(.cache, "sqlite cache initialized", .{ .path = path, .entry_limit = entry_limit, .version = version }); return .{ .allocator = allocator, .pool = pool, .entry_limit = entry_limit }; } From cfb2fc5e9c15b31e3a977b5ca81e5d54f49501d1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Aug 2026 18:52:21 +0800 Subject: [PATCH 54/61] webapi: add support for iframe srcdoc Came up in a handful of referrer-policy WPT tests. --- src/browser/Frame.zig | 113 ++++++++++--- src/browser/Session.zig | 8 +- src/browser/tests/frames/srcdoc.html | 183 +++++++++++++++++++++ src/browser/webapi/element/html/IFrame.zig | 39 +++++ 4 files changed, 314 insertions(+), 29 deletions(-) create mode 100644 src/browser/tests/frames/srcdoc.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index cdc75676d..7d6aa65bd 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -578,6 +578,16 @@ pub fn base(self: *const Frame) [:0]const u8 { return self.base_url orelse self.url; } +fn referrerSource(self: *const Frame) [:0]const u8 { + var frame = self; + while (std.mem.startsWith(u8, frame.url, "about:")) { + // about:blank and about:srcdoc documents aren't valid referrer sources, + // use the parents + frame = frame.parent orelse return frame.url; + } + return frame.url; +} + pub fn getTitle(self: *Frame) !?[]const u8 { if (self.window._document.is(Document.HTMLDocument)) |html_doc| { return try html_doc.getTitle(self); @@ -603,7 +613,7 @@ pub fn httpMetadata(self: *const Frame) HttpMetadata { // * referer pub fn headersForRequest(self: *Frame, transfer: *HttpClient.Transfer) !void { const arena = transfer.arena.allocator(); - if (try referrer.compute(arena, self.referrer_policy, self.url, transfer.req.url)) |ref| { + if (try referrer.compute(arena, self.referrer_policy, self.referrerSource(), transfer.req.url)) |ref| { try transfer.addHeader("Referer", ref, .{}); transfer.req.referrer_policy = self.referrer_policy; } @@ -646,11 +656,12 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo const http_client = &session.browser.http_client; - // Handle synthetic navigations: about:blank and blob: URLs + // Handle synthetic navigations: about:blank, about:srcdoc and blob: URLs const is_about_blank = std.mem.eql(u8, "about:blank", request_url); - const is_blob = !is_about_blank and std.mem.startsWith(u8, request_url, "blob:"); + const is_srcdoc = !is_about_blank and std.mem.eql(u8, "about:srcdoc", request_url); + const is_blob = !is_about_blank and !is_srcdoc and std.mem.startsWith(u8, request_url, "blob:"); - if (is_about_blank or is_blob) { + if (is_about_blank or is_srcdoc or is_blob) { if (is_blob) { if (!Blob.urlBelongsToOrigin(request_url, opts.initiator_origin)) { log.warn(.js, "invalid blob", .{ .url = request_url }); @@ -658,7 +669,12 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo } } - self.url = if (is_about_blank) "about:blank" else try self.arena.dupeZ(u8, request_url); + self.url = if (is_about_blank) + "about:blank" + else if (is_srcdoc) + "about:srcdoc" + else + try self.arena.dupeZ(u8, request_url); // even though about:blank navigations may share the same _data_, we // have to do this to make sure window.location is at a unique _address_. @@ -675,13 +691,17 @@ 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; - if (is_about_blank) { + if (is_about_blank or is_srcdoc) { self.base_url = parent.base(); + // about:blank and about:srcdoc documents inherit their + // creator's policy container, including the referrer policy + self.referrer_policy = parent.referrer_policy; } } else if (self.window._opener) |opener| { self.origin = opener._frame.origin; if (is_about_blank) { self.base_url = opener._frame.base(); + self.referrer_policy = opener._frame.referrer_policy; } } else { self.origin = null; @@ -706,6 +726,30 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo const html = try parse_arena.dupe(u8, blob._slice); var parser = Parser.init(parse_arena.allocator(), self.document.asNode(), self, .{ .allow_declarative_shadow = true }); parser.parse(html); + } else if (is_srcdoc) { + // The "response body" is the iframe's srcdoc attribute. Only an + // iframe can navigate here (e.g. location = 'about:srcdoc' on a + // root frame ends up with an empty document, like Chrome). + const content = blk: { + const iframe = self.iframe orelse break :blk ""; + break :blk iframe.asElement().getAttributeSafe(comptime .wrap("srcdoc")) orelse ""; + }; + if (content.len == 0) { + // the parser emits nothing for an empty input; commit the + // same html/head/body scaffolding an empty srcdoc implies + self.document.injectBlank(self) catch |err| { + log.err(.browser, "inject blank", .{ .err = err }); + return error.InjectBlankFailed; + }; + } else { + const parse_arena = try self.getArena(content.len, "Frame.parseSrcdoc"); + defer parse_arena.release(); + // A script executed mid-parse can rewrite the srcdoc attribute, + // freeing the value under the parser; parse a copy. + const html = try parse_arena.dupe(u8, content); + var parser = Parser.init(parse_arena.allocator(), self.document.asNode(), self, .{ .allow_declarative_shadow = true }); + parser.parse(html); + } } else { self.document.injectBlank(self) catch |err| { log.err(.browser, "inject blank", .{ .err = err }); @@ -875,7 +919,7 @@ pub fn scheduleNavigation(self: *Frame, request_url: []const u8, opts: NavigateO // might change inside the function. So the code should be explicit about the // frame that it's acting on. fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void { - const resolved_url, const is_about_blank = blk: { + const resolved_url, const is_about_something = blk: { if (URL.isCompleteHTTPUrl(request_url)) { break :blk .{ try arena.dupeZ(u8, request_url), false }; } @@ -885,6 +929,11 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url break :blk .{ "about:blank", true }; } + if (std.mem.eql(u8, request_url, "about:srcdoc")) { + // like about:blank, a synchronous navigation handled by navigate + break :blk .{ "about:srcdoc", true }; + } + // request_url isn't a "complete" URL, so it has to be resolved with the // originator's base. Unless, originator's base is "about:blank", in which // case we have to walk up the parents and find a real base. @@ -970,13 +1019,14 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url // runs (processRootQueuedNavigation rebuilds the Page in-place), so // allocate from the QueuedNavigation arena which outlives that tear-down. var nav_opts = opts; - if (std.mem.startsWith(u8, originator.url, "http")) { + const referrer_source = originator.referrerSource(); + if (std.mem.startsWith(u8, referrer_source, "http")) { if (nav_opts.referer == null) { - nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, originator.url, resolved_url); + nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, referrer_source, resolved_url); nav_opts.referrer_policy = originator.referrer_policy; } if (nav_opts.initiator_url == null) { - nav_opts.initiator_url = try arena.dupeZ(u8, originator.url); + nav_opts.initiator_url = try arena.dupeZ(u8, referrer_source); } } if (nav_opts.initiator_origin == null) { @@ -990,7 +1040,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url .opts = nav_opts, .arena = arena, .url = resolved_url, - .is_about_blank = is_about_blank, + .is_about_something = is_about_something, .navigation_type = std.meta.activeTag(nt), }; @@ -1117,7 +1167,7 @@ pub fn iframeCompletedLoading(self: *Frame, iframe: *IFrame, delays_load: bool) .html => true, else => false, }; - if (parsing_html and iframe._src.len > 0) { + if (parsing_html and (iframe._src.len > 0 or iframe.hasSrcdoc())) { self.queueElementEvent(Factory.protoOf(iframe), .load) catch |err| { log.err(.frame, "iframe queue load", .{ .err = err, .url = iframe._src }); }; @@ -1797,15 +1847,23 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { return; } - var src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse ""; - if (src.len == 0) { - src = "about:blank"; - } + const src = blk: { + if (iframe.hasSrcdoc()) { + // srcdoc takes precedence over src, even when empty + break :blk "about:srcdoc"; + } - if (URL.isCompleteHTTPUrl(src) and !URL.canParse(src, null)) { - // per spec, if we can't parse the URL, we should load about:blank - src = "about:blank"; - } + var src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse ""; + if (src.len == 0) { + src = "about:blank"; + } + + if (URL.isCompleteHTTPUrl(src) and !URL.canParse(src, null)) { + // per spec, if we can't parse the URL, we should load about:blank + src = "about:blank"; + } + break :blk src; + }; if (iframe._window != null) { // This frame is being re-navigated. We need to do this through a @@ -1849,6 +1907,9 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { if (std.mem.eql(u8, src, "about:blank")) { break :blk "about:blank"; // navigate will handle this special case } + if (std.mem.eql(u8, src, "about:srcdoc")) { + break :blk "about:srcdoc"; // navigate will handle this special case + } break :blk try URL.resolve( self.call_arena, // ok to use, frame.navigate dupes this self.base(), @@ -1869,12 +1930,14 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { // Iframe's initial src request carries the parent's URL as Referer // (subject to the parent's Referrer-Policy) and as the SameSite - // initiator. Parent frame outlives this navigate() call, so the slice - // is safe; navigate dupes what it keeps. - const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, self.url, "http")) self.url else null; + // initiator. When this frame is itself an about: document, the nearest + // ancestor's URL is the referrer source. Parent frame outlives this + // navigate() call, so the slice is safe; navigate dupes what it keeps. + const referrer_source = self.referrerSource(); + const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, referrer_source, "http")) referrer_source else null; new_frame.navigate(url, .{ .reason = .initialFrameNavigation, - .referer = try referrer.compute(self.call_arena, self.referrer_policy, self.url, url), + .referer = try referrer.compute(self.call_arena, self.referrer_policy, referrer_source, url), .referrer_policy = self.referrer_policy, .initiator_url = parent_url, .initiator_origin = self.origin, @@ -3174,7 +3237,7 @@ pub const QueuedNavigation = struct { arena: *lp.Arena, url: [:0]const u8, opts: NavigateOpts, - is_about_blank: bool, + is_about_something: bool, // about:blank or about:srcdoc navigation_type: NavigationType, }; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index bc1ee1a92..f24c58fc8 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -598,8 +598,8 @@ fn processPageQueuedNavigation(self: *Session, page: *Page) !void { continue; }; - if (qn.is_about_blank) { - // Defer about:blank to second pass + if (qn.is_about_something) { + // Defer about:blank or about:srcdoc to second pass try about_blank_queue.append(self.arena.allocator(), frame); continue; } @@ -637,7 +637,7 @@ fn processPageQueuedNavigation(self: *Session, page: *Page) !void { while (i < new_navigations.items.len) { const frame = new_navigations.items[i]; if (frame._queued_navigation) |qn| { - if (qn.is_about_blank) { + if (qn.is_about_something) { log.warn(.frame, "recursive about blank", .{}); _ = page.queued_navigation.swapRemove(i); continue; @@ -783,7 +783,7 @@ fn processRootQueuedNavigation(self: *Session, page: *Page) !void { // Synthetic navigations (about:blank, blob:) commit instantly — no HTTP, // so there is no in-flight window to worry about. Use the optimized // immediate-swap path for them. - const is_synthetic = qn.is_about_blank or std.mem.startsWith(u8, qn.url, "blob:"); + const is_synthetic = qn.is_about_something or std.mem.startsWith(u8, qn.url, "blob:"); // The qn arena is consumed here regardless of success — frame.navigate // dupes the URL into the page's own arena, so we can release the qn diff --git a/src/browser/tests/frames/srcdoc.html b/src/browser/tests/frames/srcdoc.html new file mode 100644 index 000000000..debd1333d --- /dev/null +++ b/src/browser/tests/frames/srcdoc.html @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 8849c8f6b..4dc882b4d 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -31,6 +31,7 @@ const DOMTokenList = @import("../../collections.zig").DOMTokenList; const HtmlElement = @import("../Html.zig"); +const String = lp.String; const IFrame = @This(); pub const Proto = HtmlElement; @@ -79,6 +80,19 @@ pub fn setSrc(self: *IFrame, src: []const u8, frame: *Frame) !void { } } +pub fn hasSrcdoc(self: *IFrame) bool { + return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) != null; +} + +pub fn getSrcdoc(self: *IFrame) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) orelse ""; +} + +pub fn setSrcdoc(self: *IFrame, value: []const u8, frame: *Frame) !void { + // Build.attributeChange triggers the (re)navigation. + try self.asElement().setAttributeSafe(comptime .wrap("srcdoc"), .wrap(value), frame); +} + pub fn getName(self: *IFrame) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } @@ -105,6 +119,7 @@ pub const JsApi = struct { }; pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{ .ce_reactions = true }); + pub const srcdoc = bridge.accessor(IFrame.getSrcdoc, IFrame.setSrcdoc, .{ .ce_reactions = true }); pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{ .ce_reactions = true }); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); @@ -117,4 +132,28 @@ pub const Build = struct { const element = self.asElement(); self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; } + + pub fn attributeChange(element: *Element, name: String, _: String, frame: *Frame) !void { + if (!name.eql(comptime .wrap("srcdoc"))) { + return; + } + if (element.asNode().isConnected()) { + // like src, setting srcdoc reloads the frame even if the value didn't change + const self = element.as(IFrame); + self._executed = false; + try frame.iframeAddedCallback(self); + } + } + + pub fn attributeRemove(element: *Element, name: String, frame: *Frame) !void { + if (!name.eql(comptime .wrap("srcdoc"))) { + return; + } + if (element.asNode().isConnected()) { + const self = element.as(IFrame); + // removing srcdoc falls back to src (or about:blank) + self._executed = false; + try frame.iframeAddedCallback(self); + } + } }; From ec16da49e37fa1c6f8ef086060aac5fe5993da62 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 10 Aug 2026 09:22:45 +0200 Subject: [PATCH 55/61] mask url fields from crash report --- src/crash_handler.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/crash_handler.zig b/src/crash_handler.zig index 41200409b..b3b86c41a 100644 --- a/src/crash_handler.zig +++ b/src/crash_handler.zig @@ -101,6 +101,11 @@ fn report(reason: []const u8, begin_addr: usize, args: anytype) !void { const body = blk: { var writer: std.Io.Writer = .fixed(body_buffer[0..8191]); // reserve 1 space inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| { + // remove url value from the crash report. + if (comptime std.mem.eql(u8, f.name, "url")) { + writer.writeAll("url: REDACTED\n") catch break; + continue; + } writer.writeAll(f.name ++ ": ") catch break; lp.log.writeValue(.pretty, @field(args, f.name), &writer) catch {}; writer.writeByte('\n') catch {}; From 73de272755bdd2ab26115ca195282f889a2c8cf0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 10 Aug 2026 15:51:58 +0800 Subject: [PATCH 56/61] chore: speed up unit tests on linux --- src/TestHTTPServer.zig | 5 +++++ src/TestWSServer.zig | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/TestHTTPServer.zig b/src/TestHTTPServer.zig index 46649de33..574fb125a 100644 --- a/src/TestHTTPServer.zig +++ b/src/TestHTTPServer.zig @@ -19,6 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const posix = std.posix; const sys_net = @import("sys/net.zig"); const URL = @import("browser/URL.zig"); @@ -76,6 +77,10 @@ pub fn run(self: *TestHTTPServer, wg: *lp.WaitGroup) !void { fn handleConnection(self: *TestHTTPServer, conn: std.Io.net.Stream) !void { defer conn.close(lp.io); + if (@hasDecl(posix.TCP, "NODELAY")) { + posix.setsockopt(conn.socket.handle, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))) catch {}; + } + var req_buf: [2048]u8 = undefined; var conn_reader = conn.reader(lp.io, &req_buf); var conn_writer = conn.writer(lp.io, &req_buf); diff --git a/src/TestWSServer.zig b/src/TestWSServer.zig index 971b3f8df..ed24c380a 100644 --- a/src/TestWSServer.zig +++ b/src/TestWSServer.zig @@ -87,6 +87,10 @@ fn runImpl(self: *TestWSServer, wg: *lp.WaitGroup) !void { fn handleClient(client: posix.socket_t) void { defer _ = std.c.close(client); + if (@hasDecl(posix.TCP, "NODELAY")) { + posix.setsockopt(client, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))) catch {}; + } + var buf: [4096]u8 = undefined; const n = posix.read(client, &buf) catch return; From 77ef54f0ac353d4e4a710a70efcbb498b3480ecb Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 11 Aug 2026 09:35:03 +0800 Subject: [PATCH 57/61] dev: add -Ddev_fast flag Only works in Debug mode. Only works on x86_64 Linux. sets use_llvm = false and forces glibc 2.43 (1), causes v8 to be dynamically linked (https://github.com/lightpanda-io/zig-v8-fork/pull/197). For me, zig build -Ddev_fast is about 6x faster (~60 seconds -> ~10 seconds) (1) https://codeberg.org/ziglang/zig/issues/31272 --- build.zig | 41 +++++++++++++++++++++++++++++++++++------ build.zig.zon | 10 +++++----- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/build.zig b/build.zig index 92609cc9c..a95a6ba8b 100644 --- a/build.zig +++ b/build.zig @@ -36,12 +36,39 @@ const Build = blk: { }; pub fn build(b: *Build) !void { - const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // The three settings below only work as a set, so they get one knob rather + // than three defaults: the self-hosted backend needs the shared V8 (it + // cannot apply the CREL relocations in the archive) and needs zig's own CRT + // (a system crt1.o with SFrame unwind data has relocations its linker does + // not handle). Debug-only, opt-in, and a good deal faster to rebuild. + const dev_fast = b.option(bool, "dev_fast", "Linux debug builds: shared V8 + self-hosted backend. Implies -Dshared_v8, -Duse_llvm=false and a bundled-CRT target") orelse false; + + const target = if (dev_fast) b.resolveTargetQuery(.{ + .cpu_arch = .x86_64, + .os_tag = .linux, + .abi = .gnu, + // https://codeberg.org/ziglang/zig/issues/31272 + .glibc_version = .{ .major = 2, .minor = 43, .patch = 0 }, + }) else b.standardTargetOptions(.{}); + + if (dev_fast) { + if (builtin.os.tag != .linux) { + std.debug.print("-Ddev_fast is Linux-only (host is {s})\n", .{@tagName(builtin.os.tag)}); + return error.DevFastUnsupportedHost; + } + if (optimize != .Debug) { + std.debug.print("-Ddev_fast is Debug-only (optimize is {s})\n", .{@tagName(optimize)}); + return error.DevFastRequiresDebug; + } + } + const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a"); const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot"); const wpt_extensions = b.option(bool, "wpt_extensions", "Extend WebAPI with WPT driver behavior") orelse false; + const shared_v8 = b.option(bool, "shared_v8", "Link V8 as a shared library") orelse dev_fast; + const use_llvm = b.option(bool, "use_llvm", "Use the LLVM backend") orelse !dev_fast; const version = resolveVersion(b); std.debug.print("Lightpanda {f}\n", .{version}); @@ -83,7 +110,7 @@ pub fn build(b: *Build) !void { // Set default behavior b.default_step.dependOn(fmt_step); - try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path); + try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path, shared_v8); try linkCurl(b, mod, enable_tsan); try linkHtml5Ever(b, mod); linkZenai(b, mod); @@ -112,7 +139,7 @@ pub fn build(b: *Build) !void { // browser const exe = b.addExecutable(.{ .name = "lightpanda", - .use_llvm = true, + .use_llvm = use_llvm, .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, @@ -149,7 +176,7 @@ pub fn build(b: *Build) !void { // snapshot creator const exe = b.addExecutable(.{ .name = "lightpanda-snapshot-creator", - .use_llvm = true, + .use_llvm = use_llvm, .root_module = b.createModule(.{ .root_source_file = b.path("src/main_snapshot_creator.zig"), .target = target, @@ -179,7 +206,7 @@ pub fn build(b: *Build) !void { // skills generator const exe = b.addExecutable(.{ .name = "lightpanda-skills", - .use_llvm = true, + .use_llvm = use_llvm, .root_module = b.createModule(.{ .root_source_file = b.path("src/main_skills.zig"), .target = target, @@ -211,7 +238,7 @@ pub fn build(b: *Build) !void { // test const tests = b.addTest(.{ .root_module = lightpanda_module, - .use_llvm = true, + .use_llvm = use_llvm, .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple }, }); const run_tests = b.addRunArtifact(tests); @@ -226,6 +253,7 @@ fn linkV8( is_asan: bool, is_tsan: bool, prebuilt_v8_path: ?[]const u8, + shared_v8: bool, ) !void { const target = mod.resolved_target.?; @@ -238,6 +266,7 @@ fn linkV8( .v8_enable_sandbox = is_tsan, .cache_root = b.pathFromRoot(".lp-cache"), .prebuilt_v8_path = prebuilt_v8_path, + .shared_v8 = shared_v8, }); mod.addImport("v8", dep.module("v8")); } diff --git a/build.zig.zon b/build.zig.zon index 4de13facb..16bc7f3e5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,11 +4,11 @@ .fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications. .minimum_zig_version = "0.16.0", .dependencies = .{ - .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/78b6c77d5f6f040a575539203e6f3fad42453890.tar.gz", - .hash = "v8-0.0.0-xddH6xHyAgDVbf39iQaZfmzZCdNi7m3iTqOKbKz74Ggx", - }, - // .v8 = .{ .path = "../zig-v8-fork" }, + // .v8 = .{ + // .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/78b6c77d5f6f040a575539203e6f3fad42453890.tar.gz", + // .hash = "v8-0.0.0-xddH6xHyAgDVbf39iQaZfmzZCdNi7m3iTqOKbKz74Ggx", + // }, + .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ // v1.2.0 .url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz", From 5d2910034c0d19531035ec9d94d0bfc43f84b24f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 11 Aug 2026 09:54:33 +0800 Subject: [PATCH 58/61] update v8 dep --- build.zig.zon | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 16bc7f3e5..2c8bac0ee 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,11 +4,11 @@ .fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications. .minimum_zig_version = "0.16.0", .dependencies = .{ - // .v8 = .{ - // .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/78b6c77d5f6f040a575539203e6f3fad42453890.tar.gz", - // .hash = "v8-0.0.0-xddH6xHyAgDVbf39iQaZfmzZCdNi7m3iTqOKbKz74Ggx", - // }, - .v8 = .{ .path = "../zig-v8-fork" }, + .v8 = .{ + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/db264d2c4d70c09e102167799e99cebb8a74713f.tar.gz", + .hash = "v8-0.0.0-xddH6wsDAwA_VE-G-JbVC9XnMTGzeuYmzGPmxHpj0KIF", + }, + // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ // v1.2.0 .url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz", From e1794b4768a30869d7ceeaea72c4da8d5d5399b1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 11 Aug 2026 14:12:49 +0800 Subject: [PATCH 59/61] webapi: improve events from shadowroot Correctly encapsulate events that happen within a shadowroot to stop from leaking out. This involves calculating the related_target relative to the potential node receiving the event. Also Event.composedPath now re-uses the same path-building logic as EventManager (previously, the same-ish algo in both places, that were out of sync). Some smaller changes: - input.list getter (showed up in the shadow dom wpt tests) - activeElement getter added to ShadowDOM - document activeElement is ShadowDOM-aware and won't cross --- src/browser/EventManager.zig | 215 +++++++++++++++------- src/browser/Frame.zig | 11 +- src/browser/tests/shadowroot/events.html | 138 ++++++++++++++ src/browser/webapi/Document.zig | 13 +- src/browser/webapi/Event.zig | 126 ++++--------- src/browser/webapi/Node.zig | 20 +- src/browser/webapi/ShadowRoot.zig | 18 ++ src/browser/webapi/element/html/Input.zig | 15 ++ src/browser/webapi/element/html/Slot.zig | 10 +- src/browser/webapi/element/slotting.zig | 22 +-- 10 files changed, 406 insertions(+), 182 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index df11cae6b..c598d5305 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -134,18 +134,24 @@ pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []cons } fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { - { - const et = target.asEventTarget(); - event._target = et; - event._dispatch_target = et; // Store original target for composedPath() + const target_et = target.asEventTarget(); + event._target = target_et; + event._dispatch_target = target_et; // Store original target for composedPath() - // Retarget the relatedTarget against the dispatch target up front - // (DOM dispatch step 4); listeners observe the retargeted value and - // it survives the dispatch. - if (event.relatedTargetPtr()) |related_ptr| { - if (related_ptr.*) |related| { - related_ptr.* = getAdjustedTarget(related, et); - } + // The relatedTarget as authored. Every invocation sees it retargeted + // against its own currentTarget (DOM dispatch step 5.7), so the event + // keeps the unadjusted value between invocations. + const original_related: ?*EventTarget = if (event.relatedTargetPtr()) |p| p.* else null; + event._dispatch_related_target = original_related; + if (original_related) |related| { + if (rootIsShadowRoot(related)) { + event._needs_retargeting = true; + } + // DOM dispatch step 5: an event whose relatedTarget retargets onto the + // target itself isn't dispatched at all. + const adjusted = getAdjustedTarget(related, target_et); + if (adjusted == target_et and related != target_et) { + return; } } @@ -193,8 +199,12 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { related_ptr.* = null; } } else if (event._needs_retargeting and node_path_len > 0) { - const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]); + const last = path_buffer[node_path_len - 1]; + const adjusted = getAdjustedTarget(event._dispatch_target, last); event._target = if (rootIsShadowRoot(adjusted)) null else adjusted; + if (event.relatedTargetPtr()) |related_ptr| { + related_ptr.* = getAdjustedTarget(original_related, last); + } } // Handle checkbox/radio activation rollback or commit if (activation_state) |state| { @@ -222,38 +232,12 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { } } - const target_root = target.getRootNode(.{}); - var node: ?*Node = target; - while (node) |n| { - if (path_len >= path_buffer.len) break; - path_buffer[path_len] = n.asEventTarget(); - path_len += 1; - - // Check if this node is a shadow root - if (n.is(ShadowRoot)) |shadow| { - event._needs_retargeting = true; - - // A non-composed event stops at its own tree's root. - if (!event._composed and n == target_root) { - break; - } - - // Otherwise, jump to the shadow host and continue - node = shadow._host.asNode(); - continue; - } - - // an assigned slottable's event-path parent is its assigned slot, - // routing the event into the slot's shadow tree - if (frame._assigned_slots.get(n)) |slot| { - node = slot.asNode(); - continue; - } - - node = n._parent; - } - + const built = buildEventPath(target, event, frame, &path_buffer); + path_len = built.len; node_path_len = path_len; + if (built.crosses_shadow_root) { + event._needs_retargeting = true; + } // Even though the window isn't part of the DOM, most events propagate // through it in the capture phase. It only participates when the tree's @@ -308,7 +292,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { // Phase 2: At target if (event._stop_propagation) return; event._event_phase = .at_target; - const target_et = target.asEventTarget(); blk: { // Get inline handler (e.g., onclick property) for this target @@ -320,6 +303,8 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { window._current_event = currentEventForTarget(target_et, event); defer window._current_event = prev_current_event; + const adjusted: ?AdjustedTargets = if (event._needs_retargeting) .apply(event, target_et) else null; + // Inline handlers (e.g. onclick property) follow the same "report, // don't propagate" rule as addEventListener listeners — see Listener.run. var caught: js.TryCatch.Caught = .{}; @@ -333,6 +318,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { }; processHandlerReturnValue(event, handler_return); + if (adjusted) |a| { + a.restore(event); + } + if (event._stop_propagation) { return; } @@ -377,10 +366,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { window._current_event = currentEventForTarget(current_target, event); defer window._current_event = prev_current_event; - const original_target = event._target; - if (event._needs_retargeting) { - event._target = getAdjustedTarget(original_target, current_target); - } + const adjusted: ?AdjustedTargets = if (event._needs_retargeting) .apply(event, current_target) else null; var caught: js.TryCatch.Caught = .{}; const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, current_target, .{event}, &caught) catch |err| ret: { @@ -393,8 +379,8 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { }; processHandlerReturnValue(event, handler_return); - if (event._needs_retargeting) { - event._target = original_target; + if (adjusted) |a| { + a.restore(event); } if (event._stop_propagation) { @@ -493,19 +479,15 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe event._current_target = current_target; event._in_passive_listener = listener.passive; - // Compute adjusted target for shadow DOM retargeting (only if needed) - const original_target = event._target; - if (event._needs_retargeting) { - event._target = getAdjustedTarget(original_target, current_target); - } + // Compute adjusted targets for shadow DOM retargeting (only if needed) + const adjusted: ?AdjustedTargets = if (event._needs_retargeting) .apply(event, current_target) else null; try listener.run(frame.call_arena, local, event, "listener"); event._in_passive_listener = false; - // Restore original target (only if we changed it) - if (event._needs_retargeting) { - event._target = original_target; + if (adjusted) |a| { + a.restore(event); } if (event._stop_immediate_propagation) { @@ -547,6 +529,113 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j }; } +// An invocation sees the target and the relatedTarget retargeted against its +// own currentTarget (DOM dispatch step 5.7). The event carries the unadjusted +// values in between, so each invocation adjusts and then restores them. +const AdjustedTargets = struct { + target: ?*EventTarget, + related: ?*EventTarget, + related_ptr: ?*?*EventTarget, + + fn apply(event: *Event, current_target: *EventTarget) AdjustedTargets { + const related_ptr = event.relatedTargetPtr(); + const original: AdjustedTargets = .{ + .target = event._target, + .related = if (related_ptr) |p| p.* else null, + .related_ptr = related_ptr, + }; + + event._target = getAdjustedTarget(original.target, current_target); + if (related_ptr) |p| { + p.* = getAdjustedTarget(original.related, current_target); + } + return original; + } + + fn restore(self: AdjustedTargets, event: *Event) void { + event._target = self.target; + if (self.related_ptr) |p| { + p.* = self.related; + } + } +}; + +pub const EventPath = struct { + len: usize, + // Whether a shadow root sits on the path, i.e. whether an invocation can + // see a target other than the one the event was dispatched at. + crosses_shadow_root: bool, +}; + +// Builds the node portion of an event's propagation path (DOM dispatch step +// 5.7) into `buffer`. Window, which follows the document at the end of the +// path, is left to the caller: the rules for including it differ between +// dispatch and composedPath(). +pub fn buildEventPath(target: *Node, event: *Event, frame: ?*Frame, buffer: []*EventTarget) EventPath { + if (buffer.len == 0) { + return .{ .len = 0, .crosses_shadow_root = false }; + } + + const target_root = target.getRootNode(.{}); + const related = event._dispatch_related_target; + + // The root of the spec's `target` variable, which moves to each host we + // cross on the way out. A node it still contains is inside the current + // target's tree, where the event always propagates; the first node beyond + // it is where the relatedTarget can cut the path short. + var scope_root = target_root; + + buffer[0] = target.asEventTarget(); + var path: EventPath = .{ .len = 1, .crosses_shadow_root = target.is(ShadowRoot) != null }; + + var node = eventPathParent(target, event, target_root, frame); + while (node) |n| { + if (path.len == buffer.len) { + break; + } + + const et = n.asEventTarget(); + if (!isShadowIncludingInclusiveAncestor(scope_root, n)) { + // DOM dispatch step 5.7: the path stops at the relatedTarget. + if (related != null and getAdjustedTarget(related, et) == et) { + break; + } + scope_root = n.getRootNode(.{}); + } + + if (n.is(ShadowRoot) != null) { + path.crosses_shadow_root = true; + } + buffer[path.len] = et; + path.len += 1; + + node = eventPathParent(n, event, target_root, frame); + } + + return path; +} + +// DOM spec "get the parent" for a node on the event path: an assigned +// slottable's parent is its slot, routing the event into the slot's shadow +// tree, and a shadow root's is its host — except for a non-composed event, +// which stops at the root of the tree it was dispatched in. +fn eventPathParent(node: *Node, event: *Event, target_root: *Node, frame: ?*Frame) ?*Node { + if (node.is(ShadowRoot)) |shadow| { + if (!event._composed and node == target_root) { + return null; + } + return shadow._host.asNode(); + } + + if (frame) |f| { + if (f._assigned_slots.get(node)) |slot| { + return slot.asNode(); + } + } + + return node._parent; +} + // DOM spec "retarget": walk original_target out of shadow trees until the // node is visible from current_target's tree. fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget { @@ -589,14 +678,10 @@ fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool { // shadow root. Used for the spec's post-dispatch "clear targets" step. fn rootIsShadowRoot(target_: ?*EventTarget) bool { const target = target_ orelse return false; - var current: *Node = switch (target._type) { - .node => |n| n, - else => return false, + return switch (target._type) { + .node => |n| n.containingShadowRoot() != null, + else => false, }; - while (current._parent) |p| { - current = p; - } - return current.is(ShadowRoot) != null; } // Check if ancestor is an ancestor of (or the same as) node diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 7d6aa65bd..bb4481341 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2578,6 +2578,15 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt return; } + // Focus goes with the removed subtree. The focused element can be inside a + // shadow tree hanging off it, which the walk below doesn't descend into, + // so ask it directly whether it's still in the document. + if (self.document._active_element) |active| { + if (active.asNode().isConnected() == false) { + self.document._active_element = null; + } + } + // The child was connected and now it no longer is. We need to "disconnect" // it and all of its descendants. For now "disconnect" just means updating // the ID map and invoking disconnectedCallback for custom elements @@ -2809,7 +2818,7 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No return; } - const parent_in_shadow = parent.is(ShadowRoot) != null or parent.isInShadowTree(); + const parent_in_shadow = parent.containingShadowRoot() != null; if (!parent_in_shadow and !parent_is_connected) { return; diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html index 7dbf1fea8..25c305b04 100644 --- a/src/browser/tests/shadowroot/events.html +++ b/src/browser/tests/shadowroot/events.html @@ -326,3 +326,141 @@ host.remove(); } + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index e5e0b5e6e..f3b2e75f3 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -728,7 +728,15 @@ pub fn getReadyState(self: *const Document) []const u8 { pub fn getActiveElement(self: *Document) ?*Element { if (self._active_element) |el| { - return el; + // A focused element inside a shadow tree is exposed as its outermost + // host; one in a detached tree isn't exposed at all. + var candidate = el; + while (candidate.asNode().containingShadowRoot()) |shadow| { + candidate = shadow._host; + } + if (candidate.asNode().getRootNode(.{}) == self.asNode()) { + return candidate; + } } // Default to body if it exists @@ -765,6 +773,9 @@ pub fn adoptNode(self: *Document, node: *Node, frame: *Frame) !*Node { if (node._type == .document) { return error.NotSupported; } + if (node.is(Node.ShadowRoot) != null) { + return error.HierarchyError; + } const old_owner = node.ownerDocument(frame) orelse frame.document; diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 6076e91c8..95c427598 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const EventManager = @import("../EventManager.zig"); const Node = @import("Node.zig"); const EventTarget = @import("EventTarget.zig"); @@ -40,6 +41,7 @@ _type_string: String, _target: ?*EventTarget = null, _current_target: ?*EventTarget = null, _dispatch_target: ?*EventTarget = null, // Original target for composedPath() +_dispatch_related_target: ?*EventTarget = null, _prevent_default: bool = false, _stop_propagation: bool = false, _stop_immediate_propagation: bool = false, @@ -325,113 +327,61 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget { else => return &.{}, }; - // Build the path by walking up from target - var path_len: usize = 0; - var path_buffer: [128]*EventTarget = undefined; - var stopped_at_shadow_boundary = false; - - // Track closed shadow boundaries (position in path and host position) - var closed_shadow_boundary: ?struct { shadow_end: usize, host_start: usize } = null; - const frame_ = switch (exec.js.global) { .frame => |frame| frame, else => null, }; - const target_root = target_node.getRootNode(.{}); - var node: ?*Node = target_node; - while (node) |n| { - if (path_len >= path_buffer.len) { - break; - } - path_buffer[path_len] = n.asEventTarget(); - path_len += 1; - - // Check if this node is a shadow root - if (n._type == .document_fragment) { - const df = n.subtype(Node.DocumentFragment); - if (df._type == .shadow_root) { - const shadow = df._type.shadow_root; - - if (!self._composed and n == target_root) { - stopped_at_shadow_boundary = true; - break; - } - - // Track the first closed shadow boundary we encounter - if (shadow._mode == .closed and closed_shadow_boundary == null) { - // Mark where the shadow root is in the path - // The next element will be the host - closed_shadow_boundary = .{ - .shadow_end = path_len - 1, // index of shadow root - .host_start = path_len, // index where host will be - }; - } - - // Jump to the shadow host and continue - node = shadow._host.asNode(); - continue; - } - } - - // an assigned slottable's event-path parent is its assigned slot, - // routing the event into the slot's shadow tree - if (frame_) |frame| { - if (frame._assigned_slots.get(n)) |slot| { - node = slot.asNode(); - continue; - } - } - - node = n._parent; + var path_buffer: [128]*EventTarget = undefined; + var path_len = EventManager.buildEventPath(target_node, self, frame_, &path_buffer).len; + if (path_len == 0) { + return &.{}; } - // Add window at the end. It only participates when propagation did not stop - // at a shadow boundary... - if (stopped_at_shadow_boundary == false) { - // ... AND when the tree's root is a document - const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) { - .node => |n| n._type == .document, - else => false, + // Window follows the document at the end of the path. A path that stopped + // early — at a shadow boundary, or at the relatedTarget — doesn't end on + // the document and so doesn't reach it. + const root_is_document = switch (path_buffer[path_len - 1]._type) { + .node => |n| n._type == .document, + else => false, + }; + if (root_is_document and path_len < path_buffer.len) { + if (frame_) |frame| { + path_buffer[path_len] = frame.window.asEventTarget(); + path_len += 1; + } + } + + // The host of the first closed shadow root on the path. Everything before + // it is inside that root and hidden from a currentTarget outside it. + var closed_host_index: ?usize = null; + for (path_buffer[0..path_len], 0..) |entry, i| { + const node = switch (entry._type) { + .node => |n| n, + else => continue, }; - if (root_is_document) { - if (path_len < path_buffer.len) { - switch (exec.js.global) { - .worker => {}, - .frame => |frame| { - path_buffer[path_len] = frame.window.asEventTarget(); - path_len += 1; - }, - } - } + const shadow = node.is(Node.ShadowRoot) orelse continue; + if (shadow._mode == .closed) { + closed_host_index = i + 1; + break; } } // Determine visible path based on current_target and closed shadow boundaries var visible_start_index: usize = 0; - if (closed_shadow_boundary) |boundary| { - // Check if current_target is outside the closed shadow - // If current_target is null or is at/after the host position, hide shadow internals - const current_target = self._current_target; - - if (current_target) |ct| { - // Find current_target in the path - var ct_index: ?usize = null; + if (closed_host_index) |host_index| { + // Find current_target in the path; if it's at or after the host, it's + // outside the closed shadow and must not see the nodes inside it. + if (self._current_target) |ct| { for (path_buffer[0..path_len], 0..) |elem, i| { if (elem == ct) { - ct_index = i; + if (i >= host_index) { + visible_start_index = host_index; + } break; } } - - // If current_target is at or after the host (outside the closed shadow), - // hide everything from target up to the host - if (ct_index) |idx| { - if (idx >= boundary.host_start) { - visible_start_index = boundary.host_start; - } - } } } diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index f2604ab02..03b84fa8d 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -636,15 +636,15 @@ pub fn isEqualChildren(a: *Node, b: *Node) bool { return a_count == b_count; } +// The shadow root whose tree this node belongs to, or null when it belongs to +// a document tree or a detached one. Inclusive: a shadow root is in its own +// tree. +pub fn containingShadowRoot(self: *Node) ?*ShadowRoot { + return self.getRootNode(.{}).is(ShadowRoot); +} + pub fn isInShadowTree(self: *Node) bool { - var node = self._parent; - while (node) |n| { - if (n.is(ShadowRoot) != null) { - return true; - } - node = n._parent; - } - return false; + return self.containingShadowRoot() != null; } pub fn isConnected(self: *const Node) bool { @@ -1209,6 +1209,10 @@ const CloneError = error{ ExecutionTerminated, }; pub fn cloneNode(self: *Node, deep_: ?bool, frame: *Frame) CloneError!*Node { + if (self.is(ShadowRoot) != null) { + return error.NotSupported; + } + const deep = deep_ orelse false; switch (self._type) { .cdata => { diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig index d2ea005b3..878b4d2f2 100644 --- a/src/browser/webapi/ShadowRoot.zig +++ b/src/browser/webapi/ShadowRoot.zig @@ -124,6 +124,23 @@ pub fn setOnSlotChange(self: *ShadowRoot, callback: ?js.Function.Global, frame: } } +pub fn getActiveElement(self: *ShadowRoot, frame: *Frame) ?*Element { + const root = self.asNode(); + const document = root.ownerDocument(frame) orelse frame.document; + + // This is answering two questions: + // 1 - is the active element contained by me (if not, return null) + // 2 - if it is, is there 1+ other shadowroot between us + // a - if there is, return the nearest (to self) shadowroot's host + // b - if there isn't, return the active element + var candidate = document._active_element orelse return null; + while (candidate.asNode().getRootNode(.{}) != root) { + const shadow = candidate.asNode().containingShadowRoot() orelse return null; + candidate = shadow._host; + } + return candidate; +} + pub fn getElementById(self: *ShadowRoot, id: []const u8, frame: *Frame) ?*Element { if (id.len == 0) { return null; @@ -177,6 +194,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const activeElement = bridge.accessor(ShadowRoot.getActiveElement, null, .{}); pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{}); pub const host = bridge.accessor(ShadowRoot.getHost, null, .{}); pub const delegatesFocus = bridge.accessor(ShadowRoot.getDelegatesFocus, null, .{}); diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 3ebbf3178..c823b3843 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -892,6 +892,20 @@ pub fn getLabels(self: *Input, frame: *Frame) !js.Array { return @import("Label.zig").getControlLabels(self.asElement(), frame); } +pub fn getList(self: *Input, frame: *Frame) ?*HtmlElement.DataList { + switch (self._input_type) { + .hidden, .password, .checkbox, .radio, .file, .submit, .image, .reset, .button => return null, + else => {}, + } + + const element = self.asElement(); + const list_id = element.getAttributeSafe(comptime .wrap("list")) orelse return null; + + // list= resolves in the input's own tree (shadow root or document). + const target = frame.getElementByIdFromNode(element.asNode(), list_id) orelse return null; + return target.is(HtmlElement.DataList); +} + pub fn getForm(self: *Input, frame: *Frame) ?*Form { const element = self.asElement(); @@ -1427,6 +1441,7 @@ pub const JsApi = struct { pub const size = bridge.accessor(Input.getSize, Input.setSize, .{ .ce_reactions = true }); pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{ .ce_reactions = true }); pub const form = bridge.accessor(Input.getForm, null, .{}); + pub const list = bridge.accessor(Input.getList, null, .{}); pub const formAction = bridge.accessor(Input.getFormAction, Input.setFormAction, .{}); pub const formEnctype = bridge.accessor(Input.getFormEnctype, Input.setFormEnctype, .{}); pub const formMethod = bridge.accessor(Input.getFormMethod, Input.setFormMethod, .{}); diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig index a14e53e24..0bb61a579 100644 --- a/src/browser/webapi/element/html/Slot.zig +++ b/src/browser/webapi/element/html/Slot.zig @@ -6,7 +6,6 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); -const ShadowRoot = @import("../../ShadowRoot.zig"); const slotting = @import("../slotting.zig"); const Slot = @This(); @@ -76,7 +75,7 @@ fn CollectionType(comptime elements: bool) type { // DOM spec "find flattened slottables" fn collectFlattened(self: *Slot, comptime elements: bool, coll: CollectionType(elements), frame: *Frame) error{OutOfMemory}!void { - if (self.asNode().getRootNode(.{}).is(ShadowRoot) == null) { + if (self.asNode().containingShadowRoot() == null) { return; } @@ -101,7 +100,7 @@ fn appendFlattened(comptime elements: bool, coll: CollectionType(elements), node if (node.is(Slot)) |nested| { // a slottable (or fallback child) that is itself a slot in a shadow // tree flattens to its own flattened slottables - if (nested.asNode().getRootNode(.{}).is(ShadowRoot) != null) { + if (nested.asNode().containingShadowRoot() != null) { return nested.collectFlattened(elements, coll, frame); } } @@ -153,9 +152,8 @@ pub fn assign(self: *Slot, values: []const js.Value, frame: *Frame) !void { try self._manually_assigned.append(frame.arena, node); } - const root = self.asNode().getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - slotting.assignSlottablesForTree(root, frame); + if (self.asNode().containingShadowRoot()) |shadow_root| { + slotting.assignSlottablesForTree(shadow_root.asNode(), frame); } } diff --git a/src/browser/webapi/element/slotting.zig b/src/browser/webapi/element/slotting.zig index c62ff5505..1e26ca1d6 100644 --- a/src/browser/webapi/element/slotting.zig +++ b/src/browser/webapi/element/slotting.zig @@ -23,7 +23,6 @@ const Frame = @import("../../Frame.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); -const ShadowRoot = @import("../ShadowRoot.zig"); const TreeWalker = @import("../TreeWalker.zig"); const Text = @import("../cdata/Text.zig"); @@ -93,7 +92,7 @@ fn assignSlottables(slot: *Slot, frame: *Frame) void { fn _assignSlottables(slot: *Slot, frame: *Frame) !void { var slottables: std.ArrayList(*Node) = .empty; - if (slot.asNode().getRootNode(.{}).is(ShadowRoot)) |shadow_root| { + if (slot.asNode().containingShadowRoot()) |shadow_root| { const host = shadow_root.getHost(); if (shadow_root._slot_assignment == .manual) { // manual assignment preserves the assign(...) order, not tree order @@ -184,7 +183,7 @@ pub fn insertionSteps(parent: *Node, child: *Node, in_fragment_parse: bool, fram // assignment they were parsed with. if (in_fragment_parse == false) { if (parent.is(Slot)) |parent_slot| { - if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) { + if (parent_slot._assigned.items.len == 0 and parent.containingShadowRoot() != null) { frame.signalSlotChange(parent_slot); } } @@ -192,9 +191,8 @@ pub fn insertionSteps(parent: *Node, child: *Node, in_fragment_parse: bool, fram // A subtree containing slots was inserted into a shadow tree. if (subtreeHasSlot(child)) { - const root = child.getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - assignSlottablesForTree(root, frame); + if (child.containingShadowRoot()) |shadow_root| { + assignSlottablesForTree(shadow_root.asNode(), frame); } } } @@ -213,7 +211,7 @@ pub fn removalSteps(parent: *Node, child: *Node, frame: *Frame) void { // Fallback content was removed from a slot that renders its fallback. if (parent.is(Slot)) |parent_slot| { - if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) { + if (parent_slot._assigned.items.len == 0 and parent.containingShadowRoot() != null) { frame.signalSlotChange(parent_slot); } } @@ -221,9 +219,8 @@ pub fn removalSteps(parent: *Node, child: *Node, frame: *Frame) void { // A subtree containing slots was removed: update assignments in the old // tree, and clear assignments held by slots in the detached subtree. if (subtreeHasSlot(child)) { - const root = parent.getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - assignSlottablesForTree(root, frame); + if (parent.containingShadowRoot()) |shadow_root| { + assignSlottablesForTree(shadow_root.asNode(), frame); } assignSlottablesForTree(child, frame); } @@ -248,8 +245,7 @@ pub fn nameAttributeChanged(slot: *Slot, old_value: []const u8, value: []const u if (std.mem.eql(u8, old_value, value)) { return; } - const root = slot.asNode().getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - assignSlottablesForTree(root, frame); + if (slot.asNode().containingShadowRoot()) |shadow_root| { + assignSlottablesForTree(shadow_root.asNode(), frame); } } From 4b70b9523aaad2b40bae9ee1f45a2fe3a04022bc Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 11 Aug 2026 14:46:17 +0300 Subject: [PATCH 60/61] `Element`: alias `matches` with `webkitMatchesSelector` --- src/browser/webapi/Element.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index bfa74e041..497ea81c4 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -2416,6 +2416,7 @@ pub const JsApi = struct { pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{}); pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{}); pub const matches = bridge.function(Element.matches, .{}); + pub const webkitMatchesSelector = bridge.function(Element.matches, .{}); pub const querySelector = bridge.function(Element.querySelector, .{}); pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{}); pub const closest = bridge.function(Element.closest, .{}); From 4d8c0f6d928f59dee8c2f5f80fe3ba3cb645fe04 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 11 Aug 2026 14:46:48 +0300 Subject: [PATCH 61/61] update tests --- src/browser/tests/element/matches.html | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html index f28d7a716..a1a3743e5 100644 --- a/src/browser/tests/element/matches.html +++ b/src/browser/tests/element/matches.html @@ -62,6 +62,26 @@ } + +