Skip to content

Commit

Permalink
refactor(core): Use early event contract instead of the event contrac…
Browse files Browse the repository at this point in the history
…t in bootstrap.

This also fixes an existing bug where we erase the jsaction attribute too early.

Now the event contract binary is 608 bytes :D.
  • Loading branch information
iteriani committed May 6, 2024
1 parent a1d9c25 commit 5cac9cc
Show file tree
Hide file tree
Showing 6 changed files with 76 additions and 33 deletions.
4 changes: 2 additions & 2 deletions packages/core/primitives/event-dispatch/contract_binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/

import {bootstrapEventContract} from './src/register_events';
import {bootstrapEarlyEventContract} from './src/register_events';

(window as any)['__jsaction_bootstrap'] = bootstrapEventContract;
(window as any)['__jsaction_bootstrap'] = bootstrapEarlyEventContract;
20 changes: 19 additions & 1 deletion packages/core/primitives/event-dispatch/terser.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,25 @@
},
"mangle": {
"toplevel": true,
"properties": false,
"properties": {
"reserved": [
"ecrd",
"eventType",
"event",
"targetElement",
"eic",
"timeStamp",
"eia",
"eirp",
"eiack",
"et",
"q",
"h",
"c",
"etc",
"_ejsa"
]
},
"keep_classnames": false,
"keep_fnames": false
},
Expand Down
26 changes: 21 additions & 5 deletions packages/core/src/hydration/event_replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

import {
Dispatcher,
EarlyJsactionDataContainer,
EventContract,
EventContractContainer,
EventInfoWrapper,
registerDispatcher,
} from '@angular/core/primitives/event-dispatch';
Expand All @@ -32,10 +34,11 @@ export const EVENT_REPLAY_ENABLED_DEFAULT = false;
export const CONTRACT_PROPERTY = 'ngContracts';

declare global {
var ngContracts: {[key: string]: EventContract};
var ngContracts: {[key: string]: EarlyJsactionDataContainer};
}

const JSACTION_ATTRIBUTE = 'jsaction';
const removeJsactionQueue: RElement[] = [];

