XMLHttpRequest: implement the on<event> handler properties - #221
XMLHttpRequest: implement the on<event> handler properties#221bkaradzic-microsoft wants to merge 2 commits into
on<event> handler properties#221Conversation
`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
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.
| if (!value.IsFunction()) | ||
| { | ||
| m_onEventHandlerRefs.erase(eventType); | ||
| return; | ||
| } | ||
|
|
||
| m_onEventHandlerRefs[eventType] = Napi::Persistent(value.As<Napi::Function>()); | ||
| (void)info; |
| InstanceAccessor("onload", &XMLHttpRequest::GetEventHandler<EventIndex::Load>, &XMLHttpRequest::SetEventHandler<EventIndex::Load>), | ||
| InstanceAccessor("onerror", &XMLHttpRequest::GetEventHandler<EventIndex::Error>, &XMLHttpRequest::SetEventHandler<EventIndex::Error>), | ||
| InstanceAccessor("onloadend", &XMLHttpRequest::GetEventHandler<EventIndex::LoadEnd>, &XMLHttpRequest::SetEventHandler<EventIndex::LoadEnd>), | ||
| InstanceAccessor("onabort", &XMLHttpRequest::GetEventHandler<EventIndex::Abort>, &XMLHttpRequest::SetEventHandler<EventIndex::Abort>), |
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.
|
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 |
| } | ||
| else | ||
| { | ||
| RaiseEvent(EventType::Load); |
There was a problem hiding this comment.
failed is statusCode < 200 || statusCode >= 300 (L372), so a 404 takes the error branch and load never fires. Per the XHR spec error is network-level only; a completed 404 dispatches load, with callers branching on xhr.status inside it.
The new 404 test asserts loadFired === false, locking that in. Worth confirming it is intended — the description's "per the spec" holds for transport errors, not HTTP status codes.
The condition predates this PR and is load-bearing for the UWP status-0 case, so this is not a request to simply invert it.
There was a problem hiding this comment.
You're right, and the test is wrong. A 404 is a completed HTTP transaction — per spec error is for network-level failures only, so load should fire and callers branch on xhr.status inside it.
On the part you flagged as load-bearing: I audited every m_statusCode assignment in UrlLib, and the status-0 case is narrower than the current condition assumes.
UrlStatusCode { None = 0, Ok = 200 }
Every non-HTTP success path sets Ok explicitly — UrlRequest_UWP.cpp:70,78 (local file read), Win32.cpp:78,98, Android.cpp:90,130, Apple.mm:201, Unix.cpp:251, Windows_Shared.h:131,143. None only ever appears as the initial value and in the reset at UrlRequest_Base.h:371.
So statusCode == 0 means precisely "no HTTP response was obtained", which is the transport-failure sentinel the UWP fix relies on — it is never a successful local file read. That makes the condition separable:
const bool failed = result.has_error() || statusCode == 0;That keeps the missing-local-file-on-UWP behaviour intact (still error + loadend) while letting a completed 404 dispatch load + loadend.
I'll make that change and flip the 404 test to assert loadFired === true, errorFired === false, status === 404.
| // The DOM `on<event>` handler properties (onreadystatechange, onload, ...). These are | ||
| // kept separate from m_eventHandlerRefs because they have assignment semantics -- setting | ||
| // one replaces the previous handler -- whereas addEventListener accumulates. | ||
| std::unordered_map<std::string, Napi::FunctionReference> m_onEventHandlerRefs; |
There was a problem hiding this comment.
on<event> handlers belong in the same listener list as addEventListener, not a parallel map dispatched ahead of it. Two divergences follow from the split:
- Order: browsers dispatch in registration order, so
addEventListener("load", a)thenxhr.onload = bgivesa, b; here it givesb, a. xhr.onload = f; xhr.addEventListener("load", f)throws (XMLHttpRequest.cppL269), where a browser registers both and callsftwice -- per DOM a duplicate add is a silent no-op, not an error.
HTML registers one internal listener on first set whose callback indirects through the stored value, so reassignment keeps its position ("If eventHandler's listener is not null, then return").
One vector<Listener> per type of { FunctionReference callback; bool isEventHandler; } covers it: the setter replaces the flagged entry in place, appends when absent, erases when the value is not callable; the getter reads it back. Drops this map and the on-handler-first branch in RaiseEvent.
The duplicate throw predates this PR.
There was a problem hiding this comment.
Agreed — the parallel map is the wrong model, and I'll switch to the single-list design you described.
To confirm I've got the shape right:
struct Listener
{
Napi::FunctionReference callback;
bool isEventHandler; // registered via on<event>, not addEventListener
};
std::unordered_map<std::string, std::vector<Listener>> m_listeners;- setter: find the
isEventHandlerentry — replace its callback in place if present, append if not, erase if the value isn't callable - getter: read that entry back,
nullwhen absent RaiseEvent: one pass over the vector, so dispatch is registration orderm_onEventHandlerRefsand the on-handler-first branch both go away
That fixes the ordering divergence: addEventListener("load", a) then xhr.onload = b now gives a, b, and reassigning onload keeps its slot rather than moving to the end — matching "If eventHandler's listener is not null, then return".
For the second divergence, I'll scope the duplicate check in AddEventListener to non-isEventHandler entries, so xhr.onload = f; xhr.addEventListener("load", f) registers both and calls f twice, as a browser does.
That leaves the pre-existing addEventListener duplicate throw. Per DOM a duplicate add is a silent no-op, so the throw is also wrong, but it's independent of this bug and nothing currently covers it — happy to drop it here for symmetry, or leave it for a separate PR. Let me know which you'd prefer.
I'll add a test asserting dispatch order across both registration styles.
Problem
XMLHttpRequest::RaiseEventdispatches only to handlers stored inm_eventHandlerRefs, and that map is populated exclusively byaddEventListener. The class registers no accessors for the DOMon<event>handler properties:So this ordinary, spec-compliant code silently does nothing:
The failure mode is silent and severe. The request completes normally and the
state is correct -- I instrumented a real case and observed
readyState === 4and
status === 200-- but no callback ever fires. There is no exception andno diagnostic. The caller simply waits forever.
How this surfaced
In the BabylonNative Playground validation suite, the tests that fetch their
scene script over XHR use
request.onreadystatechange. All of them hung untilthe harness timeout and were consequently marked excluded on every graphics
API (D3D11, D3D12, OpenGL, Vulkan, Metal, WebGPU), with a misattributed
"scene never becomes ready" reason. The scene was never created at all.
Changes
1.
on<event>handler properties. Addsonreadystatechange,onload,onerror,onloadendandonabortas instance accessors.They are stored in a map separate from the
addEventListenerhandlers,because they behave differently:
xhr.onloadreturns what was assigned);null/undefinedclears it.RaiseEventnow dispatches theon<event>handler in addition to anyaddEventListenerhandlers, matching the DOM, andSendreleases the newstrong references alongside the existing ones when the request settles.
2. Raise the
loadevent on success.loadwas previously neverraised, so neither
onloadnoraddEventListener("load", ...)could everfire -- only
loadend, pluserroron failure. Success now dispatchesloadthenloadend; failure continues to dispatcherrorthenloadend, per the spec.Tests
Five regression tests in
Tests/UnitTests/Scripts/tests.ts:onreadystatechangeis invoked and reachesreadyState4onload+onloadendfire on success,onerrordoes notonerrorfires on HTTP 404,onloaddoes notnullon<event>property andaddEventListenerhandlers both runAll 221 unit tests pass locally on Windows.
Compatibility
Purely additive. Existing
addEventListenerbehavior is unchanged; the onlybehavioral difference for existing code is that
loadlisteners, whichpreviously could never fire, now do -- which is the documented DOM contract.