Skip to content

XMLHttpRequest: implement the on<event> handler properties - #221

Open
bkaradzic-microsoft wants to merge 2 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:fix-xhr-on-event-handlers
Open

XMLHttpRequest: implement the on<event> handler properties#221
bkaradzic-microsoft wants to merge 2 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:fix-xhr-on-event-handlers

Conversation

@bkaradzic-microsoft

Copy link
Copy Markdown
Member

Problem

XMLHttpRequest::RaiseEvent dispatches only to handlers stored in
m_eventHandlerRefs, and that map is populated exclusively by
addEventListener. The class registers no accessors for the DOM
on<event> handler properties:

void XMLHttpRequest::RaiseEvent(const char* eventType)
{
    const auto it = m_eventHandlerRefs.find(eventType);   // addEventListener only
    ...
}

So this ordinary, spec-compliant code silently does nothing:

const request = new XMLHttpRequest();
request.open("GET", url, true);
request.onreadystatechange = function () {   // stored as a plain expando property
    if (request.readyState === 4) { /* never runs */ }
};
request.onerror = function () { /* never runs */ };
request.send(null);

The failure mode is silent and severe. The request completes normally and the
state is correct -- I instrumented a real case and observed readyState === 4
and status === 200 -- but no callback ever fires. There is no exception and
no 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 until
the 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. Adds onreadystatechange,
onload, onerror, onloadend and onabort as instance accessors.

They are stored in a map separate from the addEventListener handlers,
because they behave differently:

  • assignment replaces the previous handler rather than accumulating;
  • the property must be readable (xhr.onload returns what was assigned);
  • assigning null/undefined clears it.

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 when the request settles.

2. Raise the load event on success. load was previously never
raised, so neither onload nor addEventListener("load", ...) could ever
fire -- only loadend, plus error on failure. Success now dispatches
load then loadend; failure continues to dispatch error then
loadend, per the spec.

Tests

Five regression tests in Tests/UnitTests/Scripts/tests.ts:

  • onreadystatechange is invoked and reaches readyState 4
  • onload + onloadend fire on success, onerror does not
  • onerror fires on HTTP 404, onload does not
  • the property can be read back, replaced, and cleared with null
  • an on<event> property and addEventListener handlers both run

All 221 unit tests pass locally on Windows.

Compatibility

Purely additive. Existing addEventListener behavior is unchanged; the only
behavioral difference for existing code is that load listeners, which
previously could never fire, now do -- which is the documented DOM contract.

`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
Copilot AI lite review requested due to automatic review settings August 6, 2026 23:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and onabort, stored separately from addEventListener handlers.
  • Updated event dispatch to invoke on<event> handlers in addition to addEventListener handlers, and to raise load on success.
  • Added regression tests validating on<event> semantics (invocation, readback/replace/clear, and interaction with addEventListener).

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.

Comment on lines +94 to +101
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.
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Thanks -- both comments addressed in c870d43.

On the non-callable setter (XMLHttpRequest.cpp:101): throwing a TypeError here would actually diverge from the DOM. EventHandler attributes are declared [LegacyTreatNonObjectAsNull] in WebIDL, so a non-callable assignment is coerced to null rather than rejected -- xhr.onload = 0 leaves xhr.onload === null in every browser, silently. The current clear-on-non-function behavior matches that for primitives.

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 (should coerce a non-callable on<event> assignment to null) pinning the no-throw behavior.

On onabort (XMLHttpRequest.cpp:138): good catch, this one was a real defect -- Abort() only called m_request.Abort(), so the completion continuation reported the cancellation as a transport error and onabort was dead API. Abort() now records the intent and the continuation raises abort + loadend instead of error, per the DOM. Covered by a new test asserting that aborting an in-flight request fires abort and never error/load.

While in here I also hardened RaiseEvent along the lines of FileReader::Dispatch: it now snapshots the handler list before dispatching (a handler calling addEventListener/removeEventListener, or reassigning an on<event> property, could reallocate the vector or rehash the map out from under the in-flight dispatch -- a use-after-free) and clears pending exceptions between handlers so a throwing handler neither aborts the remaining dispatch nor escapes into the native completion continuation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants