Обновление воркера: старая версия держит вкладки

PWA≈ 19 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Доска открывается без сети, и у этой удачи есть обратная сторона: всё, что лежит у человека в кеше, само не обновится. Вы выкладываете на сервер новый sw.js, а открытые вкладки живут на старой версии — иногда неделями. В этой статье разберёмся, когда браузер вообще узнаёт о новой версии, почему не включает её сразу, чем расплачивается за спешку skipWaiting и как устроено обновление, которое не ломает открытую доску.

Разделы темы:·

Установленный воркер не ходит на сервер за новостями по таймеру. Проверку запускают события, и их три. Главное — навигация: всякий раз, когда человек открывает или перезагружает страницу из области действия, браузер заново скачивает sw.js и сравнивает его с установленным. Второй повод — всё, что будит воркер без навигации: push, sync и даже обычные запросы страницы за данными и картинками. Таких событий бывает очень много, поэтому по ним проверка идёт, только если с прошлой прошло больше суток, — так записано в спецификации. Третий повод — явный вызов registration.update() из кода страницы.

Что считать новой версией, браузер решает без вашей помощи: номер в константе и дата файла ему безразличны, он сравнивает байты. Предскажите пять строк, прежде чем запускать.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Последняя строка — ловушка из жизни. Если сборка вписывает в sw.js дату или номер сборки, новой версией становится каждая сборка, даже когда в коде не поменялось ни символа, и пользователи будут получать «Доступна новая версия» после каждой выкладки. Обратное тоже бывает: код, который воркер подключает через importScripts, поменялся, а сам sw.js — нет. Такое отличие проверка тоже замечает, но с оговоркой про HTTP-кеш; её разберём в последнем разделе.

На настоящей доске проверку видно в журнале сервера. Воркер уже установлен; доску перезагружают в первой вкладке, а потом открывают во второй:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал моего статического сервера; s1 — папка стенда, где лежат две сборки доски. Строки «===» пишет мой сценарий, чтобы было видно, какой шаг чем кончился. Chrome 152 без окна, чистый профиль
=== вкладка 1: перезагрузка ===
127.0.0.1 - - [13/Sep/2026 10:46:12] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 10:46:14] "GET /sw.js HTTP/1.1" 200 -
=== вкладка 2: открыли ту же доску ===
127.0.0.1 - - [13/Sep/2026 10:46:14] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 10:46:16] "GET /sw.js HTTP/1.1" 200 -

На каждую навигацию — свой GET /sw.js, причём не одновременно со страницей, а на пару секунд позже: так Chrome 152 ставит эту проверку. Кода и стилей в журнале нет, их отдал кеш воркера. А sw.js идёт мимо кеша воркера всегда, иначе старый воркер сам отвечал бы на вопрос о своей замене. Мимо HTTP-кеша браузера он тоже идёт: при настройках регистрации по умолчанию браузер, проверяя обновление, обязательно спрашивает сервер, какими бы заголовками файл ни отдавался. Это тоже проверим в последнем разделе.

Сам по себе воркер о новой версии не узнает. Доску, которую держат открытой весь рабочий день и ни разу не перезагружают, проверяет только update() — поэтому для долгоживущих страниц его зовут сами, например раз в час.

Допустим, браузер нашёл отличие. Новый воркер скачан и идёт по знакомой лестнице: installing, installed. Дальше он останавливается. Пока у старой версии есть хоть одна страница, новая ждёт в состоянии installed, а в API лежит в registration.waiting. Причина та же, по которой воркер не забирает страницу посреди её жизни: открытая доска загружалась со старым кешем и по старым правилам, и подменить их у неё под ногами значит получить страницу, собранную из двух версий.

Правило короткое, а следствия неочевидные. Возьмём автомат жизненного цикла из первой статьи и добавим к нему счётчик открытых страниц. Предскажите вывод, особенно строки после второй перезагрузки, когда вкладка осталась одна.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Всё решает порядок внутри reload. Перезагрузка — это навигация, и запрос новой страницы уходит, пока старая ещё открыта: новый документ получает воркера раньше, чем выгружается прежний. Страниц у старой версии на миг становится две, а не ноль, и новая остаётся ждать. Выпускает её только момент, когда не осталось ни одной: вкладку закрыли или увели на другой сайт.

