Как построить систему обновлений React-приложения с нуля

Разбираем production-подход к обновлениям React-приложения: Vite, service worker, vite-plugin-pwa, версия сборки, кнопка обновления и сброс кэша.

Как построить систему обновлений React-приложения с нуля

В вебе есть неприятная особенность: вы задеплоили новую версию приложения, а часть пользователей продолжает жить в старой вкладке.

Для обычной страницы это почти незаметно. Пользователь перешел по ссылке, браузер получил новый HTML, новые JS-файлы, и жизнь продолжается. Но современное React-приложение чаще работает как SPA: одна вкладка может быть открыта часами или днями. Пользователь не знает, что вы уже выкатили фиксы, новые переводы, другую схему API или исправление критичного бага.

Нужна система, которая умеет:

  1. Регистрировать service worker только там, где это действительно нужно.
  2. Понимать, что появилась новая сборка.
  3. Показывать интерфейсу состояние PWA: зарегистрирован, готов офлайн, есть обновление, произошла ошибка.
  4. Обновлять приложение по кнопке пользователя.
  5. Сбрасывать service worker и Cache Storage, если пользователь застрял на старой версии.

Ниже разберем такую систему на React + Vite. В основе будут три точки:

  1. Конфигурация vite-plugin-pwa.
  2. Класс ClassSw, который управляет жизненным циклом service worker.
  3. ProviderPWA, который отдает состояние обновлений в React.

Почему не достаточно просто задеплоить новую сборку

Vite собирает приложение в статические файлы с хэшами:

index.html
assets/index-Bk4n8Nf3.js
assets/index-Cq81mA2x.css

Когда пользователь впервые открывает сайт, браузер загружает index.html, затем JS и CSS. Если вкладка остается открытой, старый JS уже находится в памяти. Новый деплой сам по себе не заставит этот JS исчезнуть.

Service worker добавляет еще один слой: он может кэшировать ресурсы приложения и отдавать их без похода в сеть. Это хорошо для скорости и офлайн-режима, но плохо, если обновлениями никто не управляет.

Поэтому задача не в том, чтобы "включить PWA". Задача в том, чтобы сделать PWA управляемой.

Общая архитектура

Система состоит из четырех уровней:

  1. Build layer: Vite генерирует service worker и web manifest.
  2. Runtime layer: ClassSw регистрирует service worker, слушает события обновлений и хранит состояние.
  3. React layer: ProviderPWA синхронизирует состояние ClassSw с React.
  4. 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
Видео-демонстрация: сравнение Manual и Automatic подходов. Manual ждет подтверждения пользователя, а Automatic применяет обновление после onNeedRefresh.

Шаг 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

Добавить версию сборки

Приложение должно знать две версии:

  1. Текущую версию, которая была вшита в JS во время сборки.
  2. Новую версию, которую можно получить из файла рядом с билдом.

Текущую версию удобно передавать через 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.

Контекст будет хранить:

  1. Статусы service worker.
  2. Текущую и новую версию.
  3. Методы 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 проходит несколько стадий:

  1. Браузер видит, что файл service worker изменился.
  2. Новый service worker устанавливается, но не берет управление сразу.
  3. vite-plugin-pwa вызывает onNeedRefresh.
  4. ClassSw ставит isUpdateAvailable = true.
  5. ClassSw читает /build-info.txt с cache-busting query и сохраняет newVersion.
  6. React получает новое состояние через ProviderPWA.
  7. Пользователь нажимает "Refresh".
  8. 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. Соберите приложение с версией 1.0.0.
  2. Запустите production preview или отдайте билд статическим сервером.
  3. Откройте приложение в браузере и убедитесь, что service worker зарегистрирован.
  4. Соберите новую версию 1.0.1.
  5. Замените файлы на сервере, не закрывая вкладку.
  6. Перейдите внутри приложения или перезагрузите вкладку так, чтобы браузер проверил service worker.
  7. Убедитесь, что появился banner обновления.
  8. Нажмите "Refresh".
  9. Проверьте, что приложение загрузилось уже с новой версией.

Если просто оставить старую вкладку без действий, 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.

Надежная схема состоит из нескольких частей:

  1. Vite генерирует service worker и manifest.
  2. registerType: 'prompt' дает приложению контроль над моментом обновления.
  3. ClassSw хранит состояние service worker и умеет обновлять или сбрасывать приложение.
  4. ProviderPWA превращает этот внешний store в удобный React API.
  5. UI показывает пользователю понятное действие: "доступна новая версия, обновить сейчас".

В результате деплой перестает быть лотереей: когда браузер обнаружит новый service worker, приложение покажет понятное состояние и предложит безопасно перейти на новую сборку. Если продукту нужно обнаруживать деплой в давно открытой активной вкладке без действий пользователя, это отдельный слой polling-а, который стоит добавить явно.

Ссылки

  1. Рабочая демонстрация
  2. Исходный код