XMLHttpRequest: implement the on<event> handler properties - #221
XMLHttpRequest: implement the on<event> handler properties#221bkaradzic-microsoft wants to merge 4 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.
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 |
| // 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.
There was a problem hiding this comment.
Done in 20b0c20 — switched to the single-list design.
m_onEventHandlerRefs is gone. m_listeners is now one vector<Listener> per event type:
struct Listener
{
Napi::FunctionReference callback;
bool isEventHandler;
};- setter: finds the
isEventHandlerentry and replaces its callback in place, appends when absent, erases when the assigned value is not callable - getter: reads that entry back,
nullwhen absent RaiseEvent: one pass over the vector, so dispatch is registration orderAddEventListener: the duplicate check skipsisEventHandlerentriesRemoveEventListener: the match also skips them, since the property is cleared by assigningnull, not byremoveEventListener
Four tests cover the behaviour, and each fails against the previous implementation:
| Test | Was | Now |
|---|---|---|
addEventListener("load", a), onload = b, addEventListener("load", c) |
b, a, c |
a, b, c |
onload = x, addEventListener("load", l), onload = y |
y, l — but only incidentally, since on-handlers always ran first |
y, l, because the entry keeps its slot |
onload = f, addEventListener("load", f) |
threw | f called twice |
onload = f, removeEventListener("load", f) |
n/a | f still called |
On the pre-existing addEventListener duplicate throw: I left it as-is here, since it is independent of this bug and nothing currently covers it. Happy to drop it in a follow-up if you would like DOM-correct silent no-op behaviour.
There was a problem hiding this comment.
[Responded by Copilot on behalf of @bghgary]
Remove it here. You're already in that code, and per DOM a duplicate add is a silent no-op — leaving the throw would park a second known-wrong behaviour next to the one you're fixing.
Your UrlLib audit holds, checked independently: all sixteen m_statusCode assignments set Ok explicitly on the non-HTTP success paths, and the only None is the reset in UrlRequest_Base.h. The blob-handler path returns early via SetError when unhandled, so a successful request can't land at 0 either.
There was a problem hiding this comment.
Removed in 87c5c1b — the duplicate addEventListener now returns silently instead of throwing, with a comment citing the DOM "append listener" step.
The scan still skips isEventHandler entries, so the two-registration case is unchanged: xhr.onload = f followed by xhr.addEventListener("load", f) still calls f twice. Only two addEventListener calls with the same pair collapse to one.
Added a test pinning the new behaviour (doesn't throw, handler fires exactly once); the existing "called twice" test guards the other direction. Suite is 227 passing on Windows.
And thanks for double-checking the UrlLib status-code paths — good to have that confirmed independently rather than resting on my read alone.
One note: the identical throw also exists in Polyfills/AbortController/Source/AbortSignal.cpp:129. I left it alone since it's outside this PR's surface, but happy to fix it here or file a follow-up, whichever you prefer.
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
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>),
Problem
RaiseEventdispatches only to handlers registered viaaddEventListener. TheDOM
on<event>properties had no accessors, so they were stored as plainexpandos and never called:
The failure is silent: the request completes and the state is correct
(
readyState === 4,status === 200), but no callback fires and no error isreported. The caller waits forever.
This surfaced in the BabylonNative Playground suite, where the tests that fetch
their scene script over XHR use
onreadystatechange. All of them hung untiltimeout and were marked excluded on every graphics API, with a misattributed
"scene never becomes ready" reason.
Changes
1.
on<event>handler properties. Addsonreadystatechange,onload,onerror,onloadendandonabortas accessors. They share one listener listper event type with
addEventListener, tagged so the singleon<event>entrycan be found:
One list is what the DOM specifies, and it gets the observable details right:
dispatch follows registration order across both styles; reassigning the
property keeps its position rather than moving to the end;
xhr.onload = fandaddEventListener("load", f)are independent registrations, sofis calledtwice;
removeEventListenerdoes not remove anon<event>handler (assigningnulldoes). Per WebIDL these are[LegacyTreatNonObjectAsNull], soxhr.onload = 0clears the handler rather than throwing.2. Raise
loadon success. It was never raised, so neitheronloadnoraddEventListener("load", ...)could fire — onlyloadend, pluserroronfailure.
3. Raise
abortwhen a request is aborted.Abort()only forwarded toUrlLib, and the continuation reported the cancelled transfer as a transporterror. A cancelled request now dispatchesabort+loadend, nevererror.4. A duplicate
addEventListeneris a no-op. Re-adding an identical(type, callback)pair threwCannot add the same event handler twice; per DOMthe second add is silently ignored. The scan still skips the
on<event>entry,so the two-registration case above is unchanged.
5.
erroris transport-level only.Previously any non-2xx status took the error branch, so a 404 fired
errorandloadnever fired. Per specerrormeans the transfer did not complete; a 404is a completed exchange, so it dispatches
loadand callers branch onxhr.status.The condition also covers a missing local file on UWP, where
UrlLibleavesthe status at 0.
UrlStatusCode::None(0) is only ever the initial value andthe
ResetForOpenreset — every path that produces a response assigns anexplicit code, including non-HTTP ones, where a local file read sets
Ok. SostatusCode == 0means precisely "no response was obtained".Tests
12 regression tests in
Tests/UnitTests/Scripts/tests.tscovering: eachhandler property firing (and reading back, being replaced, cleared, and
coercing a non-callable to
null); 404 dispatchingloadrather thanerrorvia both registration styles; abort dispatching
abort; registration-orderdispatch; a reassigned handler keeping its position; a function registered both
ways being called twice; a duplicate
addEventListenernot throwing and firingonce; and
removeEventListenernot removing anon<event>handler.All 227 unit tests pass on Windows.
Note
Tests/UnitTests/dist/is gitignored and CMake only copies it, sonpm run buildinTests/is required after editingtests.ts— otherwise the buildsilently tests a stale bundle.
Compatibility
The
on<event>properties,loadandabortare additive: they cover casesthat previously could not fire at all.
One behavioral change worth a close look: a non-2xx response now fires
loadinstead oferror. Code relying ononerrorto observe an HTTP 404must check
xhr.statusinsideonloadinstead. This matches browsers, andloadendstill fires either way, so anything settling onloadendisunaffected.