Start Debugging

Исправление: Flutter web отдаёт устаревшую закешированную сборку после перезагрузки вкладки браузера

Перезагрузка перепроверяет только index.html, поэтому main.dart.js без хеша в имени продолжает браться из кеша браузера. Отдавайте результат сборки Flutter с Cache-Control: no-cache, проставляйте build id там, где заголовки задать нельзя, и позвольте самоочищающемуся service worker убрать кеши версий до 3.41.

Короткий ответ: Flutter web генерирует точки входа с фиксированными именами файлов (flutter_bootstrap.js, main.dart.js, main.dart.wasm, canvaskit/...), а обычная перезагрузка в браузере перепроверяет только HTML-документ. Если хостинг отдаёт для этих файлов хоть какой-то срок свежести (Firebase Hosting отдаёт max-age=3600, GitHub Pages отдаёт max-age=600), перезагруженная страница получает свежий index.html и старый main.dart.js из кеша. Исправляется это так: вся папка build/web отдаётся с Cache-Control: no-cache, а на хостингах, где заголовки задать нельзя, после flutter build web к flutter_bootstrap.js и main.dart.js добавляется build id. Если у пользователей всё ещё стоит offline-first service worker из Flutter 3.38 или более ранней версии, продолжайте разворачивать стандартный flutter_service_worker.js: начиная с Flutter 3.41 это самоочищающийся worker, который снимает регистрацию старого и перезагружает вкладку.

Всё описанное ниже воспроизведено на Flutter 3.44.8 (Dart 3.12.2) и сверено с исходным кодом инструмента и движка версии 3.47.3, которые ведут себя в этой проблеме так же. Тесты в браузере проводились в браузере на базе Chromium с небольшим сервером на Node, умеющим переключать политику Cache-Control.

Два разных кеша, в зависимости от того, когда вы впервые выпустили приложение

Результаты поиска по этой проблеме смешивают две эпохи, и исправление для них разное:

Понять, в каком из миров вы находитесь, можно в DevTools, Application, Service workers. Если регистрации нет, виноват HTTP-кеш.

Почему перезагрузка не загружает новый main.dart.js

Посмотрите, что flutter build web кладёт в build/web на 3.44.8:

# flutter build web, Flutter 3.44.8
index.html
flutter_bootstrap.js
flutter.js
flutter_service_worker.js
main.dart.js
version.json
manifest.json
assets/AssetManifest.bin
assets/FontManifest.json
assets/fonts/MaterialIcons-Regular.otf
canvaskit/canvaskit.js
canvaskit/canvaskit.wasm

Ни одно из этих имён не содержит хеша содержимого. index.html загружает flutter_bootstrap.js, в котором находится _flutter.buildConfig, чей mainJsPath равен литеральной строке "main.dart.js". Каждое развёртывание использует те же URL, поэтому только по URL браузер не может отличить новую сборку от старой.

Теперь добавьте к этому то, как работает перезагрузка. Перезагрузка в Chrome перепроверяет основной ресурс, а затем выполняет обычную загрузку страницы. В статье команды Chromium 2017 года сказано, что браузер решил “only validate the main resource and continue with a regular page load” (блог Chromium). Подресурсы, которые по своему Cache-Control ещё свежие, берутся прямо из дискового кеша без запроса. При обычной навигации (закладка, введённый URL, ссылка) любой браузер переиспользует свежие копии, включая сам index.html.

Поэтому покажет ли перезагрузка новую сборку, целиком зависит от того, что хостинг отдаёт для этих файлов:

ХостингCache-Control по умолчанию для статических файловОкно устаревания после развёртывания
Firebase Hostingmax-age=3600 (наблюдалось на *.firebaseapp.com)до 1 часа
GitHub Pagesmax-age=600, не настраиваетсядо 10 минут
Netlify, Vercel, Cloudflare Pagespublic, max-age=0, must-revalidateнет
Nginx, Apache, python -m http.server без настройкизаголовка нет, но отдаётся Last-Modifiedэвристика, см. ниже

На последней строке люди и попадаются. Отсутствие заголовка Cache-Control не означает “не кешировать”. При наличии заголовка Last-Modified RFC 9111, раздел 4.2.2 позволяет браузеру выбрать эвристический срок свежести, обычно 10% времени с момента последнего изменения файла. main.dart.js, который последний раз разворачивали десять дней назад, может считаться свежим целые сутки.

Firebase действительно очищает свой CDN при каждом развёртывании, так что edge сразу отдаёт новые файлы. Собственный кеш браузера не очищается, а перезагрузка использует именно эту копию.

Минимальное воспроизведение

Этот сервер отдаёт build/web с переключаемой политикой. firebase имитирует настройки Firebase Hosting по умолчанию, fixed означает исправление:

// server.mjs, Node 22. Usage: MODE=firebase node server.mjs build/web
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';

const root = process.argv[2];
const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
  '.wasm': 'application/wasm', '.json': 'application/json' };

http.createServer((req, res) => {
  let p = decodeURIComponent(req.url.split('?')[0]);
  if (p.endsWith('/')) p += 'index.html';
  const file = path.join(root, p);
  if (!fs.existsSync(file)) { res.writeHead(404); return res.end(); }
  const stat = fs.statSync(file);
  const headers = {
    'Content-Type': types[path.extname(file)] ?? 'application/octet-stream',
    'ETag': `"${stat.size}-${stat.mtimeMs}"`,
    'Cache-Control': process.env.MODE === 'fixed' ? 'no-cache' : 'max-age=3600',
  };
  if (req.headers['if-none-match'] === headers.ETag) { res.writeHead(304, headers); return res.end(); }
  console.log(200, p);
  res.writeHead(200, headers);
  fs.createReadStream(file).pipe(res);
}).listen(8765);

И приложение, единственная задача которого показать, какая сборка запущена:

// lib/main.dart, Flutter 3.44.8, Dart 3.12.2
import 'package:flutter/material.dart';

const build = 'A';

void main() => runApp(
  const MaterialApp(home: Scaffold(body: Center(child: Text('Build $build')))),
);

Шаги: соберите с build = 'A', запустите сервер с MODE=firebase, откройте http://localhost:8765/. Поменяйте константу на 'B', снова выполните flutter build web и перезагрузите вкладку. На странице по-прежнему написано “Build A”. В журнале сервера для этой перезагрузки ровно один запрос:

200 /index.html

flutter_bootstrap.js, main.dart.js, CanvasKit и шрифты были взяты из кеша браузера. Повторите всю последовательность с MODE=fixed на новом origin, и перезагрузка покажет “Build B”. Повторная перезагрузка без нового развёртывания после этого стоит одного условного запроса на файл, и на каждый приходит 304 без тела.

Исправление по шагам

  1. Отдавайте результат сборки Flutter с Cache-Control: no-cache. no-cache не отключает кеширование. Он говорит браузеру хранить файл, но перед каждым использованием перепроверять его через If-None-Match или If-Modified-Since. Неизменённые файлы стоят одного сетевого обмена и 304. Изменённые файлы загружаются заново. Применяйте это к index.html, flutter_bootstrap.js, flutter.js, flutter_service_worker.js, main.dart.js, main.dart.mjs, main.dart.wasm, version.json, manifest.json, ко всему внутри assets/ и к локальной папке canvaskit/. Самое простое корректное правило: “всё в build/web”.
  2. Пропишите правило в конфигурации хостинга. Ниже есть примеры для Firebase Hosting, Nginx и файла _headers, который используют Netlify и Cloudflare Pages.
  3. Переждите один старый срок жизни. Новые заголовки применяются только к ответам, полученным после изменения. Браузеры, закешировавшие main.dart.js с max-age=3600, будут использовать его, пока этот час не истечёт. Выкатывайте изменение заголовков на одно развёртывание раньше, чем оно понадобится, или при первом выкатывании совместите его с build id (шаг 4).
  4. Там, где заголовки задать нельзя, проставляйте build id. Типичный случай: GitHub Pages. Переписывайте URL точек входа после каждой сборки, чтобы у каждого развёртывания были новые URL.
  5. Сообщайте уже открытым вкладкам. Заголовки помогают только при следующей загрузке. Долгоживущая вкладка продолжает выполнять старую сборку, пока пользователь её не перезагрузит, поэтому периодически опрашивайте небольшой файл с build id и предлагайте перезагрузку.

