Свернули и вернулись: процесс вместо вкладки

синхронизация≈ 16 минут

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

Дневник «Подхода» уже умеет всё, чему учила глава: журнал на устройстве, пачки на сервер, курсор, вердикты, надгробия, правило слияния. Не хватает последнего — момента, когда всё это запускается. В браузере таким моментом была вкладка: ушли со страницы, вернулись на страницу. У приложения предмет другой — процесс. Его сворачивают кнопкой «Домой», он висит в фоне, и система может убить его молча, не спросив и не предупредив. Отсюда AppState вместо visibilitychange — и это то самое место, где React Native не повторяет браузер, а заменяет его. А local-first меняет смысл возврата: перечитывать данные не надо, они и так на телефоне; возврат нужен очереди.

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

В части II уход со страницы ловили visibilitychange со значением hidden и pagehide, а на beforeunload не полагались — на мобильных он часто не срабатывает вовсе («DOMContentLoaded, load и порядок загрузки», «Сохранение и восстановление доски»). У приложения вместо вкладки процесс, и рассказывает о нём AppState. В предпросмотре он настоящий: react-native-web строит его ровно на visibilitychange — это видно в exports/AppState/index.js, где currentState переводит hidden, prerender и unloaded в background, а всё остальное — в active.

В подложке лежат leaveApp и backToApp: они переключают видимость страницы, то есть нажимают за вас «Домой» и возвращают приложение. Прогноз в три этажа: что напечатает первая строка, сколько придёт событий и что покажет currentState после того, как подписку сняли?

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

Три отличия от браузера видны прямо здесь. Первое: подписка возвращает объект, и снимается она своим remove(), а не removeEventListener с той же функцией. Второе: обработчик получает не событие, а строку состояния — как onChangeText получает текст, а не event. Третье: currentState продолжает жить и после remove — это геттер, он каждый раз смотрит на текущее состояние, поэтому последняя строка и говорит background, хотя события уже никто не слушал.

Состояний на телефоне больше двух, и половину из них предпросмотр показать не может — в типах react-native 0.86 их пять:

состояниекогда наступаетв предпросмотре
activeприложение на экране, человек с ним работаетесть: страница видима
backgroundсвернули, ушли в другое приложение, погасили экранесть: visibilityState стал hidden
inactive (iOS)переход между фоном и экраном, шторка уведомлений, переключатель приложений, входящий звонокнет
unknown (iOS)состояние ещё не определено — до первого ответа нативанет
extension (iOS)код работает как расширение, а не как приложениенет
событиегде оно настоящеев предпросмотре
changeiOS и Androidесть, приходит из visibilitychange
memoryWarningiOS и Androidподписка возвращается пустой
focus и blurтолько Android (appStateFocusChange)ошибка подписки

Таблица выше — это пересказ двух исходников: списка EVENT_TYPES в react-native-web и AppState.js в react-native 0.86. Проверим её запуском. Прогноз: четыре подписки — что вернёт каждая?

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

Самая неприятная строка — вторая. memoryWarning в списке разрешённых событий есть, поэтому проверка молчит, но возвращать подписку умеет только ветка change: наружу уходит undefined. Типы при этом обещают NativeEventSubscription, так что sub.remove() компилируется без единого замечания и падает в предпросмотре с TypeError. Это та же ловушка «типы шире рантайма», что в статье «Что в этой песочнице настоящее», только на этот раз внутри объекта, который в остальном работает. focus и blur честнее: их react-native-web не знает вовсе и бросает ошибку сразу.

Теперь про состояние, которого в предпросмотре нет. На iPhone между «на экране» и «в фоне» всегда стоит inactive: его дают и шторка уведомлений, и переключатель приложений, и входящий звонок — причём выйти из inactive можно обратно в active, никуда не уходя. Чтобы увидеть эту последовательность, хватает десяти строк своего эмиттера. Прогноз: сколько раз сработает синхронизация?

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

Четыре события, одна синхронизация. Правило простое и стоит того, чтобы записать его один раз: реагируем на active, а не на «не background» и не на любое изменение. Иначе каждая шторка уведомлений на iPhone будет стоить пачки в сеть — и это ровно та ошибка, которую вам предстоит починить в первой задаче.

Опираться лучше на события, а не на currentState. В исходнике AppState.js react-native 0.86 рядом с этим полем стоит комментарий авторов: чтобы оно было свежим, приходится держать наблюдателя, даже когда поле никому не нужно, и его стоило бы объявить устаревшим.

