Skip to content

Commit

Permalink
refactor(core): Allow the container and the listenable element to be …
Browse files Browse the repository at this point in the history
…configurable for early event contract. (angular#55586)

This will allow a multi-app application to listen to early events from different elements and place them
on a separate field on the window.

PR Close angular#55586
  • Loading branch information
iteriani authored and AndrewKushnir committed May 6, 2024
1 parent 1872fcd commit 8f273ce
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 35 deletions.
14 changes: 12 additions & 2 deletions goldens/public-api/core/primitives/event-dispatch/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
```ts

// @public
export function bootstrapEventContract(field: string, container: Element, appId: string, events: string[], anyWindow?: any): EventContract;
export function bootstrapEarlyEventContract(field: string, container: HTMLElement, appId: string, eventTypes: string[], captureEventTypes: string[], earlyJsactionTracker?: EventContractTracker<EarlyJsactionDataContainer>): void;

// @public
export function bootstrapEventContract(field: string, container: Element, appId: string, events: string[], earlyJsactionTracker?: EventContractTracker<EventContract>): void;

// @public
export class Dispatcher {
Expand Down Expand Up @@ -45,7 +48,7 @@ export class EventContract implements UnrenamedEventContract {
// (undocumented)
static MOUSE_SPECIAL_SUPPORT: boolean;
registerDispatcher(dispatcher: Dispatcher_2, restriction: Restriction): void;
replayEarlyEvents(): void;
replayEarlyEvents(earlyJsactionContainer?: EarlyJsactionDataContainer): void;
}

// @public
Expand All @@ -57,6 +60,13 @@ export class EventContractContainer implements EventContractContainerManager {
readonly element: Element;
}

// @public (undocumented)
export type EventContractTracker<T> = {
[key: string]: {
[appId: string]: T;
};
};

// @public
export class EventInfoWrapper {
constructor(eventInfo: EventInfo);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/primitives/event-dispatch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
export {Dispatcher, registerDispatcher} from './src/dispatcher';
export {EventContractContainer} from './src/event_contract_container';
export {EventContract} from './src/eventcontract';
export {bootstrapEventContract} from './src/register_events';
export {bootstrapEventContract, bootstrapEarlyEventContract} from './src/register_events';

export type {EventContractTracker} from './src/register_events';
export {EventInfoWrapper} from './src/event_info';
33 changes: 22 additions & 11 deletions packages/core/primitives/event-dispatch/src/earlyeventcontract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@

import {createEventInfoFromParameters, EventInfo} from './event_info';

declare global {
interface Window {
_ejsa?: EarlyJsactionData;
}
export declare interface EarlyJsactionDataContainer {
_ejsa?: EarlyJsactionData;
}

/**
Expand All @@ -21,11 +19,17 @@ export declare interface EarlyJsactionData {
// List used to keep track of the early JSAction event types.
et: string[];

// List used to keep track of capture event types.
etc: string[];

// List used to keep track of the JSAction events if using earlyeventcontract.
q: EventInfo[];

// Early Jsaction handler
h: (event: Event) => void;

// Container for listening to events
c: HTMLElement;
}

/**
Expand All @@ -34,10 +38,15 @@ export declare interface EarlyJsactionData {
* late-loaded EventContract.
*/
export class EarlyEventContract {
constructor() {
window._ejsa = {
constructor(
private readonly replaySink: EarlyJsactionDataContainer = window as EarlyJsactionDataContainer,
private readonly container = window.document.documentElement,
) {
this.replaySink._ejsa = {
c: container,
q: [],
et: [],
etc: [],
h: (event: Event) => {
const eventInfo = createEventInfoFromParameters(
event.type,
Expand All @@ -46,19 +55,21 @@ export class EarlyEventContract {
window.document.documentElement,
Date.now(),
);
window._ejsa!.q.push(eventInfo);
this.replaySink._ejsa!.q.push(eventInfo);
},
};
}

/**
* Installs a list of event types for window.document.documentElement.
* Installs a list of event types for container .
*/
addEvents(types: string[]) {
addEvents(types: string[], capture?: boolean) {
const replaySink = this.replaySink._ejsa!;
for (let idx = 0; idx < types.length; idx++) {
const eventType = types[idx];
window._ejsa!.et.push(eventType);
window.document.documentElement.addEventListener(eventType, window._ejsa!.h);
const eventTypes = capture ? replaySink.etc : replaySink.et;
eventTypes.push(eventType);
this.container.addEventListener(eventType, replaySink.h, capture);
}
}
}
28 changes: 19 additions & 9 deletions packages/core/primitives/event-dispatch/src/eventcontract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

import * as a11yClickLib from './a11y_click';
import {ActionResolver} from './action_resolver';
import {EarlyJsactionData} from './earlyeventcontract';
import {EarlyJsactionData, EarlyJsactionDataContainer} from './earlyeventcontract';
import * as eventLib from './event';
import {EventContractContainerManager} from './event_contract_container';
import {
Expand Down Expand Up @@ -255,10 +255,12 @@ export class EventContract implements UnrenamedEventContract {
* in the provided event contract. Once all the events are replayed, it cleans
* up the early contract.
*/
replayEarlyEvents() {
replayEarlyEvents(
earlyJsactionContainer: EarlyJsactionDataContainer = window as EarlyJsactionDataContainer,
) {
// Check if the early contract is present and prevent calling this function
// more than once.
const earlyJsactionData: EarlyJsactionData | undefined = window._ejsa;
const earlyJsactionData: EarlyJsactionData | undefined = earlyJsactionContainer._ejsa;
if (!earlyJsactionData) {
return;
}
Expand All @@ -278,13 +280,10 @@ export class EventContract implements UnrenamedEventContract {
}

// Clean up the early contract.
const earlyEventTypes: string[] = earlyJsactionData.et;
const earlyEventHandler: (event: Event) => void = earlyJsactionData.h;
for (let idx = 0; idx < earlyEventTypes.length; idx++) {
const eventType: string = earlyEventTypes[idx];
window.document.documentElement.removeEventListener(eventType, earlyEventHandler);
}
delete window._ejsa;
removeEventListeners(earlyJsactionData.c, earlyJsactionData.et, earlyEventHandler);
removeEventListeners(earlyJsactionData.c, earlyJsactionData.etc, earlyEventHandler, true);
delete earlyJsactionContainer._ejsa;
}

/**
Expand Down Expand Up @@ -390,6 +389,17 @@ export class EventContract implements UnrenamedEventContract {
}
}

function removeEventListeners(
container: HTMLElement,
eventTypes: string[],
earlyEventHandler: (e: Event) => void,
capture?: boolean,
) {
for (let idx = 0; idx < eventTypes.length; idx++) {
container.removeEventListener(eventTypes[idx], earlyEventHandler, /* useCapture */ capture);
}
}

/**
* Adds a11y click support to the given `EventContract`. Meant to be called
* in a different compilation unit from the `EventContract`. The `EventContract`
Expand Down
47 changes: 38 additions & 9 deletions packages/core/primitives/event-dispatch/src/register_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,63 @@
* found in the LICENSE file at https://angular.io/license
*/

import {EarlyEventContract, EarlyJsactionDataContainer} from './earlyeventcontract';
import {EventContractContainer} from './event_contract_container';
import {EventContract} from './eventcontract';

export type EventContractTracker<T> = {[key: string]: {[appId: string]: T}};

/**
* Provides a factory function for bootstrapping an event contract on a
* window object.
* @param field The property on the window that the event contract will be placed on.
* specified object (by default, exposed on the `window`).
* @param field The property on the object that the event contract will be placed on.
* @param container The container that listens to events
* @param appId A given identifier for an application. If there are multiple apps on the page
* then this is how contracts can be initialized for each one.
* @param events An array of event names that should be listened to.
* @param anyWindow The global window object that should receive the event contract.
* @returns The `event` contract. This is both assigned to `anyWindow` and returned for testing.
* @param earlyJsactionTracker The object that should receive the event contract.
*/
export function bootstrapEventContract(
field: string,
container: Element,
appId: string,
events: string[],
anyWindow: any = window,
earlyJsactionTracker: EventContractTracker<EventContract> = window as unknown as EventContractTracker<EventContract>,
) {
if (!anyWindow[field]) {
anyWindow[field] = {};
if (!earlyJsactionTracker[field]) {
earlyJsactionTracker[field] = {};
}
const eventContract = new EventContract(new EventContractContainer(container));
anyWindow[field][appId] = eventContract;
earlyJsactionTracker[field][appId] = eventContract;
for (const ev of events) {
eventContract.addEvent(ev);
}
return eventContract;
}

/**
* Provides a factory function for bootstrapping an event contract on a
* specified object (by default, exposed on the `window`).
* @param field The property on the object that the event contract will be placed on.
* @param container The container that listens to events
* @param appId A given identifier for an application. If there are multiple apps on the page
* then this is how contracts can be initialized for each one.
* @param eventTypes An array of event names that should be listened to.
* @param captureEventTypes An array of event names that should be listened to with capture.
* @param earlyJsactionTracker The object that should receive the event contract.
*/
export function bootstrapEarlyEventContract(
field: string,
container: HTMLElement,
appId: string,
eventTypes: string[],
captureEventTypes: string[],
earlyJsactionTracker: EventContractTracker<EarlyJsactionDataContainer> = window as unknown as EventContractTracker<EarlyJsactionDataContainer>,
) {
if (!earlyJsactionTracker[field]) {
earlyJsactionTracker[field] = {};
}
earlyJsactionTracker[field][appId] = {};
const eventContract = new EarlyEventContract(earlyJsactionTracker[field][appId], container);
eventContract.addEvents(eventTypes);
eventContract.addEvents(captureEventTypes, true);
}
58 changes: 55 additions & 3 deletions packages/core/primitives/event-dispatch/test/eventcontract_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
import * as cache from '../src/cache';
import {bootstrapCustomEventSupport, fireCustomEvent} from '../src/custom_events';
import {stopPropagation} from '../src/dispatcher';
import {EarlyEventContract, EarlyJsactionData} from '../src/earlyeventcontract';
import {
EarlyEventContract,
EarlyJsactionData,
EarlyJsactionDataContainer,
} from '../src/earlyeventcontract';
import {
EventContractContainer,
EventContractContainerManager,
Expand All @@ -23,6 +27,10 @@ import {Restriction} from '../src/restriction';

import {safeElement, testonlyHtml} from './html';

declare global {
interface Window extends EarlyJsactionDataContainer {}
}

const domContent = `
<div id="container"></div>
Expand Down Expand Up @@ -182,6 +190,11 @@ const domContent = `
</div>
</div>
</div>
<div id="focus-container">
<div id="focus-action-element" jsaction="focus:handleFocus">
<button id="focus-target-element">Focus Button</button>
</div>
</div>
`;

function getRequiredElementById(id: string) {
Expand Down Expand Up @@ -1920,6 +1933,43 @@ describe('EventContract', () => {
expect(eventInfoWrapper.getAction()?.element).toBe(actionElement);
});

it('early capture events are dispatched', () => {
const container = getRequiredElementById('focus-container');
const actionElement = getRequiredElementById('focus-action-element');
const targetElement = getRequiredElementById('focus-target-element');
const replaySink = {_ejsa: undefined};
const removeEventListenerSpy = spyOn(container, 'removeEventListener').and.callThrough();

const earlyEventContract = new EarlyEventContract(replaySink, container);
earlyEventContract.addEvents(['focus'], true);

targetElement.focus();

const earlyJsactionData: EarlyJsactionData | undefined = replaySink._ejsa;
expect(earlyJsactionData).toBeDefined();
expect(earlyJsactionData!.q.length).toBe(1);
expect(earlyJsactionData!.q[0].event.type).toBe('focus');

const dispatcher = jasmine.createSpy<Dispatcher>('dispatcher');
const eventContract = createEventContract({
eventContractContainerManager: new EventContractContainer(container),
eventTypes: ['focus'],
dispatcher,
});

eventContract.replayEarlyEvents(replaySink);

expect(replaySink._ejsa).toBeUndefined();
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1);
expect(dispatcher).toHaveBeenCalledTimes(2);
const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher);
expect(eventInfoWrapper.getEventType()).toBe('focus');
expect(eventInfoWrapper.getEvent().type).toBe('focus');
expect(eventInfoWrapper.getTargetElement()).toBe(targetElement);
expect(eventInfoWrapper.getAction()?.name).toBe('handleFocus');
expect(eventInfoWrapper.getAction()?.element).toBe(actionElement);
});

