Первый кадр: пока хранилище читается
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье boot читала диск и проходила лестницу миграций, а экран монтировался после неё. В песочнице такой порядок можно просто написать. На телефоне корень приложения монтирует платформа, как только загружен бандл, и ваших промисов она не ждёт. Эта статья — о том, что показать человеку, пока диск читается, и почему «у вас ещё нет тренировок» в этот момент — ложь. У открытия базы окажется три состояния, а не два. Флаг гидратации уберёт ложное «пусто», но не лишний кадр: для этого придётся придержать нативный сплэш, и тут начинается платформа. Картинку до первого кадра JavaScript рисует система, а снимает её модуль expo-splash-screen — это этаж Expo. В конце выяснится, что в local-first вход не сторожит дневник: он нужен только синхронизации.
Корень приложения на React Native монтирует не ваш код. Как только бандл загружен, платформа зовёт компонент, зарегистрированный через AppRegistry; expo-router регистрирует свой корень сам, в renderRootComponent.js. Первая отрисовка случается сразу. А boot ещё читает диск и идёт по лестнице, и на холодном старте, когда процесс поднимается с нуля, это занимает время. Что всё это время показывает экран?
Сначала о том, что человек видит до первого кадра. Пока JavaScript грузится, экран телефона закрыт нативным сплэшем — картинкой из конфига приложения, которую рисует сама система. В браузере такого нет, поэтому подложка его изображает: сплэш снимается сам, как только нарисовано первое содержимое, — так делает и expo-splash-screen, — и печатает, что открылось под ним. boot из подложки та же, что в прошлой статье, только печатает каждую ступень лестницы, чтобы было видно, когда идёт миграция. А ящик теперь отвечает через 10 мс настоящего времени: на холодном старте диск не мгновенный.
Экран дневника подписан на хранилище с первого кадра, как лента в статье «Главная копия — в телефоне», а boot наполняет хранилище, когда диск ответит. На диске — журнал версии 1.0 с одним подходом. Прогноз: какие кадры нарисуются и что увидит человек, когда сплэш снимется?
Первый кадр сказал «у вас ещё нет тренировок», и именно его человек увидел, когда сплэш снялся. Лестница прошла уже после этого, и только тогда появился дневник. Экран не виноват: пустое хранилище выглядит в точности как пустой дневник, и отличить «ещё не прочитали» от «ничего нет» ему не по чему. На телефоне с годом записей чтение и миграция длятся дольше, и ложный кадр висит дольше. А пустое состояние обычно не молчит: оно зовёт «Создайте первую тренировку».
Этот механизм уже встречался в приложении автора, только в другом виде — мигающий редирект. Корневой маршрут stack-starter решает, куда отправить человека: с токеном — в приложение, без токена — на вход. Сессия читается из хранилища асинхронно, и до конца чтения «токена нет» значит «ещё не прочитали». Поэтому корень ждёт флаг hydrated:
В «Подходе» редиректа на вход не будет, об этом последний раздел. Но мигание никуда не делось: мигающий редирект превратился в мигающее пустое состояние. Механизм тот же — первый кадр читает хранилище, которое ещё не прочитано с диска, — и лечится он так же, флагом. Слово «гидратация» здесь не то, что в статье части IV «Серверный компонент, SSR и гидратация — три разные вещи»: там React оживлял готовую разметку, а здесь хранилище наполняется с диска. Так называют флаг в сторах, отсюда и слово.
Флаг stack-starter — булев: hydrated либо false, либо true. Для дневника двух значений мало: открытие может кончиться отказом — схема новее приложения, испорченное значение на диске. «Прочитано» и «не прочитано, и уже не будет» — разные ответы, и экран у них разный. Поэтому состояний три: открывается, готова, не открылась. Записаны они размеченным объединением из части III — у отказа есть ошибка, у остальных нет.
Состояние открытия одно на весь процесс и живёт вне React, как hydrated в сторе сессии. Корень читает его через useSyncExternalStore — так же, как экраны читают хранилище. Прогноз: что будет первым кадром теперь и что увидит человек, когда сплэш снимется?
Ложь исчезла: первый кадр — «сплэш», а не «у вас ещё нет тренировок». Пустое состояние теперь бывает только у открытой базы, где хранилище правда пусто. Но лишний кадр остался. Нативный сплэш снялся на первом же содержимом, и под ним оказался наш JavaScript-сплэш: человек видит сплэш, потом ещё один сплэш, потом дневник. Если две картинки хоть чуть различаются, это видно как мигание. Флаг убирает ложное «пусто», но не лишний кадр — им займётся раздел «Первым кадром — дневник».
| Состояние | Что на экране | Можно ли писать |
|---|---|---|
| открывается | сплэш; на телефоне — ничего, под нативным сплэшем | нет: журнал ещё не прочитан, и писать не во что |
| готова | дневник или «у вас ещё нет тренировок», если хранилище правда пусто | да |
| не открылась | отдельный экран: что случилось и что можно сделать | нет, и диск не трогается |
Это не три состояния экрана из части IV — use() с Suspense, границы ошибок, loading.tsx. Там каждый экран ждал свой запрос. Здесь ожидание одно на процесс и стоит до первого экрана. После открытия экраны не ждут ничего: чтение из памяти, как в статье «Главная копия — в телефоне».
Можно. Промис boot отдаётся use() под Suspense со сплэшем в fallback, а отказ ловит граница ошибок — всё это из части IV, механика та же. Так устроен SQLiteProvider с useSuspense в последней статье главы. Объединение здесь написано руками, чтобы все три состояния были видны в коде, а не разнесены по Suspense и границе.
Открытие срывается по разным причинам. Схема на диске новее приложения — задача 2 прошлой статьи. Значение на диске испорчено: запись оборвалась, и JSON.parse бросает. На телефоне бывает занята база или кончилось место. Рука тянется к простому выходу: не держать человека на сплэше вечно, а считать, что данных нет. Так написан hydrate сессии в stack-starter:
Для сессии это верное решение. Худшее, что случится, — человек войдёт ещё раз, и сервер выдаст новый токен. Проверим то же правило на дневнике. На диске журнал версии 1.2 со схемой 3, запущена откаченная 1.1, и человек в зале записывает присед. Прогноз для двух стратегий: что он увидит и что останется на диске?
Сброс показал пустой дневник. Человек поверил и записал присед, и первая же запись положила на диск журнал из одной операции на место журнала 1.2. Две операции исчезли, и неотправленная правка веса вместе с ними. Они были только на этом телефоне, и никакой сервер их не вернёт. Отказ показал «не открылась», и диск остался как был. Когда человек снова поставит 1.2, журнал откроется целиком.
Сброс здесь даже не в catch: openJournal с пустым журналом ничего не стирает. Стирает первая запись — она пишет на диск журнал целиком, а целиком он теперь пуст. Поэтому в состоянии «не открылась» писать нельзя вовсе, не только сносить. На экране «не открылась» — что случилось, человеческими словами. Кнопка «Повторить» — для временных сбоев вроде занятой базы. Для схемы из будущего — «Обновить приложение». И ни одной кнопки, которая пишет в журнал.
Пусто — это ответ открытой базы. Неоткрытая база не пуста, она неизвестна.
Нативный сплэш рисует система, ещё до того как запущен JavaScript. На iOS это launch screen из storyboard, который генерирует prebuild. Системный показ кончается быстро, поэтому expo-splash-screen кладёт тот же storyboard поверх корневого вида React Native, и картинка остаётся (SplashScreenManager.swift). На Android 12 и новее это системный API сплэша, а на старых версиях его повторяет библиотека AndroidX core-splashscreen. Модуль держит первый кадр окна, пока сплэш не велено снять (SplashScreenManager.kt). Картинку и её настройки разбирает статья «Иконка, сплэш, имя, версия» в главе 10. Здесь важно одно — когда сплэш снимается.
Сам по себе он снимается, как только React Native нарисовал первое содержимое: на Android — по метке CONTENT_APPEARED из ReactMarker, на iOS — по уведомлению RCTContentDidAppearNotification. Данных он не ждёт, поэтому в примерах выше и снялся на первом кадре. Отменяет это preventAutoHideAsync(): модуль ставит флаг preventAutoHideCalled, и сплэш стоит до hideAsync(). Звать её надо на уровне модуля, до первого содержимого. Отсюда рецепт: придержать сплэш, пока открытие не кончилось рисовать под ним null, а снять — когда исход известен. Прогноз: что увидит человек теперь?
Лестница прошла под нативным сплэшем, кадр дневника нарисовался под ним же, и сплэш снялся уже над дневником. Первое, что человек видит после картинки, — дневник. Вот что значит «монтировать после чтения диска»: корень смонтирован сразу, но дерево дневника появляется только после boot, а до того корень возвращает null. Нативный сплэш держится до конца миграции, и лишнего кадра нет.
hideAsync стоит в эффекте, а не в колбэке boot, и это не вкус. Эффект выполняется после коммита кадра, то есть когда дневник уже нарисован. Колбэк выполняется раньше, и сплэш открыл бы пустой экран. А условие — «не opening», а не «ready»: отказ — тоже конец ожидания, и экран «не открылась» надо показать. Что бывает иначе, покажет задача 1. В приложении автора сделано так же:
У expo-router свой порядок, и он вам не мешает. На старте корень сам просит сплэш подождать — через setTimeout, «чтобы вы успели раньше» — и снимает его, когда навигация готова. Но если вы вызвали preventAutoHideAsync, модуль это запоминает, и внутреннее снятие у expo-router ничего не делает: internalMaybeHideAsync проверяет, не взял ли управление пользователь. Подробнее — в той же статье главы 10.
В вебе нативного сплэша нет. Веб-файл expo-splash-screen — пустые функции:
В веб-версии «Подхода» null на время чтения значил бы белую страницу, поэтому там ветка «открывается» рисует свой сплэш. Развести это можно файлом с расширением .web.tsx, как в статье «Одно приложение, три платформы». Песочница — тоже браузер, и настоящего expo-splash-screen в ней нет вовсе — по той же причине, по которой в статье про журнал листингом остался expo-crypto. Нативный сплэш здесь — модель из подложки, а «видно:» — её печать, а не снимок экрана.
Две оговорки. Первая — долгая миграция. Если ступень лестницы идёт долго, неподвижная картинка выглядит как зависание. Тогда сплэш снимают раньше и показывают экран «обновляем дневник» с ходом работы — это единственный случай, когда ветку «открывается» показывают нарочно. Вторая — всё это происходит на каждом холодном старте. Если система выгрузила приложение в фоне, возврат — это новый процесс: снова нативный сплэш и снова boot. Об этом статья «Свернули и вернулись» в главе 7.
В stack-starter гидратация сторожит дверь: без токена приложения нет, есть экран входа. В «Подходе» так нельзя. Дневник принадлежит человеку и лежит на его телефоне, и чтобы записать подход, входить никуда не нужно. Вход нужен синхронизации: серверу надо знать, чей журнал он принимает. Поэтому корень «Подхода» на вход не отправляет никогда. Он ждёт открытия базы и показывает дневник, а от токена зависит только строка синхронизации и кнопка «Войти, чтобы синхронизировать».
| stack-starter | «Подход» | |
|---|---|---|
| чего ждёт первый кадр | гидратации сессии | открытия базы |
| ложный кадр без флага | мигнул экран входа | мигнуло «у вас ещё нет тренировок» |
| нет токена | редирект на вход | дневник; синхронизация ждёт входа |
| прочитать не удалось | «сессии нет» — войти ещё раз | «не открылась», диск не трогается |
Токен кончился посреди работы — ответ 401 — это тоже не повод закрыть дневник. Как с этим обходится stack-starter и почему в local-first его уборка стёрла бы неотправленные подходы, разбирает статья «Свернули и вернулись». Здесь достаточно одного: токен — не ключ от дневника.
Состояние открытия, boot и модель нативного сплэша переезжают в подложку последней статьи главы. Там на место ящика встанет настоящая база expo-sqlite, и в её SQLiteProvider с onInit и useSuspense узнаются лестница и три состояния этой статьи.
- Корень приложения монтирует платформа, как только загружен бандл, и чтения диска она не ждёт. Экран, подписанный на ещё пустое хранилище, первым кадром врёт «у вас ещё нет тренировок». Это тот же механизм, что у мигающего редиректа на вход, и лечится он так же — флагом.
- У открытия базы три состояния: открывается, готова, не открылась. Пустое состояние бывает только у открытой базы, писать можно только в готовую.
- «Не открылась» — отдельный экран, никогда не сброс. Пустой журнал на месте неоткрытого первой же записью затирает на диске неотправленное. Для сессии «считаем, что пусто» годится, для дневника — нет.
- Флаг убирает ложное «пусто», но не лишний кадр. Нативный сплэш снимается сам, как только React Native нарисовал первое содержимое (
CONTENT_APPEAREDна Android,RCTContentDidAppearNotificationна iOS), и под ним оказывается сплэш из JavaScript. - Дневник становится первым кадром, когда нативный сплэш придержан
preventAutoHideAsyncна уровне модуля. До конца открытия и миграции корень рисуетnull, аhideAsyncстоит в эффекте и срабатывает на любом исходе. В вебе нативного сплэша нет — там рисуется свой. - В local-first вход нужен только синхронизации: дневник открывается и пишется без токена, а токен меняет лишь строку синхронизации.
1/4Экран дневника подписан на хранилище с первого кадра, boot наполняет его после чтения диска. Первый кадр показал «у вас ещё нет тренировок», следующий — «дневник: 1». Почему?
2/4На диске журнал со схемой новее приложения: обновление откатили. Что должен показать корень?
3/4Флаг открытия есть, корень рисует свой сплэш, пока база открывается. preventAutoHideAsync не вызван. Что человек видит после нативного сплэша?
4/4Зачем «Подходу» вход, если главная копия дневника на телефоне?
Сплэш, который не уходит
Кто-то рассудил просто: сплэш держим, пока дневник не готов, — и снял его прямо в колбэке успеха boot. На телефоне с откаченным обновлением приложение так и не показало ничего, кроме сплэша. Почините так, чтобы человек увидел экран «не открылась».
Снимать сплэш надо на любом исходе открытия и после того, как кадр нарисован, — то есть в эффекте корня:
Вывод: кадр: не открылась: схема 3 новее приложения (2) и нативный сплэш снят, видно: не открылась: схема 3 новее приложения (2). В исходном коде экран «не открылась» рисовался, но под сплэшем, который никто не снимал: preventAutoHideAsync отменил автоматическое снятие, а колбэк отказа hideAsync не звал. Спасительный обработчик expo-router, который снимает сплэш при ошибке, здесь не поможет: он ловит необработанные исключения, а отказ boot обработан и стал состоянием.
Колбэк успеха плох и на исправном диске. Если подложить журнал 1.0, исходный код печатает нативный сплэш снят, видно: пустой экран, а уже потом кадр: дневник: 1. settle будит подписчиков, но React рисует новый кадр позже, а hideAsync в том же колбэке успевает раньше коммита. Эффект выполняется после коммита, поэтому открывает уже нарисованный кадр.
Дневник за дверью входа
Корень «Подхода» переписали с корня stack-starter: база открылась — дальше решает токен. Человек поставил приложение и в тот же день пошёл в зал, не заходя в аккаунт. Сделайте так, чтобы дневник открывался без входа, а про вход говорила только строка синхронизации.
Токен уходит из корня — в корне остаётся только открытие базы:
Вывод: лестница: схема 1 → 2, кадр: дневник: 1 | синхронизация: нужен вход и нативный сплэш снят, видно: дневник: 1 | синхронизация: нужен вход. Экран дневника от токена уже зависел ровно так, как нужно: токен меняет строку синхронизации, а не то, можно ли писать. В stack-starter редирект по токену прав: без сервера там нечего показать. В «Подходе» главная копия на телефоне, и дверь перед ней запирает человека от его же подходов — в зале, где войти всё равно не выйдет без сети. Кнопка «Войти, чтобы синхронизировать» живёт рядом со строкой синхронизации, а не вместо дневника.