Ссылки снаружи и переход по уведомлению
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Маршрутизатор главы умеет всё, что нужно переходам изнутри приложения. Осталось то, ради чего в статье «Собираем маршруты» у экранов появились имена: переход снаружи. Тренер присылает в мессенджере ссылку на упражнение, уведомление «Отдых окончен» должно открыть карточку жима. И то и другое приносит адрес экрана, но в чужой обёртке — со схемой и хостом, — и приносит его в любой момент: в живой процесс, в процесс, который эта самая ссылка только что запустила, и даже раньше, чем появится навигатор. Разберём обёртку, две двери, через которые адрес входит в приложение, и очередь, без которой переход с холодного старта пропадает молча. Этажей здесь два: Linking — это React Native, а схема в конфиге, +native-intent и anchor — Expo.
Чтобы система отдавала ссылки «Подходу», у приложения должна быть своя схема — начало адреса вроде podhod://. Её объявляют в конфиге Expo одной строкой, а в нативные проекты её переносит prebuild: плагин withScheme из @expo/config-plugins пишет схему в CFBundleURLSchemes файла Info.plist и в intent-filter с категорией BROWSABLE в AndroidManifest. Это этаж Expo, и в песочнице конфига нет.
В приложении автора на том же месте стоит scheme: 'appstarter'. Своя схема — не единственный вид ссылки, которая откроет карточку жима:
| Ссылка | Когда такая бывает | Что нужно приложению |
|---|---|---|
podhod://exercise/bench | сборка «Подхода» — своя или из магазина | scheme в конфиге |
exp://192.168.1.5:8081/--/exercise/bench | Expo Go при разработке: схема и адрес — Expo Go, путь приложения — после /--/ | ничего, так её строит сам Expo Go |
https://podhod.app/exercise/bench | универсальная ссылка iOS или App Link Android: обычный веб-адрес, который система отдаёт приложению, а не браузеру | подтверждённый домен: ios.associatedDomains и android.intentFilters с autoVerify в конфиге и файл-подтверждение на самом сайте |
Ссылку для кнопки «Поделиться» руками не склеивают: createURL("/exercise/bench") из expo-linking сам выберет обёртку. В её описании перечислено: в сборке — <scheme>://path, в Expo Go — exp://128.0.0.1:8081/--/path.
Маршрутизатору из прошлых статей нужен путь — /exercise/bench. Самое очевидное — разобрать ссылку стандартным URL и взять pathname. Прогноз: что напечатают две строки — для своей схемы и для веб-адреса?
У веб-адреса путь такой, как ждали, а у своей схемы exercise уехал в хост, и в пути остался один /bench. Ошибки нет: по стандарту URL всё, что стоит сразу после //, — хост, какой бы ни была схема. У https там домен, у podhod — первый сегмент нашего адреса. Поэтому expo-router склеивает их обратно: в build/fork/extractPathFromURL.js функция fromDeepLink собирает путь из res.host, res.pathname и строки запроса.
В песочнице URL браузерный. На телефоне глобальный URL кладёт React Native, и в 0.86 это класс из Libraries/Blob/URL.js: он разбирает адрес регулярными выражениями, а host и pathname понимает только у http и https. У podhod://exercise/bench его host — пустая строка, а pathname — "/". Типы этой разницы не видят: компилятор песочницы на пример выше не сказал ни слова. В проекте на Expo разница стирается. expo/src/winter/runtime.native.ts ставит на место глобального URL стандартную реализацию из пакета whatwg-url-minimum, и на телефоне выходит то же, что здесь. На это и полагается fromDeepLink.
Остальное — особые случаи, и миниатюра берёт их у той же функции expo-router: веб-адрес теряет схему и домен, у ссылки Expo Go путь идёт после /--/, а готовый путь проходит как есть. Маршрутизатор прошлых статей — routesFrom, match и notFound — в подложке. Прогноз: куда поведёт каждая из пяти ссылок?
Голая схема podhod:// дала корень, и на нём открылась тренировка: так и надо, приложение запустили ссылкой без пути. Ссылка на экран, которого нет, приложение не роняет, а приходит в +not-found из прошлой статьи. Настоящая extractExpoPathFromURL из expo-router 57.0.16, запущенная в Node на тех же ссылках, вернула те же пути, только без первого слеша. Ссылку exp:// она разбирает так только внутри Expo Go — для прогона ей подложили признак Expo Go, — а в своей сборке такие ссылки не приходят.
Путь из ссылки достаём. Теперь — откуда ссылка берётся. Приём ссылок — нижний этаж, Linking из самого React Native: тот единственный экспорт со словом Link в имени, что нашёлся среди 61 в статье «Экраны на useState». Дверей у него две, потому что и путей у ссылки в приложение два:
| Когда | Как приходит в JavaScript | iOS | Android |
|---|---|---|---|
| процесса не было, ссылка его запустила | Linking.getInitialURL() — промис; при запуске с иконки в нём null | launchOptions[UIApplicationLaunchOptionsURLKey] в RCTLinkingManager.mm | intent.data активности с действием ACTION_VIEW в IntentModule.kt |
| процесс жив | событие url: Linking.addEventListener("url", ({ url }) => …) | handleOpenURLNotification шлёт событие url | ReactHostImpl.onNewIntent зовёт emitNewIntentReceived, тот шлёт url |
В песочнице обе двери есть, но ведут не туда. Прогноз: что вернёт getInitialURL здесь?
Первая строка — честное напоминание: Platform.OS здесь web, песочница — браузерный кадр, а не телефон. about:srcdoc — адрес самого этого кадра. Linking из react-native-web 0.21 никакого запуска не знает: при загрузке модуля он запоминает window.location.href и отдаёт его как начальную ссылку. А событие url в браузере не придёт никогда: в открытую вкладку ссылки снаружи не присылают, и сам react-native-web шлёт подписчикам только onOpen — когда страница открывает ссылку сама, через openURL.
Поэтому телефон для этой статьи собран моделью. Она в подложке. launch запускает процесс — с иконки или ссылкой, — kill его выгружает, а openLink — это человек, нажавший ссылку: в живой процесс она приходит событием, а без процесса запускает его. Объект linking объявлен в примерах типом настоящего Linking, так что вызовы компилятор проверяет по типам react-native. Там же лежит ExpoRoot — навигатор прошлых статей со своим router и его очередью переходов, как у expo-router, — и экраны «Подхода», которые отмечают в консоли, что появились.
Первая попытка — подписаться в корневой раскладке: контейнер рисует её вместе с навигатором, так что router.push из её эффекта доходит. Прогноз: человек открыл ссылку днём, когда приложение было свёрнуто, и вечером, когда система успела выгрузить процесс. Где он окажется оба раза?
Днём ссылка пришла событием в живой процесс, и карточка открылась. Вечером процесса не было: ссылка его запустила и лежит в getInitialURL, а подписка появилась позже и события не получила — его и не было. Человек видит тренировку вместо жима. Лечится одной строкой в том же эффекте: linking.getInitialURL().then((url) => { if (url) router.push(toPath(url)); }). Двери читают вместе и всегда: одна ловит запуск, другая — всё, что пришло после.
Ссылка, которая запустила процесс, событием не приходит: её забирают вопросом getInitialURL. Та же пара — «чем запустили» и «что пришло потом» — есть у нажатий на уведомление.
В корневой раскладке навигатор уже есть. Но первым кадром приложения навигатор бывает не всегда. Пока читается диск — о нём глава 6 — и грузятся шрифты, корень рисует сплэш, а навигатор появляется позже. Разобрать, чем запустили процесс, хочется тут же, на старте. router в подложке устроен как router из expo-router 57: push не выполняет переход на месте, а кладёт его в очередь, и разбирает эту очередь эффект контейнера навигации — в модели это ExpoRoot. Прогноз: процесс запустили ссылкой на жим, а корень 300 мс показывает сплэш. Что станет с переходом?
Переход дождался навигатора и выполнился, хотя в миг вызова всё дерево состояло из одного сплэша. Очередь тут не наша — она внутри expo-router. router.push по обычному адресу сам ничего не делает: в build/global-state/router.js функция linkTo кладёт действие ROUTER_LINK в routingQueue и возвращается. Разбирает очередь useImperativeApiEmitter: он подписан на неё через useSyncExternalStore и в эффекте зовёт run. Зовут этот хук в форке NavigationContainer, а форк — предок всего дерева, и его эффект выполняется последним, когда навигатор ниже уже смонтирован. Маршрутизатор из «Собираем маршруты» так не умел: пока навигатора нет, его переход упирался в пустоту и пропадал молча. Очередь — лекарство ровно от этого.
В очередь ложится не всё. goBack, setParams и переход на .. зовут store.assertIsReady() сразу — это строки 93, 130 и 147 того же router.js. Проверка там одна: navigationRef.isReady(), и если навигатора нет, летит ошибка «Attempted to navigate before mounting the Root Layout component». Причина понятна: «назад» — не адрес, а снятие верхней записи, и откладывать его некуда, пока стека нет. Модель повторяет и это. Прогноз: тот же сплэш, но код, разобрав ссылку, ещё и решает убрать экран за спиной.
Ошибка вылетела из промиса — приложение устояло, а в консоли осталась строка. push, к тому времени лежавший в очереди, своего дождался: карточка жима всё-таки открылась. Отсюда правило: адрес снаружи отдавать router можно когда угодно, а вот «назад» и setParams — только когда навигатор точно есть.
Значит ли это, что о моменте можно не думать? Нет: у самой очереди есть окно, в котором переход пропадает молча. Контейнер навигации сначала спрашивает начальную ссылку и, пока ответа нет, вместо навигатора рисует запасную разметку. На Android это ожидание длится до 150 мс: build/fork/useLinking.native.js пускает Linking.getInitialURL() наперегонки с таймаутом, и рядом стоит ссылка на issue 25675 в репозитории React Native. Контейнер в этот миг уже смонтирован и очередь разбирает — вот только навигатора под ним ещё нет. В модели такое ожидание включает свойство awaitLink. Прогноз: процесс запустили с иконки, а на 50-й миллисекунде человек нажал уведомление «Отдых окончен» с адресом карточки жима. Где он окажется?
Человек нажал уведомление о жиме и остался на тренировке. Ни ошибки, ни строки в консоли — переход исчез. Виноват порядок строк в run: очередь сначала обнуляется, а навигатор проверяется после, и разобранные действия деть уже некуда. В исходнике рядом стоит признание — TODO «Consider warning when ref.current is null — actions are silently dropped». Таймером это не лечится: длительность ожидания зависит от платформы и от того, ответит ли натив — на iOS ссылка приходит синхронно, через getLinkingURL() из expo-linking, на Android идёт та самая гонка со 150 мс. Нужна своя очередь, и главное в ней не хранилище, а место, откуда её выпускают: такое, которого не существует, пока навигатора нет. Корневая раскладка ровно такая — контейнер рисует её только вместе с навигатором. Прогноз: тот же запуск, на 50-й миллисекунде уведомление, на 100-й — ссылка тренера на приседания. Что откроется и в каком порядке?
Оба перехода дождались навигатора и вышли в том порядке, в каком пришли: под приседаниями лежит жим, под ним — тренировка, и «назад» пройдёт этот путь обратно. Откуда адрес, очереди всё равно: openHref принимает строку. Уведомление несёт адрес экрана в своих data, и дальше его путь тот же, что у ссылки. Модель уведомлений урезана до этого пути. Настоящие getLastNotificationResponse и подписку из expo-notifications — с полной формой ответа и тем, как натив хранит нажатие до старта JavaScript, — разберёт статья «Уведомления: планировщик, канал, нажатие».
Всё, что собрано в статье, одним куском: двери процесса слушаются с его старта, адреса снаружи идут через очередь, а корневая раскладка выпускает её, когда навигатор смонтирован. Этот кусок вместе с моделью телефона и ExpoRoot уезжает в подложку проекта. Сценарий: запуск ссылкой, а через полсекунды — нажатие на уведомление, когда навигатор давно готов.
Ссылка переждала в очереди и сплэш, и ожидание контейнера; нажатие на уведомление прошло сразу. Две «назад» сняли обе карточки, и человек снова на тренировке.
В настоящем приложении обе двери Linking за вас открывает expo-router — этаж Expo. Ту же гонку он решает с другой стороны: не ссылка ждёт навигатор, а навигатор ждёт ссылку. Контейнер навигации — форк NavigationContainer в build/fork — сначала спрашивает начальную ссылку и, пока ответа нет, вместо навигатора рисует запасную разметку:
Ссылка холодного старта в expo-router — не переход, а начальное состояние. Путь из неё проходит тот же getStateFromPath, что и любой адрес, и навигатор монтируется сразу с этим стеком. Подписку на событие url ставит хук useLinking того же контейнера. Своя очередь для ссылок поэтому не нужна дважды: их ждёт сам контейнер, а переходы, отданные router, и так ложатся в его очередь. Нужна она против одного окна — того, в котором контейнер ещё ждёт ссылку, а run уже выбрасывает очередь в пустоту.
По пути ссылка проходит через +native-intent — служебный файл из прошлой статьи. Начальную ссылку отдаёт его функции redirectSystemPath сам getLinkingConfig, с initial: true; каждую пришедшую событием — подписка subscribe из link/linking.js, которую тот же конфиг и ставит, с initial: false. Дальше идёт то, что она вернула. Несмотря на имя, в path приходит ссылка целиком, со схемой. Сигнатура — из типов expo-router:
Описание рядом добавляет два правила. Если функция вернула null, перехода не будет и приложение останется на текущем экране. А ошибка, брошенная внутри, может уронить приложение, поэтому код в ней оборачивают в try/catch. Переписать старые ссылки сайта в адреса приложения — вторая задача.
Начальное состояние из ссылки — ровно то, что в ссылке, и ничего сверх. Прогон настоящих getRoutes и getStateFromPath из expo-router 57.0.16 в Node на файлах «Подхода»:
| Холодный старт по адресу | Корневой стек без anchor | С unstable_settings = { anchor: "(tabs)" } |
|---|---|---|
/exercise/bench | одна запись: exercise/[id] | две записи: (tabs), над ней exercise/[id] |
/catalog | (tabs) с вкладкой catalog | то же |
/ | (tabs) с вкладкой index | то же |
Без anchor под карточкой жима пусто, и первая же системная «назад» уводит из приложения: стек из статьи «Собираем стек экранов с историей» с первой записи отдаёт нажатие системе. Экспорт unstable_settings с полем anchor называет маршрут, который в стеке раскладки всегда лежит первым. getRoutesCore.js читает его как unstable_settings.anchor, а старое имя initialRouteName принимает запасным. Приставка unstable_ честно предупреждает: форма настройки может смениться. Собрать то же в миниатюре — первая задача.
Листинги статьи проверил tsc 6.0.3 на настоящих типах expo-router 57.0.16 — ни одной ошибки. Чтобы проверка точно смотрела в эти типы, рядом лежал пробный файл с двумя заведомыми ошибками: router.push(42) и строка в числе. Нашлись обе.
Нажатие на уведомление через +native-intent не проходит: это не системная ссылка, а событие модуля expo-notifications, и отдать его router — ваша работа. Вот как это сделано в приложении автора — не в «Подходе», а в рабочем проекте на Expo SDK 57. Компонент ничего не рисует и стоит в корневой раскладке, рядом с <Stack>:
Готовность здесь — не эффект раскладки, а ключ состояния корневого навигатора: useRootNavigationState() отдаёт это состояние (в hooks/useRootNavigationState.js это getParent(…).getState()). Ждёт этот ключ, однако, куда меньше, чем кажется. Состояние навигатора собирается во время рендера — useNavigationBuilder считает его в useMemo, а ключ вроде stack-… выдаёт StackRouter.getInitialState, — поэтому у компонента внутри навигатора ключ есть уже на первом рендере. Работает гард по другой причине: компонент живёт под контейнером и не рисуется, пока тот показывает запасную разметку, — то самое окно, в котором переход пропадает. Комментарий в этом файле про «переход до этого момента expo-router отвергает» писался под прежние выпуски: в 57 обычный router.push не отвергается, а ждёт в очереди, и настоящая готовность в смысле assertIsReady наступает даже позже ключа — isReady() в BaseNavigationContainer.js (строка 158) это «есть ли подписчик на focus», а подписываются навигаторы в своих эффектах. Очереди в JavaScript у автора нет: её роль играет натив. expo-notifications помнит последнее нажатие, и эффект, дождавшись рендера под навигатором, забирает его через takeInitialPushPayload, а та читает getLastNotificationResponse. Главное совпадает с миниатюрой: адрес отдают router из места, которое существует только при смонтированном навигаторе.
Проверить ссылку без мессенджера можно из терминала: команда отдаёт ссылку симулятору или эмулятору так же, как это сделала бы система. Если приложение закрыто, получится холодный старт, если открыто — событие url.
| Что | Этаж | Где живёт |
|---|---|---|
своя схема podhod://, домены универсальных ссылок | Expo | scheme, ios.associatedDomains и android.intentFilters в конфиге; prebuild переносит их в Info.plist и AndroidManifest |
Linking.getInitialURL, событие url | React Native | RCTLinkingManager на iOS, IntentModule и ReactHostImpl на Android |
путь из ссылки, ожидание начальной ссылки, +native-intent, anchor | Expo | expo-router: extractPathFromURL.js, NavigationContainer.js, getLinkingConfig.js, getRoutesCore.js |
| нажатие на уведомление | Expo | expo-notifications, глава «Нативные возможности» |
очередь переходов router | Expo | expo-router: routingQueue.js и imperative-api.js; разбирает её эффект NavigationContainer |
| окно, в котором переход пропадает молча | ваш код | своя очередь: адрес отдают router из места под смонтированным навигатором |
На этом глава о навигации кончается: у «Подхода» есть адреса, раскладки, стек и вход снаружи. Следующая глава даст экранам то, чего им пока не хватает, — общий источник данных, который живёт в самом телефоне.
- Ссылка снаружи — адрес экрана в обёртке: своя схема
podhod://,exp://…/--/в Expo Go или веб-адрес универсальной ссылки. У своей схемы первый сегмент стандартныйURLсчитает хостом, поэтому путь собирают из хоста и пути, какfromDeepLinkв expo-router. - Дверей у
Linkingдве: ссылку, которая запустила процесс, отдаётgetInitialURL(), а в живой процесс она приходит событиемurl. Подписка безgetInitialURLтеряет холодный старт. - Адрес снаружи может прийти раньше навигатора, и очередь для этого уже есть:
router.pushкладёт переход вroutingQueue, а разбирает её эффект контейнера навигации. Ошибку «Attempted to navigate before mounting the Root Layout component» бросают только методы без очереди —goBack,setParamsи переход на... - Для ссылок expo-router решает гонку сам: контейнер ждёт начальную ссылку, пропускает её через
redirectSystemPathиз+native-intentи строит из неё начальный стек. Пока он её ждёт, разбор очереди идёт без навигатора и молча её выбрасывает — это и есть окно, ради которого нужна своя очередь: адрес отдаютrouterиз места, которое рисуется уже под навигатором, например из эффекта корневой раскладки. КлючuseRootNavigationState()сам по себе не ждёт ничего: состояние навигатора с ключом готово уже на первом рендере. - Стек из ссылки холодного старта содержит только экран из ссылки, и системная «назад» уводит из приложения. Корневая раскладка с
unstable_settings = { anchor: "(tabs)" }кладёт вкладки под этот экран.
1/4Что напечатает new URL("podhod://exercise/bench").pathname в песочнице?
2/4Корневая раскладка подписана на событие url, но getInitialURL не читает. Процесс выгружен, человек нажимает ссылку на жим. Где он окажется?
3/4Корень 300 мс показывает сплэш, а его эффект сразу зовёт router.push с адресом из уведомления. Что сделает expo-router 57?
4/4Приложение на expo-router запустили ссылкой podhod://exercise/bench, в корневой раскладке нет unstable_settings. Что сделает первая системная «назад» на Android?
Под карточкой пусто
Корень ведёт себя как контейнер expo-router: ждёт начальную ссылку и монтирует навигатор сразу со стеком из неё. В подложке — Navigator прошлых статей, и first у него теперь бывает массивом: это начальный стек из нескольких записей. Сделайте то, что делает anchor: под экраном из ссылки должна лежать тренировка, а запуск без пути пусть остаётся одним экраном.
Якорь кладётся под экран из ссылки, если сама ссылка не ведёт на него:
Вывод становится таким: «запуск по ссылке podhod://exercise/bench», «на экране: тренировка», «на экране: карточка bench», две «нажата системная «назад»» и только после второй — «никто не ответил true — exitApp()». Тренировка смонтирована сразу, спрятанной под карточкой, поэтому первая «назад» просто снимает карточку. У expo-router то же видно в состоянии: с anchor: "(tabs)" getStateFromPath("/exercise/bench") вернул корневой стек из двух записей с index: 1. В настоящем проекте условие «ссылка ведёт не на якорь» писать не нужно: для /catalog и / прогон и с якорем дал одну запись (tabs).
Ссылки из старой рассылки
Весной «Подход» рассылал письма со ссылками старого сайта вида https://podhod.app/ex?id=bench, а прошлая версия приложения звала карточку ссылкой podhod://ex?id=squat. Экранов с такими адресами нет. Напишите redirectSystemPath, как в app/+native-intent.tsx: она получает ссылку целиком, до разбора, и возвращает то, что открыть вместо неё. Новые ссылки должны проходить как есть.
Старую форму узнаём по началу ссылки и параметру id, всё остальное возвращаем нетронутым:
Все три строки вывода ведут теперь на ./exercise/[id].tsx. Вернуть можно и путь, а не ссылку: toPath пропускает путь как есть — так же поступает fromDeepLink в expo-router с адресом, который начинается со слеша. В настоящем +native-intent.tsx тело функции оборачивают в try/catch и при ошибке возвращают исходный path: брошенная здесь ошибка, по описанию NativeIntent, может уронить приложение.