it('early events are dispatched when target is cleared', () => {
const container = getRequiredElementById('click-container');
const actionElement = getRequiredElementById('click-action-element');
Expand Down Expand Up @@ -1978,7 +2028,9 @@ describe('EventContract', () => {
relatedTarget: container,
});

const earlyJsactionData: EarlyJsactionData | undefined = window._ejsa;
const earlyJsactionData: EarlyJsactionData | undefined = (
window as EarlyJsactionDataContainer
)._ejsa;
expect(earlyJsactionData).toBeDefined();
expect(earlyJsactionData!.q.length).toBe(1);
expect(earlyJsactionData!.q[0].event).toBe(mouseOutEvent);
Expand All @@ -1992,7 +2044,7 @@ describe('EventContract', () => {

eventContract.replayEarlyEvents();

expect(window._ejsa).toBeUndefined();
expect((window as EarlyJsactionDataContainer)._ejsa).toBeUndefined();
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1);
expect(dispatcher).toHaveBeenCalledTimes(3);
const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher);
Expand Down
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 @@ -1343,6 +1343,9 @@
{
"name": "init_dom_triggers"
},
{
"name": "init_earlyeventcontract"
},
{
"name": "init_effect"
},
Expand Down

0 comments on commit 8f273ce

Please sign in to comment.