Firebase Hosting

FAQ по Flutter web предлагает max-age=0,s-maxage=604800 для js, mjs, wasm и json, что держит CDN прогретым и при этом заставляет браузер перепроверять файлы. Этот шаблон не охватывает HTML и .bin, а изображениям и шрифтам даёт max-age=3600, поэтому index.html, assets/AssetManifest.bin и любое изображение, заменённое под тем же именем, остаются устаревшими в течение часа. Этот firebase.json покрывает всю сборку:

{
  "hosting": {
    "public": "build/web",
    "headers": [
      {
        "source": "**",
        "headers": [
          { "key": "Cache-Control", "value": "no-cache" }
        ]
      }
    ]
  }
}

Поскольку Firebase очищает свой CDN при развёртывании, s-maxage для корректной работы edge не нужен. Возвращайте его, только если измерения покажут проблему с задержкой.

Nginx

# nginx 1.27, serving the output of flutter build web
server {
    listen 80;
    root /var/www/app/build/web;

    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache" always;
        etag on;
    }
}

Оставьте etag on (значение по умолчанию). Без валидатора браузеру нечем перепроверять файл, и он каждый раз загружает его целиком.

Netlify и Cloudflare Pages

Оба по умолчанию уже используют max-age=0, must-revalidate, и это работает корректно. Если прежняя конфигурация или пресет фреймворка добавили более длинный срок жизни, переопределите его файлом _headers в web/, чтобы flutter build web скопировал его в build/web:

