Repository navigation
XMLHttpRequest: implement the on<event> handler properties - #221
bkaradzic-microsoft wants to merge 16 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the XMLHttpRequest polyfill to support DOM-style on<event> handler properties (e.g., onreadystatechange, onload) and ensures successful requests also raise the load event (in addition to loadend). It adds unit tests to prevent regressions where on<event> assignments silently did nothing.
Changes:
- Added instance accessors for
onreadystatechange,onload,onerror,onloadend, andonabort, stored separately fromaddEventListenerhandlers. - Updated event dispatch to invoke
on<event>handlers in addition toaddEventListenerhandlers, and to raiseloadon success. - Added regression tests validating
on<event>semantics (invocation, readback/replace/clear, and interaction withaddEventListener).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| Tests/UnitTests/Scripts/tests.ts | Adds regression tests covering on<event> handler properties and load/loadend behavior. |
| Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h | Introduces plumbing (event indices + storage) for on<event> handler properties. |
| Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp | Implements on<event> accessors, dispatches them from RaiseEvent, raises load on success, and clears stored handlers after completion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks -- both comments addressed in c870d43. On the non-callable setter ( Strictly, the spec does store non-callable objects (they just never get invoked); we clear those too, because keeping a value we could never call would only defer the failure to dispatch time. I've documented that deliberate narrowing in a code comment and added a regression test ( On While in here I also hardened |
Addresses review feedback on BabylonJS#221. The on<event> properties lived in a parallel map dispatched ahead of the addEventListener list, which diverged from the DOM in two ways: - Dispatch ignored registration order. addEventListener("load", a) followed by xhr.onload = b called b then a; browsers call a then b. - xhr.onload = f; xhr.addEventListener("load", f) threw, where a browser registers both and calls f twice. Both kinds of listener now share one vector per event type, tagged with isEventHandler. The setter replaces the flagged entry in place so reassignment keeps its position, matching "If eventHandler's listener is not null, then return"; it appends when absent and erases when the assigned value is not callable. The duplicate check in addEventListener and the match in removeEventListener both skip the flagged entry, since those operate on addEventListener registrations only. Also narrow the failure test so a completed HTTP transaction dispatches 'load' regardless of status: const bool failed = result.has_error() || statusCode == 0; Per spec 'error' is for network-level failure; a 404 fires 'load' and callers branch on xhr.status. UrlStatusCode::None (0) is UrlLib's "no response obtained" sentinel -- it is only ever the initial value and the reset in ResetForOpen, because every path producing a response assigns an explicit code, including non-HTTP local file reads which set Ok. So the missing-local-file-on-UWP case that this condition was widened for still reports 'error'. Tests: both 404 tests now assert load fired and error did not, plus new coverage for cross-style dispatch order, position on reassignment, a function registered both ways being called twice, and removeEventListener not removing an on<event> handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (6)
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp:106
[LegacyTreatNonObjectAsNull]only converts non-object values to null; a non-callable object must fail callback-function conversion with aTypeError. This branch silently clears assignments such asxhr.onload = {}, which differs from the WebIDL contract. Handle primitive values separately and reject non-callable objects.
if (!value.IsFunction())
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp:335
m_abortedis sticky and is never reset. Callingabort()before a request, or reusing an instance after an aborted request, therefore causes a later successful transfer to dispatchabortinstead ofload. Scope this flag and the underlyingAbort()call to an active send, and reset the per-transfer state when a new send begins.
m_aborted = true;
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp:432
- Clearing the unified list now also clears every
on<event>property. After completionxhr.onloadreads back asnull, and reusing the XHR loses all registered listeners; EventTarget registrations should persist until explicitly removed or the object is destroyed.
m_listeners.clear();
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp:453
- Snapshotting bare callback functions makes listener mutations during dispatch ineffective. If an earlier callback removes a later listener, the removed function remains in
handlersand is still invoked; similarly, reassigning a pendingonloadinvokes the old snapshot. Preserve stable listener records and check their current/removed state before each invocation.
// Snapshot the handlers before dispatching. A handler may call addEventListener,
// removeEventListener, or reassign an on<event> property while it runs, which would
// otherwise reallocate the vector or rehash the map out from under this dispatch.
// (Mirrors FileReader::Dispatch.)
std::vector<Napi::Function> handlers{};
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp:472
- This overload invokes the callback with an undefined receiver and no arguments. XHR listeners and handler properties must receive an event and run with
this/currentTargetset to the XHR, so code usingevent.targetorthis.statusbreaks. Pass the wrapper object and an event value, asFileReader::Dispatchdoes inPolyfills/File/Source/FileReader.cpp:223-255.
handler.Call({});
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp:158
- The public
Polyfills/XMLHttpRequest/Readme.md:5-8still saysonload-style properties are unsupported, omitsload/abort, and says non-2xx responses fireerror. Update that documentation alongside these accessors so users are not directed away from the newly supported API or given the old error semantics.
This issue also appears in the following locations of the same file:
- line 335
- line 432
- line 449
- line 472
// DOM `on<event>` handler properties. Without these, `xhr.onreadystatechange = fn`
// silently sets an ordinary expando property that is never invoked, so code written
// against the standard XMLHttpRequest API waits forever for a callback that can
// never fire.
InstanceAccessor("onreadystatechange", &XMLHttpRequest::GetEventHandler<EventIndex::ReadyStateChange>, &XMLHttpRequest::SetEventHandler<EventIndex::ReadyStateChange>),
Addresses review feedback on BabylonJS#221. The on<event> properties lived in a parallel map dispatched ahead of the addEventListener list, which diverged from the DOM in two ways: - Dispatch ignored registration order. addEventListener("load", a) followed by xhr.onload = b called b then a; browsers call a then b. - xhr.onload = f; xhr.addEventListener("load", f) threw, where a browser registers both and calls f twice. Both kinds of listener now share one vector per event type, tagged with isEventHandler. The setter replaces the flagged entry in place so reassignment keeps its position, matching "If eventHandler's listener is not null, then return"; it appends when absent and erases when the assigned value is not callable. The duplicate check in addEventListener and the match in removeEventListener both skip the flagged entry, since those operate on addEventListener registrations only. Also narrow the failure test so a completed HTTP transaction dispatches 'load' regardless of status: const bool failed = result.has_error() || statusCode == 0; Per spec 'error' is for network-level failure; a 404 fires 'load' and callers branch on xhr.status. UrlStatusCode::None (0) is UrlLib's "no response obtained" sentinel -- it is only ever the initial value and the reset in ResetForOpen, because every path producing a response assigns an explicit code, including non-HTTP local file reads which set Ok. So the missing-local-file-on-UWP case that this condition was widened for still reports 'error'. Tests: both 404 tests now assert load fired and error did not, plus new coverage for cross-style dispatch order, position on reassignment, a function registered both ways being called twice, and removeEventListener not removing an on<event> handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
bdebbfc to
5280ae2
Compare
|
Heads-up on a rooting hazard that this PR mirrors from |
|
Follow-up: the FileReader side of this is now its own PR, #248 (snapshot |
Addresses review feedback on BabylonJS#221. The on<event> properties lived in a parallel map dispatched ahead of the addEventListener list, which diverged from the DOM in two ways: - Dispatch ignored registration order. addEventListener("load", a) followed by xhr.onload = b called b then a; browsers call a then b. - xhr.onload = f; xhr.addEventListener("load", f) threw, where a browser registers both and calls f twice. Both kinds of listener now share one vector per event type, tagged with isEventHandler. The setter replaces the flagged entry in place so reassignment keeps its position, matching "If eventHandler's listener is not null, then return"; it appends when absent and erases when the assigned value is not callable. The duplicate check in addEventListener and the match in removeEventListener both skip the flagged entry, since those operate on addEventListener registrations only. Also narrow the failure test so a completed HTTP transaction dispatches 'load' regardless of status: const bool failed = result.has_error() || statusCode == 0; Per spec 'error' is for network-level failure; a 404 fires 'load' and callers branch on xhr.status. UrlStatusCode::None (0) is UrlLib's "no response obtained" sentinel -- it is only ever the initial value and the reset in ResetForOpen, because every path producing a response assigns an explicit code, including non-HTTP local file reads which set Ok. So the missing-local-file-on-UWP case that this condition was widened for still reports 'error'. Tests: both 404 tests now assert load fired and error did not, plus new coverage for cross-style dispatch order, position on reassignment, a function registered both ways being called twice, and removeEventListener not removing an on<event> handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
5280ae2 to
c07d7cf
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Event-handler conversion, completed-request abort state, and exception-time propagation stopping remain incorrect.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
`XMLHttpRequest::RaiseEvent` only dispatches to handlers stored in
`m_eventHandlerRefs`, which is populated exclusively by `addEventListener`.
The class exposed no accessors for the DOM `on<event>` handler properties, so
`xhr.onreadystatechange = fn` merely created an ordinary expando property on
the JS wrapper that nothing ever read.
The failure mode is silent and severe: the request runs to completion and
`readyState`/`status` are updated correctly, but the callback never fires,
so code written against the standard XMLHttpRequest API waits forever for an
event that cannot arrive. There is no error and no diagnostic -- it simply
hangs.
Add `onreadystatechange`, `onload`, `onerror`, `onloadend` and
`onabort` as instance accessors, stored in a separate map from the
`addEventListener` handlers because they have assignment semantics (setting
replaces the previous handler) rather than accumulating, and because they must
be individually readable and clearable via `xhr.onload = null`.
`RaiseEvent` now dispatches the `on<event>` handler in addition to any
`addEventListener` handlers, matching the DOM, and `Send` releases the new
strong references alongside the existing ones.
Also raise the `load` event on success. It was previously never raised at
all, so neither `onload` nor `addEventListener("load", ...)` could fire;
only `loadend` and (on failure) `error` were dispatched. Success now
dispatches `load` then `loadend`, and failure continues to dispatch
`error` then `loadend`, per the spec.
Adds five regression tests covering handler invocation on success and on HTTP
404, get/replace/clear semantics of the property, and co-existence with
`addEventListener`.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Addresses review feedback on the `on<event>` handler properties: - `onabort` was exposed but no `abort` event was ever dispatched, so the handler could never fire. `Abort()` now records the caller's intent and the completion continuation reports the outcome as `abort` + `loadend` instead of `error`, matching the DOM. - `RaiseEvent` now snapshots the handler list before dispatching. A handler is free to call `addEventListener`/`removeEventListener` or reassign an `on<event>` property, either of which would reallocate the vector or rehash the map out from under an in-flight dispatch. It also clears pending exceptions between handlers so a throwing handler neither aborts the rest of the dispatch nor escapes into the native completion continuation. This mirrors `FileReader::Dispatch`. - Documented why a non-callable assignment clears the handler rather than throwing: `EventHandler` attributes are `[LegacyTreatNonObjectAsNull]` in WebIDL, so `xhr.onload = 0` yields `null` rather than a TypeError. Adds regression tests for the abort event and the non-callable coercion.
Addresses review feedback on BabylonJS#221. The on<event> properties lived in a parallel map dispatched ahead of the addEventListener list, which diverged from the DOM in two ways: - Dispatch ignored registration order. addEventListener("load", a) followed by xhr.onload = b called b then a; browsers call a then b. - xhr.onload = f; xhr.addEventListener("load", f) threw, where a browser registers both and calls f twice. Both kinds of listener now share one vector per event type, tagged with isEventHandler. The setter replaces the flagged entry in place so reassignment keeps its position, matching "If eventHandler's listener is not null, then return"; it appends when absent and erases when the assigned value is not callable. The duplicate check in addEventListener and the match in removeEventListener both skip the flagged entry, since those operate on addEventListener registrations only. Also narrow the failure test so a completed HTTP transaction dispatches 'load' regardless of status: const bool failed = result.has_error() || statusCode == 0; Per spec 'error' is for network-level failure; a 404 fires 'load' and callers branch on xhr.status. UrlStatusCode::None (0) is UrlLib's "no response obtained" sentinel -- it is only ever the initial value and the reset in ResetForOpen, because every path producing a response assigns an explicit code, including non-HTTP local file reads which set Ok. So the missing-local-file-on-UWP case that this condition was widened for still reports 'error'. Tests: both 404 tests now assert load fired and error did not, plus new coverage for cross-style dispatch order, position on reassignment, a function registered both ways being called twice, and removeEventListener not removing an on<event> handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Re-adding an identical (type, callback) pair threw "Cannot add the same
event handler twice". Per DOM the second add is a silent no-op, so the
throw made valid browser code fail against the polyfill.
The scan still skips `isEventHandler` entries, so `xhr.onload = f`
followed by `xhr.addEventListener("load", f)` remains two independent
registrations and still calls `f` twice.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 48268912-5d88-4e04-93ca-0c5cd35a03ad
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
Guard abort and completion event sequences against replacement sends; construct Event/ProgressEvent instances with dispatch lifecycle and stop-immediate-propagation semantics. Cover reentrant callbacks and event prototypes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305
ae9a4a9 to
5d9ccae
Compare
|
Rebased onto latest |
The describe() call was missing its closing `);` after the BabylonJS#257 test-file split, which broke the webpack/babel build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Prepare replacement requests before invalidating active sends and preserve responseType. Detach canceled transport state before synchronous callbacks and guard response access while the transport is active. Keep callbacks in a JavaScript WeakMap keyed by their XHR, with stable native listener records and short-lived persistent invocation references. Retain non-callable handler objects by identity without invoking them. Pin the Android response-read fix from BabylonJS/UrlLib#38. Add reuse, failed-open, object-identity, self-capturing GC, streaming-abort, and truncated HTTP response regressions, and update the polyfill documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The new loopback regression exposed NSURLSession completing a truncated 404 body without an NSError. Pick up explicit fixed-length identity-body validation from the companion UrlLib PR, retaining the original XHR regression expectations on all POSIX platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2b2272ba-79f8-41d0-aae1-f514a0f712ad
|
Fixed the four macOS CI failures in 972edff by updating the companion UrlLib pin to 85e8f026d8cb54fd6ed298b6c0be9bdadb67dcc8 (BabylonJS/UrlLib#38).
Verification: all 24 jobs passed in CI run 37398377412, including all four previously failing macOS configurations and ThreadSanitizer. Companion UrlLib CI is also green. |
| if (this.cancelable) this.defaultPrevented = true; | ||
| }; | ||
| Event.prototype.stopPropagation = function () { this.cancelBubble = true; }; | ||
| Event.prototype.stopImmediatePropagation = function () { this.cancelBubble = true; }; |
There was a problem hiding this comment.
Fixed in c1a9433. The installed Event shim and XHR dispatch now share a weak per-event immediate-stop flag, so Event.prototype.stopImmediatePropagation.call(event) skips later listeners while stopPropagation() alone does not. Added a regression for both prototype calls; the QuickJS JavaScript suite passes.
| XMLHttpRequest::XMLHttpRequest(const Napi::CallbackInfo& info) | ||
| : Napi::ObjectWrap<XMLHttpRequest>{info} | ||
| , m_runtimeScheduler{JsRuntime::GetFromJavaScript(info.Env())} | ||
| , m_makeEvent{Napi::Persistent(info.NewTarget().As<Napi::Object>().Get(EVENT_FACTORY_NAME).As<Napi::Function>())} |
There was a problem hiding this comment.
Fixed in c1a9433. XHR gets its event factory from the native constructor registered during Initialize(), rather than from info.NewTarget(), so a forwarding constructor need not inherit the private static property. Added a Reflect.construct regression using a different new target; it passes on QuickJS and V8 CI is running.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d35d0a8b-b073-4f2a-bbd3-a0b1d3584305


Problem
XHR previously dispatched only to
addEventListenerhandlers. Assignments such asrequest.onreadystatechange = fnsilently created expandos that were never invoked, leaving callers waiting indefinitely. Successful requests also never raisedload.Changes
Handler properties and ordering. Implements
onreadystatechange,onload,onerror,onloadend, andonabort. Both registration styles share one ordered listener list per event:Callbacks live in a JavaScript
WeakMapkeyed by the XHR, not permanently rooted native references. This preserves reusable handlers while allowing an unreachable XHR and its self-capturing callbacks to be collected. Dispatch snapshots stable listener records, checks removal and the current callback before each invocation, and holds that invocation's function in a short-livedNapi::FunctionReference.Reassignment keeps the property's original position.
xhr.onload = fandaddEventListener("load", f)are independent registrations; duplicateaddEventListenercalls are ignored.removeEventListenerdoes not remove property handlers. Primitive property assignments clear the handler; non-callable objects retain their identity and are skipped during dispatch.Events and exceptions. Callbacks receive the XHR as
this, with the correcttype,target, andcurrentTarget.readystatechangereceives anEvent; terminal events receive aProgressEvent. Missing constructors are installed without replacing existing ones. Event methods, dispatch-phase cleanup, andstopImmediatePropagationare supported. Listener exceptions are reported through the runtime's unhandled-exception path without preventing later listeners from running.Abort and reuse. Active abort synchronously dispatches
readystatechange,abort, andloadend, then returns toUNSENT. Abort before send is inert; abort after completion clears the response without events. Send-generation guards suppress stale continuations and preserve replacement requests started reentrantly from callbacks.A canceled transport is detached before callbacks run, so response getters cannot read a buffer the old worker is still writing. Response data is exposed only after transport completion. Reopening preserves
responseType; failed validation or backendOpenleaves the previous response or active request unchanged. Concurrent sends and mutation of transport configuration during an active send are rejected.Transport outcomes. Completed HTTP responses, including 404, raise
loadthenloadend; transport failures raiseerrorthenloadendwith status 0. The dependency is temporarily pinned to BabylonJS/UrlLib#38, which checks Android JNI read failures and Apple fixed-length identity-body truncation, clears partial responses, and publishes HTTP status only after the complete body is read. Completed HTTP error bodies usegetErrorStream(). Repoint the pin upstream when that companion merges.Regression coverage
The XHR JavaScript suite now contains 41 cases in
Tests/UnitTests/Source/Scripts/tests.xmlHttpRequest.ts, including handler identity, ordering and mutation, reuse, failedopen, response-type preservation, synchronous/reentrant abort, and typed events.Native regressions cover self-capturing-handler collection on Chakra, exception/propagation behavior, complete versus truncated 200/404 responses, and response reads while a loopback server streams a body. Loopback tests run on POSIX platforms, including Android.
Local validation:
The Android truncated-body regression crashes under CheckJNI with the old UrlLib and passes with the new published dependency pin. Test bundles are generated from
Source/Scriptsby CMake/Gradle; the oldTests/UnitTests/distinstructions no longer apply.Compatibility
Handler properties and the missing successful
loadevent are additive. These are behavioral changes, not purely additive:load, noterror; callers should inspectxhr.status.abortandloadend, not a later transporterror.Polyfills/XMLHttpRequest/Readme.mddocuments these semantics and the remaining minimal-polyfill limitations.