ReviewOS

also looking at this

craft-native/craft

fix(ios): hand a notification tap that launches the app to the page that asks for it

#255
Merged glennmichael123 wants to merge fix/ios-notification-tap-cold-start into main
5 files +194 -9
9 days ago

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.onTap is 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;
  • onTap is on the final craft.notifications, beside the contract's own show;
  • 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.

mutationfails
a tap before subscribing is not held (the page-side loss)1
notifications rebuilt without copying onTap4
an unsubscribed listener still receives held taps1
the tap goes back through NotificationCenter (the native loss)1
the SDK uses a bare listener instead of the replay1

Not covered

  • No end-to-end cold launch from a real tap. The E2E suite does not deliver notifications. xcrun simctl push can 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 .p8 key. 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).

5 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.