diff --git a/goldens/public-api/core/primitives/event-dispatch/index.api.md b/goldens/public-api/core/primitives/event-dispatch/index.api.md index 74e85ae0055..4eb33b1915b 100644 --- a/goldens/public-api/core/primitives/event-dispatch/index.api.md +++ b/goldens/public-api/core/primitives/event-dispatch/index.api.md @@ -7,15 +7,6 @@ // @public 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, { actionResolver, eventReplayer, }?: { - actionResolver?: ActionResolver; - eventReplayer?: Replayer; - }); - dispatch(eventInfo: EventInfo): void; -} - // @public (undocumented) export interface EarlyJsactionDataContainer { // (undocumented) @@ -31,13 +22,13 @@ export class EventContract implements UnrenamedEventContract { addEvent(eventType: string, prefixedEventType?: string): void; cleanUp(): void; // (undocumented) - ecaacs?: (updateEventInfoForA11yClick: typeof a11yClick.updateEventInfoForA11yClick, preventDefaultForA11yClick: typeof a11yClick.preventDefaultForA11yClick, populateClickOnlyAction: typeof a11yClick.populateClickOnlyAction) => void; - ecrd(dispatcher: Dispatcher_2, restriction: Restriction): void; + ecaacs?: (updateEventInfoForA11yClick: typeof a11yClickLib.updateEventInfoForA11yClick, preventDefaultForA11yClick: typeof a11yClickLib.preventDefaultForA11yClick, populateClickOnlyAction: typeof a11yClickLib.populateClickOnlyAction) => void; + ecrd(dispatcher: Dispatcher, restriction: Restriction): void; exportAddA11yClickSupport(): void; handler(eventType: string): EventHandler | undefined; // (undocumented) static MOUSE_SPECIAL_SUPPORT: boolean; - registerDispatcher(dispatcher: Dispatcher_2, restriction: Restriction): void; + registerDispatcher(dispatcher: Dispatcher, restriction: Restriction): void; replayEarlyEvents(earlyJsactionContainer?: EarlyJsactionDataContainer): void; } @@ -57,6 +48,12 @@ export type EventContractTracker = { }; }; +// @public +export class EventDispatcher { + constructor(dispatchDelegate: (event: Event, actionName: string) => void); + dispatch(eventInfo: EventInfo): void; +} + // @public export class EventInfoWrapper { constructor(eventInfo: EventInfo); @@ -101,6 +98,11 @@ export class EventInfoWrapper { setTimestamp(timestamp: number): void; } +// @public +export const EventPhase: { + REPLAY: number; +}; + // @public export const isCaptureEvent: (eventType: string) => boolean; @@ -108,7 +110,7 @@ export const isCaptureEvent: (eventType: string) => boolean; export const isSupportedEvent: (eventType: string) => boolean; // @public -export function registerDispatcher(eventContract: UnrenamedEventContract, dispatcher: Dispatcher): void; +export function registerDispatcher(eventContract: UnrenamedEventContract, dispatcher: EventDispatcher): void; // (No @packageDocumentation comment for this package) diff --git a/packages/core/primitives/event-dispatch/index.ts b/packages/core/primitives/event-dispatch/index.ts index ad3ccf04d83..c6f7cbfe11a 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 {Dispatcher, registerDispatcher} from './src/dispatcher'; +export {EventDispatcher, EventPhase, registerDispatcher} from './src/event_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/dispatcher.ts b/packages/core/primitives/event-dispatch/src/dispatcher.ts index 755a3db2f37..f01eac2f626 100644 --- a/packages/core/primitives/event-dispatch/src/dispatcher.ts +++ b/packages/core/primitives/event-dispatch/src/dispatcher.ts @@ -29,7 +29,7 @@ export class Dispatcher { private actionResolver?: ActionResolver; /** The replayer function to be called when there are queued events. */ - private eventReplayer: Replayer; + private eventReplayer?: Replayer; /** Whether the event replay is scheduled. */ private eventReplayScheduled = false; @@ -48,7 +48,7 @@ export class Dispatcher { private readonly dispatchDelegate: (eventInfoWrapper: EventInfoWrapper) => void, { actionResolver, - eventReplayer = createEventReplayer(dispatchDelegate), + eventReplayer, }: {actionResolver?: ActionResolver; eventReplayer?: Replayer} = {}, ) { this.actionResolver = actionResolver; @@ -83,7 +83,7 @@ export class Dispatcher { if (action && shouldPreventDefaultBeforeDispatching(action.element, eventInfoWrapper)) { eventLib.preventDefault(eventInfoWrapper.getEvent()); } - if (eventInfoWrapper.getIsReplay()) { + if (this.eventReplayer && eventInfoWrapper.getIsReplay()) { this.scheduleEventInfoWrapperReplay(eventInfoWrapper); return; } @@ -103,7 +103,7 @@ export class Dispatcher { this.eventReplayScheduled = true; Promise.resolve().then(() => { this.eventReplayScheduled = false; - this.eventReplayer(this.replayEventInfoWrappers); + this.eventReplayer!(this.replayEventInfoWrappers); }); } } diff --git a/packages/core/primitives/event-dispatch/src/event_dispatcher.ts b/packages/core/primitives/event-dispatch/src/event_dispatcher.ts index 75e1a5cbaa9..96951c11d6e 100644 --- a/packages/core/primitives/event-dispatch/src/event_dispatcher.ts +++ b/packages/core/primitives/event-dispatch/src/event_dispatcher.ts @@ -25,18 +25,18 @@ export const EventPhase = { REPLAY: 101, }; -const PREVENT_DEFAULT_ERROR_MESSAGE_DETAILS = ngDevMode - ? ' Because event replay occurs after browser dispatch, `preventDefault` would have no ' + - 'effect. You can check whether an event is being replayed by accessing the event phase: ' + - '`event.eventPhase === EventPhase.REPLAY`.' - : ''; -const PREVENT_DEFAULT_ERROR_MESSAGE = `\`preventDefault\` called during event replay.${PREVENT_DEFAULT_ERROR_MESSAGE_DETAILS}`; -const COMPOSED_PATH_ERROR_MESSAGE_DETAILS = ngDevMode - ? ' Because event replay occurs after browser ' + - 'dispatch, `composedPath()` will be empty. Iterate parent nodes from `event.target` or ' + - '`event.currentTarget` if you need to check elements in the event path.' - : ''; -const COMPOSED_PATH_ERROR_MESSAGE = `\`composedPath\` called during event replay.${COMPOSED_PATH_ERROR_MESSAGE_DETAILS}`; +const PREVENT_DEFAULT_ERROR_MESSAGE_DETAILS = + ' Because event replay occurs after browser dispatch, `preventDefault` would have no ' + + 'effect. You can check whether an event is being replayed by accessing the event phase: ' + + '`event.eventPhase === EventPhase.REPLAY`.'; +const PREVENT_DEFAULT_ERROR_MESSAGE = `\`preventDefault\` called during event replay.`; +const COMPOSED_PATH_ERROR_MESSAGE_DETAILS = () => + ngDevMode + ? ' Because event replay occurs after browser ' + + 'dispatch, `composedPath()` will be empty. Iterate parent nodes from `event.target` or ' + + '`event.currentTarget` if you need to check elements in the event path.' + : ''; +const COMPOSED_PATH_ERROR_MESSAGE = `\`composedPath\` called during event replay.`; declare global { interface Event { @@ -61,12 +61,6 @@ export class EventDispatcher { }, { actionResolver: this.actionResolver, - eventReplayer: (eventInfoWrappers: EventInfoWrapper[]) => { - for (const eventInfoWrapper of eventInfoWrappers) { - prepareEventForReplay(eventInfoWrapper); - this.dispatchToDelegate(eventInfoWrapper); - } - }, }, ); } @@ -80,6 +74,9 @@ export class EventDispatcher { /** Internal method that does basic disaptching. */ private dispatchToDelegate(eventInfoWrapper: EventInfoWrapper) { + if (eventInfoWrapper.getIsReplay()) { + prepareEventForReplay(eventInfoWrapper); + } prepareEventForBubbling(eventInfoWrapper); while (eventInfoWrapper.getAction()) { prepareEventForDispatch(eventInfoWrapper); @@ -112,10 +109,14 @@ function prepareEventForReplay(eventInfoWrapper: EventInfoWrapper) { patchEventInstance(event, 'target', target); patchEventInstance(event, 'eventPhase', EventPhase.REPLAY); patchEventInstance(event, 'preventDefault', () => { - throw new Error(PREVENT_DEFAULT_ERROR_MESSAGE); + throw new Error( + PREVENT_DEFAULT_ERROR_MESSAGE + (ngDevMode ? PREVENT_DEFAULT_ERROR_MESSAGE_DETAILS : ''), + ); }); patchEventInstance(event, 'composedPath', () => { - throw new Error(COMPOSED_PATH_ERROR_MESSAGE); + throw new Error( + COMPOSED_PATH_ERROR_MESSAGE + (ngDevMode ? COMPOSED_PATH_ERROR_MESSAGE_DETAILS : ''), + ); }); } diff --git a/packages/core/src/hydration/event_replay.ts b/packages/core/src/hydration/event_replay.ts index 82dbdcd14cc..d7032949524 100644 --- a/packages/core/src/hydration/event_replay.ts +++ b/packages/core/src/hydration/event_replay.ts @@ -7,11 +7,10 @@ */ import { - Dispatcher, + EventDispatcher, EarlyJsactionDataContainer, EventContract, EventContractContainer, - EventInfoWrapper, registerDispatcher, isSupportedEvent, isCaptureEvent, @@ -36,6 +35,9 @@ export const CONTRACT_PROPERTY = 'ngContracts'; declare global { var ngContracts: {[key: string]: EarlyJsactionDataContainer}; + interface Element { + __jsaction_fns: Map | undefined; + } } // TODO: Upstream this back into event-dispatch. @@ -46,10 +48,9 @@ function getJsactionData(container: EarlyJsactionDataContainer) { const JSACTION_ATTRIBUTE = 'jsaction'; /** - * Associates a DOM element with `jsaction` attribute to a map that contains info about all event - * types (event names) and corresponding listeners. + * A set of DOM elements with `jsaction` attributes. */ -const jsactionMap: Map> = new Map(); +const jsactionSet = new Set(); /** * Returns a set of providers required to setup support for event replay. @@ -69,10 +70,11 @@ export function withEventReplay(): Provider[] { const el = rEl as unknown as Element; // We don't immediately remove the attribute here because // we need it for replay that happens after hydration. - if (!jsactionMap.has(el)) { - jsactionMap.set(el, new Map()); + if (!jsactionSet.has(el)) { + jsactionSet.add(el); + el.__jsaction_fns = new Map(); } - const eventMap = jsactionMap.get(el)!; + const eventMap = el.__jsaction_fns!; if (!eventMap.has(eventName)) { eventMap.set(eventName, []); } @@ -110,18 +112,11 @@ export function withEventReplay(): Provider[] { eventContract.addEvent(et); } eventContract.replayEarlyEvents(container); - const dispatcher = new Dispatcher(() => {}, { - eventReplayer: (queue) => { - for (const event of queue) { - handleEvent(event); - } - jsactionMap.clear(); - queue.length = 0; - }, - }); + const dispatcher = new EventDispatcher(handleEvent); registerDispatcher(eventContract, dispatcher); - for (const el of jsactionMap.keys()) { + for (const el of jsactionSet) { el.removeAttribute(JSACTION_ATTRIBUTE); + el.__jsaction_fns = undefined; } // After hydration, we shouldn't need to do anymore work related to // event replay anymore. @@ -201,13 +196,12 @@ export function setJSActionAttribute( } } -function handleEvent(event: EventInfoWrapper) { - const nativeElement = event.getAction()!.element as Element; - const handlerFns = jsactionMap.get(nativeElement)?.get(event.getEventType()); +function handleEvent(event: Event) { + const handlerFns = (event.currentTarget as Element)?.__jsaction_fns?.get(event.type); if (!handlerFns) { return; } for (const handler of handlerFns) { - handler(event.getEvent()); + handler(event); } } diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index bd683f578ce..1340ac93f9d 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -1409,6 +1409,9 @@ { "name": "init_event_dispatch" }, + { + "name": "init_event_dispatcher" + }, { "name": "init_event_emitter" }, diff --git a/packages/platform-server/test/BUILD.bazel b/packages/platform-server/test/BUILD.bazel index de54f839f28..010228b96f5 100644 --- a/packages/platform-server/test/BUILD.bazel +++ b/packages/platform-server/test/BUILD.bazel @@ -13,11 +13,28 @@ circular_dependency_test( deps = ["//packages/platform-server/testing"], ) +ts_library( + name = "dom_utils", + srcs = ["dom_utils.ts"], + deps = [ + "//packages/common", + "//packages/core", + "//packages/platform-browser", + ], +) + ts_library( name = "test_lib", testonly = True, - srcs = glob(["*.ts"]), + srcs = glob( + ["*.ts"], + exclude = [ + "event_replay_spec.ts", + "dom_utils.ts", + ], + ), deps = [ + ":dom_utils", "//packages:types", "//packages/animations", "//packages/common", @@ -26,7 +43,6 @@ ts_library( "//packages/common/testing", "//packages/compiler", "//packages/core", - "//packages/core/primitives/event-dispatch", "//packages/core/testing", "//packages/localize", "//packages/localize/init", @@ -38,11 +54,35 @@ ts_library( ], ) +ts_library( + name = "event_replay_test_lib", + testonly = True, + srcs = ["event_replay_spec.ts"], + deps = [ + ":dom_utils", + "//packages/common", + "//packages/core", + "//packages/core/primitives/event-dispatch", + "//packages/core/testing", + "//packages/platform-browser", + "//packages/platform-server", + "//packages/private/testing", + ], +) + jasmine_node_test( name = "test", bootstrap = ["//tools/testing:node"], - data = ["//packages/core/primitives/event-dispatch:contract_bundle_min"], deps = [ ":test_lib", ], ) + +jasmine_node_test( + name = "event_replay_test", + bootstrap = ["//tools/testing:node"], + data = ["//packages/core/primitives/event-dispatch:contract_bundle_min"], + deps = [ + ":event_replay_test_lib", + ], +) diff --git a/packages/platform-server/test/event_replay_spec.ts b/packages/platform-server/test/event_replay_spec.ts index 1954b2358ff..cdccb34a7fe 100644 --- a/packages/platform-server/test/event_replay_spec.ts +++ b/packages/platform-server/test/event_replay_spec.ts @@ -17,6 +17,7 @@ import { import {provideServerRendering} from '../public_api'; import {EVENT_DISPATCH_SCRIPT_ID, renderApplication} from '../src/utils'; +import {EventPhase} from '@angular/core/primitives/event-dispatch'; import {getAppContents, hydrate, render as renderHtml, resetTViewsFor} from './dom_utils'; @@ -59,7 +60,7 @@ describe('event replay', () => { }); afterEach(() => { - doc.body.textContent = ''; + doc.body.outerHTML = ''; }); /** @@ -172,7 +173,6 @@ describe('event replay', () => { const appRef = await hydrate(doc, AppComponent, { hydrationFeatures: [withEventReplay()], }); - appRef.tick(); expect(outerOnClickSpy).toHaveBeenCalledBefore(innerOnClickSpy); }); @@ -212,7 +212,6 @@ describe('event replay', () => { const appRef = await hydrate(doc, SimpleComponent, { hydrationFeatures: [withEventReplay()], }); - appRef.tick(); expect(clickSpy).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); }); @@ -242,7 +241,6 @@ describe('event replay', () => { const appRef = await hydrate(doc, SimpleComponent, { hydrationFeatures: [withEventReplay()], }); - appRef.tick(); expect(el.hasAttribute('jsaction')).toBeFalse(); expect((el.firstChild as Element).hasAttribute('jsaction')).toBeFalse(); }); @@ -293,9 +291,7 @@ describe('event replay', () => { const appRef = await hydrate(doc, SimpleComponent, { hydrationFeatures: [withEventReplay()], }); - appRef.tick(); - // This is a bug - expect(onClickSpy).toHaveBeenCalledTimes(1); + expect(onClickSpy).toHaveBeenCalledTimes(2); onClickSpy.calls.reset(); bottomEl.click(); expect(onClickSpy).toHaveBeenCalledTimes(2); @@ -327,7 +323,6 @@ describe('event replay', () => { const appRef = await hydrate(doc, SimpleComponent, { hydrationFeatures: [withEventReplay()], }); - appRef.tick(); expect(onClickSpy).toHaveBeenCalledTimes(1); onClickSpy.calls.reset(); bottomEl.click(); @@ -336,6 +331,8 @@ describe('event replay', () => { it('should not have differences in event fields', async () => { let currentEvent!: Event; + let latestTarget: EventTarget | null = null; + let latestCurrentTarget: EventTarget | null = null; @Component({ standalone: true, selector: 'app', @@ -348,6 +345,8 @@ describe('event replay', () => { class SimpleComponent { onClick(event: Event) { currentEvent = event; + latestTarget = event.target; + latestCurrentTarget = event.currentTarget; } } const docContents = `${EVENT_DISPATCH_SCRIPT}`; @@ -357,19 +356,17 @@ describe('event replay', () => { resetTViewsFor(SimpleComponent); const bottomEl = doc.getElementById('bottom')!; bottomEl.click(); - const appRef = await hydrate(doc, SimpleComponent, { + await hydrate(doc, SimpleComponent, { hydrationFeatures: [withEventReplay()], }); - appRef.tick(); const replayedEvent = currentEvent; + expect(replayedEvent.target).not.toBeNull(); + expect(replayedEvent.currentTarget).not.toBeNull(); + expect(replayedEvent.eventPhase).toBe(EventPhase.REPLAY); bottomEl.click(); - appRef.tick(); const normalEvent = currentEvent; - expect(replayedEvent).not.toBe(normalEvent); - expect(replayedEvent.target).toBe(normalEvent.target); - expect(replayedEvent.currentTarget).toBe(normalEvent.currentTarget); - expect(replayedEvent.composedPath).toBe(normalEvent.composedPath); - expect(replayedEvent.eventPhase).toBe(normalEvent.eventPhase); + expect(replayedEvent.target).toBe(latestTarget); + expect(replayedEvent.currentTarget).toBe(latestCurrentTarget); }); });