Проверим на настоящей доске. app.js в этом опыте короткий: страница сообщает о найденной новой версии и спрашивает у служебного адреса /sw-version, кто обслуживает её запросы. На этот адрес отвечает сам воркер: в его обработчик fetch для опыта добавлена ветка if (path === "/sw-version") event.respondWith(new Response(VERSION)), а без воркера сервер отдаёт 404. Остальное — проверка, которую я вставлял в консоль DevTools. Между шагами на сервер выкладывается sw.js, в котором поменялась одна строка — VERSION стала board-v4.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, две вкладки на своём localhost, чистый профиль. Строки «===» и «---» печатает мой сценарий, [1] и [2] — из какой вкладки строка; «active», «waiting» и «кеши» печатает проверка из консоли
=== вкладка 1: первое открытие ===
[1] запросы страницы обслуживает: никто, воркера нет
[1] updatefound: браузер нашёл новый sw.js
[1] новый воркер: installed
[1] новый воркер: activating
[1] новый воркер: activated
=== вкладка 1: перезагрузка ===
[1] запросы страницы обслуживает: board-v3
=== вкладка 2: открыли ту же доску ===
[2] запросы страницы обслуживает: board-v3
=== вкладка 1, консоль: проверка ===
[1] active: activated | waiting: null
[1] кеши: board-v3
--- на сервере теперь v4 ---
=== вкладка 1, консоль: update() и проверка ===
[1] updatefound: браузер нашёл новый sw.js
[2] updatefound: браузер нашёл новый sw.js
[1] новый воркер: installed
[2] новый воркер: installed
[1] active: activated | waiting: installed
[1] кеши: board-v3, board-v4
=== вкладка 1: перезагрузка ===
[1] запросы страницы обслуживает: board-v3
[1] active: activated | waiting: installed
[1] кеши: board-v3, board-v4
=== вкладку 2 закрыли; вкладка 1: перезагрузка ===
[1] запросы страницы обслуживает: board-v3
[1] active: activated | waiting: installed
[1] кеши: board-v3, board-v4
=== вкладка 1: ушла на другой сайт и вернулась ===
[1] запросы страницы обслуживает: board-v4
[1] active: activated | waiting: null
[1] кеши: board-v4

Читаем по шагам. При первом открытии updatefound тоже пришёл: для браузера первая установка — такая же «новая версия», запомним это до задач. После update() новый воркер нашли обе вкладки: updatefound приходит во все открытые страницы сайта. Его установка уже прошла, в хранилище лежат оба кеша, board-v3 и board-v4, но activate новой версии не запускался, и запросы по-прежнему обслуживает старая.

Перезагрузка при двух вкладках ничего не меняет — это ожидаемо. Но и после того как вторую вкладку закрыли, перезагрузка единственной оставшейся тоже ничего не меняет: ровно то перекрытие, которое мы собрали в миниатюре. Сработал только уход на другой сайт: вернувшись, доска получила board-v4, а старый кеш исчез — его убрал activate новой версии.

Перезагрузка не выпускает новую версию. Она включается, когда закрыты или уведены с сайта все вкладки: у одного человека через минуту, а у того, кто держит доску открытой неделями, через неделю.

У ожидания есть лекарство в одну строку — self.skipWaiting() в обработчике install. Новая версия перестаёт ждать и сразу проходит в activating, а открытые страницы переходят под её управление прямо на ходу. clients.claim() для этого не нужен: страницы и так принадлежат этой регистрации, у неё просто сменился активный воркер.

Чем это грозит, видно на доске, у которой есть ленивая часть. Пусть в этом варианте у неё появился график, и грузится он через import() — динамический импорт из части I: код графика лежит отдельным файлом chart.1a2b3c4d.js, в SHELL версии board-v3 он стоит рядом с app.3f9a1c2b.js, но страница просит его только по нажатию. Выходит board-v4. Сборщик дал файлам новые имена, старые при выкладке удалены, а воркер написан наспех:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Прежде чем смотреть на живой браузер, соберём это миниатюрой. Маршрутизатор со всеми ветками из прошлых статей лежит на странице примера. Старая страница, которой управлял board-v3, просит график, а меняются два условия: вызван ли skipWaiting и остались ли старые файлы на сервере. Предскажите три строки.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка — то, ради чего новая версия ждала: старая страница живёт под старым воркером, и всё, что может ей понадобиться, лежит в его кеше. Во второй платим за skipWaiting: activate новой версии уже убрал board-v3, в board-v4 файла со старым хешем нет и быть не может, а на сервере его удалили. Третья строка уточняет диагноз: ломает не сам skipWaiting, а его сочетание с удалёнными файлами. Если старые файлы какое-то время полежат на сервере, страница дотянется до них сетью — правда, только пока сеть есть.