/**
* Returns a set of providers required to setup support for event replay.
Expand All @@ -52,7 +55,7 @@ export function withEventReplay(): Provider[] {
useValue: () => {
setDisableEventReplayImpl((el: RElement) => {
if (el.hasAttribute(JSACTION_ATTRIBUTE)) {
el.removeAttribute(JSACTION_ATTRIBUTE);
removeJsactionQueue.push(el);
}
});
},
Expand All @@ -73,12 +76,25 @@ export function withEventReplay(): Provider[] {
// This is set in packages/platform-server/src/utils.ts
// Note: globalThis[CONTRACT_PROPERTY] may be undefined in case Event Replay feature
// is enabled, but there are no events configured in an application.
const eventContract = globalThis[CONTRACT_PROPERTY]?.[appId] as EventContract;
if (eventContract) {
const container = globalThis[CONTRACT_PROPERTY]?.[appId];
if (container._ejsa) {
const eventContract = new EventContract(
new EventContractContainer(container._ejsa.c),
);
for (const et of container._ejsa.et) {
eventContract.addEvent(et);
}
for (const et of container._ejsa.etc) {
eventContract.addEvent(et);
}
eventContract.replayEarlyEvents(container);
const dispatcher = new Dispatcher();
setEventReplayer(dispatcher);
// Event replay is kicked off as a side-effect of executing this function.
registerDispatcher(eventContract, dispatcher);
for (const el of removeJsactionQueue) {
el.removeAttribute(JSACTION_ATTRIBUTE);
}
removeJsactionQueue.length = 0;
}
});
};
Expand Down
17 changes: 16 additions & 1 deletion packages/platform-server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,25 @@ function insertEventRecordScript(
const eventDispatchScript = findEventDispatchScript(doc);
if (eventDispatchScript) {
const events = Array.from(eventTypesToBeReplayed);
const captureEventTypes = [];
const eventTypes = [];
for (const eventType of events) {
if (
eventType === 'focus' ||
eventType === 'blur' ||
eventType === 'error' ||
eventType === 'load' ||
eventType === 'toggle'
) {
captureEventTypes.push(eventType);
} else {
eventTypes.push(eventType);
}
}
// This is defined in packages/core/primitives/event-dispatch/contract_binary.ts
const replayScriptContents = `window.__jsaction_bootstrap('ngContracts', document.body, ${JSON.stringify(
appId,
)}, ${JSON.stringify(events)});`;
)}, ${JSON.stringify(eventTypes)}${captureEventTypes.length ? ',' + JSON.stringify(captureEventTypes) : ''});`;

const replayScript = createScript(doc, replayScriptContents, nonce);

Expand Down
2 changes: 1 addition & 1 deletion packages/platform-server/test/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ circular_dependency_test(
ts_library(
name = "test_lib",
testonly = True,
srcs = glob(["**/*.ts"]),
srcs = glob(["*.ts"]),
deps = [
"//packages:types",
"//packages/animations",
Expand Down
40 changes: 17 additions & 23 deletions packages/platform-server/test/event_replay_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import {DOCUMENT} from '@angular/common';
import {Component, destroyPlatform, getPlatform, Type} from '@angular/core';
import {EventContract} from '@angular/core/primitives/event-dispatch';
import {TestBed} from '@angular/core/testing';
import {bootstrapApplication, provideClientHydration} from '@angular/platform-browser';
import {withEventReplay} from '@angular/platform-browser/src/hydration';
Expand Down Expand Up @@ -72,7 +71,6 @@ describe('event replay', () => {

describe('server rendering', () => {
let doc: Document;
let eventContract: EventContract | undefined = undefined;
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;

Expand All @@ -85,8 +83,6 @@ describe('event replay', () => {
eval(script.textContent);
}
}
eventContract = globalThis.window['ngContracts']['ng'];
expect(eventContract).toBeDefined();
}

beforeAll(async () => {
Expand All @@ -100,51 +96,55 @@ describe('event replay', () => {

afterEach(() => {
doc.body.textContent = '';
eventContract?.cleanUp();
eventContract = undefined;
});
afterAll(() => {
globalThis.window = originalWindow;
globalThis.document = originalDocument;
});
it('should serialize event types to be listened to and jsaction', async () => {
it('should serialize event types to be listened to and jsaction attribute', async () => {
const clickSpy = jasmine.createSpy('onClick');
const blurSpy = jasmine.createSpy('onBlur');
const focusSpy = jasmine.createSpy('onFocus');
@Component({
standalone: true,
selector: 'app',
template: `
<div (click)="onClick()" id="1">
<div (blur)="onClick()" id="2"></div>
<div (click)="onClick()" id="click-element">
<div id="focus-container">
<div id="focus-action-element" (focus)="onFocus()">
<button id="focus-target-element">Focus Button</button>
</div>
</div>
</div>
`,
})
class SimpleComponent {
onClick = clickSpy;
onBlur = blurSpy;
onFocus = focusSpy;
}

const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(
`<script>window.__jsaction_bootstrap('ngContracts', document.body, "ng", ["click","blur"]);</script>`,
);
expect(ssrContents).toContain(
'<div id="1" jsaction="click:"><div id="2" jsaction="blur:"></div></div>',
`<script>window.__jsaction_bootstrap('ngContracts', document.body, "ng", ["click"],["focus"]);</script>`,
);

render(doc, ssrContents);
const el = doc.getElementById('1')!;
const el = doc.getElementById('click-element')!;
const button = doc.getElementById('focus-target-element')!;
const clickEvent = new CustomEvent('click', {bubbles: true});
el.dispatchEvent(clickEvent);
const focusEvent = new CustomEvent('focus');
button.dispatchEvent(focusEvent);
expect(clickSpy).not.toHaveBeenCalled();
expect(focusSpy).not.toHaveBeenCalled();
resetTViewsFor(SimpleComponent);
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
expect(clickSpy).toHaveBeenCalled();
expect(focusSpy).toHaveBeenCalled();
});

it('should remove jsaction attributes, but continue listening to events.', async () => {
Expand All @@ -164,24 +164,18 @@ describe('event replay', () => {
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
const removeEventListenerSpy = spyOn(
document.body,
'removeEventListener',
).and.callThrough();
render(doc, ssrContents);
const el = doc.getElementById('1')!;
expect(el.hasAttribute('jsaction')).toBeTrue();
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue();
resetTViewsFor(SimpleComponent);
expect(removeEventListenerSpy).not.toHaveBeenCalled();
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
expect(el.hasAttribute('jsaction')).toBeFalse();
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeFalse();
// Event contract is still listening even if jsaction attributes are removed.
expect(removeEventListenerSpy).not.toHaveBeenCalled();
appRef.tick();
});

it(`should add 'nonce' attribute to event record script when 'ngCspNonce' is provided`, async () => {
Expand Down

0 comments on commit 5cac9cc

Please sign in to comment.