diff --git a/ct-angular/src/components/output.component.ts b/ct-angular/src/components/output.component.ts new file mode 100644 index 0000000000..f8f9b15d68 --- /dev/null +++ b/ct-angular/src/components/output.component.ts @@ -0,0 +1,17 @@ +import { DOCUMENT } from "@angular/common"; +import { Component, Output, inject } from "@angular/core"; +import { Subject, finalize } from "rxjs"; + +@Component({ + standalone: true, + template: `OutputComponent`, +}) +export class OutputComponent { + @Output() answerChange = new Subject().pipe( + /* Detect when observable is unsubscribed from, + * and set a global variable `hasUnsubscribed` to true. */ + finalize(() => ((this._window as any).hasUnsubscribed = true)) + ); + + private _window = inject(DOCUMENT).defaultView; +} diff --git a/ct-angular/tests/unmount.spec.ts b/ct-angular/tests/unmount.spec.ts index ff86935387..42214f1a05 100644 --- a/ct-angular/tests/unmount.spec.ts +++ b/ct-angular/tests/unmount.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@sand4rt/experimental-ct-angular'; import { ButtonComponent } from '@/components/button.component'; import { MultiRootComponent } from '@/components/multi-root.component'; +import { OutputComponent } from '@/components/output.component'; test('unmount', async ({ page, mount }) => { const component = await mount(ButtonComponent, { @@ -21,3 +22,16 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1'); await expect(page.locator('#root')).not.toContainText('root 2'); }); + +test('unsubscribe from events when the component is unmounted', async ({ mount, page }) => { + const component = await mount(OutputComponent, { + on: { + answerChange() {}, + }, + }); + await component.unmount(); + /* Check that the output observable had been unsubscribed from + * as it sets a global variable `hasUnusbscribed` to true + * when it detects unsubscription. Cf. OutputComponent. */ + expect(await page.evaluate(() => (window as any).hasUnsubscribed)).toBe(true); +}); diff --git a/ct-angular/tests/update.spec.ts b/ct-angular/tests/update.spec.ts index 02bf9b6e65..c6d1bf0ab4 100644 --- a/ct-angular/tests/update.spec.ts +++ b/ct-angular/tests/update.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@sand4rt/experimental-ct-angular'; import { CounterComponent } from '@/components/counter.component'; +import { ButtonComponent } from '@/components/button.component'; test('update props without remounting', async ({ mount }) => { const component = await mount(CounterComponent, { @@ -30,3 +31,28 @@ test('update event listeners without remounting', async ({ mount }) => { await expect(component.getByTestId('remount-count')).toContainText('1'); }); + +test('replace existing listener when new listener is set', async ({ mount }) => { + let count = 0; + + const component = await mount(ButtonComponent, { + props: { + title: 'Submit', + }, + on: { + submit() { + count++; + }, + }, + }); + component.update({ + on: { + submit() { + count++; + }, + }, + }); + await component.click(); + expect(count).toBe(1); +}); + diff --git a/playwright-ct-angular/registerSource.mjs b/playwright-ct-angular/registerSource.mjs index f58aa75a6f..55362dd96b 100644 --- a/playwright-ct-angular/registerSource.mjs +++ b/playwright-ct-angular/registerSource.mjs @@ -20,7 +20,7 @@ import 'zone.js'; import { getTestBed, TestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; -import { EventEmitter, reflectComponentType, Component as defineComponent } from '@angular/core'; +import { reflectComponentType, Component as defineComponent } from '@angular/core'; import { Router } from '@angular/router'; /** @typedef {import('@playwright/experimental-ct-core/types/component').Component} Component */ @@ -30,6 +30,8 @@ import { Router } from '@angular/router'; /** @type {Map} */ const __pwFixtureRegistry = new Map(); +/** @type {WeakMap>} */ +const __pwOutputSubscriptionRegistry = new WeakMap(); getTestBed().initTestEnvironment( BrowserDynamicTestingModule, @@ -48,12 +50,22 @@ function __pwUpdateProps(fixture, props = {}) { * @param {import('@angular/core/testing').ComponentFixture} fixture */ function __pwUpdateEvents(fixture, events = {}) { - for (const [name, value] of Object.entries(events)) { - fixture.debugElement.children[0].componentInstance[name] = { - ...new EventEmitter(), - emit: event => value(event) - }; + const outputSubscriptionRecord = + __pwOutputSubscriptionRegistry.get(fixture) ?? {}; + for (const [name, listener] of Object.entries(events)) { + /* Unsubscribe previous listener. */ + outputSubscriptionRecord[name]?.unsubscribe(); + + const subscription = fixture.debugElement.children[0].componentInstance[ + name + ].subscribe((event) => listener(event)); + + /* Store new subscription. */ + outputSubscriptionRecord[name] = subscription; } + + /* Update output subscription registry. */ + __pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord); } function __pwUpdateSlots(Component, slots = {}, tagName) { @@ -161,6 +173,11 @@ window.playwrightUnmount = async rootElement => { if (!fixture) throw new Error('Component was not mounted'); + /* Unsubscribe from all outputs. */ + for (const subscription of Object.values(__pwOutputSubscriptionRegistry.get(fixture) ?? {})) + subscription?.unsubscribe(); + + __pwOutputSubscriptionRegistry.delete(fixture); fixture.destroy(); fixture.nativeElement.replaceChildren(); };