Теперь то же на живом браузере, три прогона с чистым профилем. В каждом доску открывают дважды, на сервер выкладывают board-v4, в консоли зовут update(), печатают список кешей и нажимают «График». Код страницы — старый, из board-v3:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна на своём localhost, три отдельных прогона. Строки «===» — мои подписи прогонов; «кеши» печатает проверка из консоли, «график нарисован» — функция draw() из самого chart.1a2b3c4d.js
=== прогон 1: v4 без skipWaiting, старые файлы с сервера удалены ===
кеши: board-v3, board-v4
график нарисован: chart.1a2b3c4d.js
=== прогон 2: v4 со skipWaiting, старые файлы с сервера удалены ===
controllerchange: у страницы сменился воркер
кеши: board-v4
график не загрузился: TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:3387/assets/chart.1a2b3c4d.js
=== прогон 3: v4 со skipWaiting, старые файлы оставлены ===
controllerchange: у страницы сменился воркер
кеши: board-v4
график нарисован: chart.1a2b3c4d.js

Первый прогон — без skipWaiting: график нарисовался, хотя на сервере его файла уже нет, — его отдал кеш старой версии. Во втором страница получила controllerchange и тут же лишилась своего кеша, а в журнале сервера на нажатие осталась одна строка:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал того же сервера во втором прогоне, строка нажатия на «График»
127.0.0.1 - - [13/Sep/2026 10:47:42] "GET /assets/chart.1a2b3c4d.js HTTP/1.1" 404 -

В настоящем приложении такое выглядит как кнопка, которая молча не работает, или белый экран при переходе в другой раздел — и только у тех, у кого вкладка была открыта в момент выкладки. Воспроизвести это у себя трудно, а в отчётах об ошибках оно похоже на случайный сбой сети. Похожая ловушка ждёт и приложения для телефона: обновление в обход магазина привозит новый JavaScript, а часть приложения, установленная из магазина, остаётся старой. Как от неё защищаются, разберём в пятой части, в статье «runtimeVersion: замок, который бережёт установленные приложения».

skipWaiting не ускоряет обновление, а меняет договор: страница, собранная для одной версии, дальше работает с кешем и правилами другой. Безопасен он, только если страница об этом узнаёт и сразу перезагружается.

Правильная схема берёт у skipWaiting скорость, но убирает переключение под ногами. Воркер не включает себя сам, а ждёт команды. Страница, узнав, что новая версия ждёт, показывает «Доступна новая версия». Человек нажимает — страница посылает ждущему воркеру сообщение, и тот вызывает skipWaiting. Когда страница перейдёт к новому воркеру, придёт событие controllerchange, и страница перезагрузится уже целиком новой версией. В sw.js для этого нужен один обработчик:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Имя сообщения — не часть API, а договорённость воркера со своей же страницей; SKIP_WAITING — просто привычное имя.

Остаются соседние вкладки. controllerchange придёт во все, но уже после переключения, когда соседняя страница оказалась под новым воркером с той самой бедой из прошлого раздела. Предупредить их можно раньше, через BroadcastChannel — он уже встречался в статье про хранилище как способ переговариваться вкладкам одного сайта. Между вкладками в песочнице его не показать: у каждого примера своё непрозрачное происхождение, и два таких кадра друг друга не слышат. Поэтому соберём маленькую шину в памяти и сверим её с настоящим каналом там, где он работает, — внутри одного документа.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Шина повторяет два свойства канала, на которых стоит схема. Отправитель своего сообщения не получает — вкладке, нажавшей «Обновить», не нужно узнавать об этом от самой себя. А получатель узнаёт о сообщении не посреди кода отправителя, а отдельной задачей, когда тот уже дописал свои строки.

