From 629b255ddb9cc44ea69b9d72c3b6f56c0bf5a9f1 Mon Sep 17 00:00:00 2001 From: Thomas Nguyen Date: Thu, 9 May 2024 17:59:56 -0700 Subject: [PATCH] refactor(core): Add four tests and fix code to make tests pass. (#55747) The first test asserts that bubbling does not work right now. The second asserts that stopPropagation works, which should pass when test #1 passes too. The third test asserts properties about the events passed to the event handler. THe fourth test asserts that mouse events do not translate to jsaction nor help emit the jsaction binary. This required a change in code to make this pass. PR Close #55747 --- packages/core/src/hydration/event_replay.ts | 8 + packages/platform-server/src/utils.ts | 8 - .../platform-server/test/event_replay_spec.ts | 533 +++++++++++------- 3 files changed, 325 insertions(+), 224 deletions(-) diff --git a/packages/core/src/hydration/event_replay.ts b/packages/core/src/hydration/event_replay.ts index b916f6881fe..15f41225d67 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 ba751d19503..c0060376951 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 2c8d309b618..e32331938c6 100644 --- a/packages/platform-server/test/event_replay_spec.ts +++ b/packages/platform-server/test/event_replay_spec.ts @@ -25,6 +25,7 @@ import {getAppContents, hydrate, render as renderHtml, resetTViewsFor} from './d * event dispatch (JSAction) logic. */ const EVENT_DISPATCH_SCRIPT = ``; +const DEFAULT_DOCUMENT = `${EVENT_DISPATCH_SCRIPT}`; /** Checks whether event dispatch script is present in the generated HTML */ function hasEventDispatchScript(content: string) { @@ -37,88 +38,78 @@ 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; + afterEach(() => { + doc.body.textContent = ''; + }); - const hydrationProviders = hydrationDisabled - ? [] - : enableEventReplay - ? provideClientHydration(withEventReplay()) - : provideClientHydration(); + /** + * 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 = DEFAULT_DOCUMENT} = options; - const bootstrap = () => - bootstrapApplication(component, { - providers: [provideServerRendering(), hydrationProviders], - }); + const hydrationProviders = hydrationDisabled + ? [] + : enableEventReplay + ? provideClientHydration(withEventReplay()) + : provideClientHydration(); - return renderApplication(bootstrap, { - document: doc, + const bootstrap = () => + bootstrapApplication(component, { + providers: [provideServerRendering(), hydrationProviders], }); - } - describe('server rendering', () => { - let doc: Document; - const originalDocument = globalThis.document; - const originalWindow = globalThis.window; + 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); - } - } + 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); - }); - - beforeEach(() => { - doc = TestBed.inject(DOCUMENT); - }); - - 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: ` + 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 +118,282 @@ describe('event replay', () => {
`, - }) - class SimpleComponent { - onClick = clickSpy; - onFocus = focusSpy; - } + }) + class SimpleComponent { + onClick = clickSpy; + onFocus = focusSpy; + } + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); - 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(); + }); - 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('` + - ``, - ); - }); + 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 html = await ssr(SimpleComponent); + 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 html = await ssr(SimpleComponent); + 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 html = await ssr(SimpleComponent, {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 html = await ssr(SimpleComponent); + 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( + `` + + ``, + ); }); }); });