Files
bare/docs/decisions/068-pwa-self-update.md
mayatnikovandClaude Opus 5 383e374f0f iOS: запрет масштабирования, версия в подвале, самообновление, строка ввода
Отзыв с iPhone после первого живого прогона.

Масштабирование запрещено (ADR-066): viewport как в соседнем sixlines плюс
viewport-fit=cover, мета-теги установленного приложения, touch-action против
двойного тапа и страховка на JS — гашение жестов iOS, потому что мета-строку
Safari вправе проигнорировать. Размеры шрифтов не тронуты: 14 px из брифа
остаются, поднимать поля до 16 px владелец отклонил. Цена — доступность
для тех, кому зум нужен — записана в ADR и в ui.md честно.

Версия в подвале (ADR-065, 067): GET /api/config отдаёт короткую ревизию
и время коммита. Именно коммита, а не сборки: штамп компиляции в бинаре
means каждая пересборка даёт новый хеш, и обещание ADR-022 «бинарь собирается
из тега байт в байт» перестаёт проверяться.

Самообновление PWA (ADR-068, 070): в режиме приложения на iPhone перезагрузки
нет, и старая версия жила бы вечно. Проверка при возврате в приложение,
включение ждущей оболочки, перезагрузка — но не тогда, когда в строке набран
текст. Замок от петли сравнивает версию оболочки, а не считает перезагрузки:
счётчик отменял саму фичу, долгоживущая вкладка переставала обновляться.
Замерено: было 30, 60, 120, 240 секунд между релизами, стало ровно 30.

Строка ввода — редактируемый блок (ADR-069): iOS рисует над клавиатурой
полосу со стрелками и «готово» для полей формы, и убрать её со страницы
нельзя. Для contenteditable не рисует. Режим plaintext-only с проверкой,
что он применился, и запасным путём с разбором вставки: невалидное значение
атрибута в части браузеров делает блок нередактируемым вовсе. Чтение
и запись только через textContent, innerHTML в клиенте по-прежнему нет.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 15:11:13 +03:00

8.4 KiB
Raw Permalink Blame History

ADR-068: Приложение обновляется само

Меняет политику обновления из ADR-023: новая оболочка забирает управление, не дожидаясь закрытия всех вкладок.

Изменён ADR-070: второй замок — растущая пауза и счётчик bare-updates — заменён сверкой версии оболочки.

Уточнён ADR-072: к отложенному набранным вкладка возвращается ещё и по таймеру — поле пустеет и молча.

Контекст

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

Для установленного на «Домой» приложения это означает «никогда». Его не закрывают: iOS держит его в списке приложений неделями. Строки адреса и кнопки перезагрузки в нём нет — обновить оболочку человеку нечем вовсе. Он остаётся на версии, которая была при установке, и починенное на сервере до него не доезжает.

Перезагрузка при этом не бесплатна: она уносит набранное в строке ввода и в формах. Пароль в форме входа теряется так же, как черновик сообщения.

Решение

  • Клиент спрашивает сервер об обновлении (registration.update()) при запуске и при каждом возвращении в приложение (visibilitychangevisible), но не чаще раза в минуту. Проверка — один условный запрос за /sw.js. Без сети запрос падает молча: офлайн не сбой.
  • Установленная версия не включается сама. Её включает страница: postMessage("skip-waiting") воркеру, который ждёт. Воркер отвечает skipWaiting(), activate забирает клиентов (clients.claim там уже есть), браузер шлёт controllerchange, страница перезагружается. Просьба уходит только тогда, когда страницей уже кто-то управляет: первая установка проходит через то же состояние «установлен», но включать там нечего — воркер активируется сам, а страница и так свежая.
  • Набранное откладывает всё: и просьбу включиться, и перезагрузку. Проверяется весь документ, а не строка ввода: клиент — одна страница, и знать про экраны здесь незачем. Набранным считается непустое textarea и непустое поле, куда набирают текст (text, password, search, email, url, tel, number); чекбокс, файл, скрытое поле и кнопка не в счёт — у них значение непусто по определению, и одно такое поле запретило бы обновление насовсем. Отложенное трогается с места, когда поля опустели (событие input) или когда человек вернулся в приложение.
  • Ждёт каждая вкладка за себя. skipWaiting() меняет контроллёра у всех клиентов профиля сразу, а вкладок одного профиля бывает несколько (ADR-035): вкладка с пустыми полями включает новую оболочку, и соседняя с недописанным сообщением остаётся под ней на старой странице — до своей перезагрузки, которой она ждёт по своим полям. Договариваться вкладкам не о чем: терять нечего никому, а пересчёт голосов стоил бы протокола между ними.
  • Петля закрыта двумя замками. Первый: controllerchange перезагружает страницу один раз за её жизнь. Первую установку он отсекает — там контроллёр появляется впервые, clients.claim меняет его и без обновления, а страница и так свежая; замок эту смену съедает и запоминает, что страница стала управляемой. Дальше смена контроллёра означает новую оболочку, чью бы просьбу воркер ни исполнял — свою или соседней вкладки. Второй: в sessionStorage лежат время последней перезагрузки ради обновления (bare-updated-at) и их число в этой вкладке (bare-updates), а пауза перед следующей удваивается — 30 секунд, минута, две, четыре и дальше. Второй замок нужен против сервера, отдающего новый sw.js на каждый запрос: постоянная пауза задавала бы такой петле только темп, а растущая гасит её — после десятой перезагрузки ждать четыре часа, после двенадцатой сутки, дальше пауза перестаёт расти за ненадобностью. Обычные релизы её не замечают: между ними проходит больше.
  • Версия кэша в sw.js по-прежнему меняется при релизе, и по-прежнему руками (docs/deploy.md): именно её смена делает файл воркера другим и запускает всё описанное.

Следствия

  • Установленное приложение доезжает до новой версии само — при следующем открытии или в течение минуты после того, как его открыли.
  • Перезагрузка случается посреди сеанса. Терять ей нечего: набранное её откладывает, история в IndexedDB её переживает, открытый чат задан адресом и открывается снова.
  • Человек, оставивший недописанное сообщение, остаётся на старой версии, пока не допишет или не сотрёт. Это правильный размен: версия важнее черновика только на словах.
  • Вкладка с недописанным сообщением какое-то время исполняет старую оболочку под новым воркером: соседняя вкладка включила его, не спросив. Это ничего не стоит — модули страницы уже загружены, догружать их некому (import() в клиенте нет), а /api/ мимо кэша идёт всегда.
  • У клиента появилось хранилище кроме IndexedDB — два ключа в sessionStorage, живущие не дольше вкладки. Записано в docs/storage.md.
  • Отката к «ждать закрытия всех вкладок» не предвидится: на iOS это состояние недостижимо.