Как построить систему обновлений React-приложения с нуля
Разбираем production-подход к обновлениям React-приложения: Vite, service worker, vite-plugin-pwa, версия сборки, кнопка обновления и сброс кэша.
В вебе есть неприятная особенность: вы задеплоили новую версию приложения, а часть пользователей продолжает жить в старой вкладке.
Для обычной страницы это почти незаметно. Пользователь перешел по ссылке, браузер получил новый HTML, новые JS-файлы, и жизнь продолжается. Но современное React-приложение чаще работает как SPA: одна вкладка может быть открыта часами или днями. Пользователь не знает, что вы уже выкатили фиксы, новые переводы, другую схему API или исправление критичного бага.
Нужна система, которая умеет:
- Регистрировать service worker только там, где это действительно нужно.
- Понимать, что появилась новая сборка.
- Показывать интерфейсу состояние PWA: зарегистрирован, готов офлайн, есть обновление, произошла ошибка.
- Обновлять приложение по кнопке пользователя.
- Сбрасывать service worker и Cache Storage, если пользователь застрял на старой версии.
Ниже разберем такую систему на React + Vite. В основе будут три точки:
- Конфигурация
vite-plugin-pwa. - Класс
ClassSw, который управляет жизненным циклом service worker. ProviderPWA, который отдает состояние обновлений в React.
Почему не достаточно просто задеплоить новую сборку
Vite собирает приложение в статические файлы с хэшами:
index.html
assets/index-Bk4n8Nf3.js
assets/index-Cq81mA2x.cssКогда пользователь впервые открывает сайт, браузер загружает index.html, затем JS и CSS. Если вкладка остается открытой, старый JS уже находится в памяти. Новый деплой сам по себе не заставит этот JS исчезнуть.
Service worker добавляет еще один слой: он может кэшировать ресурсы приложения и отдавать их без похода в сеть. Это хорошо для скорости и офлайн-режима, но плохо, если обновлениями никто не управляет.
Поэтому задача не в том, чтобы "включить PWA". Задача в том, чтобы сделать PWA управляемой.
Общая архитектура
Система состоит из четырех уровней:
- Build layer: Vite генерирует service worker и web manifest.
- Runtime layer:
ClassSwрегистрирует service worker, слушает события обновлений и хранит состояние. - React layer:
ProviderPWAсинхронизирует состояниеClassSwс React. - UI layer: кнопка, toast, banner или modal, который предлагает пользователю обновиться.
На практике поток выглядит так:
deploy new build
|
browser detects new service worker
|
ClassSw receives onNeedRefresh
|
ProviderPWA updates React context
|
UI shows "Update available"
|
user clicks update
|
updateSW(true) activates new SW and reloads pageШаг 1
Установить зависимости
Нам понадобятся vite-plugin-pwa и, опционально, use-context-selector, чтобы компоненты React подписывались только на нужные поля PWA-состояния.
yarn add vite-plugin-pwa use-context-selectorИли через npm:
npm install vite-plugin-pwa use-context-selectorШаг 2
Добавить версию сборки
Приложение должно знать две версии:
- Текущую версию, которая была вшита в JS во время сборки.
- Новую версию, которую можно получить из файла рядом с билдом.
Текущую версию удобно передавать через env:
VITE_APP_VERSION=1.4.12В коде это можно собрать в единый объект:
const version = import.meta.env.VITE_APP_VERSION;
const mode = import.meta.env.VITE_NODE_ENV;
const baseURL = import.meta.env.VITE_BASE_URL;
export const env = {
version,
mode,
baseURL,
};А рядом с финальным билдом нужно положить build-info.txt:
version: 1.4.13
commit: 8f4c9fd
builtAt: 2026-06-13T09:00:00ZВажно: файл должен быть доступен по URL /build-info.txt, потому что именно его мы будем читать, когда service worker сообщит о новой версии.
В CI это может выглядеть так:
printf "version: %s\ncommit: %s\nbuiltAt: %s\n" "$APP_VERSION" "$GIT_SHA" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > build/build-info.txtКак именно создавать или обновлять этот файл - не принципиально. Можно делать это в CI, отдельным npm-скриптом, backend-релизером, shell-командой или Vite-плагином. Главное, чтобы после деплоя по /build-info.txt отдавалась информация о новой сборке.
В моем демо-проекте для этого используется мой pluginWriteBuildInfo. Он генерирует файл прямо из Vite-конфига, поэтому после yarn build:prod рядом с билдом сразу появляется актуальный build-info.txt.
Для build-info.txt лучше настроить короткое кэширование или Cache-Control: no-cache, иначе CDN может вернуть старую информацию о версии. В клиентском коде ниже мы дополнительно читаем файл с cache-busting query и cache: 'no-store'.
Где именно это настраивать, зависит от того, кто отдает ваш build.
Это не настройка React и обычно не настройка vite-plugin-pwa. После сборки приложение становится набором статических файлов, поэтому HTTP-заголовки задаются на уровне static-сервера, CDN или платформы деплоя.
Например, для Nginx:
location = /build-info.txt {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}Для Netlify это можно описать в файле _headers:
/build-info.txt
Cache-Control: no-cache, no-store, must-revalidateДля Vercel - в vercel.json:
{
"headers": [
{
"source": "/build-info.txt",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
}
]
}Если билд лежит в S3 за CloudFront, настройка обычно состоит из двух частей: у объекта build-info.txt ставят metadata Cache-Control, а в CloudFront для пути /build-info.txt задают отдельное cache behavior с минимальным TTL или отключенным кэшированием.
Шаг 3
Настроить Vite и PWA
Основная магия живет в vite.config.ts.
Ниже упрощенная, но production-пригодная конфигурация:
import { pluginWriteBuildInfo } from '@jenesei-software/jenesei-plugin-vite';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'node:path';
import process from 'node:process';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
const VITE_BASE_URL = env.VITE_BASE_URL;
const VITE_DEFAULT_NAME = env.VITE_DEFAULT_NAME;
const VITE_DEFAULT_NAME_SHORT = env.VITE_DEFAULT_NAME_SHORT;
const VITE_DEFAULT_THEME_COLOR = env.VITE_DEFAULT_THEME_COLOR;
const VITE_DEFAULT_DESCRIPTION = env.VITE_DEFAULT_DESCRIPTION;
const VITE_OUTPUT_DIR = env.VITE_OUTPUT_DIR || 'build';
const VITE_APP_VERSION = env.VITE_APP_VERSION || 'unknown';
const buildInfoPath = path.resolve(__dirname, VITE_OUTPUT_DIR, 'build-info.txt');
return {
build: {
outDir: VITE_OUTPUT_DIR,
},
plugins: [
react(),
VitePWA({
filename: 'vite-sw.js',
strategies: 'generateSW',
registerType: 'prompt',
injectRegister: null,
includeManifestIcons: false,
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,json}'],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: new RegExp(`^${VITE_BASE_URL}/.*$`),
handler: 'NetworkOnly',
},
{
urlPattern: /build-info\.txt$/,
handler: 'NetworkFirst',
options: {
cacheName: 'version-cache',
expiration: {
maxEntries: 1,
maxAgeSeconds: env.VITE_CACHE_VERSION_MAX_AGE_SECONDS
? parseInt(env.VITE_CACHE_VERSION_MAX_AGE_SECONDS, 10)
: 60 * 60 * 24,
},
},
},
],
},
devOptions: {
enabled: false,
},
manifest: {
display: 'standalone',
orientation: 'portrait',
name: VITE_DEFAULT_NAME,
short_name: VITE_DEFAULT_NAME_SHORT,
theme_color: VITE_DEFAULT_THEME_COLOR,
background_color: VITE_DEFAULT_THEME_COLOR,
description: VITE_DEFAULT_DESCRIPTION,
start_url: '/',
icons: [],
},
}),
pluginWriteBuildInfo({
pathBuildInfo: buildInfoPath,
version: VITE_APP_VERSION,
mode,
}),
],
};
});Здесь есть несколько важных решений.
filename: 'vite-sw.js' фиксирует имя service worker. После первого production-релиза лучше не менять его без миграционного плана: браузеры уже знают старый URL service worker, и переезд может оставить часть пользователей на предыдущей схеме обновлений.
strategies: 'generateSW' говорит плагину сгенерировать service worker через Workbox. Для большинства React-приложений это хороший старт: мы не пишем service worker вручную, но управляем его регистрацией и runtime-кэшированием.
registerType: 'prompt' означает, что новая версия не будет молча перезагружать страницу. Вместо этого vite-plugin-pwa вызовет onNeedRefresh, а мы сами решим, как показать пользователю обновление.
injectRegister: null отключает автоматическую вставку регистрации service worker в HTML. Регистрацию будет выполнять наш ClassSw. Это делает систему прозрачной: все состояния проходят через один класс.
NetworkOnly для API защищает от случайного кэширования backend-ответов. Если API должен кэшироваться, это лучше проектировать отдельно, а не отдавать на откуп общему PWA-кэшу.
NetworkFirst для build-info.txt позволяет сначала проверить сеть и получить свежую версию. Если сеть недоступна, Workbox сможет вернуть последнюю кэшированную информацию. А когда ClassSw читает номер новой версии для UI, он делает cache-busting запрос, чтобы не показать старую строку как новую версию.
pluginWriteBuildInfo - это пример моего подхода к генерации build-info.txt. Вы можете обновлять этот файл любым удобным способом; service worker и React-слой зависят только от того, что файл доступен по /build-info.txt и содержит строку version:.
devOptions.enabled: false отключает service worker в dev-режиме. Это почти всегда правильное решение: service worker на localhost легко создает фантомные баги, когда вы меняете код, а браузер продолжает жить в старом кэше.
Шаг 4
Описать virtual:pwa-register для TypeScript
Если TypeScript не видит виртуальный модуль, добавьте декларацию:
declare module 'virtual:pwa-register' {
export type RegisterSWOptions = {
immediate?: boolean;
onNeedRefresh?: () => void | Promise<void>;
onOfflineReady?: () => void | Promise<void>;
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void | Promise<void>;
onRegisterError?: (error: unknown) => void | Promise<void>;
};
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>;
}registerSW() возвращает функцию updateSW. Ее мы сохраним и вызовем, когда пользователь нажмет "Обновить".
Шаг 5
Написать ClassSw
ClassSw - это маленький внешний store для service worker.
Почему не положить все сразу в React-компонент? Потому что service worker живет не по правилам React. У него есть собственный жизненный цикл, регистрация, сброс, кэши, ошибки браузера. Удобнее держать эту механику отдельно, а React использовать только как слой отображения.
Сначала опишем статусы:
type IClassSwUpdateFn = (reloadPage?: boolean) => Promise<void>;
type IClassSwChangeListener = () => void;
type IClassSwStatus =
| 'disabled'
| 'disabling'
| 'idle'
| 'registering'
| 'resetting'
| 'registered'
| 'unregistered'
| 'offline-ready'
| 'update-available'
| 'error';
export interface IClassSwInitOptions {
/** Enables or disables service worker registration for the app. */
enabled?: boolean;
/** Registers the service worker immediately after initialization. */
immediate?: boolean;
}
export interface IClassSwResetCacheOptions {
/** Reloads the page after the cache is cleared so SW registration restarts from a clean slate. */
reloadPage?: boolean;
/** Re-registers the service worker in-place when a full page reload is not desired. */
reRegister?: boolean;
}
export interface IClassSwSnapshot {
/** Current lifecycle status of the service worker manager. */
status: IClassSwStatus;
/** Whether service worker support is enabled in app configuration. */
isEnabled: boolean;
/** Whether the current browser supports service workers. */
isSupported: boolean;
/** Whether the service worker manager has already been initialized. */
isInitialized: boolean;
/** Whether a service worker registration is currently active. */
isRegistered: boolean;
/** Whether the app is ready to work offline. */
isOfflineReady: boolean;
/** Whether a newer service worker version is available. */
isUpdateAvailable: boolean;
/** Whether the UI should prompt the user to refresh the page. */
isNeedRefresh: boolean;
/** Current application version reported by the active build. */
currentVersion: string | null;
/** Version detected for the newly available service worker build. */
newVersion: string | null;
/** Scope returned by the browser for the current service worker registration. */
registrationScope: string | null;
/** Last known service worker error message, if any. */
error: string | null;
}Теперь сам класс:
import { env } from '@local/core/envs';
import { logger } from '@local/core/logger';
import { registerSW } from 'virtual:pwa-register';
export class ClassSw {
private updateSW: IClassSwUpdateFn | null = null;
private initOptions: IClassSwInitOptions = {};
// These fields are the single source of truth for SW/PWA state inside the app.
private valueStatus: IClassSwStatus = 'idle';
private valueIsEnabled = true;
private valueIsSupported = typeof window !== 'undefined' && 'serviceWorker' in navigator;
private valueIsInitialized = false;
private valueIsRegistered = false;
private valueIsUpdateAvailable = false;
private valueIsOfflineReady = false;
private valueIsNeedRefresh = false;
private valueCurrentVersion: string | null = null;
private valueNewVersion: string | null = null;
private valueRegistrationScope: string | null = null;
private valueError: string | null = null;
private listeners: IClassSwChangeListener[] = [];
init(options?: IClassSwInitOptions) {
if (this.valueIsInitialized) return;
this.valueIsInitialized = true;
this.initOptions = options ?? {};
this.valueIsEnabled = options?.enabled ?? true;
if (!this.valueIsEnabled) {
if (!this.valueIsSupported) {
this.valueStatus = 'disabled';
this.notify();
return;
}
// When PWA is disabled we still need to clean up a previously installed SW.
this.valueStatus = 'disabling';
this.notify();
void this.disableServiceWorker();
return;
}
if (!this.valueIsSupported) {
this.valueStatus = 'error';
this.valueError = 'Service workers are not supported in this browser.';
this.notify();
return;
}
this.registerServiceWorker();
}
setCurrentVersion(version: string | null) {
this.valueCurrentVersion = version;
this.notify();
}
async updateApp(reloadPage = true) {
try {
if (typeof this.updateSW === 'function') {
await this.updateSW(reloadPage);
}
} catch {
logger.warn('updateSW() called before SW initialized');
}
}
async resetAppCache(options?: IClassSwResetCacheOptions) {
if (!this.valueIsSupported) {
this.valueStatus = 'error';
this.valueError = 'Service workers are not supported in this browser.';
this.notify();
return;
}
const reloadPage = options?.reloadPage ?? true;
const reRegister = options?.reRegister ?? !reloadPage;
this.valueStatus = 'resetting';
this.valueError = null;
this.notify();
try {
const hasStorageToCleanup = await this.clearServiceWorkerStorage();
if (!this.valueIsEnabled) {
this.valueStatus = hasStorageToCleanup ? 'unregistered' : 'disabled';
this.notify();
return;
}
if (reloadPage) {
window.location.reload();
return;
}
if (reRegister) {
this.registerServiceWorker();
return;
}
this.valueStatus = hasStorageToCleanup ? 'unregistered' : 'idle';
this.notify();
} catch (error) {
this.valueStatus = 'error';
this.valueError = error instanceof Error ? error.message : 'Service worker reset failed.';
this.notify();
throw error;
}
}
get isUpdateAvailable() {
return this.valueIsUpdateAvailable;
}
get isEnabled() {
return this.valueIsEnabled;
}
get isSupported() {
return this.valueIsSupported;
}
get isInitialized() {
return this.valueIsInitialized;
}
get isRegistered() {
return this.valueIsRegistered;
}
get isOfflineReady() {
return this.valueIsOfflineReady;
}
get isNeedRefresh() {
return this.valueIsNeedRefresh;
}
get status() {
return this.valueStatus;
}
get currentVersion() {
return this.valueCurrentVersion;
}
get newVersion() {
return this.valueNewVersion;
}
get registrationScope() {
return this.valueRegistrationScope;
}
get error() {
return this.valueError;
}
get snapshot(): IClassSwSnapshot {
return {
status: this.valueStatus,
isEnabled: this.valueIsEnabled,
isSupported: this.valueIsSupported,
isInitialized: this.valueIsInitialized,
isRegistered: this.valueIsRegistered,
isOfflineReady: this.valueIsOfflineReady,
isUpdateAvailable: this.valueIsUpdateAvailable,
isNeedRefresh: this.valueIsNeedRefresh,
currentVersion: this.valueCurrentVersion,
newVersion: this.valueNewVersion,
registrationScope: this.valueRegistrationScope,
error: this.valueError,
};
}
subscribe(cb: IClassSwChangeListener) {
this.listeners.push(cb);
return () => {
this.listeners = this.listeners.filter((l) => l !== cb);
};
}
private notify() {
// ClassSw is consumed as a small external store, so every mutation fan-outs here.
this.listeners.forEach((cb) => {
try {
cb();
} catch {
/* ignore listener errors */
}
});
}
private async readBuildInfoVersion() {
const url = new URL(`${env.basePath}build-info.txt`, window.location.origin);
url.searchParams.set('sw-update', Date.now().toString());
const res = await fetch(url, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
});
if (!res.ok) return null;
const text = await res.text();
const versionLine = text
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('version:'));
const version = versionLine?.slice('version:'.length).trim() ?? null;
return version && version !== this.valueCurrentVersion ? version : null;
}
private async handleNeedRefresh() {
this.valueIsUpdateAvailable = true;
this.valueStatus = 'update-available';
this.notify();
try {
this.valueNewVersion = await this.readBuildInfoVersion();
this.valueIsNeedRefresh = true;
} catch {
this.valueNewVersion = null;
this.valueIsNeedRefresh = true;
}
this.notify();
}
private handleOfflineReady() {
this.valueIsOfflineReady = true;
this.valueIsNeedRefresh = false;
this.valueStatus = 'offline-ready';
this.notify();
}
private registerServiceWorker() {
this.valueIsRegistered = false;
this.valueRegistrationScope = null;
this.valueIsOfflineReady = false;
this.valueIsUpdateAvailable = false;
this.valueIsNeedRefresh = false;
this.valueNewVersion = null;
this.valueError = null;
this.valueStatus = 'registering';
this.notify();
this.updateSW = registerSW({
immediate: this.initOptions.immediate,
onNeedRefresh: () => void this.handleNeedRefresh(),
onOfflineReady: () => void this.handleOfflineReady(),
onRegistered: (registration) => {
this.valueIsRegistered = Boolean(registration);
this.valueRegistrationScope = registration?.scope ?? null;
this.valueStatus = registration ? 'registered' : 'idle';
this.valueError = null;
this.notify();
},
onRegisterError: (error) => {
this.valueStatus = 'error';
this.valueError = error instanceof Error ? error.message : 'Service worker registration failed.';
this.notify();
},
});
}
private async disableServiceWorker() {
try {
const hasStorageToCleanup = await this.clearServiceWorkerStorage();
this.valueStatus = hasStorageToCleanup ? 'unregistered' : 'disabled';
} catch (error) {
this.valueStatus = 'error';
this.valueError = error instanceof Error ? error.message : 'Service worker cleanup failed.';
}
this.notify();
}
private async clearServiceWorkerStorage() {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
let cacheKeys: string[] = [];
if (typeof caches !== 'undefined') {
// We clear caches together with unregister so stale offline data does not survive between installs.
cacheKeys = await caches.keys();
await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey)));
}
this.updateSW = null;
this.valueIsRegistered = false;
this.valueRegistrationScope = null;
this.valueIsOfflineReady = false;
this.valueIsUpdateAvailable = false;
this.valueIsNeedRefresh = false;
this.valueNewVersion = null;
this.valueError = null;
return registrations.length > 0 || cacheKeys.length > 0;
}
}Главная идея класса: все изменения состояния проходят через notify(). React ничего не знает о деталях registerSW, onNeedRefresh, Cache Storage и navigator.serviceWorker. Он просто подписывается на внешний store.
Отдельно обратите внимание на resetAppCache(). Это не косметическая функция. Она нужна, когда пользователь застрял между версиями: service worker уже установлен, кэш содержит старые ассеты, а приложение ведет себя странно. Кнопка "Reset App Cache" часто экономит часы поддержки.
Шаг 6
Создать singleton для ClassSw
Service worker должен управляться одним экземпляром ClassSw. Лучше вынести его в отдельный модуль:
import { ClassSw } from './classes/class-sw';
export const swService = new ClassSw();Теперь этот singleton можно использовать и в main.tsx, и в React-provider без циклических импортов.
В main.tsx инициализируем сервис до рендера приложения:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app';
import { env } from './core/envs';
import { swService } from './services/sw-service';
try {
swService.init({
enabled: env.mode === 'prod',
});
swService.setCurrentVersion(env.version ?? null);
} catch (error) {
console.warn('SW init failed', error);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);Здесь service worker включен только в prod. В dev и stage его можно отключать, чтобы не ловить баги из-за старых кэшей. Если staging должен вести себя как production, условие можно заменить на:
enabled: env.mode === 'prod' || env.mode === 'stage',Шаг 7
Создать ProviderPWA
Теперь нужно передать состояние в React.
Контекст будет хранить:
- Статусы service worker.
- Текущую и новую версию.
- Методы
updateApp()иresetAppCache().
import { swService } from './services/sw-service';
import { FC, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
type PWAStatus =
| 'disabled'
| 'disabling'
| 'idle'
| 'registering'
| 'resetting'
| 'registered'
| 'unregistered'
| 'offline-ready'
| 'update-available'
| 'error';
interface PWAContextValue {
status: PWAStatus;
isEnabled: boolean;
isSupported: boolean;
isInitialized: boolean;
isRegistered: boolean;
isOfflineReady: boolean;
isUpdateAvailable: boolean;
isNeedRefresh: boolean;
newVersion: string | null;
currentVersion: string | null;
registrationScope: string | null;
error: string | null;
updateApp: () => void;
resetAppCache: () => void;
}
const PWAContext = createContext<PWAContextValue | null>(null);
export const usePWA = <T extends keyof PWAContextValue>(props: T[]): Pick<PWAContextValue, T> => {
const context = useContextSelector(PWAContext, (value) => {
return value
? props.reduce(
(acc, prop) => {
acc[prop] = value[prop];
return acc;
},
{} as Pick<PWAContextValue, T>,
)
: null;
});
if (!context) {
throw new Error('usePWA must be used within ProviderPWA');
}
return context;
};
export const ProviderPWA: FC<PropsWithChildren> = ({ children }) => {
const [status, setStatus] = useState<PWAStatus>('idle');
const [isEnabled, setIsEnabled] = useState(true);
const [isSupported, setIsSupported] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const [isOfflineReady, setIsOfflineReady] = useState(false);
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const [isNeedRefresh, setIsNeedRefresh] = useState(false);
const [newVersion, setNewVersion] = useState<string | null>(null);
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const [registrationScope, setRegistrationScope] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const syncState = () => {
setStatus(swService.status);
setIsEnabled(swService.isEnabled);
setIsSupported(swService.isSupported);
setIsInitialized(swService.isInitialized);
setIsRegistered(swService.isRegistered);
setIsOfflineReady(swService.isOfflineReady);
setIsUpdateAvailable(swService.isUpdateAvailable);
setIsNeedRefresh(swService.isNeedRefresh);
setNewVersion(swService.newVersion ?? null);
setCurrentVersion(swService.currentVersion ?? null);
setRegistrationScope(swService.registrationScope ?? null);
setError(swService.error ?? null);
};
syncState();
const unsubscribe = swService.subscribe(syncState);
return () => unsubscribe();
}, []);
const updateApp = useCallback(() => {
try {
swService.updateApp().catch(() => window.location.reload());
} catch {
window.location.reload();
}
}, []);
const resetAppCache = useCallback(() => {
try {
swService.resetAppCache().catch(() => window.location.reload());
} catch {
window.location.reload();
}
}, []);
return (
<PWAContext.Provider
value={{
status,
isEnabled,
isSupported,
isInitialized,
isRegistered,
isOfflineReady,
isUpdateAvailable,
isNeedRefresh,
newVersion,
currentVersion,
registrationScope,
error,
updateApp,
resetAppCache,
}}
>
{children}
</PWAContext.Provider>
);
};Здесь ProviderPWA зеркалирует состояние ClassSw в React. Можно было бы использовать useSyncExternalStore, но явный provider удобен, если вы хотите отдавать состояние через уже существующую систему контекстов приложения.
use-context-selector нужен, чтобы компонент, которому нужна только isUpdateAvailable, не перерисовывался из-за изменения registrationScope или currentVersion.
Шаг 8
Обернуть приложение в ProviderPWA
В корневом компоненте:
import { ProviderPWA } from './contexts/context-pwa';
import { LayoutRouter } from './layouts/layout-router';
function App() {
return (
<ProviderPWA>
<LayoutRouter />
</ProviderPWA>
);
}
export default App;Provider должен находиться достаточно высоко, чтобы banner обновления был доступен на всех страницах.
Шаг 9
Добавить UI для обновления
Минимальный вариант:
import { usePWA } from './contexts/context-pwa';
export function UpdateAppButton() {
const pwa = usePWA(['isUpdateAvailable', 'newVersion', 'updateApp', 'resetAppCache']);
return (
<div>
<button disabled={!pwa.isUpdateAvailable} type="button" onClick={pwa.updateApp}>
{pwa.isUpdateAvailable ? `Update to ${pwa.newVersion ?? 'new version'}` : 'No updates'}
</button>
<button type="button" onClick={pwa.resetAppCache}>
Reset App Cache
</button>
</div>
);
}В production лучше показывать не просто кнопку в layout, а toast или sticky banner:
import { usePWA } from './contexts/context-pwa';
export function UpdateAppBanner() {
const pwa = usePWA(['isNeedRefresh', 'currentVersion', 'newVersion', 'updateApp']);
if (!pwa.isNeedRefresh) return null;
return (
<section role="status" aria-live="polite">
<p>
A new version is available
{pwa.newVersion ? `: ${pwa.newVersion}` : ''}.
</p>
{pwa.currentVersion && <small>Current version: {pwa.currentVersion}</small>}
<button type="button" onClick={pwa.updateApp}>
Refresh
</button>
</section>
);
}Текст можно адаптировать под продукт. Главное - не обновлять страницу без предупреждения, если пользователь мог заполнять форму или выполнять длинное действие.
Что происходит при обновлении
Когда выходит новая сборка, browser/service worker pipeline проходит несколько стадий:
- Браузер видит, что файл service worker изменился.
- Новый service worker устанавливается, но не берет управление сразу.
vite-plugin-pwaвызываетonNeedRefresh.ClassSwставитisUpdateAvailable = true.ClassSwчитает/build-info.txtс cache-busting query и сохраняетnewVersion.- React получает новое состояние через
ProviderPWA. - Пользователь нажимает "Refresh".
updateSW(true)активирует новый service worker и перезагружает страницу.
Важно: этот список начинается не с самого деплоя, а с момента, когда браузер уже решил проверить service worker и увидел новый файл. Открытая вкладка не обязана мгновенно узнать о деплое сама по себе. В базовой реализации из статьи обновление обычно обнаруживается после перезагрузки, перехода внутри приложения, повторного открытия вкладки или другой проверки service worker браузером. Если нужно гарантированное обнаружение в активной вкладке через N минут, добавьте явный polling: registration.update() или отдельную проверку build-info.txt.
За счет registerType: 'prompt' момент активации новой версии контролирует не плагин, а приложение. Разработчик сам решает, что делать после onNeedRefresh: показать пользователю banner, отложить обновление до безопасного момента, обновить после завершения сценария или сразу вызвать updateApp(). Это особенно важно для кабинетов, CRM, редакторов, корзин, форм и любых приложений с несохраненным состоянием.
Как проверять
Проверять PWA-обновления нужно на production build. В dev-режиме service worker отключен.
Примерный сценарий:
- Соберите приложение с версией
1.0.0. - Запустите production preview или отдайте билд статическим сервером.
- Откройте приложение в браузере и убедитесь, что service worker зарегистрирован.
- Соберите новую версию
1.0.1. - Замените файлы на сервере, не закрывая вкладку.
- Перейдите внутри приложения или перезагрузите вкладку так, чтобы браузер проверил service worker.
- Убедитесь, что появился banner обновления.
- Нажмите "Refresh".
- Проверьте, что приложение загрузилось уже с новой версией.
Если просто оставить старую вкладку без действий, banner может не появиться сразу. Automatic-режим в этой статье означает "автоматически применить обновление после onNeedRefresh", а не "самостоятельно опрашивать сервер в активной вкладке".
Для локальной проверки можно использовать:
yarn build:prod
npx vite preview --outDir buildВ Chrome состояние service worker удобно смотреть в DevTools:
Application -> Service workers
Application -> Cache storageТам же можно вручную сделать unregister, очистить storage и посмотреть, какой service worker сейчас управляет страницей.
Частые ошибки
Обновление есть, но интерфейс его не показывает.
Проверьте, что регистрация service worker идет через ваш ClassSw, а ProviderPWA реально подписан на swService.subscribe(). Еще убедитесь, что приложение запущено в режиме, где enabled: true.
Пользователь видит старую версию даже после деплоя.
Это нормальное состояние до тех пор, пока новый service worker не обнаружен и не активирован. Если он вообще не обнаруживается, проверьте filename, scope регистрации и HTTP-кэш для service worker файла.
build-info.txt показывает старую версию.
Скорее всего, файл кэшируется CDN или сервером. Для него лучше использовать Cache-Control: no-cache или очень короткий TTL. В Workbox мы уже поставили NetworkFirst, а в клиентском чтении версии добавили cache-busting query и cache: 'no-store', но это не отменяет поведение внешнего CDN.
API-ответы внезапно стали старыми.
Проверьте runtime caching. В примере API отправлен в NetworkOnly, чтобы service worker не кэшировал backend-данные.
На localhost постоянно странные баги после изменения PWA.
Очистите service worker и Cache Storage в DevTools. В разработке лучше держать devOptions.enabled: false.
Пользователь окончательно застрял.
Для этого нужен resetAppCache(): unregister всех service worker, удаление Cache Storage и перезагрузка страницы. Это грубый, но полезный аварийный выход.
Что можно улучшить дальше
Базовую систему легко расширить.
Можно добавить build-info.json вместо build-info.txt и хранить там version, commit, branch, builtAt, releaseNotesUrl.
Можно показывать разные сообщения для patch/minor/major обновлений.
Можно проверять новую версию по таймеру, если приложение часто открыто весь день. Например, раз в 10-15 минут дергать /build-info.txt и сравнивать версию. Но важно не путать это с активацией service worker: сам факт новой строки в build-info.txt еще не означает, что новый service worker уже установлен.
Можно отправлять события в аналитику:
pwa_registered
pwa_update_available
pwa_update_accepted
pwa_cache_reset
pwa_register_errorЭто поможет понять, сколько пользователей реально обновляется через banner, а сколько живет в старых вкладках.
Итог
Хорошая система обновлений для React-приложения - это не одна настройка vite-plugin-pwa.
Надежная схема состоит из нескольких частей:
- Vite генерирует service worker и manifest.
registerType: 'prompt'дает приложению контроль над моментом обновления.ClassSwхранит состояние service worker и умеет обновлять или сбрасывать приложение.ProviderPWAпревращает этот внешний store в удобный React API.- UI показывает пользователю понятное действие: "доступна новая версия, обновить сейчас".
В результате деплой перестает быть лотереей: когда браузер обнаружит новый service worker, приложение покажет понятное состояние и предложит безопасно перейти на новую сборку. Если продукту нужно обнаруживать деплой в давно открытой активной вкладке без действий пользователя, это отдельный слой polling-а, который стоит добавить явно.