Skip to content

fix(links): route devices and /phone-app through the linking config - #29650

Draft
chrisnojima wants to merge 3 commits into
nojima/HOTPOT-as-01-uiscenefrom
nojima/HOTPOT-as-02-links
Draft

chrisnojima wants to merge 3 commits into
nojima/HOTPOT-as-01-uiscenefrom
nojima/HOTPOT-as-02-links

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Stack 2/9, splitting #29637. Parent: #29649.

Why

Push taps are about to be resolved into keybase:// routes by the service (#29656). For that to work, the routes those taps produce have to actually be handled by the linking config. Today several are not. This PR gets the link handling correct first, so the push-tap change lands on a working foundation rather than changing two things at once.

Master bugs this fixes

  • keybase://devices is not handled at all. linking.tsx:237 matches Tabs.devicesTab, whose value is the string tabs.devicesTab — not devices. deeplinks.tsx has no devices case whatsoever. The link silently does nothing.
  • /phone-app is handled outside the linking config, by an ad-hoc maybeLoadAppLink in the settings loader, so it does not participate in normal link routing.
  • An invite launch link is lost across the signup it triggers — the link that caused the signup is gone by the time signup finishes.
  • A settings notification that lands during the settings load is dropped, so the UI keeps stale values until something else refreshes them.
  • A settings reply that lands after logout is still applied.
  • A settings reply for the previous account is written into the new one. load-settings.tsx checks loggedIn before its await and then writes at line 35 with no identity check at all, so after an account switch account A's emails and phones land in account B's stores. Fixed here by capturing the uid before the RPC and re-checking after.

One fix beyond a straight port of #29637

keybase://devices would have landed on the wrong screen on a phone. #29637 nests settingsTabs.devicesTab inside the settings tab stack for all of mobile. That is correct on tablet, but router.tsx registers only each tab's root screen on phones — everything else lives on the root stack above the tab bar — so React Navigation silently filters the unknown route and the user lands on settingsRoot. Now three-way: desktop → devices tab, tablet → nested, phone → root stack, mirroring how the KBFS case already splits.

The existing tests could not have caught this: isSplit is evaluated at module load and jest runs as desktop, so setting global.isMobile simulates a tablet. Those tests are renamed to say so, and linking-phone.test.ts mocks isSplit to cover the real phone path.

Deliberately not here

config.startup.followUser stays. It only becomes dead once push stops producing a follow-user startup, which is #29656.

Verification

yarn lint:all clean, full unit suite green. The devices and uid-guard fixes are mutation-checked — reverting either fails its test.

Note nothing emits keybase://devices until #29656; stores/push.tsx still navigates directly, and that path has the same phone bug on master. #29656 deletes it.

@chrisnojima
chrisnojima added this pull request to stack #29658 September 21, 2026 18:56
@chrisnojima chrisnojima changed the title nojima/HOTPOT as 02 links fix(links): route devices and /phone-app through the linking config Sep 21, 2026
keybase://devices had no handler at all: it fell through handleKeybaseLink's
switch and did nothing. Give it one in both places a URL can arrive, branched
per platform the way devices/index.tsx and device-revoke.tsx already do --
devices live under Settings on phone and tablet, and in their own tab on
desktop. The linking config builds launch state for it like every other known
link; handleKeybaseLink has to agree, because on desktop router.tsx passes it
as the linking subscription's listener as well as its fallback, so every URL
there lands in handleKeybaseLink.

Nothing carved phone-app out of normalizeHttpUrl's single-segment username
rule, so our own invite install link (https://keybase.io/phone-app) resolved
to a profile for a user that does not exist. It now normalizes to
keybase://settingsAddPhone, which the linking config opens as a modal over the
settings tab and handleKeybaseLink opens the same way. That was
config.startup.link's only consumer, so the field, its setStartupDetails sites
and the bespoke once-per-process check in load-settings go with it. The
launch-URL read stays: it still decides whether the saved route may be
restored.

Also two settings load fixes. Anything that writes the email/phone stores
while userLoadMySettings is in flight knows something the reply does not, so
each half of the reply is applied only to the value it was read against -- a
notification that lands mid-load is no longer overwritten. And loggedIn is
re-read after the await: a logout cannot trip those identity checks, because
Z.defaultReset restores the values captured at store creation, so without it
the reply would repopulate the stores for a logged-out app and the next
account could read the previous one's settings.
keybase://devices built one nested state for all of mobile: the devices
route inside the Settings tab stack. That resolves on a tablet, where
every tab stack registers every route, but on a phone each tab stack
holds only its own root screen and everything else is registered on the
root stack above the tabs (router.tsx phoneRootRoutes). React
Navigation's getRehydratedState silently drops the unknown nested route,
so tapping the link landed on settingsRoot instead of Devices.

Split the case three ways, the way the KBFS private/public case already
does: its own tab on desktop, nested in the Settings tab stack when
isSplit (tablet), and on the root stack above the tabs on a phone.

The imperative fallback in deeplinks had the same root cause from the
other direction: navUpToScreen pins its popTo to the deepest active
stack, which at a tab root on a phone is the Settings tab stack, so
StackRouter returned null and the action was dropped. Push instead --
untargeted, so it is handled by whichever navigator registers the route:
the root stack on a phone, the Settings tab stack on a tablet. It is the
same call the phone Settings list itself makes.

The existing "on mobile" linking test was really a tablet test: isSplit
is computed at module load and jest runs as desktop, so global.isMobile
alone never produced the phone shape. Renamed it, and added
linking-phone.test.ts, which mocks the layout module to get isSplit
false and asserts the root-stack shape.
…ount

loadSettings guarded its reply with a loggedIn re-read and a reference
check on each store, which covers a logout and a racing notification but
not an account switch. resetState puts back the values captured when the
stores were created -- the same initial emails Map, phones still
undefined -- and setLoggedIn(true) follows, so account A's in-flight
reply passes all three checks and writes A's emails and phone numbers
into account B's stores. Worse, that write changes the references B's
own loadSettings captured, so B's reply is then dropped as raced and B
keeps showing A's data until a notification happens to arrive.

Capture the uid before the RPC and compare it after: it is the only
thing an account switch actually changes.
@chrisnojima
chrisnojima force-pushed the nojima/HOTPOT-as-02-links branch from cc424ab to 99f9748 Compare September 22, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant