From 0cb50317e154bc9bf89a789da951c481a3d2fdc7 Mon Sep 17 00:00:00 2001 From: Tom Wilkinson Date: Tue, 7 May 2024 17:15:27 -0500 Subject: [PATCH] refactor(core): Rename `BaseDispatcher` to `Dispatcher`. (#55721) Rename `BaseDispatcher` to `Dispatcher` and `Dispatcher` to `LegacyDispatcher`. The `GlobalHandler` type and `stopPropagation` function needs to be left for now in dispatcher.ts as it was not exported previously from legacy_dispatcher.ts. PR Close #55721 --- .../core/primitives/event-dispatch/index.md | 16 +- .../core/primitives/event-dispatch/README.md | 42 ++- .../core/primitives/event-dispatch/index.ts | 2 +- .../event-dispatch/src/base_dispatcher.ts | 118 ------- .../event-dispatch/src/dispatcher.ts | 269 +++------------ .../event-dispatch/src/legacy_dispatcher.ts | 323 +++++++++++++++++- .../event-dispatch/test/dispatcher_test.ts | 28 +- .../event-dispatch/test/eventcontract_test.ts | 2 +- packages/core/src/hydration/event_replay.ts | 4 +- .../bundling/defer/bundle.golden_symbols.json | 6 +- 10 files changed, 415 insertions(+), 395 deletions(-) delete mode 100644 packages/core/primitives/event-dispatch/src/base_dispatcher.ts diff --git a/goldens/public-api/core/primitives/event-dispatch/index.md b/goldens/public-api/core/primitives/event-dispatch/index.md index 02622990132..80730a3fecb 100644 --- a/goldens/public-api/core/primitives/event-dispatch/index.md +++ b/goldens/public-api/core/primitives/event-dispatch/index.md @@ -5,18 +5,16 @@ ```ts // @public -export class BaseDispatcher { +export function bootstrapEarlyEventContract(field: string, container: HTMLElement, appId: string, eventTypes?: string[], captureEventTypes?: string[], earlyJsactionTracker?: EventContractTracker): void; + +// @public +export class Dispatcher { constructor(dispatchDelegate: (eventInfoWrapper: EventInfoWrapper) => void, { eventReplayer }?: { eventReplayer?: Replayer; }); dispatch(eventInfo: EventInfo): void; - queueEventInfoWrapper(eventInfoWrapper: EventInfoWrapper): void; - scheduleEventReplay(): void; } -// @public -export function bootstrapEarlyEventContract(field: string, container: HTMLElement, appId: string, eventTypes?: string[], captureEventTypes?: string[], earlyJsactionTracker?: EventContractTracker): void; - // @public (undocumented) export interface EarlyJsactionDataContainer { // (undocumented) @@ -33,12 +31,12 @@ export class EventContract implements UnrenamedEventContract { cleanUp(): void; // (undocumented) ecaacs?: (updateEventInfoForA11yClick: typeof a11yClickLib.updateEventInfoForA11yClick, preventDefaultForA11yClick: typeof a11yClickLib.preventDefaultForA11yClick, populateClickOnlyAction: typeof a11yClickLib.populateClickOnlyAction) => void; - ecrd(dispatcher: Dispatcher, restriction: Restriction): void; + ecrd(dispatcher: Dispatcher_2, restriction: Restriction): void; exportAddA11yClickSupport(): void; handler(eventType: string): EventHandler | undefined; // (undocumented) static MOUSE_SPECIAL_SUPPORT: boolean; - registerDispatcher(dispatcher: Dispatcher, restriction: Restriction): void; + registerDispatcher(dispatcher: Dispatcher_2, restriction: Restriction): void; replayEarlyEvents(earlyJsactionContainer?: EarlyJsactionDataContainer): void; } @@ -99,7 +97,7 @@ export class EventInfoWrapper { } // @public -export function registerDispatcher(eventContract: UnrenamedEventContract, dispatcher: BaseDispatcher): void; +export function registerDispatcher(eventContract: UnrenamedEventContract, dispatcher: Dispatcher): void; // (No @packageDocumentation comment for this package) diff --git a/packages/core/primitives/event-dispatch/README.md b/packages/core/primitives/event-dispatch/README.md index 6c5113fc642..631ed6c1d30 100644 --- a/packages/core/primitives/event-dispatch/README.md +++ b/packages/core/primitives/event-dispatch/README.md @@ -20,7 +20,7 @@ This can introduce a couple problems: 1. Server rendered applications will silently ignore user events that happen before the app hydrates and registers handlers - + ```html @@ -45,14 +45,14 @@ This can introduce a couple problems: 2. Applications must eagerly load any possible handler that could be needed to handle user interactions, even if that handler is never invoked or even rendered on the page - + ```html // This button is rarely clicked, but the code to show the dialog must be // loaded for every user - + // Non-admins will never see this button, and yet they still have to load // this handler. @if (isAdmin) { @@ -61,7 +61,7 @@ This can introduce a couple problems: } ``` - + It's possible to write these handlers so that they will late-load their inner logic, but that's a manual, opt-in solution. @@ -114,13 +114,13 @@ valid-char = character - invalid-name-chars invalid-name-chars = ":" | ";" | "." ``` - - Omitting the event type and colon will default the binding to the `click` - event (e.g.`jsaction="handleClick;hover:handleHover"`) - - Both the event type and handler name can be the empty string (but make sure - to keep the colon: `jsaction="change:;"`) - - The `event-handler` is an arbitrary string that can store metadata needed to - find the handler that handles the event. The user of JSAction can choose to - define the semantics of the handler string however they like. +- Omitting the event type and colon will default the binding to the `click` + event (e.g.`jsaction="handleClick;hover:handleHover"`) +- Both the event type and handler name can be the empty string (but make sure + to keep the colon: `jsaction="change:;"`) +- The `event-handler` is an arbitrary string that can store metadata needed to + find the handler that handles the event. The user of JSAction can choose to + define the semantics of the handler string however they like. #### Example @@ -200,10 +200,8 @@ Finally, once your application is bootstrapped and ready to handle events, you'll need to create a `Dispatcher` and register it with the `EventContract` that has been queueing events. - - ```javascript -import {BaseDispatcher as Dispatcher, registerDispatcher} from '@angular/core/primitives/event-dispatch/src/base_dispatcher'; +import {Dispatcher, registerDispatcher} from '@angular/core/primitives/event-dispatch'; function handleEvent(eventInfoWrapper) { // eventInfoWrapper contains all the information about the event @@ -246,16 +244,16 @@ some tradeoffs to doing this: Pros of cleaning up event contract: - - Native handlers avoid the [quirks](#known-caveats) of JSAction dispatching +- Native handlers avoid the [quirks](#known-caveats) of JSAction dispatching Pros of keeping event contract: - - JSAction's event delegation drastically reduces the number of event - listeners registered with the browser. In extreme cases, registering - thousands of listeners in your app can be noticably slow. - - There may be slight behavior differences when your event is dispatched via - JSAction vs native event listeners. Always using JSAction dispatch keeps - things consistent. +- JSAction's event delegation drastically reduces the number of event + listeners registered with the browser. In extreme cases, registering + thousands of listeners in your app can be noticably slow. +- There may be slight behavior differences when your event is dispatched via + JSAction vs native event listeners. Always using JSAction dispatch keeps + things consistent. @@ -269,4 +267,4 @@ Because JSAction may potentially replay queued events some time after the events originally fired, certain APIs like `e.preventDefault()` or `e.stopPropagation()` won't function correctly. - \ No newline at end of file + diff --git a/packages/core/primitives/event-dispatch/index.ts b/packages/core/primitives/event-dispatch/index.ts index e7a4969eaa3..b45e75036b6 100644 --- a/packages/core/primitives/event-dispatch/index.ts +++ b/packages/core/primitives/event-dispatch/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -export {BaseDispatcher, registerDispatcher} from './src/base_dispatcher'; +export {Dispatcher, registerDispatcher} from './src/dispatcher'; export {EventContractContainer} from './src/event_contract_container'; export type {EarlyJsactionDataContainer} from './src/earlyeventcontract'; export {EventContract} from './src/eventcontract'; diff --git a/packages/core/primitives/event-dispatch/src/base_dispatcher.ts b/packages/core/primitives/event-dispatch/src/base_dispatcher.ts deleted file mode 100644 index ad7a326870e..00000000000 --- a/packages/core/primitives/event-dispatch/src/base_dispatcher.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {EventInfo, EventInfoWrapper} from './event_info'; -import {UnrenamedEventContract} from './eventcontract'; -import {Restriction} from './restriction'; -/** - * A replayer is a function that is called when there are queued events, - * either from the `EventContract` or when there are no detected handlers. - */ -export type Replayer = (eventInfoWrappers: EventInfoWrapper[]) => void; -/** - * A handler is dispatched to during normal handling. - */ -export type EventInfoWrapperHandler = (eventInfoWrapper: EventInfoWrapper) => void; -/** - * Receives a DOM event, determines the jsaction associated with the source - * element of the DOM event, and invokes the handler associated with the - * jsaction. - */ -export class BaseDispatcher { - /** The queue of events. */ - private readonly queuedEventInfoWrappers: EventInfoWrapper[] = []; - /** The replayer function to be called when there are queued events. */ - private eventReplayer?: Replayer; - /** Whether the event replay is scheduled. */ - private eventReplayScheduled = false; - - /** - * Options are: - * 1. `eventReplayer`: When the event contract dispatches replay events - * to the Dispatcher, the Dispatcher collects them and in the next tick - * dispatches them to the `eventReplayer`. - * @param dispatchDelegate A function that should handle dispatching an `EventInfoWrapper` to handlers. - */ - constructor( - private readonly dispatchDelegate: (eventInfoWrapper: EventInfoWrapper) => void, - {eventReplayer = undefined}: {eventReplayer?: Replayer} = {}, - ) { - this.eventReplayer = eventReplayer; - } - - /** - * Receives an event or the event queue from the EventContract. The event - * queue is copied and it attempts to replay. - * If event info is passed in it looks for an action handler that can handle - * the given event. If there is no handler registered queues the event and - * checks if a loader is registered for the given namespace. If so, calls it. - * - * Alternatively, if in global dispatch mode, calls all registered global - * handlers for the appropriate event type. - * - * The three functionalities of this call are deliberately not split into - * three methods (and then declared as an abstract interface), because the - * interface is used by EventContract, which lives in a different jsbinary. - * Therefore the interface between the three is defined entirely in terms that - * are invariant under jscompiler processing (Function and Array, as opposed - * to a custom type with method names). - * - * @param eventInfo The info for the event that triggered this call or the - * queue of events from EventContract. - */ - dispatch(eventInfo: EventInfo): void { - const eventInfoWrapper = new EventInfoWrapper(eventInfo); - if (eventInfoWrapper.getIsReplay()) { - if (!this.eventReplayer) { - return; - } - this.queueEventInfoWrapper(eventInfoWrapper); - this.scheduleEventReplay(); - return; - } - this.dispatchDelegate(eventInfoWrapper); - } - - /** Queue an `EventInfoWrapper` for replay. */ - queueEventInfoWrapper(eventInfoWrapper: EventInfoWrapper) { - this.queuedEventInfoWrappers.push(eventInfoWrapper); - } - - /** - * Replays queued events, if any. The replaying will happen in its own - * stack once the current flow cedes control. This is done to mimic - * browser event handling. - */ - scheduleEventReplay() { - if ( - this.eventReplayScheduled || - !this.eventReplayer || - this.queuedEventInfoWrappers.length === 0 - ) { - return; - } - this.eventReplayScheduled = true; - Promise.resolve().then(() => { - this.eventReplayScheduled = false; - this.eventReplayer!(this.queuedEventInfoWrappers); - }); - } -} - -/** - * Registers deferred functionality for an EventContract and a Jsaction - * Dispatcher. - */ -export function registerDispatcher( - eventContract: UnrenamedEventContract, - dispatcher: BaseDispatcher, -) { - eventContract.ecrd((eventInfo: EventInfo) => { - dispatcher.dispatch(eventInfo); - }, Restriction.I_AM_THE_JSACTION_FRAMEWORK); -} diff --git a/packages/core/primitives/event-dispatch/src/dispatcher.ts b/packages/core/primitives/event-dispatch/src/dispatcher.ts index bd4c6c23296..d15efccc3e7 100644 --- a/packages/core/primitives/event-dispatch/src/dispatcher.ts +++ b/packages/core/primitives/event-dispatch/src/dispatcher.ts @@ -6,15 +6,22 @@ * found in the LICENSE file at https://angular.io/license */ -import {BaseDispatcher, EventInfoWrapperHandler} from './base_dispatcher'; -import {Char} from './char'; -import * as eventLib from './event'; import {EventInfo, EventInfoWrapper} from './event_info'; import {EventType} from './event_type'; -import {UnrenamedEventContract} from './eventcontract'; import {Restriction} from './restriction'; +import {UnrenamedEventContract} from './eventcontract'; +import * as eventLib from './event'; -export type {EventInfoWrapperHandler as EventInfoHandler} from './base_dispatcher'; +/** + * A replayer is a function that is called when there are queued events, + * either from the `EventContract` or when there are no detected handlers. + */ +export type Replayer = (eventInfoWrappers: EventInfoWrapper[]) => void; + +/** + * A handler is dispatched to during normal handling. + */ +export type EventInfoHandler = (eventInfoWrapper: EventInfoWrapper) => void; /** * A global handler is dispatched to before normal handler dispatch. Returning @@ -22,65 +29,31 @@ export type {EventInfoWrapperHandler as EventInfoHandler} from './base_dispatche */ export type GlobalHandler = (event: Event) => boolean | void; -/** - * A replayer is a function that is called when there are queued events, - * either from the `EventContract` or when there are no detected handlers. - */ -export type Replayer = (eventInfoWrappers: EventInfoWrapper[], dispatcher: Dispatcher) => void; - /** * Receives a DOM event, determines the jsaction associated with the source * element of the DOM event, and invokes the handler associated with the * jsaction. */ export class Dispatcher { - private readonly baseDispatcher: BaseDispatcher; - - /** Whether to stop propagation for an `EventInfo`. */ - private readonly stopPropagation: boolean; - - /** - * The actions that are registered for this Dispatcher instance. - * This should be the primary one used once migration off of registerHandlers - * is done. - */ - private readonly actions: {[key: string]: EventInfoWrapperHandler} = {}; - - /** A map of global event handlers, where each key is an event type. */ - private readonly globalHandlers = new Map>(); - - /** The event replayer. */ + /** The queue of events. */ + private readonly replayEventInfoWrappers: EventInfoWrapper[] = []; + /** The replayer function to be called when there are queued events. */ private eventReplayer?: Replayer; + /** Whether the event replay is scheduled. */ + private eventReplayScheduled = false; /** - * Receives a DOM event, determines the jsaction associated with the source - * element of the DOM event, and invokes the handler associated with the - * jsaction. - * - * @param getHandler A function that knows how to get the handler for a - * given event info. + * Options are: + * 1. `eventReplayer`: When the event contract dispatches replay events + * to the Dispatcher, the Dispatcher collects them and in the next tick + * dispatches them to the `eventReplayer`. + * @param dispatchDelegate A function that should handle dispatching an `EventInfoWrapper` to handlers. */ constructor( - private readonly getHandler?: ( - eventInfoWrapper: EventInfoWrapper, - ) => EventInfoWrapperHandler | void, - { - stopPropagation = false, - eventReplayer = undefined, - }: {stopPropagation?: boolean; eventReplayer?: Replayer} = {}, + private readonly dispatchDelegate: (eventInfoWrapper: EventInfoWrapper) => void, + {eventReplayer = undefined}: {eventReplayer?: Replayer} = {}, ) { this.eventReplayer = eventReplayer; - this.baseDispatcher = new BaseDispatcher( - (eventInfoWrapper: EventInfoWrapper) => { - this.dispatchToHandler(eventInfoWrapper); - }, - { - eventReplayer: (eventInfoWrappers) => { - this.eventReplayer?.(eventInfoWrappers, this); - }, - }, - ); - this.stopPropagation = stopPropagation; } /** @@ -103,185 +76,33 @@ export class Dispatcher { * @param eventInfo The info for the event that triggered this call or the * queue of events from EventContract. */ - dispatch(eventInfo: EventInfo, isGlobalDispatch?: boolean): void { - this.baseDispatcher.dispatch(eventInfo); - } - - /** - * Dispatches an `EventInfoWrapper`. - */ - private dispatchToHandler(eventInfoWrapper: EventInfoWrapper) { - if (this.globalHandlers.size) { - const globalEventInfoWrapper = eventInfoWrapper.clone(); - - // In some cases, `populateAction` will rewrite `click` events to - // `clickonly`. Revert back to a regular click, otherwise we won't be able - // to execute global event handlers registered on click events. - if (globalEventInfoWrapper.getEventType() === EventType.CLICKONLY) { - globalEventInfoWrapper.setEventType(EventType.CLICK); + dispatch(eventInfo: EventInfo): void { + const eventInfoWrapper = new EventInfoWrapper(eventInfo); + if (eventInfoWrapper.getIsReplay()) { + if (!this.eventReplayer) { + return; } - // Skip everything related to jsaction handlers, and execute the global - // handlers. - const event = globalEventInfoWrapper.getEvent(); - const eventTypeHandlers = this.globalHandlers.get(globalEventInfoWrapper.getEventType()); - let shouldPreventDefault = false; - if (eventTypeHandlers) { - for (const handler of eventTypeHandlers) { - if (handler(event) === false) { - shouldPreventDefault = true; - } - } - } - if (shouldPreventDefault) { - eventLib.preventDefault(event); - } - } - - const action = eventInfoWrapper.getAction(); - if (!action) { + this.scheduleEventInfoWrapperReplay(eventInfoWrapper); return; } + this.dispatchDelegate(eventInfoWrapper); + } - if (this.stopPropagation) { - stopPropagation(eventInfoWrapper); - } - - let handler: EventInfoWrapperHandler | void = undefined; - if (this.getHandler) { - handler = this.getHandler(eventInfoWrapper); - } - - if (!handler) { - handler = this.actions[action.name]; - } - - if (handler) { - handler(eventInfoWrapper); + /** + * Schedules an `EventInfoWrapper` for replay. The replaying will happen in its own + * stack once the current flow cedes control. This is done to mimic + * browser event handling. + */ + private scheduleEventInfoWrapperReplay(eventInfoWrapper: EventInfoWrapper) { + this.replayEventInfoWrappers.push(eventInfoWrapper); + if (this.eventReplayScheduled || !this.eventReplayer) { return; } - - // No handler was found. - this.baseDispatcher.queueEventInfoWrapper(eventInfoWrapper); - } - - /** - * Registers multiple methods all bound to the same object - * instance. This is a common case: an application module binds - * multiple of its methods under public names to the event contract of - * the application. So we provide a shortcut for it. - * Attempts to replay the queued events after registering the handlers. - * - * @param namespace The namespace of the jsaction name. - * - * @param instance The object to bind the methods to. If this is null, then - * the functions are not bound, but directly added under the public names. - * - * @param methods A map from public name to functions that will be bound to - * instance and registered as action under the public name. I.e. the - * property names are the public names. The property values are the - * methods of instance. - */ - registerEventInfoHandlers( - namespace: string, - instance: T | null, - methods: {[key: string]: EventInfoWrapperHandler}, - ) { - for (const [name, method] of Object.entries(methods)) { - const handler = instance ? method.bind(instance) : method; - if (namespace) { - // Include a '.' separator between namespace name and action name. - // In the case that no namespace name is provided, the jsaction name - // consists of the action name only (no period). - const fullName = namespace + Char.NAMESPACE_ACTION_SEPARATOR + name; - this.actions[fullName] = handler; - } else { - this.actions[name] = handler; - } - } - - this.baseDispatcher.scheduleEventReplay(); - } - - /** - * Unregisters an action. Provided as an easy way to reverse the effects of - * registerHandlers. - * @param namespace The namespace of the jsaction name. - * @param name The action name to unbind. - */ - unregisterHandler(namespace: string, name: string) { - const fullName = namespace ? namespace + Char.NAMESPACE_ACTION_SEPARATOR + name : name; - delete this.actions[fullName]; - } - - /** Registers a global event handler. */ - registerGlobalHandler(eventType: string, handler: GlobalHandler) { - if (!this.globalHandlers.has(eventType)) { - this.globalHandlers.set(eventType, new Set([handler])); - } else { - this.globalHandlers.get(eventType)!.add(handler); - } - } - - /** Unregisters a global event handler. */ - unregisterGlobalHandler(eventType: string, handler: GlobalHandler) { - if (this.globalHandlers.has(eventType)) { - this.globalHandlers.get(eventType)!.delete(handler); - } - } - - /** - * Checks whether there is an action registered under the given - * name. This returns true if there is a namespace handler, even - * if it can not yet handle the event. - * - * @param name Action name. - * @return Whether the name is registered. - * @see #canDispatch - */ - hasAction(name: string): boolean { - return this.actions.hasOwnProperty(name); - } - - /** - * Whether this dispatcher can dispatch the event. This can be used by - * event replayer to check whether the dispatcher can replay an event. - */ - canDispatch(eventInfoWrapper: EventInfoWrapper): boolean { - const action = eventInfoWrapper.getAction(); - if (!action) { - return false; - } - return this.hasAction(action.name); - } - - /** - * Sets the event replayer, enabling queued events to be replayed when actions - * are bound. To replay events, you must register the dispatcher to the - * contract after setting the `EventReplayer`. The event replayer takes as - * parameters the queue of events and the dispatcher (used to check whether - * actions have handlers registered and can be replayed). The event replayer - * is also responsible for dequeuing events. - * - * Example: An event replayer that replays only the last event. - * - * const dispatcher = new Dispatcher(); - * // ... - * dispatcher.setEventReplayer((queue, dispatcher) => { - * const lastEventInfoWrapper = queue[queue.length -1]; - * if (dispatcher.canDispatch(lastEventInfoWrapper.getAction())) { - * jsaction.replay.replayEvent( - * lastEventInfoWrapper.getEvent(), - * lastEventInfoWrapper.getTargetElement(), - * lastEventInfoWrapper.getEventType(), - * ); - * queue.length = 0; - * } - * }); - * - * @param eventReplayer It allows elements to be replayed and dequeuing. - */ - setEventReplayer(eventReplayer: Replayer) { - this.eventReplayer = eventReplayer; + this.eventReplayScheduled = true; + Promise.resolve().then(() => { + this.eventReplayScheduled = false; + this.eventReplayer!(this.replayEventInfoWrappers); + }); } } diff --git a/packages/core/primitives/event-dispatch/src/legacy_dispatcher.ts b/packages/core/primitives/event-dispatch/src/legacy_dispatcher.ts index 968cd44c377..ae7ea3d6738 100644 --- a/packages/core/primitives/event-dispatch/src/legacy_dispatcher.ts +++ b/packages/core/primitives/event-dispatch/src/legacy_dispatcher.ts @@ -6,4 +6,325 @@ * found in the LICENSE file at https://angular.io/license */ -export {Dispatcher as LegacyDispatcher, registerDispatcher} from './dispatcher'; +import { + Dispatcher, + EventInfoHandler as EventInfoWrapperHandler, + GlobalHandler, + stopPropagation, +} from './dispatcher'; +import {Char} from './char'; +import * as eventLib from './event'; +import {EventInfo, EventInfoWrapper} from './event_info'; +import {EventType} from './event_type'; +import {UnrenamedEventContract} from './eventcontract'; +import {Restriction} from './restriction'; + +/** Re-exports that should eventually be moved into this file. */ +export type { + EventInfoHandler, + EventInfoHandler as EventInfoWrapperHandler, + GlobalHandler, +} from './dispatcher'; +export {stopPropagation} from './dispatcher'; + +/** + * A replayer is a function that is called when there are queued events, + * either from the `EventContract` or when there are no detected handlers. + */ +export type Replayer = ( + eventInfoWrappers: EventInfoWrapper[], + dispatcher: LegacyDispatcher, +) => void; + +/** + * Receives a DOM event, determines the jsaction associated with the source + * element of the DOM event, and invokes the handler associated with the + * jsaction. + */ +export class LegacyDispatcher { + private readonly dispatcher: Dispatcher; + + /** Whether to stop propagation for an `EventInfo`. */ + private readonly stopPropagation: boolean; + + /** + * The actions that are registered for this Dispatcher instance. + * This should be the primary one used once migration off of registerHandlers + * is done. + */ + private readonly actions: {[key: string]: EventInfoWrapperHandler} = {}; + + /** A map of global event handlers, where each key is an event type. */ + private readonly globalHandlers = new Map>(); + + /** The event replayer. */ + private eventReplayer?: Replayer; + + /** The event infos that have be replayed. */ + private eventInfoWrapperQueue?: EventInfoWrapper[]; + + /** Whether event replay is scheduled. */ + private eventReplayScheduled: boolean = false; + + /** + * Receives a DOM event, determines the jsaction associated with the source + * element of the DOM event, and invokes the handler associated with the + * jsaction. + * + * @param getHandler A function that knows how to get the handler for a + * given event info. + */ + constructor( + private readonly getHandler?: ( + eventInfoWrapper: EventInfoWrapper, + ) => EventInfoWrapperHandler | void, + { + stopPropagation = false, + eventReplayer = undefined, + }: {stopPropagation?: boolean; eventReplayer?: Replayer} = {}, + ) { + this.eventReplayer = eventReplayer; + this.dispatcher = new Dispatcher( + (eventInfoWrapper: EventInfoWrapper) => { + this.dispatchToHandler(eventInfoWrapper); + }, + { + eventReplayer: (eventInfoWrappers) => { + this.eventInfoWrapperQueue = eventInfoWrappers; + this.eventReplayer?.(this.eventInfoWrapperQueue, this); + }, + }, + ); + this.stopPropagation = stopPropagation; + } + + /** + * Receives an event or the event queue from the EventContract. The event + * queue is copied and it attempts to replay. + * If event info is passed in it looks for an action handler that can handle + * the given event. If there is no handler registered queues the event and + * checks if a loader is registered for the given namespace. If so, calls it. + * + * Alternatively, if in global dispatch mode, calls all registered global + * handlers for the appropriate event type. + * + * The three functionalities of this call are deliberately not split into + * three methods (and then declared as an abstract interface), because the + * interface is used by EventContract, which lives in a different jsbinary. + * Therefore the interface between the three is defined entirely in terms that + * are invariant under jscompiler processing (Function and Array, as opposed + * to a custom type with method names). + * + * @param eventInfo The info for the event that triggered this call or the + * queue of events from EventContract. + */ + dispatch(eventInfo: EventInfo, isGlobalDispatch?: boolean): void { + this.dispatcher.dispatch(eventInfo); + } + + /** + * Dispatches an `EventInfoWrapper`. + */ + private dispatchToHandler(eventInfoWrapper: EventInfoWrapper) { + if (this.globalHandlers.size) { + const globalEventInfoWrapper = eventInfoWrapper.clone(); + + // In some cases, `populateAction` will rewrite `click` events to + // `clickonly`. Revert back to a regular click, otherwise we won't be able + // to execute global event handlers registered on click events. + if (globalEventInfoWrapper.getEventType() === EventType.CLICKONLY) { + globalEventInfoWrapper.setEventType(EventType.CLICK); + } + // Skip everything related to jsaction handlers, and execute the global + // handlers. + const event = globalEventInfoWrapper.getEvent(); + const eventTypeHandlers = this.globalHandlers.get(globalEventInfoWrapper.getEventType()); + let shouldPreventDefault = false; + if (eventTypeHandlers) { + for (const handler of eventTypeHandlers) { + if (handler(event) === false) { + shouldPreventDefault = true; + } + } + } + if (shouldPreventDefault) { + eventLib.preventDefault(event); + } + } + + const action = eventInfoWrapper.getAction(); + if (!action) { + return; + } + + if (this.stopPropagation) { + stopPropagation(eventInfoWrapper); + } + + let handler: EventInfoWrapperHandler | void = undefined; + if (this.getHandler) { + handler = this.getHandler(eventInfoWrapper); + } + + if (!handler) { + handler = this.actions[action.name]; + } + + if (handler) { + handler(eventInfoWrapper); + return; + } + + // No handler was found. + this.eventInfoWrapperQueue?.push(eventInfoWrapper); + } + + /** + * Registers multiple methods all bound to the same object + * instance. This is a common case: an application module binds + * multiple of its methods under public names to the event contract of + * the application. So we provide a shortcut for it. + * Attempts to replay the queued events after registering the handlers. + * + * @param namespace The namespace of the jsaction name. + * + * @param instance The object to bind the methods to. If this is null, then + * the functions are not bound, but directly added under the public names. + * + * @param methods A map from public name to functions that will be bound to + * instance and registered as action under the public name. I.e. the + * property names are the public names. The property values are the + * methods of instance. + */ + registerEventInfoHandlers( + namespace: string, + instance: T | null, + methods: {[key: string]: EventInfoWrapperHandler}, + ) { + for (const [name, method] of Object.entries(methods)) { + const handler = instance ? method.bind(instance) : method; + if (namespace) { + // Include a '.' separator between namespace name and action name. + // In the case that no namespace name is provided, the jsaction name + // consists of the action name only (no period). + const fullName = namespace + Char.NAMESPACE_ACTION_SEPARATOR + name; + this.actions[fullName] = handler; + } else { + this.actions[name] = handler; + } + } + + this.scheduleEventReplay(); + } + + /** + * Unregisters an action. Provided as an easy way to reverse the effects of + * registerHandlers. + * @param namespace The namespace of the jsaction name. + * @param name The action name to unbind. + */ + unregisterHandler(namespace: string, name: string) { + const fullName = namespace ? namespace + Char.NAMESPACE_ACTION_SEPARATOR + name : name; + delete this.actions[fullName]; + } + + /** Registers a global event handler. */ + registerGlobalHandler(eventType: string, handler: GlobalHandler) { + if (!this.globalHandlers.has(eventType)) { + this.globalHandlers.set(eventType, new Set([handler])); + } else { + this.globalHandlers.get(eventType)!.add(handler); + } + } + + /** Unregisters a global event handler. */ + unregisterGlobalHandler(eventType: string, handler: GlobalHandler) { + if (this.globalHandlers.has(eventType)) { + this.globalHandlers.get(eventType)!.delete(handler); + } + } + + /** + * Checks whether there is an action registered under the given + * name. This returns true if there is a namespace handler, even + * if it can not yet handle the event. + * + * @param name Action name. + * @return Whether the name is registered. + * @see #canDispatch + */ + hasAction(name: string): boolean { + return this.actions.hasOwnProperty(name); + } + + /** + * Whether this dispatcher can dispatch the event. This can be used by + * event replayer to check whether the dispatcher can replay an event. + */ + canDispatch(eventInfoWrapper: EventInfoWrapper): boolean { + const action = eventInfoWrapper.getAction(); + if (!action) { + return false; + } + return this.hasAction(action.name); + } + + /** + * Sets the event replayer, enabling queued events to be replayed when actions + * are bound. To replay events, you must register the dispatcher to the + * contract after setting the `EventReplayer`. The event replayer takes as + * parameters the queue of events and the dispatcher (used to check whether + * actions have handlers registered and can be replayed). The event replayer + * is also responsible for dequeuing events. + * + * Example: An event replayer that replays only the last event. + * + * const dispatcher = new Dispatcher(); + * // ... + * dispatcher.setEventReplayer((queue, dispatcher) => { + * const lastEventInfoWrapper = queue[queue.length -1]; + * if (dispatcher.canDispatch(lastEventInfoWrapper.getAction())) { + * jsaction.replay.replayEvent( + * lastEventInfoWrapper.getEvent(), + * lastEventInfoWrapper.getTargetElement(), + * lastEventInfoWrapper.getEventType(), + * ); + * queue.length = 0; + * } + * }); + * + * @param eventReplayer It allows elements to be replayed and dequeuing. + */ + setEventReplayer(eventReplayer: Replayer) { + this.eventReplayer = eventReplayer; + } + + /** + * Replays queued events, if any. The replaying will happen in its own + * stack once the current flow cedes control. This is done to mimic + * browser event handling. + */ + private scheduleEventReplay() { + if (this.eventReplayScheduled || !this.eventReplayer || !this.eventInfoWrapperQueue?.length) { + return; + } + this.eventReplayScheduled = true; + Promise.resolve().then(() => { + this.eventReplayScheduled = false; + this.eventReplayer!(this.eventInfoWrapperQueue!, this); + }); + } +} + +/** + * Registers deferred functionality for an EventContract and a Jsaction + * Dispatcher. + */ +export function registerDispatcher( + eventContract: UnrenamedEventContract, + dispatcher: LegacyDispatcher, +) { + eventContract.ecrd((eventInfo: EventInfo) => { + dispatcher.dispatch(eventInfo); + }, Restriction.I_AM_THE_JSACTION_FRAMEWORK); +} diff --git a/packages/core/primitives/event-dispatch/test/dispatcher_test.ts b/packages/core/primitives/event-dispatch/test/dispatcher_test.ts index 2d20f591ef7..035179c265f 100644 --- a/packages/core/primitives/event-dispatch/test/dispatcher_test.ts +++ b/packages/core/primitives/event-dispatch/test/dispatcher_test.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Dispatcher, Replayer} from '../src/dispatcher'; +import {LegacyDispatcher, Replayer} from '../src/legacy_dispatcher'; import { ActionInfo, createEventInfo, @@ -63,7 +63,7 @@ describe('dispatcher test.ts', () => { eventInfo = event; }; - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); const actions = {'bar': actionHandler}; dispatcher.registerEventInfoHandlers('foo', null, actions); @@ -84,7 +84,7 @@ describe('dispatcher test.ts', () => { const getEventInfoHandler = () => eventInfoHandler1; const eventInfoHandler2 = jasmine.createSpy('eventInfoHandler2'); - const dispatcher = new Dispatcher(/* getHandler= */ getEventInfoHandler); + const dispatcher = new LegacyDispatcher(/* getHandler= */ getEventInfoHandler); const eventInfoHandlers = {'bar': eventInfoHandler2}; dispatcher.registerEventInfoHandlers('bar', null, eventInfoHandlers); @@ -98,7 +98,7 @@ describe('dispatcher test.ts', () => { }); it('registered EventInfo handlers are found with hasAction', () => { - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); dispatcher.registerEventInfoHandlers('', null, { 'foo': () => {}, @@ -110,7 +110,7 @@ describe('dispatcher test.ts', () => { }); it('EventInfo handlers can be unregistered', () => { - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); dispatcher.registerEventInfoHandlers('prefix', null, { 'clickaction': () => {}, @@ -133,7 +133,7 @@ describe('dispatcher test.ts', () => { } it('global event dispatch is not replayed', async () => { - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); const eventReplayer = jasmine.createSpy('eventReplayer'); dispatcher.setEventReplayer(eventReplayer); dispatcher.registerEventInfoHandlers('foo', null, {'bar': () => {}}); @@ -151,7 +151,7 @@ describe('dispatcher test.ts', () => { function expectEventReplayerToHaveBeenCalledWith( eventReplayer: jasmine.Spy, expectedEventInfos: EventInfo[], - expectedDispatcher: Dispatcher, + expectedDispatcher: LegacyDispatcher, ) { const args = eventReplayer.calls.mostRecent().args; expect(args.length).toBe(2); @@ -163,7 +163,7 @@ describe('dispatcher test.ts', () => { } it('events are collected and replayed', async () => { - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); const eventReplayer = jasmine.createSpy('eventReplayer'); dispatcher.setEventReplayer(eventReplayer); dispatcher.registerEventInfoHandlers('foo', null, {'bar': () => {}}); @@ -181,7 +181,7 @@ describe('dispatcher test.ts', () => { }); it('events are replayed when handlers are registered', async () => { - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); const eventReplayer = jasmine.createSpy('eventReplayer'); dispatcher.setEventReplayer(eventReplayer); let replayed = waitForEventReplayer(eventReplayer); @@ -202,7 +202,7 @@ describe('dispatcher test.ts', () => { it('dispatches to registered global EventInfo handler', () => { const handler = jasmine.createSpy('handler'); - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); dispatcher.registerGlobalHandler('click', handler); const eventInfo = createTestEventInfo(); @@ -214,7 +214,7 @@ describe('dispatcher test.ts', () => { it('does not dispatch to non-matching registered global EventInfo handler', () => { const handler = jasmine.createSpy('handler'); - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); dispatcher.registerGlobalHandler('click', handler); const eventInfo = createTestEventInfo({ @@ -232,7 +232,7 @@ describe('dispatcher test.ts', () => { window.document.body.appendChild(container); const targetElement = document.createElement('div'); container.appendChild(targetElement); - const dispatcher = new Dispatcher(); + const dispatcher = new LegacyDispatcher(); const targetHandler = jasmine.createSpy('targetHandler'); targetHandler.and.callFake((event) => { @@ -262,7 +262,7 @@ describe('dispatcher test.ts', () => { window.document.body.appendChild(container); const targetElement = document.createElement('div'); container.appendChild(targetElement); - const dispatcher = new Dispatcher(undefined, {stopPropagation: true}); + const dispatcher = new LegacyDispatcher(undefined, {stopPropagation: true}); const targetHandler = jasmine.createSpy('targetHandler'); targetHandler.and.callFake((event) => { @@ -292,7 +292,7 @@ describe('dispatcher test.ts', () => { window.document.body.appendChild(container); const targetElement = document.createElement('div'); container.appendChild(targetElement); - const dispatcher = new Dispatcher(undefined, {stopPropagation: true}); + const dispatcher = new LegacyDispatcher(undefined, {stopPropagation: true}); const targetHandler = jasmine.createSpy('targetHandler'); targetHandler.and.callFake((event) => { diff --git a/packages/core/primitives/event-dispatch/test/eventcontract_test.ts b/packages/core/primitives/event-dispatch/test/eventcontract_test.ts index 1b6eb4cce68..1569bc1c3a5 100644 --- a/packages/core/primitives/event-dispatch/test/eventcontract_test.ts +++ b/packages/core/primitives/event-dispatch/test/eventcontract_test.ts @@ -7,7 +7,7 @@ */ import * as cache from '../src/cache'; -import {stopPropagation} from '../src/dispatcher'; +import {stopPropagation} from '../src/legacy_dispatcher'; import { EarlyEventContract, EarlyJsactionData, diff --git a/packages/core/src/hydration/event_replay.ts b/packages/core/src/hydration/event_replay.ts index 8705a0e6acd..b916f6881fe 100644 --- a/packages/core/src/hydration/event_replay.ts +++ b/packages/core/src/hydration/event_replay.ts @@ -7,7 +7,7 @@ */ import { - BaseDispatcher, + Dispatcher, EarlyJsactionDataContainer, EventContract, EventContractContainer, @@ -96,7 +96,7 @@ export function withEventReplay(): Provider[] { eventContract.addEvent(et); } eventContract.replayEarlyEvents(container); - const dispatcher = new BaseDispatcher(() => {}, { + const dispatcher = new Dispatcher(() => {}, { eventReplayer: (queue) => { for (const event of queue) { handleEvent(event); diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 85ebcafb9de..19b5995d1d8 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -1115,9 +1115,6 @@ { "name": "init_authoring" }, - { - "name": "init_base_dispatcher" - }, { "name": "init_bindings" }, @@ -1325,6 +1322,9 @@ { "name": "init_discovery_utils" }, + { + "name": "init_dispatcher" + }, { "name": "init_document" },