fix(core): do not activate event replay when no events are registered (#56509)

This commit adds extra checks to handle a situation when an application has no events configured, but the Event Replay feature was enabled. This situation can happen when some routes in an application are mostly static, when other routes are more interactive.

Resolves #56423.

PR Close #56509
This commit is contained in:
Andrew Kushnir 2024-06-18 18:05:03 -07:00
parent 86bcfd3e49
commit bf6df6f186
2 changed files with 81 additions and 15 deletions

View file

@ -26,7 +26,11 @@ import {CLEANUP, LView, TView} from '../render3/interfaces/view';
import {isPlatformBrowser} from '../render3/util/misc_utils';
import {unwrapRNode} from '../render3/util/view_utils';
import {IS_EVENT_REPLAY_ENABLED, IS_GLOBAL_EVENT_DELEGATION_ENABLED} from './tokens';
import {
EVENT_REPLAY_ENABLED_DEFAULT,
IS_EVENT_REPLAY_ENABLED,
IS_GLOBAL_EVENT_DELEGATION_ENABLED,
} from './tokens';
import {
GlobalEventDelegation,
sharedStashFunction,
@ -50,6 +54,16 @@ function isGlobalEventDelegationEnabled(injector: Injector) {
return injector.get(IS_GLOBAL_EVENT_DELEGATION_ENABLED, false);
}
/**
* Determines whether Event Replay feature should be activated on the client.
*/
function shouldEnableEventReplay(injector: Injector) {
return (
injector.get(IS_EVENT_REPLAY_ENABLED, EVENT_REPLAY_ENABLED_DEFAULT) &&
!isGlobalEventDelegationEnabled(injector)
);
}
/**
* Returns a set of providers required to setup support for event replay.
* Requires hydration to be enabled separately.
@ -58,19 +72,28 @@ export function withEventReplay(): Provider[] {
return [
{
provide: IS_EVENT_REPLAY_ENABLED,
useValue: true,
useFactory: () => {
let isEnabled = true;
if (isPlatformBrowser()) {
// Note: globalThis[CONTRACT_PROPERTY] may be undefined in case Event Replay feature
// is enabled, but there are no events configured in this application, in which case
// we don't activate this feature, since there are no events to replay.
const appId = inject(APP_ID);
isEnabled = !!globalThis[CONTRACT_PROPERTY]?.[appId];
}
return isEnabled;
},
},
{
provide: ENVIRONMENT_INITIALIZER,
useValue: () => {
const injector = inject(Injector);
if (isGlobalEventDelegationEnabled(injector)) {
return;
if (isPlatformBrowser(injector) && shouldEnableEventReplay(injector)) {
setStashFn((rEl: RElement, eventName: string, listenerFn: VoidFunction) => {
sharedStashFunction(rEl, eventName, listenerFn);
jsactionSet.add(rEl as unknown as Element);
});
}
setStashFn((rEl: RElement, eventName: string, listenerFn: VoidFunction) => {
sharedStashFunction(rEl, eventName, listenerFn);
jsactionSet.add(rEl as unknown as Element);
});
},
multi: true,
},
@ -81,13 +104,14 @@ export function withEventReplay(): Provider[] {
const injector = inject(Injector);
const appRef = inject(ApplicationRef);
return () => {
if (!shouldEnableEventReplay(injector)) {
return;
}
// Kick off event replay logic once hydration for the initial part
// of the application is completed. This timing is similar to the unclaimed
// dehydrated views cleanup timing.
whenStable(appRef).then(() => {
if (isGlobalEventDelegationEnabled(injector)) {
return;
}
const globalEventDelegation = injector.get(GlobalEventDelegation);
initEventReplay(globalEventDelegation, injector);
jsactionSet.forEach(removeListeners);
@ -112,8 +136,6 @@ function getJsactionData(container: EarlyJsactionDataContainer) {
const initEventReplay = (eventDelegation: GlobalEventDelegation, injector: Injector) => {
const appId = injector.get(APP_ID);
// 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 container = globalThis[CONTRACT_PROPERTY]?.[appId];
const earlyJsactionData = getJsactionData(container)!;
const eventContract = (eventDelegation.eventContract = new EventContract(

View file

@ -7,7 +7,14 @@
*/
import {DOCUMENT} from '@angular/common';
import {Component, destroyPlatform, getPlatform, Type} from '@angular/core';
import {
Component,
destroyPlatform,
ErrorHandler,
getPlatform,
PLATFORM_ID,
Type,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
withEventReplay,
@ -19,7 +26,14 @@ 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';
import {
getAppContents,
hydrate,
renderAndHydrate,
render as renderHtml,
resetTViewsFor,
} from './dom_utils';
import {CONTRACT_PROPERTY} from '@angular/core/src/hydration/event_replay';
/**
* Represents the <script> tag added by the build process to inject
@ -38,6 +52,24 @@ 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', () => {
let doc: Document;
const originalDocument = globalThis.document;
@ -61,6 +93,7 @@ describe('event replay', () => {
afterEach(() => {
doc.body.outerHTML = '<body></body>';
globalThis[CONTRACT_PROPERTY] = {};
});
/**
@ -402,6 +435,17 @@ describe('event replay', () => {
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
resetTViewsFor(SimpleComponent);
await renderAndHydrate(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: [withEventReplay()],
});
});
it('should not replay mouse events', async () => {