Bug 268797 - notificationclick events in serviceworkers not firing
Summary: notificationclick events in serviceworkers not firing
Status: NEW
Alias: None
Product: WebKit
Classification: Unclassified
Component: Service Workers (show other bugs)
Version: Safari 17
Hardware: iPhone / iPad iOS 17
: P2 Major
Assignee: Nobody
URL:
Keywords: BrowserCompat, InRadar
Depends on:
Blocks:
 
Reported: 2024-02-05 19:51 PST by awe.media
Modified: 2024-09-10 14:58 PDT (History)
14 users (show)

See Also:


Attachments

Note You need to log in before you can comment on or make changes to this bug.
Description awe.media 2024-02-05 19:51:13 PST
# Action
- setup a webapp with a manifest with display:standalone
- include javascript in the webapp to setup push notifications (see # setup_notifications.js example code below)
- include a serviceworker (see # serviceworker.js example code below)
- serve the webapp via https
- load the webapp on iOS (16.4 or above - latest testing included up to 17.3)
- save the webapp to your homescreen
- launch the webapp from your homescreen
- make sure iOS device is attached to osx device with safari open then open remote debugging windows for webapp and serviceworker
- click the subscribe button to prompt permission request and allow permission
- then run some code to send a push notification using this new subscription

# What should happen
- iOS device should receive a push notification
- serviceworker's push event listener should be called and console.log should be shown in the serviceworker remote debugging console
- user should tap on notification
- serviceworker's notificationclick event listener should be called and console.log should be shown in the serviceworker remote debugging console
- NOTE: similar should happen for notificationclose event if you choose to set that up too

# What really happens
- iOS device receives a push notification (YAY!)
- serviceworker's push event listener is called with event.data.json() and showNotification() part of this handler running successfully so the notification is shown to the user
- BUT the console.log does not show in the serviceworker remote debugging console and there doesn't seem to be any way to verify what's happening inside this handler
- NOTE: You can alter the field values sent to the showNotification() call so this handler is definitely being called but all output is masked - perhaps for security reasons?! But how do you ever debug this then??
- user taps on notification
- serviceworker's notificationclick event listener is not called as far as we can see and NO console.log output is shown in the serviceworker remote debugging console - we've also been unable to call client.postMessage() in any way - so there's no way at all that we can see to pass data from the push notification to the pwa at all - and no way to actually even tell the pwa that it was opened because of a push notification (e.g. differentiate this from a user simply launching from the homescreen)
- NOTE: notificationclose doesn't fire either

One example device used for testing is an iPhone 12 Pro (Model No. MGMK3X / A2407)

There's lots of posts & comments about people struggling to get this working - and we've tried all the suggested fixes but none of them worked for us
- https://bugs.webkit.org/show_bug.cgi?id=252544#c18
- https://stackoverflow.com/questions/76399649/why-isnt-the-notificationclick-event-called-on-ios-during-pwa-push-notificati
- ...

Also seems to be a problem on other browser platforms
- https://stackoverflow.com/questions/57773138/js-notificationclick-event-not-work-in-chrome-macos
- https://stackoverflow.com/questions/66224762/notificationclick-is-not-fired-for-remote-notifications (not clear if this is iOS)
- https://stackoverflow.com/questions/56157308/firebase-web-push-notification-on-click-not-working#comment130045525_65376596

We've also scoured github for working code examples but none of them worked for us.

We've ensured that the push and notificationclick handlers use event.waitUntil() as suggested.

We've also tried all sorts of versions of identifying clients (.matchAll(), .openWindow(), etc.) to successfully get a client and send a .postMessage(), but none succeed (except edge case described below).


For further testing, if you open one of the remote debugging consoles and call ...registration.showNotification('hi', { title:123, body:'456789' }); (e.g. either from the webapp console using a promise chain to get the current registration or from the serviceworker console using self.registration) then the notification is displayed, but still the push/notificationclick events do not fire.

But if you close the webapp completely and then launch it again, and then repeat the step above the notificationclick events DO SOMETIMES FIRE. Or at least sometimes you can see the console.log output.

However, if you then send a push notification via APNS then the console.logs no longer work after that.

We've also tried all combinations of webapp open and focused, webapp hidden, screen locked and screen asleep. Only in some minimal cases for notifications generated locally via the debug console can we ever see any notificationclick event output. And any postMessage() calls inside that work in those cases too. But the rest of the time none of it works. 

Also, the push events NEVER output any console.log info.

We've also ensured there's only 1 serviceworker and only 1 webapp/context (both visually and via the devtools in safari for remote debugging).

If there is a working example app somewhere that someone can point us to we'd be happy to try to validate this using that. But as far as we can see after a lot of testing, there's no way to pass data from a server via APNS through a user gesture to a pwa. Without this push notifications on iOS are only 50% useful and you can never really verify that a user has successfully completed push notification setup.

Please let us know if you need any other information.


# setup_notifications.js example code snippet
---
function setup_push_notifications(registration) {
  return new Promise((resolve, reject) => {
    registration.pushManager.getSubscription()
    .then((subscription) => {
      if (subscription) {
        resolve(true);
      } else {
        document.querySelector('#subscribe > button').addEventListener('click', () => { // require user gesture
          registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapid_public_key })
          .then((subscription) => {
            // store the subscription for use in sending push notifications
        });
      }
    });
  });
}
---

NOTE: This code does NOT use the window.Notification() API (which seems to be off by default in iOS 17), but only uses the serviceworker based .pushManager.subscribe() (see above) and registration.showNotification() (see below).


# serviceworker.js example code snippet
---
self.addEventListener("push", (event) => {
  const io = await event.data.json(); // this works
  console.log('PUSH'); // this DOES NOT work - note we are not including any objects but just a debug comment here!
  event.waitUntil(self.registration.showNotification(io.title, io)); // this works
});

self.addEventListener("notificationclick", (event) => {
  console.log('CLICK'); // this DOES NOT work - note we are not including any objects but just a debug comment here!
});
---
Comment 1 awe.media 2024-02-05 21:22:11 PST
Related notificationclose event bug at https://bugs.webkit.org/show_bug.cgi?id=247978
Comment 2 awe.media 2024-02-06 03:18:51 PST
After even more testing it seems like the APNS notification is loading a new serviceworker and we're guessing that's not connected to any clients (or at least the web inspector doesn't know about them and we can't see them visually in iOS).

We added this code to the top of our serviceworker script.

  var load_id = new Date().getTime();

Then in the push notification we updated the code to include that in the notification title.

  event.waitUntil(self.registration.showNotification(load_id, { body:... }));

If we watch the web inspector for the serviceworker when it loads it logs one value for this. e.g.

  1707199885867

And we can see there's only one serviceworker available in the web inspector and at any time if we run console.log(load_id) it shows that value. Or we can show a notification via the service worker like this.

  self.registration.showNotification(load_id, { body:'123' });

And the title of the notification that is displayed is the same value. e.g.

  1707199885867

But if we push a notification via the APNS then the value in the title of the shown notification is different e.g.

  1707199920217

If we check the web inspector after that we still see the original serviceworker and it's load_id hasn't changed. And no new serviceworkers seem to be available. getRegistrations() doesn't return any others either.

We also added an activate_id that's initially set to 0 and then updated inside an 'activate' listener.

  activate_id = new Date().getTime();

We updated the push listener to put this into the notification body.

If we manually show a notification via the web inspector e.g. 

  self.registration.showNotification(load_id, { body:activate_id });

Then we can see in the body of the notification an updated activate_id that matches what we can get via the web inspector for the current serviceworker.

But if we push a notification then the value in the body is 0.

So it looks like the push notifications sent via APNS are creating a new serviceworker and are not activating it or not waiting until it's activated.

And each time a new push notification is pushed via APNS the load_id is different too.

We even tried this convoluted push handler with timeouts to see if it might allow time for the serviceworker to be activated.

  self.addEventListener('push', (event) => {
    var io = event.data.json();
    var p = new Promise((resolve, reject) => {
      setTimeout(() => {
        console.log("PUSH TIMEOUT SETUP!");
        self.registration.showNotification(load_id, { body:activate_id })
        .then(() => {
          console.log("PUSH RUN!", load_id, activate_id);
          resolve();
        });
      }, 1000);
    });
    event.waitUntil(p);
  });

The shown notifications still show the load_id, activate_id as defined so the setTimeout() is definitely running. But the console.log's never output any info (I guess they're writing to the console of the new [invisible] service worker - which explains this whole issue really) and the activate_id is always 0.

To explore a little further we also setup an install_id that's initially 0 and then set in an install listener. And then put this into the push handlers showNotification() call.

  self.registration.showNotification(`LI:${load_id}`, { body:`I:${install_id} - A:${activate_id} - S:${self.serviceWorker.state}` })

If we call this from the web inspector console the body shows updated id's for install_id and activate_id and it's state is 'activated'.

But if we push a notification via APNS the body shows install_id as 0, activated_id as 0 BUT the state as 'activated'. This is really confusing because if the serviceworker had called the install and activated events then those id's should be updated.

Note, we also experimented with adding/removing self.skipWaiting() and clients.claim() in the relevant spots in case they were creating issues with the APNS spawned serviceworker lifecycle. But this didn't fix the issue.

Strangely, if you fully close the webapp and then launch it from the homescreen again then the install_id and activated_id remain 0 too. Surely either the existing serviceworker should be re-used or a new one should be created but also go through the install and activate lifecycle?!

If we run the same tests in desktop chrome then it re-uses the serviceworkers as expected and all notifications show the same loaded_id, install_id and activation_id.

Hope this helps...
Comment 3 awe.media 2024-02-06 14:46:13 PST
I think this bug (268797) is really the root cause of this other issue too https://bugs.webkit.org/show_bug.cgi?id=263687
Comment 4 awe.media 2024-02-06 15:05:54 PST
One more detail.

Even though the push notifications via APNS seem to be starting a new serviceworker, these notifications do still show up in the registration.getNotifications() list - as long as the user hasn't already tapped/clicked on them of course.
Comment 5 awe.media 2024-02-06 16:12:07 PST
Even more details.

We've found a way to verify that the notificationclick event is actually firing.

We updated this handler to add a windowOpen() call and found that in one scenario (see below) this does open a new client.

  self.addEventListener('notificationclick', (event) => {
    return new Promise((resolve, reject) => {
      clients.openWindow(`${self.registration.scope}app.html?${new Date().getTime()}`)
      .then((c) => {
        c.postMessage('new notification');
        resolve();
      });
    });
    event.waitUntil(p);
  });

However, there's a few specific things we found.

If you save the webapp to your homescreen and then launch it and keep it open, then send a push notification via APNS and click on it then this windowOpen() call doesn't seem to impact this webapp/client at all - except maybe foreground it.

But if you fully close this webapp and then send a push notification via APNS and click on it this windowOpen() launches the app and creates a new windowclient inside that app. And if the url you pass to windowOpen() is not the same as the default url in the manifest (e.g. see the ?${new Date().getTime()} in the example code above), then a new windowclient is opened and focused inside the webapp it launches.

So the first time you do that you end up with 2 windowclients visible in the web inspector.

- about:blank
- ${self.registration.scope}app.html?${new Date().getTime()}  (focused/visible windowclient)

If you keep this webapp open (in foreground or background) then each time you send a new push notification and click on it then a new windowclient gets created (leaving the about:blank one there too).

If you fully close the webapp then obviously all these windowclients get closed too and then you can repeat the process.

It seems that the new serviceworker launched by the push notifications sent via APNS creates a new windowclient and assumes it will use the default url from the manifest. If the notificationclick has a windowOpen() that uses that url then this initial windowclient gets used. Otherwise a new one is created for each new url.

But, only after the webapp is fully closed after the first launch. 

Even if the serviceworker uses skipWaiting() and clients.claim()! 

Looks like this hidden serviceworker is definitely not following the correct lifecycle (although the previous points about install_id and activate_id had also shown that).

Hope that extra info helps...
Comment 6 awe.media 2024-02-06 18:32:57 PST
Ha! You can get the notificationclick event to fire if you add a setTimeout() after the windowOpen() to allow the new windowclient time to be started up (see related https://bugs.webkit.org/show_bug.cgi?id=252544).

  self.addEventListener('notificationclick', (event) => {
    return new Promise((resolve, reject) => {
      clients.openWindow(PUT_DEFAULT_URL_FROM_YOUR_MANIFEST_HERE) // if you use another url new windowclients will be spawned after relaunch
      .then((c) => {
        setTimeout(() => { // delay until windowclient really setup
          console.log("NOTIFICATION CLICK 1");
          c.postMessage('new notification 1');
        }, 1000); // guestimate - ymmv but if it doesn't fire then make this longer
        resolve();
      });
    });
    event.waitUntil(p);
  });

This is an extension of the code in the last comment.

So it appears the events are working and windowOpen() is working.

However it's now clear that the issues are:
- push notifications sent via APNS are creating new serviceworkers (even if existing ones are running/open)
- these new serviceworkers aren't following the prescribed lifecycle (e.g. state:activated without calling install/activate events)
- these new serviceworkers aren't following skipWaiting() (or clients.claim()) so they only really work once the webapp is fully closed/re-launched (guess this is just a side effect of not firing 'install' event?)
- these new serviceworkers are leaving an about:blank dead windowclient if they do launch the app and the url passed to windowOpen() is not the default url from your manifest
- these new serviceworkers aren't queuing postMessage()'s that are sent to them during their startup (even though their state:activated)

Shout if you need any other information...
Comment 7 awe.media 2024-02-06 18:57:53 PST
Also note that the newly created invisible serviceworkers are there and definitely running. You can postMessage() to them via navigator.serviceWorker.controller of the new windowclient. But these serviceworkers really are invisible as you cannot see them from the web inspector list of serviceworkers.
Comment 8 awe.media 2024-02-06 19:35:14 PST
Another distinction. The comments above about the new invisible serviceworkers creating windowclients are only valid if the app is fully closed and the push notification via APNS launches the webapp.

If the webapp was opened by the user (even if it has been opened/closed a number of times) then the invisible serviceworkers don't seem to communicate with the webapp at all.
Comment 9 awe.media 2024-02-06 20:23:37 PST
We also tried using the BroadcastChannel API to communicate between the different serviceworkers.

They can all communicate as expected - but only IF the push notification via APNS launches the webapp.

If the webapp is launched by the user then the invisible serviceworker doesn't seem to be able to communicate at all.
Comment 10 awe.media 2024-02-11 17:36:12 PST
Today we tested with iOS updated to 17.4 Beta (21E5195e) and OSX using Safari Technology Preview Release 188 (Safari 17.4, WebKit 19619.1.2.1.1) and this still doesn't work.

Also looks like with iOS 17.4 that Service Workers aren't showing up in the web inspector Develop menu any more either now 8/
Comment 11 Radar WebKit Bug Importer 2024-02-12 19:52:16 PST
<rdar://problem/122847012>
Comment 12 awe.media 2024-03-31 23:30:58 PDT
Ping...any updates on this?

Need any other information?

Thanks.
Comment 13 awe.media 2024-05-21 17:33:08 PDT
Ping again - 2 months on - any other information required?

This bug is really a more detailed report of the last comment here https://github.com/WebKit/WebKit/pull/11848#issuecomment-1823197815

Seems like https://bugs.webkit.org/show_bug.cgi?id=252544 was prematurely closed.  Maybe it was only tested with the app still open when push notification was sent?

This really is a critical, blocking bug so any comment/update would be appreciated.

Thanks.
Comment 14 youenn fablet 2024-05-22 09:41:32 PDT
FWIW, iOS 17.5 received some fixes in that area.

@awe.media, can you provide me (youenn@apple.com) a sysdiagnose on 17.5 if it reproduces there?
Comment 15 awe.media 2024-05-22 21:15:35 PDT
Hi @youenn - sysdiagnose in your inbox as requested.
Comment 16 awe.media 2024-06-04 16:25:54 PDT
We provided a detailed/working test framework to @youenn on 24th May 2024.

If anyone else on this ticket needs this to replicate the issue please let us know and we'll provide it for you.
Comment 17 Jakob Wierzba 2024-07-02 07:20:14 PDT
I can confirm @awe.media's excellent detective work (on iOS 17.5.1).

I wish I had found this bug report earlier! I haven't even gotten around to handling notificationclick, in our case the main problem is the inability to communicate via Clients or postMessage ("simple" an BroadcastChannel).
Comment 18 awe.media 2024-07-02 15:52:12 PDT
Thanks @JakobWierzba.

Ping youennf@gmail.com, cdumez@apple.com, karlcow@apple.com and beidson@apple.com.

It's been another month+ now. 

Can you please confirm you've used the detailed/working test framework we provided (see Comment 16) and that you've at least been able to replicate the issue?

This is a critical, blocking bug and it's really just the last piece in the puzzle that's stopping people really using all the hard work you've put in to implement Push Notifications.
Comment 19 Berni 2024-07-19 02:51:03 PDT
I have a quite complex application using IDDB (via Dexie.js) and BroadcastChannel.postMessage to communicate between service worker and DOM.

After many weeks of logging and trial and error it's working pretty stable now using the following rules:
 

1) 
use 'event.preventDefault();' otherwise it doesn't work on iOS. I suspect the close handler needed e.g. for 'Chrome' does just close the notification on iOS without further processing. I tried also other solutions such as putting close on the end of the click handler but nothing works stable.

------
self.addEventListener('notificationclick', async function (event) {
    event.waitUntil(
        doNotificationClick(event)
    );
});

async function doNotificationClick(event: NotificationEvent) {
    event.preventDefault();
    event.notification.close();
    .....
}

2) 
when the DOM code has some visibility related code using IDDB as well, in my case handling PWA comes to foreground things, broadcast a simple (boolean) lock from service worker around your code inside the notificationclick handler. Otherwise you might get some threading issues. 