# web/_headers, copied to build/web by flutter build web (Flutter 3.44)
/*
  Cache-Control: no-cache

GitHub Pages и другие хостинги без заголовков

Запускайте небольшой скрипт после сборки. Он добавляет ?v=<id> к тегу скрипта bootstrap и к путям сборки внутри _flutter.buildConfig, а также записывает id в build_id.txt для шага 5:

#!/usr/bin/env bash
# bust.sh, run after `flutter build web` (Flutter 3.44 output layout)
set -euo pipefail
ID="${1:-$(git rev-parse --short HEAD 2>/dev/null || date +%s)}"
OUT=build/web
sed -i.bak "s|src=\"flutter_bootstrap.js\"|src=\"flutter_bootstrap.js?v=$ID\"|" "$OUT/index.html"
sed -i.bak -E "s#\"(main\.dart\.(js|wasm|mjs))\"#\"\1?v=$ID\"#g" "$OUT/flutter_bootstrap.js"
rm "$OUT"/*.bak
echo "$ID" > "$OUT/build_id.txt"

При политике max-age=600 перезагрузка после развёртывания помеченной сборки запросила ровно три файла (index.html, flutter_bootstrap.js?v=..., main.dart.js?v=...) и показала новую сборку, а CanvasKit и шрифты по-прежнему брались из кеша. Именно этот подход описан в FAQ по Flutter, где отмечено, что Flutter не добавляет build id автоматически. Сам index.html при обычной навигации всё ещё подчиняется 10-минутному сроку жизни (перезагрузка всегда его перепроверяет), а ресурсы, заменённые под тем же именем, этим не покрываются, поэтому изменённые изображения переименовывайте, а не перезаписывайте.

Предложите перезагрузку открытым вкладкам

Передайте тот же id приложению во время компиляции и сравнивайте его с развёрнутым build_id.txt. cache: 'no-store' не пускает саму проверку в HTTP-кеш:

// lib/update_check.dart, Flutter 3.44.8, Dart 3.12.2, package:web 1.1.1
import 'dart:async';
import 'dart:js_interop';

import 'package:flutter/material.dart';
import 'package:web/web.dart' as web;

/// flutter build web --dart-define=BUILD_ID=$(git rev-parse --short HEAD)
const buildId = String.fromEnvironment('BUILD_ID', defaultValue: 'dev');

Future<bool> newBuildAvailable() async {
  try {
    final response = await web.window
        .fetch('build_id.txt'.toJS, web.RequestInit(cache: 'no-store'))
        .toDart;
    if (!response.ok) return false;
    final deployed = (await response.text().toDart).toDart.trim();
    return deployed.isNotEmpty && deployed != buildId;
  } catch (_) {
    return false; // offline or blocked: keep running the current build
  }
}

void startUpdateCheck(GlobalKey<ScaffoldMessengerState> messenger) {
  if (buildId == 'dev') return;
  Timer.periodic(const Duration(minutes: 5), (timer) async {
    if (!await newBuildAvailable()) return;
    timer.cancel();
    messenger.currentState?.showSnackBar(
      SnackBar(
        duration: const Duration(days: 1),
        content: const Text('A new version is available.'),
        action: SnackBarAction(
          label: 'Reload',
          onPressed: () => web.window.location.reload(),
        ),
      ),
    );
  });
}

Задайте MaterialApp свойство scaffoldMessengerKey и вызовите с ним startUpdateCheck из main. При сборке с --dart-define=BUILD_ID=A и развёрнутом build_id.txt, содержащем B, newBuildAvailable() вернул true при первой же проверке. Если вы не используете bust.sh, записывайте build_id.txt в CI с тем же id. Особенно это важно, когда API бэкенда меняется вместе с фронтендом, потому что старая вкладка, вызывающая новый API, это худшая ошибка, чем старый интерфейс.

Если вы выпускали service worker до Flutter 3.41

У пользователей, впервые открывших приложение, когда оно собиралось на 3.38.x или более ранней версии, в браузере всё ещё есть offline-first worker и его flutter-app-cache. Вот что происходит, когда они впервые загружают развёртывание, собранное на 3.41 или более поздней версии, согласно исходному коду 3.44.8 и 3.47.3:

  1. Старый worker всё ещё управляет страницей, поэтому на навигацию он отвечает сначала из сети (новый index.html), но flutter_bootstrap.js и main.dart.js отдаёт из Cache Storage. Пользователь может ненадолго увидеть старую сборку.
  2. Проверка обновления service worker в браузере загружает flutter_service_worker.js из сети. Файл отличается побайтно (теперь это очищающий worker размером 784 байта), поэтому он устанавливается.
  3. Очищающий worker вызывает skipWaiting(), затем в activate вызывает self.registration.unregister() и переводит каждое управляемое им окно на его текущий URL.
  4. Эта навигация происходит без service worker, поэтому страница загружает текущую сборку через HTTP-кеш. С заголовками из предыдущего раздела это новая сборка.

Очищающий worker не удаляет flutter-app-cache, flutter-temp-cache и flutter-app-manifest. Без worker их никто не читает, но место в хранилище они продолжают занимать. Если это для вас важно, удалите их один раз при запуске через Cache Storage API (caches.delete('flutter-app-cache') и так далее, через package:web).

Эту передачу управления блокируют две ошибки при развёртывании:

Если вам нужна настоящая офлайн-поддержка, FAQ по Flutter теперь советует использовать собственный worker, например на Workbox. Дайте ему версионированное имя кеша и стратегию network-first для index.html и flutter_bootstrap.js, иначе вы воссоздадите старую проблему.

Подводные камни и похожие проблемы

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад