The first piece of iOS push work: a notification tap that cold-launches the app now reaches the page.
Why this first
Push notifications that deep-link into a feature — "What's that plant? Snap a photo… Try it →" — only work if tapping one opens that feature. On a killed app, the tap could be lost before the page ever heard about it, and the app opened to its home screen instead. Most of what push needs is server-side (storing tokens, sending through APNs), but this is the part that is craft's, and it was broken.
Two places the tap was lost
1. Natively, before the Coordinator exists.
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: …) {
NotificationCenter.default.post(name: .craftNotificationResponse, object: …userInfo)On a cold launch this runs before SwiftUI has built the Coordinator, and NotificationCenter keeps nothing for an observer that does not exist yet. The tap was simply dropped.
It now goes through CraftEventManager, which exists from process start and holds events until the page is ready — exactly what it already did for a home-screen shortcut, which is the same situation. The Coordinator's NotificationCenter observer is removed along with the post, so a warm tap is not delivered twice.
2. On the page, before it subscribes.
That held tap is flushed the moment the bridge is ready, as a DOM event. The SDK's onNotification was a plain addEventListener, so a page that wires its listener once its router has hydrated — most single-page apps, WildLoop included — was not listening yet, and missed it anyway.
Deep links hit this same wall in #198 and are held the same way, so this mirrors that design rather than inventing a new one:
- State lives on
window.__craftNotificationTapReplay, so a second injection into the same page does not start a fresh buffer. - One listener holds taps until someone subscribes.
- The first subscriber through
craft.notifications.onTapis handed what it missed.
pushNotifications.onNotification in the SDK goes through onTap where the bridge offers it. Without that, anyone using the SDK would bypass the hold entirely.
One fragile spot, now guarded
onTap is attached before installCraftMobileContract replaces craft.notifications:
craft.notifications = Object.assign({}, legacyNotifications, { show, setBadge })It survives only because Object.assign copies own properties across. Reorder those two blocks, or build the object differently, and onTap vanishes with nothing else failing. So the page-script test reads onTap off the final object, and a mutation that rebuilds notifications without copying it fails four tests.
Tests
The page-script tests run the real injected script under Bun:
- a tap that arrived before anyone subscribed is handed to the first subscriber;
onTapis on the finalcraft.notifications, beside the contract's ownshow;- a tap after subscribing arrives once, live;
- a subscriber that unsubscribes in the same tick is handed nothing.
index.test.ts pins the native wiring: the delegate goes through CraftEventManager and posts nothing to NotificationCenter, and no Coordinator observer is left to double-deliver. mobile.test.ts pins that the SDK routes through onTap.
| mutation | fails |
|---|---|
| a tap before subscribing is not held (the page-side loss) | 1 |
notifications rebuilt without copying onTap | 4 |
| an unsubscribed listener still receives held taps | 1 |
the tap goes back through NotificationCenter (the native loss) | 1 |
| the SDK uses a bare listener instead of the replay | 1 |
Not covered
- No end-to-end cold launch from a real tap. The E2E suite does not deliver notifications.
xcrun simctl pushcan deliver one to a simulator, but tapping it to cold-launch the app needs UI automation the harness does not have yet. The two loss points are each tested; the full launch-from-tap sequence is not. - The rest of push. Sending notifications is server work, and APNs needs a
.p8key. Rich notifications with images would need a Notification Service Extension, which craft does not have. The two AllTrails-style examples that prompted this are plain title-and-body with no images, so they do not need one.
Verification
packages/ios/scripts/typecheck-template.sh, bun run typecheck, bun run verify:packages, Pickier (0 errors), the iOS package tests, and the TypeScript SDK tests (560).