Files
browser/src/browser/ScriptManagerBase.zig
Francis Bouvier dd97f46ef8 webapi: spec-conformant script insertion/execution steps
Fixes 10 failing files under /dom/nodes/insertion-removing-steps/
(5/20 -> 15/20 files passing), which pin down when dynamically inserted
scripts execute:

- Scripts (and iframes/links/styles) appended to a DETACHED parent no
  longer activate; the ready work is gated on the node being connected,
  and runs later if the subtree gets inserted into the document.
- Inserting a subtree runs the ready work for its descendant elements
  too, in tree order (nodeIsReadySubtree), after the node is in place.
- Multi-node insertions (fragments, and ParentNode.append/prepend with
  several arguments, which now convert to a fragment per spec) run all
  the ready work only after every node is inserted: an earlier script
  observes its later siblings connected, and can remove one to prevent
  it from running.
- Inserting into a not-yet-started connected script runs the script's
  children-changed steps (once, with the full content) before the
  inserted nodes' own ready work, per whatwg/html#10188: the outer
  script executes before an inner one.
- Mutation records queue before any script runs, so an inserted script
  observes its own insertion record via takeRecords. The script
  manager's pre-execution microtask drain now only happens at a real
  checkpoint (empty JS stack): a script executed synchronously from a
  running script must not flush the queue mid-task.

The remaining files in the directory need meta-referrer /
meta-default-style / media <source> insertion steps and style/iframe
load interleavings.

Coverage: /dom/nodes/insertion-removing-steps/: 15/20 files passing
(10 newly); /dom/nodes and /dom/events regressions clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:41:27 +02:00

1068 lines
39 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const http = @import("../network/http.zig");
const HttpClient = @import("../network/HttpClient.zig");
const js = @import("js/js.zig");
const Session = @import("Session.zig");
const Frame = @import("Frame.zig");
const ImportMap = @import("ImportMap.zig");
const WorkerGlobalScope = @import("webapi/WorkerGlobalScope.zig");
const Element = @import("webapi/Element.zig");
const log = lp.log;
const String = lp.String;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const ScriptManagerBase = @This();
// Either a *Frame (for page ScriptManagers) or *WorkerGlobalScope (for workers).
// Used from HTTP callbacks that only have a *Script in hand; the Script reaches
// the owner through its manager pointer.
pub const Owner = union(enum) {
frame: *Frame,
worker: *WorkerGlobalScope,
pub fn url(self: Owner) [:0]const u8 {
return switch (self) {
inline else => |g| g.url,
};
}
pub fn frameId(self: Owner) u32 {
return switch (self) {
inline else => |g| g._frame_id,
};
}
pub fn loaderId(self: Owner) u32 {
return switch (self) {
inline else => |g| g._loader_id,
};
}
pub fn session(self: Owner) *Session {
return switch (self) {
inline else => |g| g._session,
};
}
pub fn jsContext(self: Owner) *js.Context {
return switch (self) {
inline else => |g| g.js,
};
}
pub fn addHeaders(self: Owner, headers: *HttpClient.Headers) !void {
return switch (self) {
inline else => |g| g.headersForRequest(headers),
};
}
pub fn makeRequest(self: Owner, req: HttpClient.Request) !void {
return switch (self) {
inline else => |g| g.makeRequest(req),
};
}
};
owner: Owner,
// used to prevent recursive evaluation
is_evaluating: bool,
// an evaluate() arrived while is_evaluating was set, signal to call evaluate()
// when is_evaluating becomes false (to prevent re-entrancy)
evaluate_pending: bool = false,
// Only once this is true can deferred scripts be run
static_scripts_done: bool,
// List of async scripts. We don't care about the execution order of these, but
// on shutdown/abort, we need to cleanup any pending ones. Used for both
// frame-side .async scripts and .import / .import_async modules.
async_scripts: std.DoublyLinkedList,
// List of deferred scripts. These must be executed in order, but only once
// dom_loaded == true. Workers never populate this list.
defer_scripts: std.DoublyLinkedList,
// When an async script is ready, it's queued here.
ready_scripts: std.DoublyLinkedList,
shutdown: bool = false,
client: *HttpClient,
allocator: Allocator,
// See ScriptManager.zig for the type's documentation.
imported_modules: std.StringHashMapUnmanaged(ImportedModule),
// For workers this stays empty
importmap: ImportMap,
// Called at the end of evaluate() after all Base-owned work has run. Frame
// wrapper uses this to drain defer_scripts and fire documentIsLoaded /
// scriptsCompletedLoading. Null for workers.
tail_hook: ?*const fn (*ScriptManagerBase) void,
pub fn init(allocator: Allocator, http_client: *HttpClient, owner: Owner) ScriptManagerBase {
return .{
.owner = owner,
.async_scripts = .{},
.defer_scripts = .{},
.ready_scripts = .{},
.importmap = .empty,
.is_evaluating = false,
.allocator = allocator,
.imported_modules = .empty,
.client = http_client,
.static_scripts_done = false,
.tail_hook = null,
};
}
pub fn deinit(self: *ScriptManagerBase) void {
// necessary to free any arenas scripts may be referencing
self.reset();
self.imported_modules.deinit(self.allocator);
}
pub fn reset(self: *ScriptManagerBase) void {
var it = self.imported_modules.valueIterator();
while (it.next()) |value_ptr| {
switch (value_ptr.state) {
.done => |script| script.deinit(),
else => {},
}
}
self.imported_modules.clearRetainingCapacity();
// The importmap's contents were allocated from the owner's arena, which
// has been reset, so just zero the struct.
self.importmap = .empty;
clearList(&self.defer_scripts);
clearList(&self.async_scripts);
clearList(&self.ready_scripts);
self.static_scripts_done = false;
self.evaluate_pending = false;
}
fn clearList(list: *std.DoublyLinkedList) void {
while (list.popFirst()) |n| {
const script: *Script = @fieldParentPtr("node", n);
script.deinit();
}
}
pub fn getHeaders(self: *ScriptManagerBase) !http.Headers {
var headers = try self.client.newHeaders();
try self.owner.addHeaders(&headers);
return headers;
}
fn acquireArena(self: *ScriptManagerBase, size_or_bucket: anytype, debug: []const u8) !Allocator {
return self.owner.session().getArena(size_or_bucket, debug);
}
fn releaseArena(self: *ScriptManagerBase, arena: Allocator) void {
self.owner.session().releaseArena(arena);
}
pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLinkedList {
return switch (script.extra) {
.import, .import_async => &self.async_scripts,
.preload => unreachable, // done/error are handled directly, never via scriptList
.frame => |fe| switch (fe.mode) {
.normal => unreachable, // not added to a list, executed immediately
.@"defer" => &self.defer_scripts,
.async => &self.async_scripts,
},
};
}
// Resolve a module specifier to a valid URL.
pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]const u8, specifier: [:0]const u8) ![:0]const u8 {
if (try self.importmap.resolve(arena, base, specifier)) |url| {
return url;
}
// The importmap _always_ resolves specifies if they're valid, falling back
// to the base + specifier itself. So we can only be here on something invalid.
return error.SpecifierResolutionFailed;
}
const PreloadOpts = struct {
// set when the preload comes from a <link rel=modulepreload> hint
hint_element: ?*Element.Html = null,
};
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8, opts: PreloadOpts) !void {
const gop = try self.imported_modules.getOrPut(self.allocator, url);
if (gop.found_existing) {
if (gop.value_ptr.hint) {
// A <link rel=modulepreload> never calls waitForImport, so the
// first real import adopts its waiter slot rather than adding one.
gop.value_ptr.hint = false;
} else {
gop.value_ptr.waiters += 1;
}
return;
}
errdefer _ = self.imported_modules.remove(url);
const arena = try self.acquireArena(.large, "SM.preloadImport");
errdefer self.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.arena = arena,
.url = url,
.node = .{},
.manager = self,
.complete = false,
.source = .{ .remote = .{} },
.extra = .import,
.hint_element = opts.hint_element,
};
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint_element != null };
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.owner.jsContext().localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "module",
.referrer = referrer,
.stack = ls.local.stackTrace() catch "???",
});
}
// This seems wrong since we're not dealing with an async import (unlike
// getAsyncModule below), but all we're trying to do here is pre-load the
// script for execution at some point in the future (when waitForImport is
// called).
self.async_scripts.append(&script.node);
const owner = self.owner;
const session = owner.session();
owner.makeRequest(.{
.ctx = script,
.url = url,
.method = .GET,
.frame_id = owner.frameId(),
.loader_id = owner.loaderId(),
.headers = try self.getHeaders(),
.cookie_jar = &session.cookie_jar,
.cookie_origin = owner.url(),
.resource_type = .script,
.notification = session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
.shutdown_callback = Script.shutdownCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
// <link rel=modulepreload href=...> — start fetching a module before import
// resolution discovers it. Returns false when no fetch was started (the
// module is already fetched/fetching): no event will fire on the link.
pub fn preloadModuleHint(self: *ScriptManagerBase, element: *Element.Html, url: [:0]const u8, referrer: []const u8) !bool {
if (self.imported_modules.contains(url)) {
return false;
}
try self.preloadImport(url, referrer, .{ .hint_element = element });
return true;
}
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.endEvaluationWindow(was_evaluating);
var client = self.client;
while (true) {
// imported_modules can be mutated by client.tick, so we need to lookup
// the entry on each iteration.
const entry = self.imported_modules.getEntry(url) orelse {
// It shouldn't be possible for v8 to ask for a module that we
// didn't `preloadImport` above.
return error.UnknownModule;
};
switch (entry.value_ptr.state) {
.loading => {
_ = try client.tickSync(200);
continue;
},
.done => |script| {
var shared = false;
const buffer = entry.value_ptr.buffer;
const waiters = entry.value_ptr.waiters;
if (waiters == 1) {
self.imported_modules.removeByPtr(entry.key_ptr);
} else {
shared = true;
entry.value_ptr.waiters = waiters - 1;
}
return .{
.buffer = buffer,
.shared = shared,
.script = script,
};
},
.err => return error.Failed,
}
}
}
pub fn releaseImport(self: *ScriptManagerBase, url: [:0]const u8) void {
const entry = self.imported_modules.getEntry(url) orelse {
return;
};
if (entry.value_ptr.waiters > 1) {
entry.value_ptr.waiters -= 1;
return;
}
switch (entry.value_ptr.state) {
.done => |script| script.deinit(),
.loading, .err => return,
}
self.imported_modules.removeByPtr(entry.key_ptr);
}
pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
// A <link rel=modulepreload> hint may already be fetching/fetched this module
if (self.imported_modules.getEntry(url)) |entry| {
if (entry.value_ptr.hint) {
switch (entry.value_ptr.state) {
.loading => |script| {
// fetch is in flight, take the script and turn it into
// our normal getAsyncImport flow (e.g. what we do at the
// end of this file as-if imported_modules didn't have this script)
if (comptime IS_DEBUG) {
log.debug(.http, "script adopt", .{ .url = url, .ctx = "dynamic module", .state = "loading" });
}
script.extra = .{ .import_async = .{ .callback = cb, .data = cb_data } };
self.imported_modules.removeByPtr(entry.key_ptr);
return;
},
.done => |script| {
// fetch is complete; deliver through the normal
// ready_scripts flow. evaluate() runs it now, or — if an
// evaluation window is open — evaluate_pending runs it
// when that window closes.
if (comptime IS_DEBUG) {
log.debug(.http, "script adopt", .{ .url = url, .ctx = "dynamic module", .state = "done" });
}
script.extra = .{ .import_async = .{ .callback = cb, .data = cb_data } };
self.imported_modules.removeByPtr(entry.key_ptr);
self.ready_scripts.append(&script.node);
self.evaluate();
return;
},
// The hint's fetch failed; give the import its own attempt.
.err => {},
}
}
}
const arena = try self.acquireArena(.large, "SM.getAsyncImport");
errdefer self.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.arena = arena,
.url = url,
.node = .{},
.manager = self,
.complete = false,
.source = .{ .remote = .{} },
.extra = .{ .import_async = .{
.callback = cb,
.data = cb_data,
} },
};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.owner.jsContext().localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "dynamic module",
.referrer = referrer,
.stack = ls.local.stackTrace() catch "???",
});
}
// It's possible, but unlikely, for client.request to immediately finish
// a request, thus calling our callback. We don't want that to trigger a
// script evaluation while we're still setting the request up (the script
// isn't fully linked into our lists yet).
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.endEvaluationWindow(was_evaluating);
const owner = self.owner;
const session = self.owner.session();
self.async_scripts.append(&script.node);
owner.makeRequest(.{
.ctx = script,
.url = url,
.method = .GET,
.frame_id = owner.frameId(),
.loader_id = owner.loaderId(),
.headers = try self.getHeaders(),
.resource_type = .script,
.cookie_jar = &session.cookie_jar,
.cookie_origin = owner.url(),
.notification = session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
.shutdown_callback = Script.shutdownCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
// Called from the Page / Frame to signal it's done parsing the HTML, so
// deferred scripts can start evaluating. Workers never call this.
pub fn staticScriptsDone(self: *ScriptManagerBase) void {
lp.assert(self.static_scripts_done == false, "ScriptManagerBase.staticScriptsDone", .{});
self.static_scripts_done = true;
self.evaluate();
}
// A script-created parser (document.open/write/close) finished. Run any
// deferred scripts it produced. Unlike staticScriptsDone, this can run after
// the initial parse already completed (so it must not re-assert the flag): a
// frame that was loaded (or document.write'd into multiple times) keeps
// static_scripts_done set, and evaluate() only drains defer_scripts when it is.
pub fn scriptCreatedParseDone(self: *ScriptManagerBase) void {
self.static_scripts_done = true;
self.evaluate();
}
pub fn evaluate(self: *ScriptManagerBase) void {
if (self.is_evaluating) {
// It's possible for a script.eval to cause evaluate to be called again.
// Signal that this happened so that, once the outer evaluate finishes,
// we evaluate() again to catch what we're skipping now.
self.evaluate_pending = true;
return;
}
self.is_evaluating = true;
defer self.endEvaluationWindow(false);
while (true) {
self.evaluate_pending = false;
while (self.ready_scripts.popFirst()) |n| {
var script: *Script = @fieldParentPtr("node", n);
switch (script.extra) {
.frame => {
// Only .async mode reaches ready_scripts (defer stays in
// defer_scripts, normal is sync and never queued).
defer script.deinit();
script.eval();
},
.import_async => |ia| {
if (script.status < 200 or script.status > 299) {
script.deinit();
ia.callback(ia.data, error.FailedToLoad);
} else {
ia.callback(ia.data, .{
.shared = false,
.script = script,
.buffer = script.source.remote,
});
}
},
.import => unreachable, // .import doesn't go through ready_scripts
.preload => unreachable, // .preload is buffered in the map, never queued
}
}
if (self.static_scripts_done) {
while (self.defer_scripts.first) |n| {
var script: *Script = @fieldParentPtr("node", n);
if (script.complete == false) break;
defer {
_ = self.defer_scripts.popFirst();
script.deinit();
}
// Only frame scripts populate defer_scripts.
script.eval();
}
}
// A script.eval above may have pumped the http client (module
// imports, preloads); anything that completed during the pump had
// its evaluate() swallowed by the re-entrancy guard, so re-drain
// here rather than relying on a future doneCallback that may never
// come.
if (self.evaluate_pending) {
continue;
}
if (self.static_scripts_done == false) {
// We can only execute deferred scripts if
// 1 - all the normal scripts are done
// 2 - we've finished parsing the HTML and at least queued all the scripts
// The last one isn't obvious, but it's possible for self.scripts to
// be empty not because we're done executing all the normal scripts
// but because we're done executing some (or maybe none), but we're still
// parsing the HTML.
return;
}
if (self.defer_scripts.first != null) {
// Head of the defer list is still in flight; its doneCallback
// re-enters evaluate.
return;
}
break;
}
// Frame wrapper uses this to fire documentIsLoaded and
// scriptsCompletedLoading. Null for workers.
if (self.tail_hook) |hook| {
hook(self);
}
}
pub fn endEvaluationWindow(self: *ScriptManagerBase, was_evaluating: bool) void {
self.is_evaluating = was_evaluating;
if (was_evaluating == false and self.evaluate_pending) {
// we have something to evaluate
self.evaluate();
}
}
pub const Script = struct {
complete: bool,
status: u16 = 0,
source: Source,
url: []const u8,
arena: Allocator,
extra: Extra,
node: std.DoublyLinkedList.Node,
manager: *ScriptManagerBase,
// Set when this fetch was started by a <link rel=preload as=script> or
// <link rel=modulepreload> hint: the link element to fire load/error on
// once the fetch settles. Lives outside `extra` so adoption (e.g.
// getAsyncImport replacing .import with .import_async) can't lose it.
hint_element: ?*Element.Html = null,
// for debugging a rare production issue
header_callback_called: bool = false,
// for debugging a rare production issue
debug_transfer_id: u32 = 0,
debug_transfer_tries: u8 = 0,
debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false,
debug_transfer_auth_challenge: bool = false,
debug_transfer_easy_id: usize = 0,
pub const Source = union(enum) {
@"inline": []const u8,
remote: std.ArrayList(u8),
pub fn content(self: Source) []const u8 {
return switch (self) {
.remote => |buf| buf.items,
.@"inline" => |c| c,
};
}
};
// The mode-specific extension. Only `.frame` carries frame-only state
// (script_element, kind, *Frame); workers and dynamic JS imports use
// `.import` / `.import_async` and never reach the .frame arm.
pub const Extra = union(enum) {
// Static module import — V8 resolution via imported_modules. Also
// <link rel=modulepreload> hints (see Script.hint_element).
import,
// Dynamic JS import() — resolved via ready_scripts callback.
import_async: ImportAsync,
// <link rel=preload as=script href=...>
preload,
// <script> tag in a frame.
frame: FrameExtra,
pub const FrameExtra = struct {
kind: Kind,
mode: Mode,
frame: *Frame,
script_element: *Element.Html.Script,
pub const Kind = enum {
module,
javascript,
importmap,
};
pub const Mode = enum {
// sync <script src="..."> — blocks parsing, evaluated
// immediately at the end of addFromElement via syncRequest.
normal,
// <script defer> / <script type=module> — queued in
// defer_scripts, drained in document order.
@"defer",
// <script async> / dynamically-inserted scripts — queued in
// async_scripts; once HTTP completes, doneCallback moves to
// ready_scripts and evaluate drains them.
async,
};
};
};
pub fn deinit(self: *Script) void {
self.manager.releaseArena(self.arena);
}
pub fn startCallback(transfer: *HttpClient.Transfer) !void {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
pub fn headerCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult {
const self: *Script = @ptrCast(@alignCast(transfer.req.ctx));
self.status = transfer.responseStatus().?;
if (transfer.responseStatus() != 200) {
log.info(.http, "script header", .{
.req = transfer,
.status = transfer.responseStatus(),
.content_type = transfer.contentType(),
});
return .abort;
}
if (comptime IS_DEBUG) {
log.debug(.http, "script header", .{
.req = transfer,
.status = transfer.responseStatus(),
.content_type = transfer.contentType(),
});
}
{
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManagerBase.Header recall", .{
.m = @tagName(std.meta.activeTag(self.extra)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.state == .aborted,
.b4 = transfer.res.bytes_received,
.b5 = transfer._notified_fail,
.b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
self.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.state == .aborted;
self.debug_transfer_bytes_received = transfer.res.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
}
lp.assert(self.source.remote.capacity == 0, "ScriptManagerBase.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer: std.ArrayList(u8) = .empty;
if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.arena, cl);
}
self.source = .{ .remote = buffer };
return .proceed;
}
pub fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.req.ctx));
self._dataCallback(transfer, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
return err;
};
}
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
try self.source.remote.appendSlice(self.arena, data);
}
pub fn doneCallback(ctx: *anyopaque) !void {
const self: *Script = @ptrCast(@alignCast(ctx));
self.complete = true;
if (comptime IS_DEBUG) {
log.debug(.http, "script fetch complete", .{ .req = self.url });
}
const manager = self.manager;
switch (self.extra) {
.frame => |fe| switch (fe.mode) {
.async => {
manager.async_scripts.remove(&self.node);
manager.ready_scripts.append(&self.node);
},
.@"defer" => {}, // stays in defer_scripts; drained in order
.normal => unreachable, // syncRequest path doesn't go through callbacks
},
.import_async => {
manager.async_scripts.remove(&self.node);
manager.ready_scripts.append(&self.node);
},
.import => {
manager.async_scripts.remove(&self.node);
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .{ .done = self };
entry.buffer = self.source.remote;
},
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.doneCallback
}
self.queueHintEvent(.load);
manager.evaluate();
}
pub fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *Script = @ptrCast(@alignCast(ctx));
if (self.status == 404) {
log.info(.http, "script 404", .{
.req = self.url,
.extra = std.meta.activeTag(self.extra),
});
} else {
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.extra = std.meta.activeTag(self.extra),
.status = self.status,
});
}
if (self.extra == .frame and self.extra.frame.mode == .normal) {
// This is blocked in a loop at the end of addFromElement, setting
// it to complete with a status of 0 will signal the error.
self.status = 0;
self.complete = true;
return;
}
const manager = self.manager;
manager.scriptList(self).remove(&self.node);
if (manager.shutdown) {
self.deinit();
return;
}
switch (self.extra) {
.import_async => |ia| ia.callback(ia.data, err),
.import => {
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
},
.frame => self.executeCallback(comptime .wrap("error")),
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.errorCallback
}
self.queueHintEvent(.@"error");
self.deinit();
manager.evaluate();
}
// Owner-driven teardown (Frame / WorkerGlobalScope) killed this module
// fetch via Transfer.kill, which fires shutdown_callback — NOT
// error_callback (error_callback runs JS via manager.evaluate(), unsafe
// mid-teardown). Registered only on `.import` requests (preloadImport).
// Move the imported_modules entry off `.loading` to `.err` so a
// synchronous waitForImport returns error.Failed instead of spinning
// forever. We must not run JS or touch lists here; the orphaned Script is
// reaped by manager.reset()'s clearList over async_scripts.
pub fn shutdownCallback(ctx: *anyopaque) void {
const self: *Script = @ptrCast(@alignCast(ctx));
const entry = self.manager.imported_modules.getPtr(self.url) orelse return;
switch (entry.state) {
.loading => entry.state = .err,
.done, .err => {},
}
}
// Frame-only. Asserts extra == .frame; callers from the worker path never
// reach here (workers only produce .import / .import_async).
pub fn eval(self: *Script) void {
const fe = self.extra.frame;
const frame = fe.frame;
if (frame.isGoingAway()) {
// don't evaluate scripts for a dying frame.
return;
}
const previous_script = frame.document._current_script;
frame.document._current_script = fe.script_element;
defer frame.document._current_script = previous_script;
// Clear the document.write insertion point for this script
const previous_write_insertion_point = frame.document._write_insertion_point;
frame.document._write_insertion_point = null;
defer frame.document._write_insertion_point = previous_write_insertion_point;
// inline scripts aren't cached. remote ones are.
const cacheable = self.source == .remote;
const url = self.url;
log.info(.browser, "executing script", .{
.src = url,
.kind = fe.kind,
.cacheable = cacheable,
});
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
// Per spec, trigger any microtasks BEFORE execution in case the parser
// (e.g. via event callbacks) queued anything. Only at a microtask
// checkpoint though (empty JS stack): a script executing because a
// running script inserted it must not drain the queue mid-task -
// e.g. its own insertion record must survive for takeRecords().
if (frame.js.call_depth == 0) {
local.runMicrotasks();
}
// Handle importmap special case here: the content is a JSON containing imports.
// Multiple <script type="importmap"> elements merge with first-wins semantics.
if (fe.kind == .importmap) {
self.manager.importmap.merge(frame.arena, frame.base(), self.source.content()) catch |err| {
log.err(.browser, "parse importmap script", .{
.err = err,
.src = url,
.kind = fe.kind,
.cacheable = cacheable,
});
self.executeCallback(comptime .wrap("error"));
return;
};
self.executeCallback(comptime .wrap("load"));
return;
}
defer frame._event_manager.clearIgnoreList();
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
// Custom-element reactions: the script body is a JS-execution
// boundary. Open a scope so any reactions it queues (or that were
// queued by the parser since the previous boundary) drain at the
// end of the script, before the parser resumes.
const ce_checkpoint = frame._ce_reactions.push();
defer frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
const success = blk: {
const content = self.source.content();
switch (fe.kind) {
.javascript => _ = local.eval(content, url) catch break :blk false,
.module => {
// We don't care about waiting for the evaluation here.
frame.js.module(false, local, content, url, cacheable) catch break :blk false;
},
.importmap => unreachable, // handled before the try/catch.
}
break :blk true;
};
if (comptime IS_DEBUG) {
log.debug(.browser, "executed script", .{ .src = url, .success = success });
}
defer {
local.runMacrotasks(); // also runs microtasks
_ = frame.js.scheduler.run() catch |err| {
log.err(.frame, "scheduler", .{ .err = err });
};
}
if (success) {
self.executeCallback(comptime .wrap("load"));
return;
}
const caught = try_catch.caughtOrError(frame.local_arena, error.Unknown);
lp.metrics.script_errors.incr();
log.warn(.js, "eval script", .{
.url = url,
.caught = caught,
.cacheable = cacheable,
});
self.executeCallback(comptime .wrap("error"));
}
// Frame-only: fires load/error on the <script> element itself,
// synchronously. Hint <link> events go through queueHintEvent instead.
pub fn executeCallback(self: *const Script, typ: String) void {
const fe = self.extra.frame;
const frame = fe.frame;
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(typ, .{}, frame._page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,
.err = err,
});
return;
};
frame._event_manager.dispatchOpts(fe.script_element.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| {
log.warn(.js, "script callback", .{
.url = self.url,
.type = typ,
.err = err,
});
};
}
// Fire the hint <link>'s load/error event, queued on the scheduler rather
// than dispatched inline: done/error callbacks run inside HTTP pumps
// (possibly mid sync-wait, or inside V8's module-resolve callback for
// imports), where dispatching user JS would be a novel re-entrancy point.
// Browsers queue these as tasks anyway. The queued entry holds only the
// element, so it stays valid even if the script is adopted/freed first.
pub fn queueHintEvent(self: *const Script, kind: Frame.QueuedEvent.Kind) void {
const element = self.hint_element orelse return;
// hints only originate from a <link> in a frame, never from a worker
const frame = self.manager.owner.frame;
frame.queueElementEvent(element, kind) catch |err| {
log.warn(.js, "script hint event", .{ .url = self.url, .kind = kind, .err = err });
};
}
};
pub const ImportAsync = struct {
data: *anyopaque,
callback: ImportAsync.Callback,
pub const Callback = *const fn (ptr: *anyopaque, result: anyerror!ModuleSource) void;
};
pub const ModuleSource = struct {
shared: bool,
script: *Script,
buffer: std.ArrayList(u8),
pub fn deinit(self: *ModuleSource) void {
if (self.shared == false) {
self.script.deinit();
}
}
pub fn src(self: *const ModuleSource) []const u8 {
return self.buffer.items;
}
};
pub const ImportedModule = struct {
waiters: u16 = 1,
// Created by a <link rel=modulepreload> hint and not yet claimed by a real
// import. While set, the single waiter slot belongs to the hint, which
// will never collect it (see preloadModuleHint). A dynamic import may
// adopt a hint entry outright (see getAsyncImport).
hint: bool = false,
state: State,
buffer: std.ArrayList(u8) = .{},
pub const State = union(enum) {
err,
loading: *Script,
done: *Script,
};
};
const testing = @import("../testing.zig");
test "ScriptManagerBase: shutdownCallback fails a .loading module" {
defer testing.reset();
const page = try testing.pageTest("mcp_nav.html", .{});
defer page.close();
const frame = page.frame().?;
const sm = &frame._script_manager.base;
const url: [:0]const u8 = "http://127.0.0.1:9582/killed-module.js";
// Build a `.loading` import entry directly (mirroring preloadImport) so the
// test doesn't depend on the network. The orphaned Script is reaped by
// reset()'s clearList over async_scripts.
const arena = try sm.acquireArena(.large, "test.shutdown");
const script = try arena.create(Script);
script.* = .{
.arena = arena,
.url = url,
.node = .{},
.manager = sm,
.complete = false,
.source = .{ .remote = .{} },
.extra = .import,
.hint_element = null,
};
try sm.imported_modules.put(sm.allocator, url, .{ .state = .{ .loading = script } });
sm.async_scripts.append(&script.node);
// Transfer.kill fires this on owner teardown. It must move the entry off
// `.loading` so a synchronous waitForImport returns instead of hanging.
Script.shutdownCallback(script);
try testing.expect(sm.imported_modules.getPtr(url).?.state == .err);
try testing.expectError(error.Failed, sm.waitForImport(url));
}