Skip to content

fix: support native Event and CustomEvent and avoid overwriting globals - #58

Merged
alan-agius4 merged 2 commits into
angular:mainfrom
JeanMeche:fix/events-compatibility
Nov 13, 2026
Merged

alan-agius4 merged 2 commits into
angular:mainfrom
JeanMeche:fix/events-compatibility

Conversation

@JeanMeche

Copy link
Copy Markdown
Member

Description

Modern Node.js (v18+) provides native standard implementations of Event, CustomEvent, and EventTarget.

When @angular/platform-server imports Domino and copies domino.impl onto globalThis, Domino's internal Event and CustomEvent constructors overwrite Node's native globals. This causes issues for libraries extending Node's native EventTarget (such as p-queue v10), because Node's native EventTarget.prototype.dispatchEvent() enforces an internal brand check requiring the event to be an instance of Node's native Event, throwing:

TypeError [ERR_INVALID_ARG_TYPE]: The "event" argument must be an instance of Event. Received an instance of CustomEvent

Furthermore, Domino's EventTarget.prototype._dispatchEvent previously rejected native Event instances because it required event._initialized === true and directly assigned to getter-only properties (target, currentTarget, isTrusted, eventPhase).

Changes

  1. Prefer globalThis.Event / CustomEvent: Updated lib/events.js to defer to globalThis event constructors when available in the environment.
  2. Native Event Dispatching: Updated lib/EventTarget.js to:
    • Accept native events by only checking event._initialized === false.
    • Use Object.defineProperty to assign target, currentTarget, isTrusted, and eventPhase on events with read-only prototype getters.
    • Respect cancelBubble and stopImmediatePropagation() on native events.
  3. Legacy createEvent compatibility: Fallback to passing an empty type to constructor if new constructor() throws due to argument length.

Fixes angular/angular#71977

- Defer to `globalThis.Event` and `globalThis.CustomEvent` (as well as UIEvent and MouseEvent) when available in the runtime environment.
- Allow `EventTarget.prototype._dispatchEvent` to dispatch native `Event` and `CustomEvent` objects by properly defining properties (such as `target`, `currentTarget`, `isTrusted`, `eventPhase`) without failing on read-only getters.
- Support native `stopPropagation()` and `stopImmediatePropagation()`.

Fixes angular/angular#71977

@alan-agius4 alan-agius4 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: Thanks for working on this fix! Supporting native Event and CustomEvent without overwriting globalThis makes sense for Node EventTarget compatibility. I've left a few inline comments regarding document.createEvent + initEvent, UIEvent/MouseEvent prototype inheritance, and a few edge cases/performance improvements in _dispatchEvent.

