Skip to content

Commit

Permalink
refactor(core): Use event_dispatcher in event_replay code. (#56036)
Browse files Browse the repository at this point in the history
This makes events bubble! This change also contains changes to
dispatcher and event_dispatcher to make replay synchronous,
so that we avoid odd timing issues. This can be split out though.

Lastly, we have one cleanup change to move the mapping from
event type to functions on the element itself.

PR Close #56036
  • Loading branch information
iteriani authored and pkozlowski-opensource committed May 29, 2024
1 parent 09a7e9d commit 1223122
Show file tree
Hide file tree
Showing 8 changed files with 116 additions and 79 deletions.
28 changes: 15 additions & 13 deletions goldens/public-api/core/primitives/event-dispatch/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,6 @@
// @public
export function bootstrapEarlyEventContract(field: string, container: HTMLElement, appId: string, eventTypes?: string[], captureEventTypes?: string[], earlyJsactionTracker?: EventContractTracker<EarlyJsactionDataContainer>): 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)
Expand All @@ -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;
}

Expand All @@ -57,6 +48,12 @@ export type EventContractTracker<T> = {
};
};

// @public
export class EventDispatcher {
constructor(dispatchDelegate: (event: Event, actionName: string) => void);
dispatch(eventInfo: EventInfo): void;
}

// @public
export class EventInfoWrapper {
constructor(eventInfo: EventInfo);
Expand Down Expand Up @@ -101,14 +98,19 @@ export class EventInfoWrapper {
setTimestamp(timestamp: number): void;
}

// @public
export const EventPhase: {
REPLAY: number;
};

// @public
export const isCaptureEvent: (eventType: string) => boolean;

// @public
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)

Expand Down
2 changes: 1 addition & 1 deletion packages/core/primitives/event-dispatch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 4 additions & 4 deletions packages/core/primitives/event-dispatch/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -103,7 +103,7 @@ export class Dispatcher {
this.eventReplayScheduled = true;
Promise.resolve().then(() => {
this.eventReplayScheduled = false;
this.eventReplayer(this.replayEventInfoWrappers);
this.eventReplayer!(this.replayEventInfoWrappers);
});
}
}
Expand Down
41 changes: 21 additions & 20 deletions packages/core/primitives/event-dispatch/src/event_dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -61,12 +61,6 @@ export class EventDispatcher {
},
{
actionResolver: this.actionResolver,
eventReplayer: (eventInfoWrappers: EventInfoWrapper[]) => {
for (const eventInfoWrapper of eventInfoWrappers) {
prepareEventForReplay(eventInfoWrapper);
this.dispatchToDelegate(eventInfoWrapper);
}
},
},
);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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 : ''),
);
});
}

Expand Down
38 changes: 16 additions & 22 deletions packages/core/src/hydration/event_replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
*/

import {
Dispatcher,
EventDispatcher,
EarlyJsactionDataContainer,
EventContract,
EventContractContainer,
EventInfoWrapper,
registerDispatcher,
isSupportedEvent,
isCaptureEvent,
Expand All @@ -36,6 +35,9 @@ export const CONTRACT_PROPERTY = 'ngContracts';

declare global {
var ngContracts: {[key: string]: EarlyJsactionDataContainer};
interface Element {
__jsaction_fns: Map<string, Function[]> | undefined;
}
}

// TODO: Upstream this back into event-dispatch.
Expand All @@ -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<Element, Map<string, Function[]>> = new Map();
const jsactionSet = new Set<Element>();

/**
* Returns a set of providers required to setup support for event replay.
Expand All @@ -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, []);
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions packages/core/test/bundling/defer/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,9 @@
{
"name": "init_event_dispatch"
},
{
"name": "init_event_dispatcher"
},
{
"name": "init_event_emitter"
},
Expand Down
46 changes: 43 additions & 3 deletions packages/platform-server/test/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
],
)
Loading

0 comments on commit 1223122

Please sign in to comment.