firebase-js-sdk icon indicating copy to clipboard operation
firebase-js-sdk copied to clipboard

iOS Web Push Device Unregisters Spontaneously

Open fred-boink opened this issue 1 year ago • 81 comments

Operating System

iOS 18.4+

Browser Version

Safari

Firebase SDK Version

10.7.2

Firebase SDK Product:

Messaging

Describe your project's tooling

NextJS 13 PWA

Describe the problem

Push notifications eventually stop being received until device is re-registered. Can take a few hours and lots of messages to occur but eventually stops receiving push.

People mention this can be a cause, Silent Push can cause your device to become unregistered: https://dev.to/progressier/how-to-fix-ios-push-subscriptions-being-terminated-after-3-notifications-39a7

Safari doesn’t support invisible push notifications. Present push notifications to the user immediately after your service worker receives them. If you don’t, Safari revokes the push notification permission for your site.

https://developer.apple.com/documentation/usernotifications/sending_web_push_notifications_in_web_apps_and_browsers

Possible that Firebase does not waitUntil and WebKit thinks its a invisible push?

Steps and code to reproduce issue

public/firebase-messaging-sw.js

importScripts('https://www.gstatic.com/firebasejs/10.7.2/firebase-app-compat.js');

importScripts('https://www.gstatic.com/firebasejs/10.7.2/firebase-messaging-compat.js');

firebase.initializeApp({
    apiKey: '',
    authDomain: '',
    projectId: '',
    storageBucket: '',
    messagingSenderId: '',
    appId: '',
    measurementId: ''
});

const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) => {
    const {data} = payload;
    // Customize notification here
    const notificationTitle = data?.title;
    const notificationOptions = {
        data: {
            link: data?.link
        },
        body: data?.body,
        badge: './icons/icon-mono.png',
        icon:
            data?.senderProfileImage
    };

    return self.registration.showNotification(
        notificationTitle,
        notificationOptions
    );
});

self.addEventListener('notificationclick', function (event) {
    event.notification.close();

    event.waitUntil(
        clients
            .matchAll({
                type: 'window'
            })
            .then(function (clientList) {
                for (var i = 0; i < clientList.length; i++) {
                    var client = clientList[i];
                    if (client.url === '/' && 'focus' in client) {
                        return event?.notification?.data?.link
                            ? client.navigate(
                                `${self.origin}/${event?.notification?.data?.link}`
                            )
                            : client.focus();
                    }
                }
                if (clients.openWindow) {
                    return clients.openWindow(
                        event?.notification?.data?.link
                            ? `${self.origin}/${event?.notification?.data?.link}`
                            : '/'
                    );
                }
            })
    );
});
  • install app to homescreen
  • Receive notifications
  • Notice notifications no longer are received
  • Re-register device
  • Receive notifications

fred-boink avatar Feb 05 '24 01:02 fred-boink

Looks like the same issue as https://github.com/firebase/firebase-js-sdk/issues/8013!

JVijverberg97 avatar Feb 13 '24 13:02 JVijverberg97

This keeps happening to users. This is the extent of our code. Why would the device stop sending pushes?

messaging.onBackgroundMessage((payload) => {
    const {data} = payload;
    const notificationTitle = data?.title;
    const notificationOptions = {
        data: {
            link: data?.link
        },
        body: data?.body,
        badge: './icons/icon-mono.png',
        icon:
            data?.senderProfileImage
    };

    return self.registration.showNotification(
        notificationTitle,
        notificationOptions
    );
});

fred-boink avatar Mar 11 '24 23:03 fred-boink

We are also having the same issue, looks like it works about 3 times on iOS and then just stops until the app is then opened and the token refreshed again.

messaging.onBackgroundMessage((payload) => {
    const notificationTitle = payload.notification.title;
    const notificationOptions = {
        body: payload.notification.body,
        icon: payload.notification.image ?? "/icon-256x256.png",
        click_action: payload.data.link,
    };

    return self.registration.showNotification(
        notificationTitle,
        notificationOptions
    );
});

gbaggaley avatar Mar 25 '24 15:03 gbaggaley

Could this be related to this? https://dev.to/progressier/how-to-fix-ios-push-subscriptions-being-terminated-after-3-notifications-39a7

