From 6ed41ea3468b7c3156a319741510332184ed5c4d Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sat, 16 May 2026 19:03:15 +0200 Subject: [PATCH 1/6] Add --enable-external-stylesheets flag (no-op surface) Reserves the CLI flag and LP.configureLoading externalStylesheets field so drivers can adopt the API before the fetch implementation lands in a follow-up that depends on #2303. The bool is intentionally unread in this PR. Mirrors the existing --disable-subframes / --disable-workers plumbing; the CDP field extends LP.configureLoading alongside subFrame and worker without breaking existing callers. Refs #2343 --- src/Config.zig | 8 ++++++++ src/browser/Session.zig | 10 ++++++++++ src/cdp/domains/lp.zig | 35 +++++++++++++++++++++++++++++++++++ src/help.zon | 14 ++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/src/Config.zig b/src/Config.zig index 9013ab35..2bedfd1f 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -100,6 +100,7 @@ const CommonOptions = .{ .{ .name = "storage_sqlite_path", .type = ?[:0]const u8 }, .{ .name = "disable_subframes", .type = bool }, .{ .name = "disable_workers", .type = bool }, + .{ .name = "enable_external_stylesheets", .type = bool }, }; fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { @@ -254,6 +255,13 @@ pub fn disableWorkers(self: *const Config) bool { }; } +pub fn enableExternalStylesheets(self: *const Config) bool { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.enable_external_stylesheets, + else => unreachable, + }; +} + pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.http_proxy, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index aa34370d..d044d0ef 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -91,6 +91,15 @@ subframe_loading_enabled: bool = true, // session init; the LP.configureLoading CDP method can flip it per-session. worker_loading_enabled: bool = true, +// Opt-in fetch of external resources. Defaults to +// false to preserve the current rendering-free fast path: drivers that +// don't need accurate visibility checks pay nothing. Set from the +// `--enable-external-stylesheets` CLI flag at session init; the +// LP.configureLoading CDP method can flip it per-session. Currently +// unread — the fetch path lands in a follow-up that depends on the +// network refactor in #2303. +load_external_stylesheets: bool = false, + pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void { const allocator = browser.app.allocator; const arena_pool = browser.arena_pool; @@ -112,6 +121,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi // CLI defaults; LP.configureLoading can flip these per-session. .subframe_loading_enabled = !browser.app.config.disableSubframes(), .worker_loading_enabled = !browser.app.config.disableWorkers(), + .load_external_stylesheets = browser.app.config.enableExternalStylesheets(), }; } diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index ffc2cf92..b3b3aec0 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -68,11 +68,13 @@ fn configureLoading(cmd: *CDP.Command) !void { const params = (try cmd.params(struct { subFrame: ?bool = null, worker: ?bool = null, + externalStylesheets: ?bool = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.NoBrowserContext; if (params.subFrame) |v| bc.session.subframe_loading_enabled = v; if (params.worker) |v| bc.session.worker_loading_enabled = v; + if (params.externalStylesheets) |v| bc.session.load_external_stylesheets = v; return cmd.sendResult(null, .{}); } @@ -688,3 +690,36 @@ test "cdp.lp: configureLoading toggles subFrame and worker independently" { try testing.expectEqual(true, bc.session.subframe_loading_enabled); try testing.expectEqual(true, bc.session.worker_loading_enabled); } + +test "cdp.lp: configureLoading toggles externalStylesheets independently" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + _ = try bc.session.createPage(); + + // Default is opt-in: off unless the CLI flag or CDP toggle enables it. + try testing.expectEqual(false, bc.session.load_external_stylesheets); + + // Enable via CDP; the other two loading toggles stay at their defaults. + try ctx.processMessage(.{ + .id = 1, + .method = "LP.configureLoading", + .params = .{ .externalStylesheets = true }, + }); + try ctx.expectSentResult(null, .{ .id = 1 }); + try testing.expectEqual(true, bc.session.load_external_stylesheets); + try testing.expectEqual(true, bc.session.subframe_loading_enabled); + try testing.expectEqual(true, bc.session.worker_loading_enabled); + + // Flip back off; partial params must not reset the other fields. + try ctx.processMessage(.{ + .id = 2, + .method = "LP.configureLoading", + .params = .{ .externalStylesheets = false }, + }); + try ctx.expectSentResult(null, .{ .id = 2 }); + try testing.expectEqual(false, bc.session.load_external_stylesheets); + try testing.expectEqual(true, bc.session.subframe_loading_enabled); + try testing.expectEqual(true, bc.session.worker_loading_enabled); +} diff --git a/src/help.zon b/src/help.zon index 4962f768..aae77fea 100644 --- a/src/help.zon +++ b/src/help.zon @@ -152,6 +152,20 @@ \\ script fetch is initiated and its scope never runs. \\ Defaults to false. \\ + \\--enable-external-stylesheets + \\ Fetch external resources so + \\ their rules contribute to computed styles (and + \\ therefore to visibility checks like display, + \\ visibility, opacity, pointer-events). The default + \\ skips these fetches, which makes utility-class- + \\ heavy sites read as not-visible from the driver + \\ side. Currently a no-op: reserves the surface + \\ (CLI + LP.configureLoading externalStylesheets) + \\ so drivers can adopt it before the fetch path + \\ lands in a follow-up. Drivers can also toggle + \\ this per-session via LP.configureLoading. + \\ Defaults to false. + \\ \\--block-private-networks Block HTTP requests to private/internal IP \\ addresses after DNS resolution. \\ Defaults to false. From 3e409d49e9d803dd6ae54aa9b598f4e96048f6c5 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sun, 17 May 2026 16:19:55 +0200 Subject: [PATCH 2/6] Implement external stylesheet fetch + parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up --enable-external-stylesheets / LP.configureLoading.externalStylesheets from the prior surface-only commit. When the flag is set, parser- and JS-created elements now synchronously fetch and parse their href, register a CSSStyleSheet on document.styleSheets, and feed StyleManager so checkVisibility() reflects external rules. Flag stays default-off — scrapers that don't need accurate visibility pay nothing. Frame.loadExternalStylesheet mirrors ScriptManager.addFromElement: same HttpClient.syncRequest path, same arena ownership, same per-frame notification + cookie wiring. Body is routed through CSSStyleSheet.replaceSync, which already parses, populates cssRules, and calls sheetModified() — no StyleManager changes needed. 2 MiB hard cap on a single sheet body, status non-2xx and oversize both fire `error` on the link. Link.Build.created is added so static head elements reach linkAddedCallback at all — void elements never trigger nodeComplete, which is why static `` had no observable effect before. Mirrors Image. HttpClient.Request.ResourceType gains a `.stylesheet` variant so CDP Network events report the right type; cdp.fetch.zig switches updated. Refs #2343 --- src/browser/Frame.zig | 89 +++++++++++++++++++ src/browser/HttpClient.zig | 2 + src/browser/Session.zig | 6 +- .../tests/css/external_stylesheet.html | 85 ++++++++++++++++++ src/browser/tests/element/html/link.html | 16 ++++ src/browser/webapi/element/html/Link.zig | 23 +++++ src/cdp/domains/fetch.zig | 2 + src/testing.zig | 40 +++++++++ 8 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/browser/tests/css/external_stylesheet.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 1f081ded..b23a85aa 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -52,6 +52,7 @@ const AbstractRange = @import("webapi/AbstractRange.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const Worker = @import("webapi/Worker.zig"); +const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); @@ -1679,6 +1680,94 @@ pub fn queueLoad(self: *Frame, html: *Element.Html) !void { } } +// Hard cap on a single external stylesheet body. CSS rule storage is per- +// arena so a hostile sheet could otherwise inflate page memory; 2 MiB is +// well above anything seen on real sites (Tailwind's `preflight + utilities` +// build is ~400 KiB gzipped, ~3 MiB raw — at which point a site should be +// splitting by route anyway). +const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024; + +// Synchronously fetch and parse an external ``. Opt-in +// behind `session.load_external_stylesheets` — scrapers/crawlers that don't +// need accurate visibility checks still get the cheap no-fetch path via +// `Link.linkAddedCallback`. Mirrors `ScriptManager.addFromElement`'s use of +// `syncRequest`: stylesheets are render-blocking in real browsers, so a +// synchronous fetch from inside the parser callback matches expected +// document-load ordering without manual `_pending_loads` bookkeeping. +pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link) !void { + if (self.isGoingAway()) return; + + const element = link.asElement(); + const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; + if (href.len == 0) return; + + const arena = try self.getArena(.medium, "Frame.loadExternalStylesheet"); + defer self._session.releaseArena(arena); + + const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch |err| { + log.warn(.browser, "external stylesheet resolve", .{ .err = err, .href = href }); + try self.fireLinkEvent(link, comptime .wrap("error")); + return; + }; + + const session = self._session; + // HttpClient takes ownership of `headers` via the request struct (see + // HttpClient.zig:411 — must NOT pair with a local `defer deinit`). + var headers = try session.browser.http_client.newHeaders(); + try headers.add("Accept: text/css,*/*;q=0.1"); + + var response = session.browser.http_client.syncRequest(arena, .{ + .url = resolved, + .method = .GET, + .frame_id = self._frame_id, + .loader_id = self._loader_id, + .headers = headers, + .cookie_jar = &session.cookie_jar, + .cookie_origin = self.url, + .resource_type = .stylesheet, + .notification = session.notification, + }) catch |err| { + log.warn(.browser, "external stylesheet fetch", .{ .err = err, .url = resolved }); + try self.fireLinkEvent(link, comptime .wrap("error")); + return; + }; + defer response.deinit(arena); + + if (response.status < 200 or response.status >= 300) { + log.info(.browser, "external stylesheet status", .{ .status = response.status, .url = resolved }); + try self.fireLinkEvent(link, comptime .wrap("error")); + return; + } + + if (response.body.items.len > MAX_STYLESHEET_BYTES) { + log.warn(.browser, "external stylesheet too large", .{ + .bytes = response.body.items.len, + .max = MAX_STYLESHEET_BYTES, + .url = resolved, + }); + try self.fireLinkEvent(link, comptime .wrap("error")); + return; + } + + const sheet = try CSSStyleSheet.initWithOwner(element, self); + sheet._href = try self.arena.dupe(u8, resolved); + sheet.replaceSync(response.body.items, self) catch |err| { + log.warn(.browser, "external stylesheet parse", .{ .err = err, .url = resolved }); + try self.fireLinkEvent(link, comptime .wrap("error")); + return; + }; + + const sheets = try self.document.getStyleSheets(self); + try sheets.add(sheet, self); + + try self.fireLinkEvent(link, comptime .wrap("load")); +} + +fn fireLinkEvent(self: *Frame, link: *Element.Html.Link, name: String) !void { + const event = try Event.initTrusted(name, .{}, self._page); + try self._event_manager.dispatch(link._proto.asEventTarget(), event); +} + fn dispatchLoad(self: *Frame) !void { const has_dom_load_listener = self._event_manager.has_dom_load_listener; diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 6f2d19b7..04b5661a 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -910,6 +910,7 @@ pub const Request = struct { xhr, script, fetch, + stylesheet, // Allowed Values: Document, Stylesheet, Image, Media, Font, Script, // TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest, @@ -921,6 +922,7 @@ pub const Request = struct { .xhr => "XHR", .script => "Script", .fetch => "Fetch", + .stylesheet => "Stylesheet", }; } }; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index d044d0ef..70ec5d3e 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -95,9 +95,9 @@ worker_loading_enabled: bool = true, // false to preserve the current rendering-free fast path: drivers that // don't need accurate visibility checks pay nothing. Set from the // `--enable-external-stylesheets` CLI flag at session init; the -// LP.configureLoading CDP method can flip it per-session. Currently -// unread — the fetch path lands in a follow-up that depends on the -// network refactor in #2303. +// LP.configureLoading CDP method can flip it per-session. When true, +// `Link.linkAddedCallback` routes to `Frame.loadExternalStylesheet` +// (synchronous fetch + parse + register on `document.styleSheets`). load_external_stylesheets: bool = false, pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void { diff --git a/src/browser/tests/css/external_stylesheet.html b/src/browser/tests/css/external_stylesheet.html new file mode 100644 index 00000000..cbd6df0d --- /dev/null +++ b/src/browser/tests/css/external_stylesheet.html @@ -0,0 +1,85 @@ + + + + + + +
always visible
+
hidden by external rule
+ + + + + + + + + + + diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index 9f4dd6a8..d3f2faa0 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -137,3 +137,19 @@ }); } + + diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 1b83dacf..2d5bfef6 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -114,6 +114,14 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { return; } + // Opt-in fetch for `rel="stylesheet"` — drives `frame.loadExternalStylesheet`, + // which fires the load/error event itself. Other rels (preload, + // modulepreload) and the disabled case keep the rendering-free stub that + // fires a synthetic `load` event without touching the network. + if (std.mem.eql(u8, rel, "stylesheet") and frame._session.load_external_stylesheets) { + return frame.loadExternalStylesheet(self); + } + try frame.queueLoad(self._proto); } @@ -143,7 +151,22 @@ pub const JsApi = struct { } }; +// Parser-created elements are void (no closing tag) so they never +// reach `Frame.nodeComplete`. Mirror `Image.Build.created` so static head +// links in HTML go through `linkAddedCallback` at element-create time, +// with attributes already populated by `populateElementAttributes`. +pub const Build = struct { + pub fn created(node: *Node, frame: *Frame) !void { + const self = node.as(Link); + return self.linkAddedCallback(frame); + } +}; + const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Link" { try testing.htmlRunner("element/html/link.html", .{}); } + +test "WebApi: HTML.Link external stylesheet" { + try testing.htmlRunner("css/external_stylesheet.html", .{ .load_external_stylesheets = true }); +} diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index 64a1ec7e..b2831abe 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -209,6 +209,7 @@ pub fn requestIntercept(bc: *CDP.BrowserContext, intercept: *const Notification. .xhr => "XHR", .document => "Document", .fetch => "Fetch", + .stylesheet => "Stylesheet", }, .networkId = &id.toRequestId(transfer), // matches the Network REQ-ID }, .{ .session_id = session_id }); @@ -453,6 +454,7 @@ pub fn requestAuthRequired(bc: *CDP.BrowserContext, intercept: *const Notificati .xhr => "XHR", .document => "Document", .fetch => "Fetch", + .stylesheet => "Stylesheet", }, .authChallenge = .{ .origin = "", // TODO get origin, could be the proxy address for example. diff --git a/src/testing.zig b/src/testing.zig index 3897a06a..a9ecff71 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -341,6 +341,7 @@ const WEB_API_TEST_ROOT = "src/browser/tests/"; const HtmlRunnerOpts = struct { timeout_ms: u32 = 2000, inject_script: ?[]const u8 = null, + load_external_stylesheets: bool = false, }; pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { @@ -353,6 +354,9 @@ pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { } defer test_session.inject_scripts = &.{}; + test_session.load_external_stylesheets = opts.load_external_stylesheets; + defer test_session.load_external_stylesheets = false; + const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path }); const stat = std.fs.cwd().statFile(root) catch |err| { std.debug.print("Failed to stat file: '{s}'", .{root}); @@ -678,6 +682,42 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/styles/visibility.css")) { + // Used by css/external_stylesheet.html — drives the visibility + // cascade through StyleManager via Frame.loadExternalStylesheet + // so a `.ext-hide` element is observable to checkVisibility(). + return req.respond(".ext-hide { display: none; }", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/styles/404.css")) { + return req.respond("/* unused */", .{ + .status = .not_found, + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/styles/oversize.css")) { + // Body that exceeds Frame.MAX_STYLESHEET_BYTES (2 MiB) — written as a + // long sequence of valid declarations so the response itself parses + // fine and the error path is exercised by the size cap, not by a + // CSS parse failure. + const chunk = ".pad { color: #abcdef; } "; // 25 bytes + const repeats = (2 * 1024 * 1024 / chunk.len) + 1024; + var body = try std.ArrayList(u8).initCapacity(arena_allocator, chunk.len * repeats); + for (0..repeats) |_| body.appendSliceAssumeCapacity(chunk); + return req.respond(body.items, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + if (std.mem.eql(u8, path, "/echo_referer")) { // Echo the request's Referer header back as HTML so tests can assert // what Referer the navigation sent. Used by the cross-page Referer test. From 4592812027095946c16036241bed82c9897f646f Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sun, 17 May 2026 16:32:53 +0200 Subject: [PATCH 3/6] Reuse cached sheet on link href change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught in code review: `loadExternalStylesheet` created a fresh `CSSStyleSheet` and appended to `document.styleSheets` on every call, so mutating `link.href` on a connected stylesheet element accumulated stale sheets — the old rules kept cascading because the previous sheet was never removed. Cache the sheet on `Link._sheet` (mirroring `Style._sheet`) and reuse it via `replaceSync` on re-fetch. First load creates + registers as before; subsequent loads swap content in place, keeping `document.styleSheets` length stable. On fetch failure the cached sheet is untouched — matches browser behavior where a broken href doesn't invalidate the previously loaded sheet until the link itself is removed. Refs #2343 --- src/browser/Frame.zig | 16 ++++++-- .../tests/css/external_stylesheet.html | 38 +++++++++++++++++++ src/browser/webapi/element/html/Link.zig | 6 +++ src/testing.zig | 11 ++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index b23a85aa..d424c4bc 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1749,7 +1749,18 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link) !void { return; } - const sheet = try CSSStyleSheet.initWithOwner(element, self); + // Reuse the cached sheet on re-fetch (href mutation on a connected + // link) so `document.styleSheets` keeps a single entry per + // instead of accumulating one per href change. On first load, create + // and register; on subsequent loads, replace content in place. + const sheet = link._sheet orelse blk: { + const new_sheet = try CSSStyleSheet.initWithOwner(element, self); + link._sheet = new_sheet; + const sheets = try self.document.getStyleSheets(self); + try sheets.add(new_sheet, self); + break :blk new_sheet; + }; + sheet._href = try self.arena.dupe(u8, resolved); sheet.replaceSync(response.body.items, self) catch |err| { log.warn(.browser, "external stylesheet parse", .{ .err = err, .url = resolved }); @@ -1757,9 +1768,6 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link) !void { return; }; - const sheets = try self.document.getStyleSheets(self); - try sheets.add(sheet, self); - try self.fireLinkEvent(link, comptime .wrap("load")); } diff --git a/src/browser/tests/css/external_stylesheet.html b/src/browser/tests/css/external_stylesheet.html index cbd6df0d..5ec76a5b 100644 --- a/src/browser/tests/css/external_stylesheet.html +++ b/src/browser/tests/css/external_stylesheet.html @@ -82,4 +82,42 @@ testing.expectEqual(true, true); } + + diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 2d5bfef6..2aa0e4c2 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -26,6 +26,12 @@ const HtmlElement = @import("../Html.zig"); const Link = @This(); _proto: *HtmlElement, +// Cached CSSStyleSheet for an external `rel=stylesheet` once +// `Frame.loadExternalStylesheet` has registered it. Re-fetches (href +// mutated on a connected link) reuse this sheet via `replaceSync` so the +// old rules are dropped instead of accumulating in `document.styleSheets`. +// Mirrors `Style._sheet`. +_sheet: ?*@import("../../css/CSSStyleSheet.zig") = null, pub fn asElement(self: *Link) *Element { return self._proto._proto; diff --git a/src/testing.zig b/src/testing.zig index a9ecff71..62b1c67d 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -693,6 +693,17 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/styles/visibility2.css")) { + // Second visibility sheet used by the href-change regression test: + // mutating link.href must replace the cached sheet's rules in place, + // not append a new entry to document.styleSheets. + return req.respond(".ext-hide-2 { display: none; }", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + if (std.mem.eql(u8, path, "/styles/404.css")) { return req.respond("/* unused */", .{ .status = .not_found, From f05efd6719bacff379204b485d160c544417d0f9 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sun, 17 May 2026 16:50:29 +0200 Subject: [PATCH 4/6] Harden external stylesheet path per code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 8 findings from ultrareview on the external stylesheet feature: * UAF on CDP teardown during syncRequest. `loadExternalStylesheet` pumps the CDP socket inline, so a `Target.closeTarget` arriving mid-fetch could drive `Session.removePage` and free the frame while we still held `self`. Set `_script_manager.base.is_evaluating` around the call — the same bracket every other syncRequest caller uses, which is what `Session.removePage`'s reentrancy guard checks. * Disconnect leak. `link.remove()` left the sheet on `document.styleSheets` and in the cascade forever; the disconnect walker had a `