Comment thread lib/Document.js
Comment on lines 258 to 267
if (constructor) {
var e = new constructor();
var e;
try {
e = new constructor();
} catch (err) {
e = new constructor('');
}
e._initialized = false;
return e;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: There are two issues here when constructor is a native globalThis.Event or globalThis.CustomEvent:

  1. initEvent leaves e._initialized === false (breaking dispatchEvent):
    e._initialized is a Domino-specific flag. When a caller runs var e = doc.createEvent('Event'); e.initEvent('click', true, true); el.dispatchEvent(e);, calling e.initEvent(...) invokes globalThis.Event.prototype.initEvent, which does not set e._initialized = true. As a result, e._initialized remains false and el.dispatchEvent(e) unconditionally throws InvalidStateError. (Additionally, Node's native CustomEvent.prototype does not implement legacy initCustomEvent.)
  2. Throwing in normal control flow:
    new constructor('') works for both Domino's internal event constructors (Event, CustomEvent, UIEvent, MouseEvent) and native globalThis.Event / globalThis.CustomEvent, avoiding throwing and catching a TypeError on every createEvent call.
Suggested change
if (constructor) {
var e = new constructor();
var e;
try {
e = new constructor();
} catch (err) {
e = new constructor('');
}
e._initialized = false;
return e;
}
if (constructor) {
var e = new constructor('');
if (!('_initialized' in e)) {
var origInitEvent = e.initEvent;
Object.defineProperty(e, 'initEvent', {
value: function initEvent(type, bubbles, cancelable) {
this._initialized = true;
if (this._dispatching) return;
this._propagationStopped = false;
this._immediatePropagationStopped = false;
if (origInitEvent) origInitEvent.call(this, type, bubbles, cancelable);
},
configurable: true,
writable: true
});
if (name === 'customevent' && typeof e.initCustomEvent !== 'function') {
Object.defineProperty(e, 'initCustomEvent', {
value: function initCustomEvent(type, bubbles, cancelable, detail) {
this.initEvent(type, bubbles, cancelable);
Object.defineProperty(this, 'detail', {
value: detail,
configurable: true,
enumerable: true,
writable: true
});
},
configurable: true,
writable: true
});
}
}
e._initialized = false;
return e;
}

Comment thread lib/events.js
Comment on lines +3 to +6
Event: typeof globalThis !== 'undefined' && globalThis.Event ? globalThis.Event : require('./Event'),
UIEvent: typeof globalThis !== 'undefined' && globalThis.UIEvent ? globalThis.UIEvent : require('./UIEvent'),
MouseEvent: typeof globalThis !== 'undefined' && globalThis.MouseEvent ? globalThis.MouseEvent : require('./MouseEvent'),
CustomEvent: typeof globalThis !== 'undefined' && globalThis.CustomEvent ? globalThis.CustomEvent : require('./CustomEvent')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: In Node.js (v18+), globalThis.Event and globalThis.CustomEvent are defined natively, whereas globalThis.UIEvent and globalThis.MouseEvent are undefined.

Because lib/UIEvent.js still has var Event = require('./Event') (Domino's fallback Event), domino.impl.UIEvent and domino.impl.MouseEvent inherit from Domino's ./Event instead of domino.impl.Event (globalThis.Event). This breaks the DOM prototype chain in Node.js:

  • new domino.impl.UIEvent() instanceof domino.impl.Event is false
  • new domino.impl.MouseEvent() instanceof domino.impl.Event is false
  • Events dispatched by HTMLElement.prototype.click() (which creates a MouseEvent) fail event instanceof Event checks in listeners.

Please also update lib/UIEvent.js and lib/MouseEvent.js so UIEvent.prototype inherits from Event.prototype (globalThis.Event || require('./Event')) and constructs via Reflect.construct when Event is a native class constructor, preserving both instanceof Event and the native Event brand.

Comment thread lib/EventTarget.js
Comment on lines +7 to +17
function setEventProperty(event, prop, value) {
try {
event[prop] = value;
} catch (e) {
Object.defineProperty(event, prop, {
value: value,
configurable: true,
writable: true
});
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: Two issues in setEventProperty:

  1. Performance (TypeError thrown on hot path): Because lib/EventTarget.js runs in "use strict" mode and target, currentTarget, eventPhase, and isTrusted are getter-only accessor properties on globalThis.Event.prototype, event[prop] = value throws and catches 4 TypeError exceptions (with stack trace generation) on the first dispatch of every native Event. Checking Object.prototype.hasOwnProperty.call(event, prop) first avoids throwing exceptions while keeping fast direct assignment for Domino events and subsequent phases.
  2. Non-configurable properties: Angular's event replay (patchEventInstance) and spec-compliant [LegacyUnforgeable] isTrusted descriptors can define non-configurable properties on Event instances. Skipping mutation when event[prop] === value and guarding Object.defineProperty with try / catch prevents TypeError: Cannot redefine property.
Suggested change
function setEventProperty(event, prop, value) {
try {
event[prop] = value;
} catch (e) {
Object.defineProperty(event, prop, {
value: value,
configurable: true,
writable: true
});
}
}
function setEventProperty(event, prop, value) {
if (Object.prototype.hasOwnProperty.call(event, prop)) {
try {
event[prop] = value;
return;
} catch (e) {}
} else if (event[prop] === value) {
return;
}
try {
Object.defineProperty(event, prop, {
value: value,
configurable: true,
enumerable: true,
writable: true
});
} catch (e) {}
}

Comment thread lib/EventTarget.js Outdated
Comment on lines +169 to +180
if (!event._immediatePropagationStopped && typeof event.stopImmediatePropagation === 'function') {
var origStopImmediate = event.stopImmediatePropagation;
Object.defineProperty(event, 'stopImmediatePropagation', {
value: function() {
event._propagationStopped = true;
event._immediatePropagationStopped = true;
origStopImmediate.call(this);
},
configurable: true,
writable: true
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: Checking !event._immediatePropagationStopped causes issues on Domino events and repeated dispatches:

  1. On Domino's own Event instances (where this._immediatePropagationStopped = false is initialized in new Event() and Event.prototype.stopImmediatePropagation already sets it), _dispatchEvent unnecessarily allocates a closure and calls Object.defineProperty on every dispatch.
  2. When the same native Event is dispatched more than once (e.g., @angular/platform-server's DominoAdapter.prototype.dispatchEvent, which dispatches every event to el and then to win), !event._immediatePropagationStopped remains true and wraps stopImmediatePropagation in nested closures—or throws TypeError: Cannot redefine property if a listener during the first dispatch marked stopImmediatePropagation as configurable: false (which Angular's EventDispatcher does).
Suggested change
if (!event._immediatePropagationStopped && typeof event.stopImmediatePropagation === 'function') {
var origStopImmediate = event.stopImmediatePropagation;
Object.defineProperty(event, 'stopImmediatePropagation', {
value: function() {
event._propagationStopped = true;
event._immediatePropagationStopped = true;
origStopImmediate.call(this);
},
configurable: true,
writable: true
});
}
if (!('_immediatePropagationStopped' in event) && typeof event.stopImmediatePropagation === 'function') {
var origStopImmediate = event.stopImmediatePropagation;
event._immediatePropagationStopped = false;
try {
Object.defineProperty(event, 'stopImmediatePropagation', {
value: function() {
this._propagationStopped = true;
this._immediatePropagationStopped = true;
origStopImmediate.call(this);
},
configurable: true,
writable: true
});
} catch (e) {}
}

Comment thread lib/EventTarget.js
Comment on lines 198 to 203
// Capturing phase
event.eventPhase = Event.CAPTURING_PHASE;
setEventProperty(event, 'eventPhase', Event.CAPTURING_PHASE);
for(var i = ancestors.length-1; i >= 0; i--) {
invoke(ancestors[i], event);
if (event._propagationStopped) break;
if (isStopped(event)) break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: Unlike the at-target and bubbling phases below, the capturing phase loop does not check isStopped(event) before invoking the first ancestor. If event.stopPropagation() or event.stopImmediatePropagation() (which sets event.cancelBubble = true on native events) is called prior to dispatchEvent(event), capturing listeners on the root ancestor will still be invoked.

Suggested change
// Capturing phase
event.eventPhase = Event.CAPTURING_PHASE;
setEventProperty(event, 'eventPhase', Event.CAPTURING_PHASE);
for(var i = ancestors.length-1; i >= 0; i--) {
invoke(ancestors[i], event);
if (event._propagationStopped) break;
if (isStopped(event)) break;
}
// Capturing phase
setEventProperty(event, 'eventPhase', Event.CAPTURING_PHASE);
for(var i = ancestors.length-1; i >= 0 && !isStopped(event); i--) {
invoke(ancestors[i], event);
}

Comment thread test/domino.js
Comment on lines +2526 to +2533
// preventDefault
btn.addEventListener('cancel', function(e) {
e.preventDefault();
});
var cancelEvent = new CustomEvent('cancel', { cancelable: true });
btn.dispatchEvent(cancelEvent);
cancelEvent.defaultPrevented.should.equal(true);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: Please also add test coverage for:

  1. stopImmediatePropagation() on a native event when multiple listeners are registered on the same target.
  2. doc.createEvent('Event') and doc.createEvent('CustomEvent') followed by initEvent(...) / initCustomEvent(...) and dispatchEvent(...).
  3. new domino.impl.UIEvent('resize') instanceof domino.impl.Event, new domino.impl.MouseEvent('click') instanceof domino.impl.Event, and btn.click() dispatching an event that is instanceof domino.impl.Event.
  4. Re-dispatching the same native Event instance across multiple targets (btn.dispatchEvent(e) followed by doc.defaultView.dispatchEvent(e)).

@JeanMeche
JeanMeche force-pushed the fix/events-compatibility branch 2 times, most recently from 7606638 to 8d58090 Compare November 13, 2026 09:51
Comment thread lib/EventTarget.js Outdated
event.isTrusted = trusted;
if (event._initialized === false || event._dispatching) utils.InvalidStateError();

if (!event._immediatePropagationStopped && typeof event.stopImmediatePropagation === 'function') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AGENT: Minor nit: because event._immediatePropagationStopped is initialized to false (both on line 178 for native events and in lib/Event.js for Domino events), !event._immediatePropagationStopped still evaluates to true (!false === true) on Domino events and on subsequent dispatches of the same native event.

Checking !('_immediatePropagationStopped' in event) ensures stopImmediatePropagation is only wrapped once on native events and skipped on Domino events:

Suggested change
if (!event._immediatePropagationStopped && typeof event.stopImmediatePropagation === 'function') {
if (!('_immediatePropagationStopped' in event) && typeof event.stopImmediatePropagation === 'function') {

@JeanMeche
JeanMeche force-pushed the fix/events-compatibility branch from 8d58090 to 4319e7e Compare November 13, 2026 10:21
@alan-agius4
alan-agius4 merged commit 49e2e15 into angular:main Nov 13, 2026
8 checks passed
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.

Angular's global patching in @angular/platform-server causes issues for other code expecting node's native event types

2 participants