diff --git a/src/browser/EventManagerBase.zig b/src/browser/EventManagerBase.zig index 3b782db83..c18253426 100644 --- a/src/browser/EventManagerBase.zig +++ b/src/browser/EventManagerBase.zig @@ -288,7 +288,11 @@ pub fn dispatchDirect( if (getFunction(handler, &ls.local)) |func| { event._current_target = target; _ = func.callWithThis(void, target, .{event}) catch |err| { - log.warn(.event, opts.context, .{ .err = err }); + if (err == error.JsException) { + event._listeners_did_throw = true; + } else { + log.warn(.event, opts.context, .{ .err = err }); + } }; } @@ -432,24 +436,45 @@ pub const Listener = struct { ) error{OutOfMemory}!void { switch (self.function) { .value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| { - log.warn(.event, context, .{ .err = err }); + if (err == error.JsException) { + event._listeners_did_throw = true; + } else { + log.warn(.event, context, .{ .err = err }); + } }, .string => |string| { const str = try arena.dupeZ(u8, string.str()); local.eval(str, null) catch |err| { - log.warn(.event, context, .{ .err = err }); + if (err == error.JsException) { + event._listeners_did_throw = true; + } else { + log.warn(.event, context, .{ .err = err }); + } }; }, .object => |obj_global| { const obj = local.toLocal(obj_global); const handle_event = obj.getFunction("handleEvent") catch |err| blk: { - log.warn(.event, context, .{ .err = err }); + // Getting "handleEvent" threw (e.g. a throwing getter). + if (err == error.JsException) { + event._listeners_did_throw = true; + } else { + log.warn(.event, context, .{ .err = err }); + } break :blk null; }; if (handle_event) |handleEvent| { handleEvent.callWithThis(void, obj, .{event}) catch |err| { - log.warn(.event, context, .{ .err = err }); + if (err == error.JsException) { + event._listeners_did_throw = true; + } else { + log.warn(.event, context, .{ .err = err }); + } }; + } else { + // The listener was an object without the handleEvent function + // This is throwing a TypeError + event._listeners_did_throw = true; } }, } diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index d3aa63fa2..3fac87066 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -49,6 +49,7 @@ _time_stamp: u64, _needs_retargeting: bool = false, _is_trusted: bool = false, _in_passive_listener: bool = false, +_listeners_did_throw: bool = false, // IndexedDB needs to abort on callback throw // There's a period of time between creating an event and handing it off to v8 // where things can fail. If it does fail, we need to deinit the event. The timing diff --git a/src/browser/webapi/collections/DOMStringList.zig b/src/browser/webapi/collections/DOMStringList.zig index c5224a8c5..99531e6e2 100644 --- a/src/browser/webapi/collections/DOMStringList.zig +++ b/src/browser/webapi/collections/DOMStringList.zig @@ -106,7 +106,6 @@ pub const JsApi = struct { pub const name = "DOMStringList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const length = bridge.accessor(DOMStringList.length, null, .{}); diff --git a/src/browser/webapi/storage/idb/IDBDatabase.zig b/src/browser/webapi/storage/idb/IDBDatabase.zig index ddcbd1920..5df778060 100644 --- a/src/browser/webapi/storage/idb/IDBDatabase.zig +++ b/src/browser/webapi/storage/idb/IDBDatabase.zig @@ -23,11 +23,14 @@ const js = @import("../../../js/js.zig"); const EventTarget = @import("../../EventTarget.zig"); +const idb = @import("idb.zig"); const Engine = @import("Engine.zig"); const IDBTransaction = @import("IDBTransaction.zig"); const IDBObjectStore = @import("IDBObjectStore.zig"); const DOMStringList = @import("../../collections.zig").DOMStringList; +const FunctionSetter = idb.FunctionSetter; + const Execution = js.Execution; const Allocator = std.mem.Allocator; @@ -40,6 +43,7 @@ _database_id: i64, _name: []const u8, _version: i64, _txn: ?*IDBTransaction = null, // only set during upgradeneeded +_on_error: ?js.Function.Global = null, pub fn init(exec: *Execution, engine: *Engine, database_id: i64, name: []const u8, version: i64) !*IDBDatabase { return exec._factory.eventTarget(IDBDatabase{ @@ -192,6 +196,17 @@ pub fn getObjectStoreNames(self: *IDBDatabase, exec: *Execution) !*DOMStringList return list; } +pub fn getOnError(self: *const IDBDatabase) ?js.Function.Global { + return self._on_error; +} + +pub fn setOnError(self: *IDBDatabase, setter: ?FunctionSetter) void { + self._on_error = if (setter) |s| switch (s) { + .func => |f| f, + .anything => null, + } else null; +} + pub const JsApi = struct { pub const bridge = js.Bridge(IDBDatabase); @@ -208,4 +223,5 @@ pub const JsApi = struct { pub const deleteObjectStore = bridge.function(IDBDatabase.deleteObjectStore, .{}); pub const transaction = bridge.function(IDBDatabase.transaction, .{}); pub const close = bridge.function(IDBDatabase.close, .{}); + pub const onerror = bridge.accessor(IDBDatabase.getOnError, IDBDatabase.setOnError, .{}); }; diff --git a/src/browser/webapi/storage/idb/IDBRequest.zig b/src/browser/webapi/storage/idb/IDBRequest.zig index b2dbd0cd7..2a95d2555 100644 --- a/src/browser/webapi/storage/idb/IDBRequest.zig +++ b/src/browser/webapi/storage/idb/IDBRequest.zig @@ -36,6 +36,7 @@ const IDBObjectStore = @import("IDBObjectStore.zig"); const IDBTransaction = @import("IDBTransaction.zig"); const IDBVersionChangeEvent = @import("IDBVersionChangeEvent.zig"); +const log = lp.log; const Execution = js.Execution; const FunctionSetter = idb.FunctionSetter; @@ -178,14 +179,14 @@ pub fn failed(self: *const IDBRequest) bool { pub fn deliver(self: *IDBRequest, exec: *Execution) !void { self._ready_state = .done; if (self._error != null) { - return self.fire(exec, comptime .wrap("error"), self._on_error); + return self.fireError(exec); } if (self._cursor) |cursor| { // A cursor request re-fires on every iteration; let the cursor mark itself // readable (got value) right before the success handler runs. cursor.beforeDeliver(); } - return self.fire(exec, comptime .wrap("success"), self._on_success); + return self.fireSuccess(exec); } pub fn fireUpgradeNeeded(self: *IDBRequest, exec: *Execution, old_version: u64, new_version: u64) !void { @@ -196,12 +197,73 @@ pub fn fireUpgradeNeeded(self: *IDBRequest, exec: *Execution, old_version: u64, pub fn fireSuccess(self: *IDBRequest, exec: *Execution) !void { self._ready_state = .done; - return self.fire(exec, comptime .wrap("success"), self._on_success); + + const event = try Event.initTrusted(comptime .wrap("success"), null, exec.page); + event.acquireRef(); + defer _ = event.releaseRef(exec.page); + + try exec.dispatch(self.asEventTarget(), event, self._on_success, .{ .context = "IDBRequest.success" }); + + if (event._listeners_did_throw) blk: { + // if the event threw, we must abort + const txn = switch (self._txn) { + .owned => |t| t, + .none, .borrowed => break :blk, + }; + + if (!txn._settled and !txn._committing) { + txn.abortWith(exec, error.AbortError) catch |err| { + log.warn(.storage, "idb success-event abort", .{ .err = err }); + }; + } + } } -fn fire(self: *IDBRequest, exec: *Execution, typ: lp.String, handler: ?js.Function.Global) !void { - const event = try Event.initTrusted(typ, null, exec.page); - try exec.dispatch(self.asEventTarget(), event, handler, .{ .context = "IDBRequest" }); +fn fireError(self: *IDBRequest, exec: *Execution) !void { + // Requests created inside a transaction own an abortable transaction; open/ + // delete requests (and the borrowed upgrade-transaction view) do not. + const txn: ?*IDBTransaction = switch (self._txn) { + .owned => |t| t, + .none, .borrowed => null, + }; + + const event = try Event.initTrusted(comptime .wrap("error"), .{ .bubbles = true, .cancelable = true }, exec.page); + event.acquireRef(); + defer _ = event.releaseRef(exec.page); + + const et = self.asEventTarget(); + event._target = et; + event._dispatch_target = et; + + try exec.dispatch(et, event, self._on_error, .{ .context = "IDBRequest.error", .inject_target = false }); + if (txn) |tx| { + if (!event._stop_propagation) { + try exec.dispatch(tx.asEventTarget(), event, tx._on_error, .{ .context = "IDBTransaction.error", .inject_target = false }); + } + if (!event._stop_propagation) { + const db = tx._db; + try exec.dispatch(db.asEventTarget(), event, db._on_error, .{ .context = "IDBDatabase.error", .inject_target = false }); + } + + // Don't re-abort a transaction that's already finishing — the AbortError + // events an abort itself delivers come back through here. + if (!tx._settled and !tx._committing) { + const reason: ?anyerror = if (event._listeners_did_throw) + error.AbortError + else if (!event._prevent_default) + self._error + else + null; + if (reason != null) { + // catch (rather than propagate): the abort's own event dispatch + // can re-enter here, so keeping the error set out of deliver's + // recursion is both simpler and avoids an unresolvable inferred set. + tx.abortWith(exec, reason) catch |err| { + log.warn(.storage, "idb error-event abort", .{ .err = err }); + }; + } + } + } } pub fn getReadyState(self: *const IDBRequest) ReadyState { diff --git a/src/browser/webapi/storage/idb/IDBTransaction.zig b/src/browser/webapi/storage/idb/IDBTransaction.zig index 864336c0b..3b5da92d6 100644 --- a/src/browser/webapi/storage/idb/IDBTransaction.zig +++ b/src/browser/webapi/storage/idb/IDBTransaction.zig @@ -24,6 +24,7 @@ const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Event = @import("../../Event.zig"); const EventTarget = @import("../../EventTarget.zig"); +const DOMException = @import("../../DOMException.zig"); const idb = @import("idb.zig"); const Engine = @import("Engine.zig"); @@ -80,6 +81,7 @@ _begun: bool = false, _settled: bool = false, _aborted: bool = false, _committing: bool = false, +_error: ?anyerror = null, _gate_waiter: Engine.GateWaiter, // A transaction is only active for one execution of a Scheduler's task. We // capture the scheduler's generation here and reject any request made in a @@ -230,13 +232,20 @@ pub fn commit(self: *IDBTransaction, exec: *Execution) !void { } } +// JS-facing, no reason pub fn abort(self: *IDBTransaction, exec: *Execution) !void { + return self.abortWith(exec, null); +} + +// Internal, optional reason +pub fn abortWith(self: *IDBTransaction, exec: *Execution, reason: ?anyerror) error{InvalidStateError}!void { if (self._settled or self._committing) { return error.InvalidStateError; } self._aborted = true; self._settled = true; + self._error = reason; if (self._begun) { self._engine.rollback(); @@ -417,6 +426,15 @@ pub fn getObjectStoreNames(self: *IDBTransaction, exec: *Execution) !*DOMStringL return list; } +pub fn getError(self: *const IDBTransaction) ?DOMException { + const err = self._error orelse return null; + const mapped: anyerror = switch (err) { + error.Constraint => error.ConstraintError, + else => err, + }; + return DOMException.fromError(mapped) orelse DOMException.init(null, "UnknownError"); +} + pub fn getOnComplete(self: *const IDBTransaction) ?js.Function.Global { return self._on_complete; } @@ -629,6 +647,7 @@ pub const JsApi = struct { pub const durability = bridge.accessor(IDBTransaction.getDurability, null, .{}); pub const db = bridge.accessor(IDBTransaction.getDb, null, .{}); pub const objectStoreNames = bridge.accessor(IDBTransaction.getObjectStoreNames, null, .{}); + pub const @"error" = bridge.accessor(IDBTransaction.getError, null, .{ .null_as_undefined = true }); pub const objectStore = bridge.function(IDBTransaction.objectStore, .{}); pub const abort = bridge.function(IDBTransaction.abort, .{}); pub const commit = bridge.function(IDBTransaction.commit, .{});