diff --git a/packages/core/src/hydration/event_replay.ts b/packages/core/src/hydration/event_replay.ts index 8705a0e6acdb18..728743e3d71edf 100644 --- a/packages/core/src/hydration/event_replay.ts +++ b/packages/core/src/hydration/event_replay.ts @@ -142,6 +142,14 @@ export function collectDomEventsInfo( continue; } const name: string = firstParam; + if ( + name === 'mouseenter' || + name === 'mouseleave' || + name === 'pointerenter' || + name === 'pointerleave' + ) { + continue; + } eventTypesToReplay.add(name); const listenerElement = unwrapRNode(lView[secondParam]) as any as Element; i++; // move the cursor to the next position (location of the listener idx) diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index ba751d1950391b..c0060376951a21 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -145,14 +145,6 @@ function insertEventRecordScript( const captureEventTypes = []; const eventTypes = []; for (const eventType of events) { - if ( - eventType === 'mouseenter' || - eventType === 'mouseleave' || - eventType === 'pointerenter' || - eventType === 'pointerleave' - ) { - continue; - } if ( eventType === 'focus' || eventType === 'blur' || diff --git a/packages/platform-server/test/event_replay_spec.ts b/packages/platform-server/test/event_replay_spec.ts index 2c8d309b6181d8..e1b827b383ff23 100644 --- a/packages/platform-server/test/event_replay_spec.ts +++ b/packages/platform-server/test/event_replay_spec.ts @@ -37,88 +37,82 @@ function hasJSActionAttrs(content: string) { } describe('event replay', () => { + let doc: Document; + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + + beforeAll(async () => { + globalThis.window = globalThis as unknown as Window & typeof globalThis; + await import('@angular/core/primitives/event-dispatch/contract_bundle_min.js' as string); + }); + beforeEach(() => { if (getPlatform()) destroyPlatform(); + doc = TestBed.inject(DOCUMENT); }); afterAll(() => { + globalThis.window = originalWindow; + globalThis.document = originalDocument; destroyPlatform(); }); - describe('event replay', () => { - /** - * This renders the application with server side rendering logic. - * - * @param component the test component to be rendered - * @param doc the document - * @param envProviders the environment providers - * @returns a promise containing the server rendered app as a string - */ - async function ssr( - component: Type, - options: {doc?: string; enableEventReplay?: boolean; hydrationDisabled?: boolean} = {}, - ): Promise { - const { - enableEventReplay = true, - hydrationDisabled, - doc = `${EVENT_DISPATCH_SCRIPT}`, - } = options; - - const hydrationProviders = hydrationDisabled - ? [] - : enableEventReplay - ? provideClientHydration(withEventReplay()) - : provideClientHydration(); - - const bootstrap = () => - bootstrapApplication(component, { - providers: [provideServerRendering(), hydrationProviders], - }); - - return renderApplication(bootstrap, { - document: doc, - }); - } - - describe('server rendering', () => { - let doc: Document; - const originalDocument = globalThis.document; - const originalWindow = globalThis.window; - - function render(doc: Document, html: string) { - renderHtml(doc, html); - globalThis.document = doc; - const scripts = doc.getElementsByTagName('script'); - for (const script of Array.from(scripts)) { - if (script?.textContent?.startsWith('window.__jsaction_bootstrap')) { - eval(script.textContent); - } - } - } - - beforeAll(async () => { - globalThis.window = globalThis as unknown as Window & typeof globalThis; - await import('@angular/core/primitives/event-dispatch/contract_bundle_min.js' as string); - }); + afterEach(() => { + doc.body.textContent = ''; + }); - beforeEach(() => { - doc = TestBed.inject(DOCUMENT); + /** + * This renders the application with server side rendering logic. + * + * @param component the test component to be rendered + * @param doc the document + * @param envProviders the environment providers + * @returns a promise containing the server rendered app as a string + */ + async function ssr( + component: Type, + options: {doc?: string; enableEventReplay?: boolean; hydrationDisabled?: boolean} = {}, + ): Promise { + const { + enableEventReplay = true, + hydrationDisabled, + doc = `${EVENT_DISPATCH_SCRIPT}`, + } = options; + + const hydrationProviders = hydrationDisabled + ? [] + : enableEventReplay + ? provideClientHydration(withEventReplay()) + : provideClientHydration(); + + const bootstrap = () => + bootstrapApplication(component, { + providers: [provideServerRendering(), hydrationProviders], }); - afterEach(() => { - doc.body.textContent = ''; - }); - afterAll(() => { - globalThis.window = originalWindow; - globalThis.document = originalDocument; - }); - it('should serialize event types to be listened to and jsaction attribute', async () => { - const clickSpy = jasmine.createSpy('onClick'); - const focusSpy = jasmine.createSpy('onFocus'); - @Component({ - standalone: true, - selector: 'app', - template: ` + return renderApplication(bootstrap, { + document: doc, + }); + } + + function render(doc: Document, html: string) { + renderHtml(doc, html); + globalThis.document = doc; + const scripts = doc.getElementsByTagName('script'); + for (const script of Array.from(scripts)) { + if (script?.textContent?.startsWith('window.__jsaction_bootstrap')) { + eval(script.textContent); + } + } + } + + it('should serialize event types to be listened to and jsaction attribute', async () => { + const clickSpy = jasmine.createSpy('onClick'); + const focusSpy = jasmine.createSpy('onFocus'); + @Component({ + standalone: true, + selector: 'app', + template: `
@@ -127,172 +121,289 @@ describe('event replay', () => {
`, - }) - class SimpleComponent { - onClick = clickSpy; - onFocus = focusSpy; - } + }) + class SimpleComponent { + onClick = clickSpy; + onFocus = focusSpy; + } - const docContents = `${EVENT_DISPATCH_SCRIPT}`; - const html = await ssr(SimpleComponent, {doc: docContents}); - const ssrContents = getAppContents(html); - expect(ssrContents).toContain( - ``, - ); - - render(doc, ssrContents); - 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(); - }); + const docContents = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc: docContents}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain( + ``, + ); + + render(doc, ssrContents); + 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 () => { - @Component({ - standalone: true, - selector: 'app', - template: ` + it('should remove jsaction attributes, but continue listening to events.', async () => { + @Component({ + standalone: true, + selector: 'app', + template: `
`, - }) - class SimpleComponent { - onClick() {} - } + }) + class SimpleComponent { + onClick() {} + } - const docContents = `${EVENT_DISPATCH_SCRIPT}`; - const html = await ssr(SimpleComponent, {doc: docContents}); - const ssrContents = getAppContents(html); - render(doc, ssrContents); - const el = doc.getElementById('1')!; - expect(el.hasAttribute('jsaction')).toBeTrue(); - expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue(); - resetTViewsFor(SimpleComponent); - const appRef = await hydrate(doc, SimpleComponent, { - hydrationFeatures: [withEventReplay()], - }); - appRef.tick(); - expect(el.hasAttribute('jsaction')).toBeFalse(); - expect((el.firstChild as Element).hasAttribute('jsaction')).toBeFalse(); - }); + const docContents = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc: docContents}); + const ssrContents = getAppContents(html); + render(doc, ssrContents); + const el = doc.getElementById('1')!; + expect(el.hasAttribute('jsaction')).toBeTrue(); + expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue(); + resetTViewsFor(SimpleComponent); + const appRef = await hydrate(doc, SimpleComponent, { + hydrationFeatures: [withEventReplay()], + }); + appRef.tick(); + 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({ - standalone: true, - selector: 'app', - template: ` + it(`should add 'nonce' attribute to event record script when 'ngCspNonce' is provided`, async () => { + @Component({ + standalone: true, + selector: 'app', + template: `
`, - }) - class SimpleComponent { - onClick() {} - } + }) + class SimpleComponent { + onClick() {} + } - const doc = - `${EVENT_DISPATCH_SCRIPT}` + - ``; - const html = await ssr(SimpleComponent, {doc}); - expect(getAppContents(html)).toContain( - '` + - ``, - ); - }); + describe('bubbling behavior', () => { + it('should propagate events', async () => { + const onClickSpy = jasmine.createSpy(); + @Component({ + standalone: true, + selector: 'app', + template: ` +
+
+
+ `, + }) + class SimpleComponent { + onClick = onClickSpy; + } + const docContents = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc: docContents}); + const ssrContents = getAppContents(html); + render(doc, ssrContents); + resetTViewsFor(SimpleComponent); + const bottomEl = doc.getElementById('bottom')!; + bottomEl.click(); + const appRef = await hydrate(doc, SimpleComponent, { + hydrationFeatures: [withEventReplay()], }); + appRef.tick(); + // This is a bug + expect(onClickSpy).toHaveBeenCalledTimes(1); + onClickSpy.calls.reset(); + bottomEl.click(); + expect(onClickSpy).toHaveBeenCalledTimes(2); + }); + it('should not propagate events if stopPropagation is called', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` +
+
+
+ `, + }) + class SimpleComponent { + onClick(e: Event) { + e.stopPropagation(); + } + } + const onClickSpy = spyOn(SimpleComponent.prototype, 'onClick').and.callThrough(); + const docContents = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc: docContents}); + const ssrContents = getAppContents(html); + render(doc, ssrContents); + resetTViewsFor(SimpleComponent); + const bottomEl = doc.getElementById('bottom')!; + bottomEl.click(); + const appRef = await hydrate(doc, SimpleComponent, { + hydrationFeatures: [withEventReplay()], + }); + appRef.tick(); + 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; + @Component({ + standalone: true, + selector: 'app', + template: ` +
+
+
+ `, + }) + class SimpleComponent { + onClick(event: Event) { + currentEvent = event; + } + } + const docContents = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc: docContents}); + const ssrContents = getAppContents(html); + render(doc, ssrContents); + resetTViewsFor(SimpleComponent); + const bottomEl = doc.getElementById('bottom')!; + bottomEl.click(); + const appRef = await hydrate(doc, SimpleComponent, { + hydrationFeatures: [withEventReplay()], + }); + appRef.tick(); + const replayedEvent = currentEvent; + bottomEl.click(); + appRef.tick(); + const normalEvent = currentEvent; + expect(replayedEvent).not.toBe(normalEvent); + expect(replayedEvent.target).toBe(normalEvent.target); + expect(replayedEvent.currentTarget).toBe(normalEvent.currentTarget); + expect(replayedEvent.composedPath).toBe(normalEvent.composedPath); + expect(replayedEvent.eventPhase).toBe(normalEvent.eventPhase); + }); + }); + + describe('event dispatch script', () => { + it('should not be present on a page when hydration is disabled', async () => { + @Component({ + standalone: true, + selector: 'app', + template: '', + }) + class SimpleComponent { + onClick() {} + } + + const doc = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc, hydrationDisabled: true}); + 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({ + standalone: true, + selector: 'app', + template: 'Some text', + }) + class SimpleComponent {} + + const doc = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc}); + const ssrContents = getAppContents(html); + + expect(hasJSActionAttrs(ssrContents)).toBeFalse(); + expect(hasEventDispatchScript(ssrContents)).toBeFalse(); + }); + + it('should not replay mouse events', async () => { + @Component({ + standalone: true, + selector: 'app', + template: '
', + }) + class SimpleComponent { + doThing() {} + } + + const doc = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc}); + 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({ + standalone: true, + selector: 'app', + template: '', + }) + class SimpleComponent { + onClick() {} + } + + const doc = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc, enableEventReplay: false}); + 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({ + standalone: true, + selector: 'app', + template: '', + }) + class SimpleComponent { + onClick() {} + } + + const doc = `${EVENT_DISPATCH_SCRIPT}`; + const html = await ssr(SimpleComponent, {doc}); + 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( + `` + + ``, + ); }); }); });