mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
d5fd51e956
When `withEventReplay()` is enabled and a component hydrates before the application becomes stable (e.g. while a pending HTTP request is in flight), a user interaction on the hydrated element triggers both the real DOM listener registered by Angular and the jsaction replay path. This causes the event handler to be invoked twice. The root cause is that `listenToDomEvent` registers the same `wrappedListener` both as a stashed jsaction handler (via `stashEventListenerImpl`) and as a native DOM listener (via `renderer.listen`). When the user interacts after hydration but before app stability, jsaction queues the event because no dispatcher is registered yet. Once the app stabilises and `initEventReplay` runs, jsaction replays the queued event through `invokeListeners`, which calls the stashed handler a second time. The fix tracks dispatched `(event, element)` pairs in a `WeakMap<Event, WeakSet<Element>>`. The native DOM listener wrapper records each pair via `markEventHandledForElement`, and `invokeListeners` skips replay for any pair already present. Keying by element (rather than event alone) preserves incremental hydration behaviour, where jsaction legitimately replays the same event on a different element (the deferred block content) from the one that originally triggered hydration. Fixes #67328
897 lines
29 KiB
TypeScript
897 lines
29 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import {
|
|
APP_ID,
|
|
Component,
|
|
destroyPlatform,
|
|
Directive,
|
|
ErrorHandler,
|
|
HostListener,
|
|
inject,
|
|
PendingTasks,
|
|
PLATFORM_ID,
|
|
ɵgetDocument as getDocument,
|
|
ɵEVENT_REPLAY_QUEUE as EVENT_REPLAY_QUEUE,
|
|
} from '@angular/core';
|
|
import {isPlatformBrowser} from '@angular/common';
|
|
import {
|
|
bootstrapApplication,
|
|
provideClientHydration,
|
|
withEventReplay,
|
|
withNoIncrementalHydration,
|
|
} from '@angular/platform-browser';
|
|
|
|
import {EventPhase} from '@angular/core/primitives/event-dispatch';
|
|
|
|
import {
|
|
getAppContents,
|
|
hydrate,
|
|
prepareEnvironment,
|
|
prepareEnvironmentAndHydrate,
|
|
resetTViewsFor,
|
|
} from './dom_utils';
|
|
import {EVENT_DISPATCH_SCRIPT, ssr} from './hydration_utils';
|
|
import {EVENT_DISPATCH_SCRIPT_ID} from '../src/utils';
|
|
|
|
/** Checks whether event dispatch script is present in the generated HTML */
|
|
function hasEventDispatchScript(content: string) {
|
|
return content.includes(EVENT_DISPATCH_SCRIPT_ID);
|
|
}
|
|
|
|
/** Checks whether there are any `jsaction` attributes present in the generated HTML */
|
|
function hasJSActionAttrs(content: string) {
|
|
return content.includes('jsaction="');
|
|
}
|
|
|
|
/**
|
|
* Enables strict error handler that fails a test
|
|
* if there was an error reported to the ErrorHandler.
|
|
*/
|
|
function withStrictErrorHandler() {
|
|
class StrictErrorHandler extends ErrorHandler {
|
|
override handleError(error: any): void {
|
|
fail(error);
|
|
}
|
|
}
|
|
return [
|
|
{
|
|
provide: ErrorHandler,
|
|
useClass: StrictErrorHandler,
|
|
},
|
|
];
|
|
}
|
|
|
|
describe('event replay', () => {
|
|
const originalDocument = globalThis.document;
|
|
const originalWindow = globalThis.window;
|
|
|
|
beforeAll(async () => {
|
|
globalThis.window = globalThis as unknown as Window & typeof globalThis;
|
|
await import('../../core/primitives/event-dispatch/contract_bundle_min.js' as string);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
destroyPlatform();
|
|
});
|
|
|
|
afterAll(() => {
|
|
globalThis.window = originalWindow;
|
|
globalThis.document = originalDocument;
|
|
});
|
|
|
|
afterEach(() => {
|
|
destroyPlatform();
|
|
window._ejsas = {};
|
|
});
|
|
|
|
// Issue #67328: post-hydration events must not be replayed
|
|
it('should not replay an event that was already handled by a real DOM listener after component hydration', async () => {
|
|
const onClickSpy = jasmine.createSpy();
|
|
let removeTask: (() => void) | null = null;
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
template: `<button id="btn" (click)="onClick()"></button>`,
|
|
})
|
|
class AppComponent {
|
|
constructor() {
|
|
if (isPlatformBrowser(inject(PLATFORM_ID))) {
|
|
removeTask = inject(PendingTasks).add();
|
|
}
|
|
}
|
|
onClick = onClickSpy;
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(AppComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
|
|
// Hydrate the component (registers real DOM listeners) but keep app unstable
|
|
const appRef = await hydrate(doc, AppComponent, {hydrationFeatures});
|
|
appRef.tick();
|
|
|
|
// Click AFTER hydration: real DOM listener fires once, jsaction also queues it
|
|
const btn = doc.getElementById('btn')!;
|
|
btn.click();
|
|
|
|
// Real DOM listener should have fired once
|
|
expect(onClickSpy).toHaveBeenCalledTimes(1);
|
|
|
|
// Let the app become stable — triggers whenStable() which kicks off jsaction replay
|
|
removeTask!();
|
|
await appRef.whenStable();
|
|
appRef.tick();
|
|
|
|
// Without the fix, jsaction replay fires the handler a second time (count = 2).
|
|
// With the fix, the post-hydration event must not be replayed again (count = 1).
|
|
expect(onClickSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should work for elements with local refs', async () => {
|
|
const onClickSpy = jasmine.createSpy();
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
template: ` <button id="btn" (click)="onClick()" #localRef></button> `,
|
|
})
|
|
class AppComponent {
|
|
onClick = onClickSpy;
|
|
}
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(AppComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
const btn = doc.getElementById('btn')!;
|
|
btn.click();
|
|
const appRef = await hydrate(doc, AppComponent, {hydrationFeatures});
|
|
appRef.tick();
|
|
expect(onClickSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('stash event listeners should not conflict when multiple apps are bootstrapped', async () => {
|
|
const onClickSpy = jasmine.createSpy();
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
template: ` <button id="btn-1" (click)="onClick()"></button> `,
|
|
})
|
|
class AppComponent_1 {
|
|
onClick = onClickSpy;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-2',
|
|
template: ` <button id="btn-2" (click)="onClick()"></button> `,
|
|
})
|
|
class AppComponent_2 {
|
|
onClick() {}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const docHtml = `
|
|
<html>
|
|
<head></head>
|
|
<body>
|
|
${EVENT_DISPATCH_SCRIPT}
|
|
<app></app>
|
|
<app-2></app-2>
|
|
</body>
|
|
</html>
|
|
`;
|
|
const html = await ssr(AppComponent_1, {hydrationFeatures, doc: docHtml});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent_1);
|
|
|
|
const btn = doc.getElementById('btn-1')!;
|
|
btn.click();
|
|
|
|
// It's hard to server-side render multiple applications in this
|
|
// particular unit test and hydrate them on the client, so instead,
|
|
// let's render the application with `provideClientHydration` to enable
|
|
// event replay features and ensure the stash event listener is set.
|
|
await bootstrapApplication(AppComponent_2, {
|
|
providers: [
|
|
provideClientHydration(withEventReplay()),
|
|
{provide: APP_ID, useValue: 'random_name'},
|
|
],
|
|
});
|
|
|
|
// Now let's hydrate the second application and ensure that the
|
|
// button click event has been replayed.
|
|
const appRef = await hydrate(doc, AppComponent_1, {hydrationFeatures});
|
|
appRef.tick();
|
|
|
|
expect(onClickSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should cleanup `window._ejsas[appId]` once app is destroyed', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: ` <button id="btn" (click)="onClick()"></button> `,
|
|
})
|
|
class AppComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(AppComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
|
|
const btn = doc.getElementById('btn')!;
|
|
btn.click();
|
|
|
|
const appRef = await hydrate(doc, AppComponent, {hydrationFeatures});
|
|
appRef.tick();
|
|
const appId = appRef.injector.get(APP_ID);
|
|
|
|
// Important: This is done intentionally because `ApplicationRef` registers
|
|
// `onDestroy` callbacks, and we want to ensure that they execute successfully
|
|
// without resulting in any errors. This is necessary because the bodies of
|
|
// these `onDestroy` callbacks use the `ngServerMode` variable.
|
|
// Prior to setting this flag, the unit test was throwing a "destroyed injector"
|
|
// error — but we weren't capturing it because we hadn't explicitly set the flag to false.
|
|
globalThis['ngServerMode'] = false;
|
|
appRef.destroy();
|
|
globalThis['ngServerMode'] = undefined;
|
|
|
|
// This ensure that `_ejsas` for the current application is cleaned up
|
|
// once the application is destroyed.
|
|
expect(window._ejsas![appId]).toBeUndefined();
|
|
});
|
|
|
|
it('should route to the appropriate component with content projection', async () => {
|
|
const outerOnClickSpy = jasmine.createSpy();
|
|
const innerOnClickSpy = jasmine.createSpy();
|
|
@Component({
|
|
selector: 'app-card',
|
|
template: `
|
|
<div class="card">
|
|
<button id="inner-button" (click)="onClick()"></button>
|
|
<ng-content></ng-content>
|
|
</div>
|
|
`,
|
|
})
|
|
class CardComponent {
|
|
onClick = innerOnClickSpy;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
imports: [CardComponent],
|
|
template: `
|
|
<app-card>
|
|
<h2>Card Title</h2>
|
|
<p>This is some card content.</p>
|
|
<button id="outer-button" (click)="onClick()">Click Me</button>
|
|
</app-card>
|
|
`,
|
|
})
|
|
class AppComponent {
|
|
onClick = outerOnClickSpy;
|
|
}
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(AppComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
const outer = doc.getElementById('outer-button')!;
|
|
const inner = doc.getElementById('inner-button')!;
|
|
outer.click();
|
|
inner.click();
|
|
await hydrate(doc, AppComponent, {
|
|
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
|
|
hydrationFeatures,
|
|
});
|
|
expect(outerOnClickSpy).toHaveBeenCalledBefore(innerOnClickSpy);
|
|
});
|
|
|
|
describe('host bindings', () => {
|
|
it('should not error when when binding to document:click on a container', async () => {
|
|
const clickSpy = jasmine.createSpy();
|
|
@Directive({
|
|
selector: '[add-listener]',
|
|
})
|
|
class AddGlobalListener {
|
|
@HostListener('document:click')
|
|
handleClick = clickSpy;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
template: ` <ng-container add-listener>
|
|
<button id="click-me">Click me!</button>
|
|
</ng-container>`,
|
|
imports: [AddGlobalListener],
|
|
})
|
|
class AppComponent {}
|
|
|
|
const appId = 'custom-app-id';
|
|
const providers = [{provide: APP_ID, useValue: appId}];
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
|
|
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
const clickMe = doc.getElementById('click-me')!;
|
|
clickMe.click();
|
|
await hydrate(doc, AppComponent, {
|
|
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
|
|
hydrationFeatures,
|
|
});
|
|
|
|
expect(clickSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not error when when binding to window:click on a container', async () => {
|
|
const clickSpy = jasmine.createSpy();
|
|
@Directive({
|
|
selector: '[add-listener]',
|
|
})
|
|
class AddGlobalListener {
|
|
@HostListener('window:click')
|
|
handleClick = clickSpy;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
template: ` <ng-container add-listener>
|
|
<button id="click-me">Click me!</button>
|
|
</ng-container>`,
|
|
imports: [AddGlobalListener],
|
|
})
|
|
class AppComponent {}
|
|
|
|
const appId = 'custom-app-id';
|
|
const providers = [{provide: APP_ID, useValue: appId}];
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
|
|
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
const clickMe = doc.getElementById('click-me')!;
|
|
clickMe.click();
|
|
await hydrate(doc, AppComponent, {
|
|
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
|
|
hydrationFeatures,
|
|
});
|
|
|
|
expect(clickSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not error when when binding to body:click on a container', async () => {
|
|
const clickSpy = jasmine.createSpy();
|
|
@Directive({
|
|
selector: '[add-listener]',
|
|
})
|
|
class AddGlobalListener {
|
|
@HostListener('body:click')
|
|
handleClick = clickSpy;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
template: ` <ng-container add-listener>
|
|
<button id="click-me">Click me!</button>
|
|
</ng-container>`,
|
|
imports: [AddGlobalListener],
|
|
})
|
|
class AppComponent {}
|
|
|
|
const appId = 'custom-app-id';
|
|
const providers = [{provide: APP_ID, useValue: appId}];
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
|
|
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
const clickMe = doc.getElementById('click-me')!;
|
|
clickMe.click();
|
|
await hydrate(doc, AppComponent, {
|
|
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
|
|
hydrationFeatures,
|
|
});
|
|
|
|
expect(clickSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should remove jsaction attributes, but continue listening to events.', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: `
|
|
<div (click)="onClick()" id="1">
|
|
<div (click)="onClick()" id="2"></div>
|
|
</div>
|
|
`,
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
prepareEnvironment(doc, ssrContents);
|
|
const el = doc.getElementById('1')!;
|
|
expect(el.hasAttribute('jsaction')).toBeTrue();
|
|
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue();
|
|
resetTViewsFor(SimpleComponent);
|
|
await hydrate(doc, SimpleComponent, {hydrationFeatures});
|
|
expect(el.hasAttribute('jsaction')).toBeFalse();
|
|
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeFalse();
|
|
});
|
|
|
|
it(`should add 'nonce' attribute to event record script when 'ngCspNonce' is provided`, async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: `
|
|
<div (click)="onClick()">
|
|
<div (blur)="onClick()"></div>
|
|
</div>
|
|
`,
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
|
|
const doc =
|
|
`<html><head></head><body>${EVENT_DISPATCH_SCRIPT}` +
|
|
`<app ngCspNonce="{{nonce}}"></app></body></html>`;
|
|
const html = await ssr(SimpleComponent, {doc, hydrationFeatures});
|
|
expect(getAppContents(html)).toContain('<script nonce="{{nonce}}">window.__jsaction_bootstrap');
|
|
});
|
|
|
|
it('should not throw an error when app is destroyed before becoming stable', async () => {
|
|
// Spy manually, because we may not be able to retrieve the `Console`
|
|
// after we destroy the application, but we still want to ensure that
|
|
// no error is thrown in the console.
|
|
const errorSpy = spyOn(console, 'error').and.callThrough();
|
|
const logs: string[] = [];
|
|
|
|
@Component({
|
|
selector: 'app',
|
|
template: ` <button id="btn" (click)="onClick()"></button> `,
|
|
})
|
|
class AppComponent {
|
|
constructor() {
|
|
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
if (isBrowser) {
|
|
const pendingTasks = inject(PendingTasks);
|
|
// Given that, in a real-world scenario, some APIs add a pending
|
|
// task and don't remove it until the app is destroyed.
|
|
// This could be an HTTP request that contributes to app stability
|
|
// and does not respond until the app is destroyed.
|
|
pendingTasks.add();
|
|
}
|
|
}
|
|
|
|
onClick(): void {}
|
|
}
|
|
const html = await ssr(AppComponent);
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(AppComponent);
|
|
const btn = doc.getElementById('btn')!;
|
|
btn.click();
|
|
const appRef = await hydrate(doc, AppComponent, {
|
|
hydrationFeatures: () => [withEventReplay()],
|
|
});
|
|
|
|
appRef.isStable.subscribe((isStable) => {
|
|
logs.push(`isStable=${isStable}`);
|
|
});
|
|
|
|
// Destroy the application before it becomes stable, because we added
|
|
// a task and didn't remove it explicitly.
|
|
appRef.destroy();
|
|
|
|
// Wait for a microtask so that `whenStable` resolves.
|
|
await Promise.resolve();
|
|
|
|
expect(logs).toEqual([
|
|
'isStable=false',
|
|
// In the end, the application became stable while being destroyed.
|
|
'isStable=true',
|
|
]);
|
|
|
|
// Ensure no error has been logged in the console,
|
|
// such as "injector has already been destroyed."
|
|
expect(errorSpy).not.toHaveBeenCalledWith(/Injector has already been destroyed/);
|
|
});
|
|
|
|
describe('bubbling behavior', () => {
|
|
it('should propagate events', async () => {
|
|
const onClickSpy = jasmine.createSpy();
|
|
@Component({
|
|
selector: 'app',
|
|
template: `
|
|
<div id="top" (click)="onClick()">
|
|
<div id="bottom" (click)="onClick()"></div>
|
|
</div>
|
|
`,
|
|
})
|
|
class SimpleComponent {
|
|
onClick = onClickSpy;
|
|
}
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(SimpleComponent);
|
|
const bottomEl = doc.getElementById('bottom')!;
|
|
bottomEl.click();
|
|
await hydrate(doc, SimpleComponent, {
|
|
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
|
|
hydrationFeatures,
|
|
});
|
|
expect(onClickSpy).toHaveBeenCalledTimes(2);
|
|
onClickSpy.calls.reset();
|
|
bottomEl.click();
|
|
expect(onClickSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should not propagate events if stopPropagation is called', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: `
|
|
<div id="top" (click)="onClick($event)">
|
|
<div id="bottom" (click)="onClick($event)"></div>
|
|
</div>
|
|
`,
|
|
})
|
|
class SimpleComponent {
|
|
onClick(e: Event) {
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
const onClickSpy = spyOn(SimpleComponent.prototype, 'onClick').and.callThrough();
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(SimpleComponent);
|
|
const bottomEl = doc.getElementById('bottom')!;
|
|
bottomEl.click();
|
|
await hydrate(doc, SimpleComponent, {hydrationFeatures});
|
|
expect(onClickSpy).toHaveBeenCalledTimes(1);
|
|
onClickSpy.calls.reset();
|
|
bottomEl.click();
|
|
expect(onClickSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not have differences in event fields', async () => {
|
|
let currentEvent!: Event;
|
|
let latestTarget: EventTarget | null = null;
|
|
let latestCurrentTarget: EventTarget | null = null;
|
|
@Component({
|
|
selector: 'app',
|
|
template: `
|
|
<div id="top" (click)="onClick($event)">
|
|
<div id="bottom" (click)="onClick($event)"></div>
|
|
</div>
|
|
`,
|
|
})
|
|
class SimpleComponent {
|
|
onClick(event: Event) {
|
|
currentEvent = event;
|
|
latestTarget = event.target;
|
|
latestCurrentTarget = event.currentTarget;
|
|
}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(SimpleComponent);
|
|
const bottomEl = doc.getElementById('bottom')!;
|
|
bottomEl.click();
|
|
await hydrate(doc, SimpleComponent, {
|
|
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
|
|
hydrationFeatures,
|
|
});
|
|
const replayedEvent = currentEvent;
|
|
expect(replayedEvent.target).not.toBeNull();
|
|
expect(replayedEvent.currentTarget).not.toBeNull();
|
|
expect(replayedEvent.eventPhase).toBe(EventPhase.REPLAY);
|
|
bottomEl.click();
|
|
expect(replayedEvent.target).toBe(latestTarget);
|
|
expect(replayedEvent.currentTarget).toBe(latestCurrentTarget);
|
|
});
|
|
});
|
|
|
|
describe('event dispatch script', () => {
|
|
it('should not be present on a page when hydration is disabled', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: '<input (click)="onClick()" />',
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const html = await ssr(SimpleComponent, {enableHydration: false});
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
|
|
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
|
|
});
|
|
|
|
it('should not be present on a page if there are no events to replay', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: 'Some text',
|
|
})
|
|
class SimpleComponent {}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
|
|
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
|
|
|
|
resetTViewsFor(SimpleComponent);
|
|
const doc = getDocument();
|
|
await prepareEnvironmentAndHydrate(doc, ssrContents, SimpleComponent, {
|
|
envProviders: [
|
|
{provide: PLATFORM_ID, useValue: 'browser'},
|
|
// This ensures that there are no errors while bootstrapping an application
|
|
// that has no events, but enables Event Replay feature.
|
|
withStrictErrorHandler(),
|
|
],
|
|
hydrationFeatures,
|
|
});
|
|
});
|
|
|
|
it('should not replay mouse events', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: '<div (mouseenter)="doThing()"><div>',
|
|
})
|
|
class SimpleComponent {
|
|
doThing() {}
|
|
}
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
|
|
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
|
|
});
|
|
|
|
it('should not be present on a page where event replay is not enabled', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: '<input (click)="onClick()" />',
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const html = await ssr(SimpleComponent, {
|
|
hydrationFeatures: () => [withNoIncrementalHydration()],
|
|
});
|
|
const ssrContents = getAppContents(html);
|
|
|
|
// Expect that there are no JSAction artifacts in the HTML
|
|
// (even though there are events in a template), since event
|
|
// replay is disabled in the config.
|
|
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
|
|
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
|
|
});
|
|
|
|
it('should be retained if there are events to replay', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: '<input (click)="onClick()" />',
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(hasJSActionAttrs(ssrContents)).toBeTrue();
|
|
expect(hasEventDispatchScript(ssrContents)).toBeTrue();
|
|
|
|
// Verify that inlined event delegation script goes first and
|
|
// event contract setup goes second (since it uses some code from
|
|
// the inlined script).
|
|
expect(ssrContents).toContain(
|
|
`<script type="text/javascript" id="ng-event-dispatch-contract"></script>` +
|
|
`<script>window.__jsaction_bootstrap(document.body,"ng",["click"],[]);</script>`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('event replay queue', () => {
|
|
it('should be empty on init', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: '<input (click)="onClick()" />',
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(SimpleComponent);
|
|
const appRef = await hydrate(doc, SimpleComponent, {hydrationFeatures});
|
|
const queue = appRef.injector.get(EVENT_REPLAY_QUEUE);
|
|
expect(queue.length).toBe(0);
|
|
});
|
|
|
|
it('should be different for different apps', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: '<input (click)="onClick()" />',
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
const appRef1 = await hydrate(doc, SimpleComponent, {hydrationFeatures});
|
|
const queue1 = appRef1.injector.get(EVENT_REPLAY_QUEUE);
|
|
|
|
const appRef2 = await hydrate(doc, SimpleComponent, {hydrationFeatures});
|
|
const queue2 = appRef2.injector.get(EVENT_REPLAY_QUEUE);
|
|
|
|
expect(queue1).not.toBe(queue2);
|
|
});
|
|
|
|
it('should clear the queue after events are replayed', async () => {
|
|
@Component({
|
|
selector: 'app',
|
|
template: `
|
|
@defer (on interaction(trigger)) {
|
|
<div id="content" (click)="onClick()"></div>
|
|
} @placeholder {
|
|
<button id="trigger">Trigger</button>
|
|
}
|
|
`,
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {hydrationFeatures});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
const appRef = await hydrate(doc, SimpleComponent, {hydrationFeatures});
|
|
const queue = appRef.injector.get(EVENT_REPLAY_QUEUE);
|
|
const trigger = doc.getElementById('trigger')!;
|
|
// This should queue the event
|
|
trigger.click();
|
|
|
|
// Wait for hydration to complete
|
|
await appRef.whenStable();
|
|
|
|
// The queue should be cleared after replay/hydration cycle completion
|
|
// Note: We might need to wait for idle/microtasks if the replay is async.
|
|
// But verify expectation:
|
|
// The current implementation requeues if not hydrated.
|
|
// But here we expect it to hydrate.
|
|
|
|
// For this test to trigger replay we need to ensure the block hydrates.
|
|
// interaction(trigger) hydrates on click.
|
|
|
|
// Check that queue is handled.
|
|
// queue size initially should be 0.
|
|
// After click, it might briefly be 1 if we inspect synchronously?
|
|
// but `triggerHydrationFromBlockName` is called.
|
|
// Eventually it should be empty again.
|
|
// Since `invokeRegisteredReplayListeners` triggers hydration directly and pushes to queue.
|
|
|
|
// wait for replay
|
|
const start = Date.now();
|
|
while (queue.length > 0 && Date.now() - start < 1_000) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
expect(queue.length).toBe(0);
|
|
});
|
|
|
|
it('should release event queue references on app destroy', async () => {
|
|
let appRef: any;
|
|
const appId = 'app-id-for-memory-test';
|
|
{
|
|
@Component({
|
|
selector: 'app',
|
|
template: '<input (click)="onClick()" />',
|
|
})
|
|
class SimpleComponent {
|
|
onClick() {}
|
|
}
|
|
|
|
const providers = [{provide: APP_ID, useValue: appId}];
|
|
const hydrationFeatures = () => [withEventReplay()];
|
|
const html = await ssr(SimpleComponent, {
|
|
hydrationFeatures,
|
|
envProviders: providers,
|
|
});
|
|
const ssrContents = getAppContents(html);
|
|
const doc = getDocument();
|
|
prepareEnvironment(doc, ssrContents);
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
appRef = await hydrate(doc, SimpleComponent, {
|
|
hydrationFeatures,
|
|
envProviders: providers,
|
|
});
|
|
|
|
// Access queue to make sure it exists
|
|
const queue = appRef.injector.get(EVENT_REPLAY_QUEUE);
|
|
expect(queue).toBeInstanceOf(Array);
|
|
|
|
// Simulate event in queue
|
|
queue.push({event: new Event('click'), currentTarget: doc.createElement('div')});
|
|
expect(queue.length).toBe(1);
|
|
|
|
appRef.destroy();
|
|
}
|
|
|
|
// Verify global cleanup
|
|
expect(window._ejsas![appId]).toBeUndefined();
|
|
});
|
|
});
|
|
});
|