Слышали шутку о том, что если установил ServiceWorker - пора менять домен? Сейчас я расскажу, в чём её смысл и что делать, если вы всё-таки решили, что вам необходим PWA.

В инструкциях по типу этой или этой ServiceWorker'у и работе с ним почти не уделяется внимания. И, я уверен, подобные статьи - первое, что вы прочтёте перед использованием. Но в момент, когда после подобных статей ваш свежеиспечённый PWA наконец-то появился на продакшене и у юзеров появилась возможность добавить ещё одну иконку на свой рабочий стол, знайте: вами пройдена точка невозврата.

С вашего позволения, я не буду останавливаться на описании Service Worker (далее SW) и том, как он работает. На Хабре уже есть хорошая статья об этом. Даже не важно, какой SW конкретно у вас. Может, вы используете create-react-app, а значит за SW у вас отвечает библиотека Workbox. Возможно, вы реализовывали SW сами, с какой-то мудрённой стратегией кэширования. Стек на самом деле не важен. В той же документации CRA говорится, что всё, что вам нужно - это поменять одну строчку и получить все прелести app-like поведения. Вы написали .register() и ожидаете результат. И вы его получите.

В следующий раз, когда недовольный клиент попросит вас поменять цвет этой оранжевой кнопки или решить наконец тот баг со слетающим фокусом, вы обнаружите себя в удивительной ситуации. Хотфикс в репозитории, контейнер собрался и nginx точно раздаёт последнюю версию, но клиент почему-то всё ещё недоволен. Ах да, мы же теперь PWA.

— Обновите, пожалуйста, страницу. Как не помогает? А если CTRL+R ?

Итак, что же делать, когда судорожное обновление страницы не помогает и клиент всё ещё видит издевательски оранжевую кнопку?

Важно помнить, что SW пытается вести себя как десктопное приложение.

Давайте вспомним, как себя обновляет десктопное приложение. Оно скачивает свежий инсталлятор, удаляет старую версию и устанавливается заново. Только после этого пользователь получает новую версию приложения.

Схожим образом действует и браузер при обновлении SW.

Всего у SW три статуса: installing, waiting и active. Active - это ваш текущий, работающий SW. Стадии installing и waiting SW проходит на пути к active. На стадии installing SW нужно время, чтобы установиться. На стадии waiting ему нужна причина, чтобы заменить текущий SW (обычно это закрытие всех вкладок приложения). Вот в этом поведении и весь подвох.

Браузер получает новый SW, но юзер увидит изменения только тогда, когда приложение - все вкладки сайта будут закрыты. SW даже установит эту новую версию, но не активирует её. Пока все вкладки со старой версией не будут закрыты. Неважно, сколько раз вы обновляли страницу. Даже если это одна страница, вам всё равно нужно её закрыть.

Дело в том, что браузер начинает загружать обновлённую страницу до того, как старая "умрёт". И когда вы перезагружаете страницу, для SW существует аж две вкладки: старая, обречённая на смерть, и новая, которая ещё запускается. Пока не будут закрыты все, SW не обновится. Это нужно для того, чтобы вы не получали разные версии приложения во вкладках браузера.

Я намеренно пропускаю глубокий разбор механизма обновления SW с его озвученными выше статусами installing, waiting и active. Больше об этом написано тут - рекомендую ознакомиться. Мы уже понимаем механизм и вообще мы здесь, чтобы решить проблему.

Что же, вы можете делать рассылки клиентам с просьбой перезагрузить страницу со сбросом кэша, а можете воспользоваться следующими вариантами решения.

Вариант №1: Заставить SW обновляться сразу

Самый простой (и опасный) способ - это просто пропустить ожидание в установке SW. В скоупе вашего SW есть прекрасная функция skipWaiting(), которая сделает это для вас. При её вызове новый SW после своей установки сразу убивает старый. Вам лишь надо дождаться "перезапуска" приложения.
Но будьте осторожны: данный подход несёт опасность, если у вашего пользователя открыты другие вкладки с приложением. Вам может показаться, что слепо вызывать skipWaiting() более чем достаточно, но это приводит к багам на вашем продакшене, которые потом сложно понять и воспроизвести.

Вариант №2: Перезагружать все вкладки когда новый SW установлен

Это слегка лучше, чем прошлый подход. В navigator.serviceWorker происходит эвентcontrollerchange ,когда новый SW получает контроль над текущей страницей. Это происходит сразу после прохождения этапа installing.
Теперь можно вызвать skipWaiting() во время установки, отловить эвент и заставить вкладку обновиться. Это будет выглядеть вот так:

navigator.serviceWorker.addEventListener('controllerchange',  ()  => window.location.reload());

В таком случае SW устанавливается и сразу перезагружает все страницы, переводя их на новую версию.
Но, с точки зрения пользовательского опыта, это не очень хороший подход. Может, ваш пользователь заполняет сложную форму или вносит данные своей карточки, а тут установилось ваше обновление и перезагружает ему страницу посреди дела. Нехорошо.

Вариант №3: Дать пользователю самому вызвать обновление

Суть этого метода в том, что мы ждём, пока SW установится, а потом показываем пользователю какое-то сообщение - модальное окно или алерт с предложением перезагрузить страницу и перейти на новую версию.

Мы всё ещё перезагружаем страницу на срабатывании controllerchange, как и в предыдущем способе, но теперь пользователь знает о том, что это произойдёт и может этого избежать.
Для того, чтобы отследить новый SW, нам понадобится объект ServiceWorkerRegistration. Раньше мы просто вызывыли .register() и не знали, что этот метод возвращает промис с объектом регистрации. В этом API регистрации есть несколько интересных возможностей. Например, можно вызвать update(), чтобы обновить SW вручную. Обычно он делает это сам после регистрации, но вдруг вы хотите проверять наличие обновлений чаще.

Ссылку на текущий (active) SW можно получить через navigator.serviceWorker.controller из поля active в регистрации. Таким же образом можно достучаться до ожидающего (waiting) или устанавливающегося (installing) SW.

Любому SW можно отправить сообщение через postMessage(), если вы работали с iframe и передавали сообщения между окнами, вам знаком этот API. Внутри кода SW мы можем слушать это событие. Вы можете добавить следующий код в ваш SW.

addEventListener('message', ev => {  
  if (ev.data === 'skipWaiting') return skipWaiting();
});

Если вы используете Workbox или CRA, то примерно этот код там уже есть.

Дальше нам нужно отследить появление ожидающего SW. На мой взгляд лучше не реагировать каждый раз на SW со статусом installing, как это пишут в некоторых руководствах, а дождаться когда объект регистрации SW вернёт true в поле waiting. Это замедляет обновление, но не триггерит ваше модальное окно когда SW устанавливается в первый раз.

После того, как мы дождались ожидающий SW, вызываем модальное окно, в котором пользователь может подтвердить обновление. При подтверждении мы вызываем skipWaiting() и насильно перезагружаем страницу, как описано выше. При отказе обновление будет отложено. Выглядеть код в моём случае будет так :

// вызов модального окна
const askUserToUpdate = reg => {
  return Modal.confirm({
    onOk: async () => {
      // вешаем обработчик изменения состояния
      navigator.serviceWorker.addEventListener('controllerchange', () => {
        window.location.reload();
      });

      // пропускаем ожидание 
      if (reg && reg.waiting) {
        reg.waiting.postMessage({ type: 'SKIP_WAITING' });
      }
    },

    onCancel: () => {
      Modal.destroyAll();
    },
    icon: null,
    title: 'Хорошие новости! ? ',
    content:
      'Мы только что обновили версию приложения! Чтобы получить обновления, нажмите на кнопку ниже (страница перезагрузится)',
    cancelText: 'Не обновлять',
    okText: 'Обновить'
  });
};

// проверка регистрации
const registerValidSW = (swUrl, config) => {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      if (registration.waiting) {
        // оброботчик SW в ожидании
        askUserToUpdate(registration);
      }
    ...

Обращаю внимание на то, что этот способ работает и с несколькими открытыми вкладками. Все ваши вкладки покажут модальное окно с вопросом обновления и, если пользователь обновится хотя бы на одном из них, новый SW установится по всем вкладкам, заставляя их перезагрузиться.

Соглашусь, что у этого подхода есть и минусы. Во-первых, он сложный. Даже с примерами кода в него сложновато врубиться, пока вы сами не поигрались с SW и его регистрацией.

Во-вторых, этот подход сработает у пользователя, только если он нажмёт на вашу кнопку для обновления. Обычный рефреш страницы всё ещё не поможет, так что будьте аккуратны в формулировках.

Однако, именно этот подход считается рекомендованным. Я уверен, вы видели его использование во многих сервисах. Он даже включен в Workbox Advanced Recipes, но лично меня удручает тот факт, что вам о нём не говорят сразу и нужно реально покопаться, а иногда и самому до него дойти.

Хотелось бы, чтобы обновление SW не приносило столько боли. Это явно точка роста для данной технологии.

Желаю вам удачи в обновлении ваших воркеров на проде :)