Not sure if the onBackgroundMessage message takes care of the waitUntil

graphem avatar Apr 03 '24 01:04 graphem

Could this be related to this? https://dev.to/progressier/how-to-fix-ios-push-subscriptions-being-terminated-after-3-notifications-39a7

Not sure if the onBackgroundMessage message takes care of the waitUntil

I suspect the issue is iOS thinks this is a invisible push notifications because firebase is doing it async, but until someone actually looks into, I don't know. We are debating moving off of FCM for this reason, it just stops working after some time.

fred-boink avatar Apr 03 '24 02:04 fred-boink

Alright that is indeed the issue, I just ran a bunch of test and I replace the onBackgroundMessage with this in my service worker:

self.addEventListener('push', function(event) {
    console.log('[Service Worker] Push Received.');
    const payload = event.data.json();  // Assuming the payload is sent as JSON
    const notificationTitle = payload.notification.title;
    const notificationOptions = {
        body: payload.notification.body,
        icon: payload.notification.icon,
        image: payload.notification.image,
        badge: payload.notification.badge,
    };
    event.waitUntil(
        self.registration.showNotification(notificationTitle, notificationOptions)
    );
});

This is working perfectly, so I just remove all trace of firebase in the service worker. All the notifications are coming in now in iOS.

I think this need to be address in the firebase codebase.

graphem avatar Apr 03 '24 02:04 graphem

Alright that is indeed the issue, I just ran a bunch of test and I replace the onBackgroundMessage with this in my service worker:

``self.addEventListener('push', function(event) { console.log('[Service Worker] Push Received.'); const payload = event.data.json(); // Assuming the payload is sent as JSON const notificationTitle = payload.notification.title; const notificationOptions = { body: payload.notification.body, icon: payload.notification.icon, image: payload.notification.image, badge: payload.notification.badge, }; event.waitUntil( self.registration.showNotification(notificationTitle, notificationOptions) ); });`

This is working perfectly, so I just remove all trace of firebase in the service worker. All the notifications are coming in now in iOS.

I think this need to be address in the firebase codebase.

How do we get their attention!

fred-boink avatar Apr 03 '24 02:04 fred-boink

Alright that is indeed the issue, I just ran a bunch of test and I replace the onBackgroundMessage with this in my service worker: ``self.addEventListener('push', function(event) { console.log('[Service Worker] Push Received.'); const payload = event.data.json(); // Assuming the payload is sent as JSON const notificationTitle = payload.notification.title; const notificationOptions = { body: payload.notification.body, icon: payload.notification.icon, image: payload.notification.image, badge: payload.notification.badge, }; event.waitUntil( self.registration.showNotification(notificationTitle, notificationOptions) ); });` This is working perfectly, so I just remove all trace of firebase in the service worker. All the notifications are coming in now in iOS. I think this need to be address in the firebase codebase.

How do we get their attention!

Yeah, seems like a big deal, since it is not working on iOS

graphem avatar Apr 03 '24 02:04 graphem

Wow this solved my exact problem. Thank you!!

jonathanyin12 avatar Apr 03 '24 06:04 jonathanyin12

Alright that is indeed the issue, I just ran a bunch of test and I replace the onBackgroundMessage with this in my service worker:

self.addEventListener('push', function(event) {
    console.log('[Service Worker] Push Received.');
    const payload = event.data.json();  // Assuming the payload is sent as JSON
    const notificationTitle = payload.notification.title;
    const notificationOptions = {
        body: payload.notification.body,
        icon: payload.notification.icon,
        image: payload.notification.image,
        badge: payload.notification.badge,
    };
    event.waitUntil(
        self.registration.showNotification(notificationTitle, notificationOptions)
    );
});

This is working perfectly, so I just remove all trace of firebase in the service worker. All the notifications are coming in now in iOS.

I think this need to be address in the firebase codebase.

Not working for me. OK for Android but not in IOS.

laubelette avatar Apr 04 '24 14:04 laubelette

Wow this solved my exact problem. Thank you!!

Hello. Possible to have a piece of code ?

laubelette avatar Apr 05 '24 06:04 laubelette

Update: After fixing some issues with my service worker and nixing my foreground notification handlers, it seems to work more reliably with the event.waitUntil() solution.

Another Update: A notification failed to send after 44 restarts. I was able to reactivate by requesting another token (after an additional restart) but I don't know what causes it as I'm just calling FCM's getToken. I'm thinking of occasionally checking if the token changes against the one stored in local storage and replacing it when needed.

More Findings: When FCM's getToken is called, it appears to update that client's token in Firebase's Topics. It's not reactivating the old token. The old token returns UNREGISTERED. The double notification might be that the token gets subscribed twice to a Topic (one is a new subscription, and the other one is mapped from the old one?).


I also removed Firebase from the service worker. I then tested if notifications would break by restarting the iOS device over and over while also sending a notification between restarts. Eventually, a notification would fail. It is possible it triggered three silent notifications but I also noticed other PWAs on the same device would not be able to confirm the subscription status either.

I don't believe this issue is specific to one PWA's implementation and it's just a bug with Safari. Or somehow another PWA's implementation is causing the others to fail on the same device.

I also noticed that requesting a new messaging token seems to "reactivate" the old one. If you subscribe with this new token, and send a notification to the topic, the iOS device will get two separate notifications.

~~Edit: I removed the other PWAs, and after a dozen restarts, the notifications still work as expected. I'm still doubtful, so I'll keep trying to reproduce it.~~

~~Edit 2: It eventually ended up failing twice afterwards.~~

rchan41 avatar Apr 08 '24 10:04 rchan41

@rchan41 Hi! Sorry just to be clear did you get your PWA to send Push notifications to IOS ? Did it work?

ZackOvando avatar May 02 '24 21:05 ZackOvando

@rchan41 Hi! Sorry just to be clear did you get your PWA to send Push notifications to IOS ? Did it work?

Yes, I didn't have issues sending push notifications to iOS. The issue I described with notifications after restarting the iOS device. However, these issues might be unrelated to the topic's issue.

rchan41 avatar May 02 '24 23:05 rchan41

I can confirm that this is indeed because of silent notifications, when you have an onMessage handler for the foreground to handle the notification yourself (for example showing a toast in your app) and the app gets a push notification, Safari's console logs: Push event ended without showing any notification may trigger removal of the push subscription. Screenshot 2024-05-19 at 5  27 52@2x

On the third silent notification, Safari disables push notifications.

Having the app in the background, so that onBackgroundMessage is being triggered, does not cause this issue.

My code
service-worker.js
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />

const sw = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (self));
import { initializeApp, getApps, getApp } from "firebase/app";
import { getMessaging, onBackgroundMessage } from "firebase/messaging/sw";

const firebase =
  getApps().length === 0
    ? initializeApp({
        apiKey: "apiKey",
        authDomain: "authDomain",
        projectId: "projectId",
        storageBucket: "storageBucket",
        messagingSenderId: "messagingSenderId",
        appId: "appId",
        measurementId: "measurementId"
      })
    : getApp();

const messaging = getMessaging(firebase);
onBackgroundMessage(messaging, async (/** @type {import("firebase/messaging").MessagePayload} */ payload) => {
  console.log("Received background message ", payload);
  const notification = /** @type {import("firebase/messaging").NotificationPayload} */ (payload.notification);
  const notificationTitle = notification?.title ?? "Example";
  const notificationOptions = /** @type {NotificationOptions} */ ({
    body: notification?.body ?? "New message from Example",
    icon: notification?.icon ?? "https://example.com/favicon.png",
    image: notification?.image ?? "https://example.com/favicon.png"
  });

  if (navigator.setAppBadge) {
    console.log("setAppBadge is supported");
    if (payload.data.unreadCount && payload.data.unreadCount > 0) {
      console.log("There are unread messages");
      if (!isNaN(Number(payload.data.unreadCount))) {
        console.log("Unread count is a number");
        await navigator.setAppBadge(Number(payload.data.unreadCount));
      } else {
        console.log("Unread count is not a number");
      }
    } else {
      console.log("There are no unread messages");
      await navigator.clearAppBadge();
    }
  }

  await sw.registration.showNotification(notificationTitle, notificationOptions);
});
Layout file
// ...some checks before onMessage
onMessage(messaging, (payload) => {
  toast(payload.notification?.title || "New message", {
    description: MessageToast,
    componentProps: {
      image: payload.notification?.image || "/favicon.png",
      text: payload.notification?.body || "You have a new message",
      username: payload.data?.username || "Unknown"
    },
    action: {
      label: "View",
      onClick: async () => {
        await goto(`/${dynamicUrl}`);
      }
    }
  });
});

DarthGigi avatar May 19 '24 15:05 DarthGigi

@DarthGigi This is working properly? What version of firebase are you using? I am still getting unregistering even when using simple code like:

self.addEventListener('push', function(event) {
    console.log('[Service Worker] Push Received.');
    const payload = event.data.json();  // Assuming the payload is sent as JSON
    const notificationTitle = payload.notification.title;
    const notificationOptions = {
        body: payload.notification.body,
        icon: payload.notification.icon,
        image: payload.notification.image,
        badge: payload.notification.badge,
    };
    event.waitUntil(
        self.registration.showNotification(notificationTitle, notificationOptions)
    );
});
`

fred-boink avatar Jun 22 '24 10:06 fred-boink

@fred-boink this is the latest iteration of things on one of the projects that I'm working on. I wasn't the original developer so it's been quite a journey trying to troubleshoot. The current iteration of the firebase service worker is still leading to reports of notifications stopping after a while. Don't know what the secret sauce is.

firebase-sw.js

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  if (event.notification && event.notification.data && event.notification.data.notification) {
    const url = event.notification.data.notification.click_action;
    event.waitUntil(
      self.clients.matchAll({type: 'window'}).then( windowClients => {
        for (var i = 0; i < windowClients.length; i++) {
          var client = windowClients[i];
          if (client.url === url && 'focus' in client) {
            return client.focus();
          }
        }
        if (self.clients.openWindow) {
          console.log("open window")
          return self.clients.openWindow(url);
        }
      })
    )
  }
}, false);

importScripts('https://www.gstatic.com/firebasejs/9.2.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.2.0/firebase-messaging-compat.js');

const firebaseConfig = {
  apiKey: "...",
  authDomain: "...",
  databaseURL: "...",
  projectId: "...",
  storageBucket: "...",
  messagingSenderId: "...",
  appId: "...",
}

firebase.initializeApp(firebaseConfig);

const messaging = firebase.messaging();

self.addEventListener('push', function (event) {
  messaging.onBackgroundMessage(async (payload) => {
    const notification = JSON.parse(payload.data.notification);

    const notificationOptions = {
      body: notification.body,
      icon: notification.icon,
      data: {
        notification: {
          click_action: notification.click_action
        }
      },
    };

    return event.waitUntil(
      self.registration.showNotification(notification.title, notificationOptions)
    );
  });
});

Then there's also a component with the following:

receiveMessage() {
      try {
          onMessage(this.messaging, (payload) => {
              this.currentMessage = payload;
              let message = payload.data.username + ":\n\n" + payload.data.message;
              this.setNotificationBoxForm(
                  payload.data.a,
                  payload.data.b,
                  payload.data.c
              );
              this.notify = true;
              setTimeout(() => {
                  this.notify = false;
              }, 3000);
          });
      } catch (e) {
          console.log(e);
      }
    },
...
created() {
      this.receiveMessage();
  },

garethnic avatar Jun 22 '24 14:06 garethnic

@fred-boink I'm using Firebase version 10.12.2 which is the latest version at the time of writing. Safari unregistering is still an issue and I don't think we can easily fix it without firebase's help.

DarthGigi avatar Jun 22 '24 15:06 DarthGigi

@DarthGigi Yes, none of my changes have worked. They need to fix this, its. a major problem. Anyway we can get their attention?

fred-boink avatar Jul 16 '24 17:07 fred-boink

@fred-boink It is indeed a major problem, I thought this ticket would get their attention, I have no idea why it didn’t. I don’t know any other ways to get their attention other than mail them.

DarthGigi avatar Jul 17 '24 08:07 DarthGigi

Greetings. We have faced the same problem for our PWA on iOS. Code works perfect for Android, Windows, Mac and other systems, except iOS. Version of iOS is 17+ for all our devices. Unfortunately, it stopped to show notifications at all. Before we at least got some, but now we can't get even a single notification for iOS (thus we can see in logs that it was successfully send to FCM). Our implementation in the SW:

importScripts("https://www.gstatic.com/firebasejs/10.0.0/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.0.0/firebase-messaging-compat.js");
firebase.initializeApp({
 ...
});
const messaging = firebase.messaging();



self.addEventListener('push', (event) => {
    event.stopImmediatePropagation();
    const data = event.data?.json() ?? {};
    console.log('Got push notification', data);

    event.waitUntil(
        processNotificationData(data)
            .then(payload => {
                console.log('Notification:', payload);
                return clients.matchAll({ includeUncontrolled: true, type: 'window' }).then((clientList) => {
                    return self.registration.getNotifications().then((notifications) => {
                        // Concatenate notifications with the same tag and show only one
                        for (let i = 0; i < notifications.length; i++) {
                            const isEqualNotEmptyTag = notifications[i].data?.tag && payload.data?.['tag'] && (notifications[i].data.tag === payload.data?.['tag']);
                            if (isEqualNotEmptyTag) {
                                payload.body = payload.data.text = notifications[i].body + '\n' + payload.body;
                            }
                        }
                        // Show notification
                        return self.registration.showNotification(payload.data?.title, payload);
                    });
                });
            })
            .catch(error => {
                console.error('Error processing push notification', error);
            })
    );
});

async function processNotificationData(payload) {
    const icon = payload.data?.['icon'] || 'assets/logo.svg';
    const text =  payload.data?.['text'];
    const title =  payload.data?.['title'];
    const url = payload.data?.['redirectUrl'] || '';

    const options = {
        tag: payload.data?.['tag'],
        timestamp: Date.now(),
        body: text,
        icon,
        data: {
            ...payload.data,
            icon,
            text,
            title,
            redirectUrl: url,
            onActionClick: {
                default: {
                    operation: 'focusLastFocusedOrOpen',
                    url
                }
            }
        },
        silent: payload?.data?.['silent'] === 'true' ?? false
    };

    if (!options.silent) {
        options.vibrate = [200, 100, 200, 100, 200, 100, 200];
    }

    return options;
}




// handle notification click
self.addEventListener('notificationclick', (event) => {
    console.log('notificationclick received: ', event);
    event.notification.close();

    // Get the URL from the notification
    const url = event.notification.data?.onActionClick?.default?.url || event.notification.data?.click_action;

    event.waitUntil(
        ...
    );
});

self.addEventListener('install', (event) => {
    console.log('Service Worker installing.');
    event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (event) => {
    console.log('Service Worker activating.');
    event.waitUntil(clients.claim().then(() => {
        console.log('Service Worker clients claimed.');
    }));
});

self.addEventListener('message', (event) => {
    if (event.data && event.data.type === 'SKIP_WAITING') {
        self.skipWaiting().then(() => {
            console.log('Service Worker skipWaiting called.');
        });
    }
});

self.addEventListener('fetch', (event) => {
    console.log('Fetching:', event.request.url);
});

Can anyone please help? Or any advices how to get them?

Dolgovec avatar Jul 18 '24 08:07 Dolgovec

After some reproduction attempts, here's what I've found:

Silent Push I have not been able to reproduce this issue. My PWA continues to receive notifications (I have sent 50+) even if I don't have event.waitUntil in my onBackgroundMessage handler. Any additional information from anyone experiencing this issue (@fred-boink, @DarthGigi, @gbaggaley, @graphem) would be very helpful. This could be Safari/iOS version, logs, exact reproduction steps, minimal reproduction codebase, or state of browser storage.

Device Restarts I have been able to reproduce the issue that @rchan41 is facing, where notifications are no longer sent after a device restarts, and then they're all received once the PWA is opened again. If I replace my usage of onBackgroundMessage with self.addEventListener('push', () => self.registration.showNotification('test notification', {})), the issue goes away, and notifications continue to be received after devices restarts.

dlarocque avatar Aug 15 '24 20:08 dlarocque