Repository navigation
fix: support native Event and CustomEvent and avoid overwriting globals - #58
Conversation
- 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
left a comment
There was a problem hiding this comment.
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.
| if (constructor) { | ||
| var e = new constructor(); | ||
| var e; | ||
| try { | ||
| e = new constructor(); | ||
| } catch (err) { | ||
| e = new constructor(''); | ||
| } | ||
| e._initialized = false; | ||
| return e; | ||
| } |
There was a problem hiding this comment.
AGENT: There are two issues here when constructor is a native globalThis.Event or globalThis.CustomEvent:
initEventleavese._initialized === false(breakingdispatchEvent):
e._initializedis a Domino-specific flag. When a caller runsvar e = doc.createEvent('Event'); e.initEvent('click', true, true); el.dispatchEvent(e);, callinge.initEvent(...)invokesglobalThis.Event.prototype.initEvent, which does not sete._initialized = true. As a result,e._initializedremainsfalseandel.dispatchEvent(e)unconditionally throwsInvalidStateError. (Additionally, Node's nativeCustomEvent.prototypedoes not implement legacyinitCustomEvent.)- Throwing in normal control flow:
new constructor('')works for both Domino's internal event constructors (Event,CustomEvent,UIEvent,MouseEvent) and nativeglobalThis.Event/globalThis.CustomEvent, avoiding throwing and catching aTypeErroron everycreateEventcall.
| 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; | |
| } |
| 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') |
There was a problem hiding this comment.
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.Eventisfalsenew domino.impl.MouseEvent() instanceof domino.impl.Eventisfalse- Events dispatched by
HTMLElement.prototype.click()(which creates aMouseEvent) failevent instanceof Eventchecks 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.
| function setEventProperty(event, prop, value) { | ||
| try { | ||
| event[prop] = value; | ||
| } catch (e) { | ||
| Object.defineProperty(event, prop, { | ||
| value: value, | ||
| configurable: true, | ||
| writable: true | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
AGENT: Two issues in setEventProperty:
- Performance (
TypeErrorthrown on hot path): Becauselib/EventTarget.jsruns in"use strict"mode andtarget,currentTarget,eventPhase, andisTrustedare getter-only accessor properties onglobalThis.Event.prototype,event[prop] = valuethrows and catches 4TypeErrorexceptions (with stack trace generation) on the first dispatch of every nativeEvent. CheckingObject.prototype.hasOwnProperty.call(event, prop)first avoids throwing exceptions while keeping fast direct assignment for Domino events and subsequent phases. - Non-configurable properties: Angular's event replay (
patchEventInstance) and spec-compliant[LegacyUnforgeable]isTrusteddescriptors can define non-configurable properties onEventinstances. Skipping mutation whenevent[prop] === valueand guardingObject.definePropertywithtry / catchpreventsTypeError: Cannot redefine property.
| 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) {} | |
| } |
| 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 | ||
| }); | ||
| } |
There was a problem hiding this comment.
AGENT: Checking !event._immediatePropagationStopped causes issues on Domino events and repeated dispatches:
- On Domino's own
Eventinstances (wherethis._immediatePropagationStopped = falseis initialized innew Event()andEvent.prototype.stopImmediatePropagationalready sets it),_dispatchEventunnecessarily allocates a closure and callsObject.definePropertyon every dispatch. - When the same native
Eventis dispatched more than once (e.g.,@angular/platform-server'sDominoAdapter.prototype.dispatchEvent, which dispatches every event toeland then towin),!event._immediatePropagationStoppedremainstrueand wrapsstopImmediatePropagationin nested closures—or throwsTypeError: Cannot redefine propertyif a listener during the first dispatch markedstopImmediatePropagationasconfigurable: false(which Angular'sEventDispatcherdoes).
| 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) {} | |
| } |
| // 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; | ||
| } |
There was a problem hiding this comment.
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.
| // 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); | |
| } |
| // preventDefault | ||
| btn.addEventListener('cancel', function(e) { | ||
| e.preventDefault(); | ||
| }); | ||
| var cancelEvent = new CustomEvent('cancel', { cancelable: true }); | ||
| btn.dispatchEvent(cancelEvent); | ||
| cancelEvent.defaultPrevented.should.equal(true); | ||
| }; |
There was a problem hiding this comment.
AGENT: Please also add test coverage for:
stopImmediatePropagation()on a native event when multiple listeners are registered on the same target.doc.createEvent('Event')anddoc.createEvent('CustomEvent')followed byinitEvent(...)/initCustomEvent(...)anddispatchEvent(...).new domino.impl.UIEvent('resize') instanceof domino.impl.Event,new domino.impl.MouseEvent('click') instanceof domino.impl.Event, andbtn.click()dispatching an event that isinstanceof domino.impl.Event.- Re-dispatching the same native
Eventinstance across multiple targets (btn.dispatchEvent(e)followed bydoc.defaultView.dispatchEvent(e)).
7606638 to
8d58090
Compare
| event.isTrusted = trusted; | ||
| if (event._initialized === false || event._dispatching) utils.InvalidStateError(); | ||
|
|
||
| if (!event._immediatePropagationStopped && typeof event.stopImmediatePropagation === 'function') { |
There was a problem hiding this comment.
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:
| if (!event._immediatePropagationStopped && typeof event.stopImmediatePropagation === 'function') { | |
| if (!('_immediatePropagationStopped' in event) && typeof event.stopImmediatePropagation === 'function') { |
8d58090 to
4319e7e
Compare
Description
Modern Node.js (v18+) provides native standard implementations of
Event,CustomEvent, andEventTarget.When
@angular/platform-serverimports Domino and copiesdomino.implontoglobalThis, Domino's internalEventandCustomEventconstructors overwrite Node's native globals. This causes issues for libraries extending Node's nativeEventTarget(such asp-queuev10), because Node's nativeEventTarget.prototype.dispatchEvent()enforces an internal brand check requiring the event to be an instance of Node's nativeEvent, throwing:Furthermore, Domino's
EventTarget.prototype._dispatchEventpreviously rejected nativeEventinstances because it requiredevent._initialized === trueand directly assigned to getter-only properties (target,currentTarget,isTrusted,eventPhase).Changes
globalThis.Event/CustomEvent: Updatedlib/events.jsto defer toglobalThisevent constructors when available in the environment.lib/EventTarget.jsto:event._initialized === false.Object.definePropertyto assigntarget,currentTarget,isTrusted, andeventPhaseon events with read-only prototype getters.cancelBubbleandstopImmediatePropagation()on native events.createEventcompatibility: Fallback to passing an empty type to constructor ifnew constructor()throws due to argument length.Fixes angular/angular#71977