mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
refactor(core): Use event_dispatcher in event_replay code. (#56036)
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
This commit is contained in:
committed by
Pawel Kozlowski
parent
09a7e9d41e
commit
1223122080
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, Function[]> | 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<Element, Map<string, Function[]>> = new Map();
|
||||
const jsactionSet = new Set<Element>();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1409,6 +1409,9 @@
|
||||
{
|
||||
"name": "init_event_dispatch"
|
||||
},
|
||||
{
|
||||
"name": "init_event_dispatcher"
|
||||
},
|
||||
{
|
||||
"name": "init_event_emitter"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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 = '<body></body>';
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -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 = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user