Теперь вся схема миниатюрой: автомат и шина лежат на странице, две вкладки видят плашку, нажимают в A. Предскажите, в какой момент о переключении узнаёт B и где в выводе встанет controllerchange.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Предупреждение по каналу пришло в B раньше, чем что-то произошло: у соседки было время сохранить черновик, пока у руля старый воркер. Сохранять черновик доска умеет с проекта части II: это flushSave из задачи статьи «Сохранение и восстановление доски». Задержка в 20 мс здесь условная, и порядок этот стандарт не обещает: канал и воркер — независимые очереди, и на стенде ниже канал выиграл гонку, а не воспользовался гарантией. Черновику проигрыш не страшен: flushSave пишет в localStorage, и воркер для этого не нужен. Потом — порядок самого переключения: старая версия уходит в redundant, новая проходит activating, страницы получают controllerchange, и только после activate наступает activated. A перезагружается, потому что нажимали в ней. B человека не перебивает: вдруг он там что-то печатает, поэтому она лишь просит перезагрузиться.

На настоящей доске добавим одно правило: невидимой вкладке спрашивать некого, и она перезагружается молча. Разметка плашки — <p id="update-banner" hidden>Доступна новая версия <button id="update">Обновить</button></p>, код страницы такой:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, две вкладки на своём localhost: B открыта первой, A — поверх неё, поэтому B невидима. Строки «===» и «---» печатает мой сценарий, [A] и [B] — из какой вкладки строка; строки двух вкладок идут в том порядке, в каком сценарий их получил. «старый воркер» и «кеши» печатает проверка из консоли вкладки A
=== вкладка B: первое открытие и перезагрузка ===
[B] запросы страницы обслуживает: никто, воркера нет
[B] запросы страницы обслуживает: board-v3
=== вкладка A: открыли доску поверх B ===
[A] запросы страницы обслуживает: board-v3
--- на сервере теперь v4 ---
=== вкладка A, консоль: следим за старым воркером, update() ===
[B] показали плашку «Доступна новая версия»
[A] показали плашку «Доступна новая версия»
=== вкладка A: нажали «Обновить» ===
[B] соседняя вкладка включает новую версию, черновик сохранён
[A] старый воркер: redundant
[B] новый воркер: activating
[A] новый воркер: activating
[B] controllerchange, вкладка: hidden
[A] controllerchange, вкладка: visible
[A] новый воркер: activated
[B] новый воркер: activated
[B] запросы страницы обслуживает: board-v4
[A] запросы страницы обслуживает: board-v4
=== вкладка A, консоль: кеши ===
[A] кеши: board-v4

Всё по плану. Плашку показали обе вкладки. B узнала о переключении раньше, чем оно началось. Дальше порядок из миниатюры: redundant, activating, controllerchange, activated. A перезагрузилась, потому что нажимали в ней, B — потому что невидима. Обе вернулись уже под board-v4, а в хранилище остался один кеш.

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

Файлы с хешем в имени — хорошая привычка для кода и стилей, и её хочется по инерции приложить к самому воркеру: sw.5d1e8a42.js, при следующей сборке sw.b3c9f071.js. Проверим, что будет. Воркер в этом опыте отдаёт из кеша всё, включая саму страницу, — как в первой статье главы; так часто делают ради мгновенного открытия. Главное, что меняется между сборками, — строка регистрации:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна на своём localhost, чистый профиль; строки «===» и «---» печатает мой сценарий
=== загрузка 1 ===
запросы страницы обслуживает: никто, воркера нет
=== загрузка 2 ===
запросы страницы обслуживает: board-v3
--- на сервере теперь v4 ---
=== загрузка 3 ===
запросы страницы обслуживает: board-v3
=== ушли на другой сайт и вернулись ===
запросы страницы обслуживает: board-v3
=== консоль ===
скрипт воркера: http://127.0.0.1:3387/sw.5d1e8a42.js
ждёт своей очереди: null
код страницы: http://127.0.0.1:3387/assets/app.3f9a1c2b.js
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал того же прогона после выкладки board-v4 целиком: других запросов к серверу доски не было
--- на сервере теперь v4 ---
=== загрузка 3 ===
127.0.0.1 - - [13/Sep/2026 10:49:11] "GET /sw.5d1e8a42.js HTTP/1.1" 404 -
=== ушли на другой сайт и вернулись ===
127.0.0.1 - - [13/Sep/2026 10:49:16] "GET /sw.5d1e8a42.js HTTP/1.1" 404 -

Это тупик, и выхода из него изнутри нет. Страницу отдаёт кеш старого воркера, в ней старый код, и он регистрирует старое имя. Браузер проверяет старый адрес, получает 404, а неудачная проверка ничего не меняет: работает прежний воркер. Новый sw.b3c9f071.js лежит на сервере, но знает о нём только новая index.html, до которой старый воркер не пускает. Выбраться человек может, очистив данные сайта, а вы — выложив новый воркер по старому адресу, который браузер продолжает проверять. С постоянным именем sw.js такой истории не бывает: браузер спрашивает тот же адрес и находит там новые байты.

