Skip to content

Commit d06a3e7

Browse files
asyncLizcopybara-github
authored andcommitted
chore: add event hooks for default prevention behavior
PiperOrigin-RevId: 592375327
1 parent eca1357 commit d06a3e7

File tree

2 files changed

+356
-0
lines changed

2 files changed

+356
-0
lines changed

internal/events/dispatch-hooks.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* A symbol used to access dispatch hooks on an event.
9+
*/
10+
const dispatchHooks = Symbol('dispatchHooks');
11+
12+
/**
13+
* An `Event` with additional symbols for dispatch hooks.
14+
*/
15+
interface EventWithDispatchHooks extends Event {
16+
[dispatchHooks]: EventTarget;
17+
}
18+
19+
/**
20+
* Add a hook for an event that is called after the event is dispatched and
21+
* propagates to other event listeners.
22+
*
23+
* This is useful for behaviors that need to check if an event is canceled.
24+
*
25+
* The callback is invoked synchronously, which allows for better integration
26+
* with synchronous platform APIs (like `<form>` or `<label>` clicking).
27+
*
28+
* Note: `setupDispatchHooks()` must be called on the element before adding any
29+
* other event listeners. Call it in the constructor of an element or
30+
* controller.
31+
*
32+
* @example
33+
* ```ts
34+
* class MyControl extends LitElement {
35+
* constructor() {
36+
* super();
37+
* setupDispatchHooks(this, 'click');
38+
* this.addEventListener('click', event => {
39+
* afterDispatch(event, () => {
40+
* if (event.defaultPrevented) {
41+
* return
42+
* }
43+
*
44+
* // ... perform logic
45+
* });
46+
* });
47+
* }
48+
* }
49+
* ```
50+
*
51+
* @example
52+
* ```ts
53+
* class MyController implements ReactiveController {
54+
* constructor(host: ReactiveElement) {
55+
* // setupDispatchHooks() may be called multiple times for the same
56+
* // element and events, making it safe for multiple controllers to use it.
57+
* setupDispatchHooks(host, 'click');
58+
* host.addEventListener('click', event => {
59+
* afterDispatch(event, () => {
60+
* if (event.defaultPrevented) {
61+
* return;
62+
* }
63+
*
64+
* // ... perform logic
65+
* });
66+
* });
67+
* }
68+
* }
69+
* ```
70+
*
71+
* @param event The event to add a hook to.
72+
* @param callback A hook that is called after the event finishes dispatching.
73+
*/
74+
export function afterDispatch(event: Event, callback: () => void) {
75+
const hooks = (event as EventWithDispatchHooks)[dispatchHooks];
76+
if (!hooks) {
77+
throw new Error(`'${event.type}' event needs setupDispatchHooks().`);
78+
}
79+
80+
hooks.addEventListener('after', callback);
81+
}
82+
83+
/**
84+
* A lookup map of elements and event types that have a dispatch hook listener
85+
* set up. Used to ensure we don't set up multiple hook listeners on the same
86+
* element for the same event.
87+
*/
88+
const ELEMENT_DISPATCH_HOOK_TYPES = new WeakMap<Element, Set<string>>();
89+
90+
/**
91+
* Sets up an element to add dispatch hooks to given event types. This must be
92+
* called before adding any event listeners that need to use dispatch hooks
93+
* like `afterDispatch()`.
94+
*
95+
* This function is safe to call multiple times with the same element or event
96+
* types. Call it in the constructor of elements, mixins, and controllers to
97+
* ensure it is set up before external listeners.
98+
*
99+
* @example
100+
* ```ts
101+
* class MyControl extends LitElement {
102+
* constructor() {
103+
* super();
104+
* setupDispatchHooks(this, 'click');
105+
* this.addEventListener('click', this.listenerUsingAfterDispatch);
106+
* }
107+
* }
108+
* ```
109+
*
110+
* @param element The element to set up event dispatch hooks for.
111+
* @param eventTypes The event types to add dispatch hooks to.
112+
*/
113+
export function setupDispatchHooks(
114+
element: Element,
115+
...eventTypes: [string, ...string[]]
116+
) {
117+
let typesAlreadySetUp = ELEMENT_DISPATCH_HOOK_TYPES.get(element);
118+
if (!typesAlreadySetUp) {
119+
typesAlreadySetUp = new Set();
120+
ELEMENT_DISPATCH_HOOK_TYPES.set(element, typesAlreadySetUp);
121+
}
122+
123+
for (const eventType of eventTypes) {
124+
// Don't register multiple dispatch hook listeners. A second registration
125+
// would lead to the second listener re-dispatching a re-dispatched event,
126+
// which can cause an infinite loop inside the other one.
127+
if (typesAlreadySetUp.has(eventType)) {
128+
continue;
129+
}
130+
131+
// When we re-dispatch the event, it's going to immediately trigger this
132+
// listener again. Use a flag to ignore it.
133+
let isRedispatching = false;
134+
element.addEventListener(
135+
eventType,
136+
(event: Event) => {
137+
if (isRedispatching) {
138+
return;
139+
}
140+
141+
// Do not let the event propagate to any other listener (not just
142+
// bubbling listeners with `stopPropagation()`).
143+
event.stopImmediatePropagation();
144+
// Make a copy.
145+
const eventCopy = Reflect.construct(event.constructor, [
146+
event.type,
147+
event,
148+
]);
149+
150+
// Add hooks onto the event.
151+
const hooks = new EventTarget();
152+
(eventCopy as EventWithDispatchHooks)[dispatchHooks] = hooks;
153+
154+
// Re-dispatch the event. We can't reuse `redispatchEvent()` since we
155+
// need to add the hooks to the copy before it's dispatched.
156+
isRedispatching = true;
157+
const dispatched = element.dispatchEvent(eventCopy);
158+
isRedispatching = false;
159+
if (!dispatched) {
160+
event.preventDefault();
161+
}
162+
163+
// Synchronously call afterDispatch() hooks.
164+
hooks.dispatchEvent(new Event('after'));
165+
},
166+
{
167+
// Ensure this listener runs before other listeners.
168+
// `setupDispatchHooks()` should be called in constructors to also
169+
// ensure they run before any other externally-added capture listeners.
170+
capture: true,
171+
},
172+
);
173+
174+
typesAlreadySetUp.add(eventType);
175+
}
176+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// import 'jasmine'; (google3-only)
8+
9+
import {afterDispatch, setupDispatchHooks} from './dispatch-hooks.js';
10+
11+
describe('dispatch hooks', () => {
12+
let element: HTMLDivElement;
13+
14+
beforeEach(() => {
15+
element = document.createElement('div');
16+
document.body.appendChild(element);
17+
});
18+
19+
afterEach(() => {
20+
document.body.removeChild(element);
21+
});
22+
23+
describe('setupDispatchHooks()', () => {
24+
it('does not add more than one setup listener for an event type', () => {
25+
spyOn(element, 'addEventListener').and.callThrough();
26+
setupDispatchHooks(element, 'foo');
27+
setupDispatchHooks(element, 'foo');
28+
29+
expect(element.addEventListener)
30+
.withContext('element.addEventListener')
31+
.toHaveBeenCalledTimes(1);
32+
});
33+
34+
it('can add setup listeners for multiple event types', () => {
35+
spyOn(element, 'addEventListener').and.callThrough();
36+
37+
setupDispatchHooks(element, 'foo', 'bar', 'baz');
38+
expect(element.addEventListener)
39+
.withContext('element.addEventListener')
40+
.toHaveBeenCalledTimes(3);
41+
});
42+
});
43+
44+
describe('afterDispatch()', () => {
45+
it('resolves synchronously after the event is finished dispatching', () => {
46+
setupDispatchHooks(element, 'click');
47+
48+
const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback');
49+
const clickListener = jasmine
50+
.createSpy('clickListener')
51+
.and.callFake((event: Event) => {
52+
afterDispatch(event, afterDispatchCallback);
53+
});
54+
55+
element.addEventListener('click', clickListener);
56+
element.click();
57+
58+
expect(clickListener)
59+
.withContext('clickListener')
60+
.toHaveBeenCalledTimes(1);
61+
expect(afterDispatchCallback)
62+
.withContext('afterDispatch() callback')
63+
.toHaveBeenCalledTimes(1);
64+
});
65+
66+
it('supports multiple afterDispatch listeners', () => {
67+
setupDispatchHooks(element, 'click');
68+
69+
const firstAfterDispatchCallback = jasmine.createSpy(
70+
'firstAfterDispatchCallback',
71+
);
72+
element.addEventListener('click', (event) => {
73+
afterDispatch(event, firstAfterDispatchCallback);
74+
});
75+
76+
const secondAfterDispatchCallback = jasmine.createSpy(
77+
'secondAfterDispatchCallback',
78+
);
79+
element.addEventListener('click', (event) => {
80+
afterDispatch(event, secondAfterDispatchCallback);
81+
});
82+
83+
element.click();
84+
85+
expect(firstAfterDispatchCallback)
86+
.withContext('afterDispatch() first callback')
87+
.toHaveBeenCalledTimes(1);
88+
expect(secondAfterDispatchCallback)
89+
.withContext('afterDispatch() second callback')
90+
.toHaveBeenCalledTimes(1);
91+
});
92+
93+
it('resolves synchronously after the event is finished dispatching', () => {
94+
setupDispatchHooks(element, 'click');
95+
96+
const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback');
97+
const clickListener = jasmine
98+
.createSpy('clickListener')
99+
.and.callFake((event: Event) => {
100+
afterDispatch(event, afterDispatchCallback);
101+
});
102+
103+
element.addEventListener('click', clickListener);
104+
element.click();
105+
106+
expect(clickListener)
107+
.withContext('clickListener')
108+
.toHaveBeenCalledTimes(1);
109+
expect(afterDispatchCallback)
110+
.withContext('afterDispatch() callback')
111+
.toHaveBeenCalledTimes(1);
112+
});
113+
114+
it('can be used to synchronously detect if event was canceled', () => {
115+
setupDispatchHooks(element, 'click');
116+
117+
// element listener
118+
let eventDefaultPreventedInAfterDispatch: boolean | null = null;
119+
element.addEventListener('click', (event) => {
120+
afterDispatch(event, () => {
121+
eventDefaultPreventedInAfterDispatch = event.defaultPrevented;
122+
});
123+
});
124+
125+
// client listener
126+
element.addEventListener('click', (event) => {
127+
event.preventDefault();
128+
});
129+
130+
element.click();
131+
132+
expect(eventDefaultPreventedInAfterDispatch)
133+
.withContext('event.defaultPrevented() in afterDispatch() callback')
134+
.toBeTrue();
135+
});
136+
137+
it('throws if setupDispatchHooks() was not called for the event type', () => {
138+
// Do not set up hooks
139+
let errorThrown: unknown;
140+
element.addEventListener('click', (event) => {
141+
try {
142+
afterDispatch(event, () => {});
143+
} catch (error) {
144+
errorThrown = error;
145+
}
146+
});
147+
148+
element.click();
149+
expect(errorThrown)
150+
.withContext('error thrown calling afterDispatch()')
151+
.toBeInstanceOf(Error);
152+
153+
expect((errorThrown as Error).message)
154+
.withContext('errorThrown.message')
155+
.toMatch('setupDispatchHooks');
156+
});
157+
158+
it('does not fire multiple times if setupDispatchHooks() is called multiple times for the same element', () => {
159+
setupDispatchHooks(element, 'click');
160+
setupDispatchHooks(element, 'click');
161+
162+
const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback');
163+
const clickListener = jasmine
164+
.createSpy('clickListener')
165+
.and.callFake((event: Event) => {
166+
afterDispatch(event, afterDispatchCallback);
167+
});
168+
169+
element.addEventListener('click', clickListener);
170+
element.click();
171+
172+
expect(clickListener)
173+
.withContext('clickListener')
174+
.toHaveBeenCalledTimes(1);
175+
expect(afterDispatchCallback)
176+
.withContext('afterDispatch() callback')
177+
.toHaveBeenCalledTimes(1);
178+
});
179+
});
180+
});

0 commit comments

Comments
 (0)