------------------------

Out of the question using push notifications will up mess thins a lot especially
on iOS. 
1) on iOS it spans sometimes multiple service workers for the same root URL
2) Safari WebInspektor will also show multiple DOM instances from same root
3) Safari WebInspektor never ever shows a service worker for iOS (mostly true for Safari Desktop PWAs as well)
4) tag property for notification is not respected -> https://bugs.webkit.org/show_bug.cgi?id=258922
Comment 20 awe.media 2024-08-04 01:21:32 PDT
We've tested this in iOS17.6 and we can now get notificationclick to work (even without the .preventDefault() or waitUntil()).

But it still requires the setTimeout() while waiting for the windowClient to become responsive which seems very fragile (would be much better if this was a promise)

It also requires some code to select an existing client if one has a URL that matches what you're about to open.

Here's what now seems to work.

self.addEventListener('notificationclick', (event) => {
  let client_count = 0;
  return new Promise((resolve, reject) => {
    clients.matchAll({ 
      type:'window', 
      includeUncontrolled:true // optional based on your requirements
    })
    .then((client_list) => {
      client_list.forEach((client) => {
        if (client.url == YOUR_URL) { // or similar based on your requirements
          client_count++;
          delayed_function(() => { client.postMessage(`Scenario A ${event.notification.title}`); });
        }
      });
      if (!client_count) {
        clients.openWindow(YOUR_URL)
        .then((client) => {
          delayed_function(() => { client.postMessage(`Scenario B ${event.notification.title}`); });
        });
      }
    })
    .catch((e) => {
      console.error('clients.matchAll error', e);
    });
    if (client_count) {
      resolve();
    } else {
      reject();
    }
  });
});