Второе правило — про HTTP-кеш, и в нём есть кажущееся противоречие. Мы видели, что при проверке браузер идёт за sw.js на сервер всегда. Зачем тогда запрещать долгий кеш? Ответ — в файлах, которые воркер подключает через importScripts. Регистрация по умолчанию создаётся с updateViaCache: "imports": HTTP-кеш обходится для самого sw.js, но не для того, что он подключает.

Воркер подключает router.js, и сервер отдаёт оба файла с Cache-Control: max-age=31536000, то есть «хранить год». Во второй выкладке поменялись оба: в sw.js — VERSION, в router.js — строка ROUTER.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна на своём localhost, чистый профиль; b — вторая выкладка, в ней board-v4 и router 2
=== загрузка 2 ===
запросы страницы обслуживает: board-v3, router 1
--- на сервере теперь b ---
=== загрузка 3 ===
запросы страницы обслуживает: board-v3, router 1
updatefound
=== ушли на другой сайт и вернулись ===
запросы страницы обслуживает: board-v4, router 1
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал того же прогона, только запросы sw.js и router.js; a — первая выкладка, b — вторая
--- на сервере теперь a ---
=== загрузка 1 ===
127.0.0.1 - - [13/Sep/2026 10:48:06] "GET /sw.js HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 10:48:06] "GET /router.js HTTP/1.1" 200 -
=== загрузка 2 ===
127.0.0.1 - - [13/Sep/2026 10:48:10] "GET /sw.js HTTP/1.1" 200 -
--- на сервере теперь b ---
=== загрузка 3 ===
127.0.0.1 - - [13/Sep/2026 10:48:14] "GET /sw.js HTTP/1.1" 200 -
=== ушли на другой сайт и вернулись ===
127.0.0.1 - - [13/Sep/2026 10:48:19] "GET /sw.js HTTP/1.1" 200 -

Годовой кеш sw.js не помешал: его запрашивали на каждой проверке, новую версию нашли и поставили. А router.js сервер после первой загрузки больше не видел — новый воркер board-v4 собрался со старым router 1 из HTTP-кеша. Ещё два прогона показывают, как выглядит правка одного только подключаемого файла:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, два отдельных прогона с чистым профилем; строки «=== прогон …» — мои подписи
=== прогон 2: поменяли только router.js, годовой кеш ===
=== загрузка 3 ===
запросы страницы обслуживает: board-v3, router 1
=== ушли на другой сайт и вернулись ===
запросы страницы обслуживает: board-v3, router 1
=== прогон 3: поменяли только router.js, Cache-Control: no-store ===
=== загрузка 3 ===
запросы страницы обслуживает: board-v3, router 1
updatefound
=== ушли на другой сайт и вернулись ===
запросы страницы обслуживает: board-v3, router 2

С годовым кешем правку не заметил никто: проверка сравнила sw.js, не нашла отличий, а router.js взяла из кеша. Без кеша тот же файл перепроверялся на каждой навигации, отличие нашлось, и новая версия поставилась, хотя sw.js не менялся. При годовом кеше сервер увидит запрос router.js, только если с прошлой проверки регистрации прошло больше суток: тогда, по спецификации, и подключаемые файлы перепроверяются в обход кеша. У человека, который заходит каждый день, этого не случится.

ФайлИмяHTTP-кешПочему
sw.jsпостоянноебез долгого кешастарые страницы знают только этот адрес; браузер сам кеш для него обходит, но по дороге ответ могут хранить кеширующие посредники — например, CDN, сеть серверов, раздающих копии файлов ближе к пользователю; они смотрят на заголовки
файлы из importScriptsс хешем в именидолгий — только вместе с хешемпо умолчанию идут через HTTP-кеш: без хеша новый воркер соберётся со старой копией, а новое имя меняет и байты sw.js
код и стили страницыс хешем в именидолгийадрес уникален для версии, старое содержимое к нему не прилипнет