В вебе возврат на вкладку означает «сходи за свежим»: кеш запросов помечает данные устаревшими и перезапрашивает. В React Native это не работает само — фокуса окна тут нет, и в настоящем приложении автора фокус сообщают руками: AppState.addEventListener('change', …) зовёт focusManager.setFocused(status === 'active') (providers.tsx в stack-starter). В local-first перечитывать нечего: на экране местная копия, она и есть оригинал. Возврат нужен другому — исходящей очереди, которая всё это время ждала сети.

Экран ниже подписан на хранилище через useSyncExternalStore («Свой хук: логика без разметки» в части IV, заново не объясняем), а на AppState — обычным эффектом с уборкой. Сценарий: в зале сети нет, человек записывает подход, сворачивает телефон и открывает снова; потом выходит на улицу и открывает приложение ещё раз. Прогноз: сколько будет строк AppState и что покажет индикатор после каждого возврата?

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

Уход в фон не делает ничего — строка AppState: background есть, а отрисовки после неё нет. Первый возврат зовёт sync(), тот падает, и в индикаторе появляется причина. Второй возврат — сеть на месте — отправляет ту же самую очередь, которую никто не трогал, и индикатор возвращается к «сохранено на телефоне». Обратите внимание, чего в этом выводе нет: ни одного состояния запроса, ни «загрузка», ни «ошибка загрузки». Подход всё это время был на экране, потому что он всё это время был в телефоне.

В приложении подписка живёт не на экране, а в корне — там же, где в главе 6 стояла гидратация. Экранов у «Подхода» пять, и пять подписок дали бы пять синхронизаций на каждый возврат:

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

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

В браузере на visibilitychange вешали сохранение черновика и navigator.sendBeacon: вкладку вот-вот закроют, надо успеть. В local-first успевать нечего — запись легла в журнал в момент нажатия, одним шагом со строкой («Журнал: имена и номера рождаются на телефоне»). В фоне остаётся ровно одно дело: подтолкнуть отправку. И вот её оборвать как раз могут — процесс в фоне система убивает молча, без события и без обещаний.

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

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

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

Проверять это надо на устройстве, и способы простые: на Android включить «Не сохранять действия» в параметрах разработчика или убить процесс командой adb shell am kill, на iPhone — смахнуть приложение из переключателя. Если после такого убийства неотправленное пропало, значит запись всё-таки жила в памяти, а не на диске.

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

Про сеть телефон спрашивают у NetInfo — @react-native-community/netinfo, пакет сообщества с нативным модулем (в песочнице он не соберётся, поэтому ниже листинг). Полей в его ответе два, и путать их дорого: isConnected — «подключение есть», isInternetReachable — «библиотека сходила наружу и получила ожидаемый ответ». В зале это ровно та разница, которая ломает синхронизацию: Wi-Fi со страницей входа честно подключён и не ведёт никуда.

Прогноз: четыре попытки в четырёх местах. Сколько из них дойдут до сервера и какой будет отсрочка у каждой?

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

NetInfo пригодился ровно один раз — в подвале, где попытку можно не делать вовсе. В зале он сказал «подключение есть», и это была правда, от которой не было никакого толку: отвечает не Wi-Fi, а сервер. Отсюда правило: NetInfo — это повод попробовать, а не разрешение считать, что получилось. Единственный настоящий ответ — ответ сервера, и до него очередь не трогают.

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

поле ответа NetInfoчто значиткак этим пользоваться
typenone, wifi, cellular, ethernet, vpn…грубая подсказка: например, не тянуть большое по сотовой
isConnectedподключение к сети естьповод попробовать отправку
isInternetReachableпроверка достижимости прошла; null — «ещё не знаем»ближе к правде, но приходит с задержкой и про ваш сервер ничего не знает
detailsssid, carrier, isConnectionExpensiveрешения «качать ли фото прямо сейчас»
tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Лестница отсрочек нужна и ради батареи. Телефон в шкафчике с мёртвым Wi-Fi при постоянном интервале в секунду будит радиомодуль тысячи раз за тренировку; с удвоением до минуты — десяток. Сбрасывать лестницу надо после успеха, а не после каждой ошибки, и это третья задача статьи.

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

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

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

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

Первая строка — это тренировка, которой больше нет нигде: ни на экране, ни в очереди, ни на сервере. Вторая и третья — та же ситуация с паузой: синхронизация встала, очередь цела, вход её отпустил, и оба подхода уехали как были. Различить эти случаи в коде помогает код ошибки, а не её текст: 401 ставит паузу, любая другая неудача — отсрочку из прошлого раздела. И заметьте, чего пауза не делает: она не мешает записывать. Дневник в local-first пишется без токена, токен нужен только синхронизации («Первый кадр: пока хранилище читается»).

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