function delayed_function(f) {
  setTimeout(() => {
    f();
  }, 3000); // guestimate - ymmv but if it doesn't fire then make this longer
}


Generally this works if the app is:
- still open and focused
- in the background 
- completely closed

There does still seem to be a corner case where it doesn't work, but we haven't been able to reliably isolate that yet.

There's also the problem with the "about:blank" windowclient visible in the web inspector, but this is not critical.
Comment 21 awe.media 2024-08-05 20:46:03 PDT
Looking at the ServiceWorker spec it looks like the promises for Clients.matchAll() and Clients.openWindow() shouldn't resolve with a client until the client is ready to handle events (e.g. client.postMessage()).

- .matchAll() -> https://w3c.github.io/ServiceWorker/#dom-clients-matchall
- .openWindow() -> https://w3c.github.io/ServiceWorker/#dom-clients-openwindow

So no timeout should be required. If nothing else, then the .postMessage() calls should be queued.
Comment 22 dlarocque 2024-08-27 11:31:10 PDT
The Firebase JS SDK has received a few issue reports that may be related to this bug:
 - https://github.com/firebase/firebase-js-sdk/issues/7698
 - https://github.com/firebase/firebase-js-sdk/issues/8002
Comment 23 Stefan 2024-08-28 07:08:16 PDT
We are also experience this issue where notificationClick event is not fired as of 17.6.1 version of Safari. 

Any update on this one as it's also big blocker for us?

Issue we experience is exactly the same as described in following post: 
https://intercom.help/progressier/en/articles/9213767-why-ios-push-notifications-sometimes-don-t-redirect-to-the-correct-url-in-a-pwa
Comment 24 Peter 2024-08-30 06:31:35 PDT
+1
Comment 25 Ben Nham 2024-09-10 11:21:10 PDT
> But it still requires the setTimeout() while waiting for the windowClient to become responsive which seems very fragile (would be much better if this was a promise)

There were a couple of recent (like fixed yesterday) fixes in this area to fix a race condition between when a WindowClient became visible to the code and when it actually became ready to accept focus events:

https://bugs.webkit.org/show_bug.cgi?id=279181
https://bugs.webkit.org/show_bug.cgi?id=279263

It will take a little while for those fixes to filter in to a customer build.
Comment 26 Ben Nham 2024-09-10 12:03:05 PDT
So reading through this bug a bit more, it sounds like the notificationclick event is firing properly, but some methods called in the notificationclick event handler are not working as intended. It sounds like some Clients and WindowClient methods resolve too early. Let's fork off new bugs on these issues to make this clearer:

1. WindowClient.matchAll resolves promise too early (279458)
2. Clients.openWindow resolves promise too early (279456)

The first bug sounds a bit like the focus bugs I linked above, in that some operation (in that case focus, in your case postMessage) was being performed on WindowClient before it was ready. It might be a very similar issue but requires some debugging.

The second bug sounds like we might just be resolving the promise too early (from cursory code inspection this seems like a reasonable first guess).
Comment 27 awe.media 2024-09-10 13:48:39 PDT
sgtm!