From 03427c60d379eb0e85f49875411a41efa7d7e797 Mon Sep 17 00:00:00 2001 From: arturovt Date: Sat, 17 May 2025 00:59:15 +0300 Subject: [PATCH] feat(service-worker): support notification closes In this commit, support for `notificationclose` events has been added to the service worker Driver. When a notification is closed (either by user dismissal or programmatically), the Driver now captures the event, extracts the relevant notification options, and broadcasts a `NOTIFICATION_CLOSE` message to clients. This ensures the application is aware of notification lifecycle events and can react accordingly. --- .../public-api/service-worker/index.api.md | 6 +++ packages/service-worker/src/push.ts | 24 ++++++++++++ packages/service-worker/test/comm_spec.ts | 16 ++++++++ packages/service-worker/worker/src/driver.ts | 33 +++++++++++++++- .../service-worker/worker/test/happy_spec.ts | 38 +++++++++++++++++++ .../service-worker/worker/testing/scope.ts | 9 +++++ 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/goldens/public-api/service-worker/index.api.md b/goldens/public-api/service-worker/index.api.md index 649fda53396..eb9d69bf51a 100644 --- a/goldens/public-api/service-worker/index.api.md +++ b/goldens/public-api/service-worker/index.api.md @@ -46,6 +46,12 @@ export class SwPush { title: string; }; }>; + readonly notificationCloses: Observable<{ + action: string; + notification: NotificationOptions & { + title: string; + }; + }>; requestSubscription(options: { serverPublicKey: string; }): Promise; diff --git a/packages/service-worker/src/push.ts b/packages/service-worker/src/push.ts index a8491a711fb..40a694cb7c1 100644 --- a/packages/service-worker/src/push.ts +++ b/packages/service-worker/src/push.ts @@ -117,6 +117,25 @@ export class SwPush { }; }>; + /** + * Emits the payloads of notifications that were closed, along with the action (if any) + * associated with the close event. If no action was used, the `action` property contains + * an empty string `''`. + * + * Note that the `notification` property does **not** contain a + * [Notification][Mozilla Notification] object but rather a + * [NotificationOptions](https://notifications.spec.whatwg.org/#dictdef-notificationoptions) + * object that also includes the `title` of the [Notification][Mozilla Notification] object. + * + * [Mozilla Notification]: https://developer.mozilla.org/en-US/docs/Web/API/Notification + */ + readonly notificationCloses: Observable<{ + action: string; + notification: NotificationOptions & { + title: string; + }; + }>; + /** * Emits the currently active * [PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) @@ -139,6 +158,7 @@ export class SwPush { if (!sw.isEnabled) { this.messages = NEVER; this.notificationClicks = NEVER; + this.notificationCloses = NEVER; this.subscription = NEVER; return; } @@ -149,6 +169,10 @@ export class SwPush { .eventsOfType('NOTIFICATION_CLICK') .pipe(map((message: any) => message.data)); + this.notificationCloses = this.sw + .eventsOfType('NOTIFICATION_CLOSE') + .pipe(map((message: any) => message.data)); + this.pushManager = this.sw.registration.pipe(map((registration) => registration.pushManager)); const workerDrivenSubscriptions = this.pushManager.pipe( diff --git a/packages/service-worker/test/comm_spec.ts b/packages/service-worker/test/comm_spec.ts index dbfe1946482..51d36f43569 100644 --- a/packages/service-worker/test/comm_spec.ts +++ b/packages/service-worker/test/comm_spec.ts @@ -353,6 +353,22 @@ describe('ServiceWorker library', () => { }); }); + describe('notificationCloses', () => { + it('receives notification closes messages', () => { + const sendMessage = (type: string, action: string) => + mock.sendMessage({type, data: {action}}); + + const receivedMessages: string[] = []; + push.notificationCloses.subscribe((msg: {action: string}) => + receivedMessages.push(msg.action), + ); + + sendMessage('NOTIFICATION_CLOSE', 'empty_string'); + + expect(receivedMessages).toEqual(['empty_string']); + }); + }); + describe('subscription', () => { let nextSubEmitResolve: () => void; let nextSubEmitPromise: Promise; diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index 8ce7d7dbaed..cebf361c94a 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -181,11 +181,12 @@ export class Driver implements Debuggable, UpdateSource { } }); - // Handle the fetch, message, and push events. + // Handle the fetch, message, and push, notificationclick and notificationclose events. this.scope.addEventListener('fetch', (event) => this.onFetch(event!)); this.scope.addEventListener('message', (event) => this.onMessage(event!)); this.scope.addEventListener('push', (event) => this.onPush(event!)); - this.scope.addEventListener('notificationclick', (event) => this.onClick(event!)); + this.scope.addEventListener('notificationclick', (event) => this.onClick(event)); + this.scope.addEventListener('notificationclose', (event) => this.onClose(event)); // The debugger generates debug pages in response to debugging requests. this.debugger = new DebugHandler(this, this.adapter); @@ -313,6 +314,11 @@ export class Driver implements Debuggable, UpdateSource { event.waitUntil(this.handleClick(event.notification, event.action)); } + private onClose(event: NotificationEvent): void { + // Handle the close event and keep the SW alive until it's handled. + event.waitUntil(this.handleClose(event.notification, event.action)); + } + private async ensureInitialized(event: ExtendableEvent): Promise { // Since the SW may have just been started, it may or may not have been initialized already. // `this.initialized` will be `null` if initialization has not yet been attempted, or will be a @@ -418,6 +424,29 @@ export class Driver implements Debuggable, UpdateSource { }); } + /** + * Handles the closing of a notification by extracting its options and + * broadcasting a `NOTIFICATION_CLOSE` message. + * + * This is typically called when a notification is dismissed by the user + * or closed programmatically, and it relays that information to clients + * listening for service worker events. + * + * @param notification - The original `Notification` object that was closed. + * @param action - The action string associated with the close event, if any (usually an empty string). + */ + private async handleClose(notification: Notification, action: string): Promise { + const options: {-readonly [K in keyof Notification]?: Notification[K]} = {}; + NOTIFICATION_OPTION_NAMES.filter((name) => name in notification).forEach( + (name) => (options[name] = notification[name]), + ); + + await this.broadcast({ + type: 'NOTIFICATION_CLOSE', + data: {action, notification: options}, + }); + } + private async getLastFocusedMatchingClient( scope: ServiceWorkerGlobalScope, ): Promise { diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index 6d92fdae04c..e25b6f02886 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -1193,6 +1193,44 @@ import {envIsSupported} from '../testing/utils'; }); }); + describe('notification close events', () => { + it('broadcasts notification close events', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + const notification = {title: 'This is a test with action', body: 'Test body with action'}; + await driver.initialized; + await scope.handleClick( + {title: 'This is a test with action', body: 'Test body with action'}, + 'button', + ); + await scope.handleClose(notification, ''); + + const {messages} = scope.clients.getMock('default')!; + + expect(messages).toEqual([ + { + type: 'NOTIFICATION_CLICK', + data: { + action: 'button', + notification: { + title: notification.title, + body: notification.body, + }, + }, + }, + { + type: 'NOTIFICATION_CLOSE', + data: { + action: '', + notification: { + title: notification.title, + body: notification.body, + }, + }, + }, + ]); + }); + }); + it('prefetches updates to lazy cache when set', async () => { expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); await driver.initialized; diff --git a/packages/service-worker/worker/testing/scope.ts b/packages/service-worker/worker/testing/scope.ts index d99e7039e82..ef4fd9a79dd 100644 --- a/packages/service-worker/worker/testing/scope.ts +++ b/packages/service-worker/worker/testing/scope.ts @@ -254,6 +254,15 @@ export class SwTestHarnessImpl return event.ready; } + handleClose(notification: Object, action: string): Promise { + if (!this.eventHandlers.has('notificationclose')) { + throw new Error('No notificationclose handler registered'); + } + const event = new MockNotificationEvent(notification, action); + this.eventHandlers.get('notificationclose')!.call(this, event); + return event.ready; + } + override timeout(ms: number): Promise { const promise = new Promise((resolve) => { this.timers.push({