Отдельный случай: вошли ДРУГИМ пользователем. Очередь принадлежит не устройству, а человеку, и отправлять неотправленное в чужой аккаунт нельзя. Поэтому очередь помечают владельцем и при чужом входе она продолжает ждать своего — а «стереть копию на этом телефоне» остаётся отдельным осознанным действием с предупреждением о числе неотправленных.

  • У приложения не вкладка, а процесс, и рассказывает о нём AppState: подписка возвращает объект с remove(), обработчик получает строку состояния, а currentState — геттер, живой и после отписки. В предпросмотре состояний два, на телефоне пять: inactive, unknown и extension есть только на iOS, focus и blur — только на Android, а подписка на memoryWarning здесь возвращает undefined, хотя типы обещают подписку.
  • Синхронизироваться надо на active, а не на «не background»: на iPhone между фоном и экраном всегда стоит inactive, и его же дают шторка уведомлений и переключатель приложений.
  • Возврат из фона — повод позвать sync(), а не перечитать экраны: на экране местная копия, она и есть оригинал. Подписка живёт в корне приложения, одна на все экраны.
  • При уходе в фон успевать нечего — запись легла на диск в момент нажатия. Убийство процесса посреди отправки не страшно: очередь пустеет только по вердиктам, а повтор пачки не добавил серверу ни строки, ни записи журнала.
  • «Есть сеть» не значит «сервер доступен»: Wi-Fi зала с порталом входа даёт isConnected: true и ноль пользы. NetInfo — повод попробовать, ответ даёт только сервер, а неудача уезжает по лестнице отсрочек, которая сбрасывается после успеха.
  • Ответ 401 ставит синхронизацию на паузу и не трогает ни очередь, ни журнал. Уборка в стиле кеша запросов — signOut и clear() — стирает единственный экземпляр тренировки: в прогоне после неё на экране 0, в очереди 0 и у сервера 0.

1/4Что вернёт AppState.addEventListener("memoryWarning", handler) в предпросмотре?

2/4Приложение вернулось из фона. Что делает клиент в local-first?

3/4NetInfo сообщает isConnected: true, isInternetReachable: false. Что делать синхронизации?

4/4Сервер ответил 401 на пачку с двумя неотправленными подходами. Что делает приложение?

Синхронизация на каждое шевеление

Обработчик подписан на change и синхронизируется на каждое событие. На Android это выглядит терпимо, а на iPhone — нет: между фоном и экраном всегда стоит inactive, и его же дают шторка уведомлений и переключатель приложений. Почините обработчик так, чтобы на уход в фон и возврат приходилась одна синхронизация. Эмиттер с последовательностью iPhone лежит в подложке.

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

Синхронизируемся на возврате, то есть на одном-единственном состоянии:

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

Вывод становится таким: четыре строки состояний, затем sync #1 и синхронизаций: 1. Соблазнительный вариант if (state === "background") return; проверку не проходит: inactive он пропускает, и на iPhone остаётся три синхронизации из четырёх. Проверять надо то, что нужно, а не то, что мешает: нужен active.

Пауза, которая не снимается

Синхронизация встаёт на паузу при 401 — это правильно, неотправленное цело. Человек входит снова, приложение кладёт новый токен… и подходы не уезжают никогда. Почините signIn так, чтобы после входа очередь дошла до сервера.

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

Пауза — это состояние, которое кто-то обязан снять. Вход — единственное событие, которое имеет на это право:

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

Вывод становится «после входа: отправлено | у сервера строк 2», а последняя строка — «у сервера строк: 2 | ждут отправки: 0». В настоящем приложении тут же уместен schedule(0) из «Цикла синхронизации»: ждать до следующего возврата из фона незачем, человек прямо сейчас смотрит на экран. И обратите внимание, что paused снимает именно вход, а не удачный ответ сервера, — сервер ведь и не спрашивали, пока стояла пауза.

Отсрочка, которая не растёт

Лестница отсрочек написана, массив есть, а телефон всё равно долбится в недоступный сервер раз в секунду. Найдите строку, из-за которой лестница не растёт, и почините — так, чтобы после успешной отправки отсрочка всё-таки возвращалась к началу.

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

Счётчик попыток сбрасывался в catch — то есть ровно там, где он обязан расти. Сбрасывать его надо после успеха:

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

Вывод: 1000, 2000, 4000 и «отправлено». Math.min нужен, чтобы лестница упёрлась в последнюю ступень, а не вышла за край массива и не дала undefined вместо задержки — при таком setTimeout сработал бы немедленно, и вместо редких попыток получился бы цикл.