Closes #198. Android parity is #215.
What was wrong
#198: an app cold-started through wildloop://record launched but stayed on its feed. I reproduced it with a generated app, cold-started from an XCUITest through XCUIDevice.shared.system.open. The page recorded every craftDeepLink event from its first line, and tried to subscribe to craft.deepLinks.onLink as early as possible:
{"rawEvents":[{"at":187,"url":"crafte2e://probe/..."}],"earlyOnLink":[],"readyAt":188,
"initial":{"url":"crafte2e://probe/record?..."}}The launch link was delivered, as an event 187ms after load. The earliest possible subscription came at 188ms. The event is dispatched the moment the bridge script has run, and that same script is what defines onLink, so no page can ever have subscribed in time. getInitialURL() did return the link. A page that subscribes rather than asks, which is how a router is usually wired, never learned how it was opened. That is the reported symptom.
Two smaller things turned up in the same code:
DeepLinkManagerkept early links in a single slot. A second link before the page was ready overwrote the first; in the reproduction a stale link replaced the launch link.- It never became not-ready on navigation, so a link arriving during a reload was dispatched into the page being torn down.
The fix
Page bridge (CraftApp.swift script). Links dispatched while nothing is subscribed are held, and handed to the first subscriber on the next turn. They are delivered on the next turn rather than immediately, so an unsubscribe returned in the same tick still applies. onLink and onDeepLink now return an unsubscribe function.
No double delivery. A page that wires both getInitialURL() and onLink would otherwise get the launch link twice. Native now marks the launch link initial: true, and getInitialURL() withdraws that entry synchronously when called. A page that calls both in the same tick, in either order, gets the link once, from getInitialURL(). If getInitialURL() is called on a later turn, after the replay already happened, the link does arrive twice; craft.d.ts documents that.
Native (DeepLinkManager).
- A queue instead of a slot.
setLoading()ondidStartProvisionalNavigation, alongsideCraftEventManager.initialURLis set only for a link that arrives before the first page is ready. Previously the first link ever received counted as the initial URL, even one that arrived minutes after launch.
Why replay, rather than document "use getInitialURL"
That is React Native's contract, and Android's today. Replay was chosen because it loses nothing. A subscriber-only page works, a getInitialURL() page works, and a link that lands during a reload is still delivered. The double-delivery guard keeps the common both-at-mount pattern from paying for it. #215 brings Android to the same contract.
The test: a real cold start on every CI run
Each iOS leg now builds the probe with build-for-testing and, after the suite, runs ios-uitests/DeepLinkColdStartTests.swift. The test terminates the app, opens crafte2eprobe://e2e/cold?run=<nonce>&receive=… as the system, taps SpringBoard's "Open" if it asks, and reads the page's report off the screen.
It is an XCUITest because nothing simpler works. simctl openurl triggers iOS 26's "Open in …?" confirmation, which simctl cannot answer. An app SpringBoard launches has no console to read, so the report has to come off the screen.
After the suite, the page subscribes seconds late, the way a hydrating app would, and reports what it received:
| receive | must hold |
|---|---|
subscribe | onLink received the launch link exactly once, with initial: true |
both | getInitialURL() returned the launch link and onLink received nothing |
The page knows which run it is from its own first-line event recorder. Calling getInitialURL() to find out would change the very thing being measured.
The judgement lives in protocol.ts (deepLinkResults, deepLinkProblems), with unit tests for each failure: never delivered, delivered twice, never reported, or launched by some other link.
ok ios-shim — 6/6 cases, no zig
ok ios-runtime — 6/6 cases, zig served checkPermission, clipboardRead, ...with both cold starts passing on both legs.
Failing without the fix
With only the replay removed from the template:
FAIL ios-shim — 6/6 cases, no zig
a page that only subscribes received [] from onLink, expected the launch link once
FAIL ios-runtime — 6/6 cases, zig served ...
a page that only subscribes received [] from onLink, expected the launch link onceVerification
- Locally:
bun run test,bun run typecheck,bun run verify:packages, Pickier, 46 protocol tests, and the android/ios/typescript package tests. - Both iOS legs, including both cold starts, on an iOS 26.5 simulator.
- The mutation above.