Доска теперь обновляется честно: новая версия ждёт своей очереди, спрашивает человека, переключается по команде и не оставляет соседние вкладки со сломанной страницей. Осталось сделать её приложением, которое ставят на экран, — этим займёмся в следующей статье, про манифест и установку.

  • Браузер проверяет обновление при навигации на страницу из области действия, при update() и при прочих поводах вроде push, sync и запросов страницы — если с прошлой проверки прошло больше суток. Новая версия — любое отличие в байтах, хоть дата сборки в комментарии.
  • Новая версия ставится и ждёт в registration.waiting, пока у старой не останется ни одной страницы. Перезагрузка единственной вкладки её не выпускает: новый документ получает воркера раньше, чем выгружается старый.
  • skipWaiting в install переводит открытые страницы под новый воркер на ходу: activate уже удалил их кеш, и ленивый import() старого файла падает, если того нет и на сервере.
  • Правильная схема: плашка «Доступна новая версия» (только если у страницы уже есть controller), по нажатию — сообщение SKIP_WAITING ждущему воркеру, на controllerchange — перезагрузка. Соседние вкладки предупреждают по BroadcastChannel до переключения.
  • Схема должна быть в старой странице заранее: плашку показывает загруженный код, а не лежащий на сервере.
  • sw.js — постоянное имя и без долгого кеша: старая страница из кеша знает только старый адрес. Файлы из importScripts по умолчанию идут через HTTP-кеш, поэтому им нужен хеш в имени.

1/4На сервер выложили новый sw.js. У пользователя открыта одна вкладка доски, и он жмёт F5. Какой воркер обслуживает перезагруженную страницу?

2/4В install новой версии стоит self.skipWaiting(), при выкладке старые файлы с хешем удалены с сервера. Открытая старая страница по нажатию делает import("/assets/chart.1a2b3c4d.js"). Что будет?

3/4Зачем соседним вкладкам сообщение по BroadcastChannel, если controllerchange и так придёт в каждую?

4/4Воркер подключает router.js через importScripts, и оба файла сервер отдаёт с max-age на год. В новой выкладке поменяли и sw.js, и router.js. Что получит пользователь, который заходит каждый день?

Первая установка — не обновление

Воркер дошёл до installed, и страница решает, показывать ли плашку «Доступна новая версия». Сейчас shouldOfferUpdate смотрит только на состояние и предлагает обновление даже при самом первом открытии доски, когда обновлять нечего.

Допишите функцию так, чтобы плашка появлялась только при настоящем обновлении. controller — версия, которая сейчас обслуживает страницу, или null, если воркера у страницы нет. Сейчас вывод — true, true, false, а нужен false, true, false.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Отличает обновление от первой установки не состояние нового воркера, а наличие старого у страницы.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В настоящем коде это fresh.state === "installed" && navigator.serviceWorker.controller. На стенде видно, почему проверка нужна: при первом открытии доски updatefound пришёл так же, как при обновлении. А без проверки при загрузке (registration.waiting уже есть) плашку не увидит тот, кто открыл доску, когда новая версия давно ждала: updatefound в этом документе уже не случится.

Невидимой вкладке спрашивать некого

Автомат, шина и сторона воркера из статьи уже на странице. Открыты три вкладки: A и C на виду (скажем, в разных окнах), B — фоновая. Нажимают в A. Сейчас на controllerchange перезагружается только A, а B и C показывают плашку «перезагрузите», причём в B её никто не увидит.

Поменяйте onControllerChange так, чтобы невидимая вкладка перезагружалась сама, а видимая соседка по-прежнему спрашивала человека.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Условие перезагрузки расширяется одним «или»:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строка B меняется на B: controllerchange, перезагружаюсь, A и C остаются прежними. В настоящем коде видимость читают в момент события — document.visibilityState, а не запоминают при открытии: человек мог переключиться на вкладку минуту назад. Черновик к этому моменту уже сохранён по сообщению из канала, так что молчаливая перезагрузка ничего не отнимет.

Уборка только своего

В activate новая версия удаляет «все кеши, кроме своего». Но кеши принадлежат не воркеру, а происхождению: на том же сайте есть долгоживущий кеш аватаров доски board-avatars, который не зависит от версии, и кеши чата chat-v7 со своим воркером в другой области действия.

Сейчас cachesToDelete сносит и их. Сделайте так, чтобы удалялись только прошлые версии оболочки доски — кеши вида board-v и номер.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Отбирать надо по своему признаку, а не по «всё, что не текущее»:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится удалить: board-v3. Регулярное выражение строже, чем startsWith("board-v"): префикс задел бы и какой-нибудь board-vendor. В настоящем activate этот фильтр встаёт на место names.filter((name) => name !== VERSION), остальное не меняется.