ios: notification taps route resiliently — never a silent strand on the chat list (#1368)
A tapped notification's route hung entirely on the parked SharedThreadKeys
entry: missing or stale (origin no longer matching a saved instance), the
guard bailed silently and the app cold-launched to the chat list with the
tap swallowed — the 'tap opens the message list and hangs' report
(2026-07-28). Reproduced in the simulator via the new unattended rig.
- NotificationRoute (LChatCore): the resolution ladder — parked key →
per-space thread-snapshot scan → only-signed-in-space — with seams for
unit tests (6 cases). An unresolvable multi-space tap logs why instead
of vanishing.
- RootView.routeNotificationTap folds through the ladder; worst case now
lands on the pushed chat screen's own hydrate/refresh ('Chat not found'
with a working back button, verified) instead of a stranded list.
- DEBUG tap door (LCHAT_TEST_TAP_THREAD/_DELAY, the LCHAT_DEV_JOIN
pattern): replays a tap's exact effect after launch so the cold-launch
route runs unattended in the simulator, where no script can press a
springboard banner.
- NotificationTapUITests: dev-server rig — sign in, visit the chat, cold
relaunch with the tap waiting, assert the feed lands and back works.
- RowLadderTests: catch up with MDContext's mentions parameter (#1344) —
the test target had stopped compiling.
Mac deliberately unscoped: its tap route selects an instance from the
payload directly and has no SharedThreadKeys dependency.
Claude-Session: https://claude.ai/code/session_01KSHZzDxuWsLEnoVv4XhSB1
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+249
−3
ios/LChat/LChatApp.swift+16 −2ios/LChat/PushManager.swift+16 −0ios/LChatCore/Sources/LChatCore/NotificationRoute.swift+50 −0ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift+79 −0ios/LChatFeedTests/RowLadderTests.swift+1 −1ios/LChatUITests/NotificationTapUITests.swift+87 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index e1684b9..0c09738 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -560,8 +560,22 @@ struct RootView: View { |
| private func routeNotificationTap() { |
| guard let threadID = push.pendingThreadID else { return } |
| push.pendingThreadID = nil |
| - guard let entry = SharedThreadKeys.lookup(threadID: threadID), |
| - let inst = app.instances.first(where: { $0.origin == entry.origin }) else { return } |
| + // The parked SharedThreadKeys entry is the fast path; a stale or |
| + // missing park falls back to the spaces' thread snapshots, then to |
| + // the only signed-in space (NotificationRoute) — a tap must never |
| + // silently strand the member on the chat list. |
| + let inst: SavedInstance |
| + switch NotificationRoute.resolve(threadID: threadID, instances: app.instances) { |
| + case .parkedKey(let i), .snapshot(let i), .onlySpace(let i): |
| + inst = i |
| + case .unresolved: |
| + // Multi-space and the thread is in none of their records — no |
| + // safe guess exists. Leave the member where the launch landed |
| + // them, and say why in the log (the one place field reports of |
| + // "the tap did nothing" can be diagnosed from). |
| + NSLog("notification tap: thread not resolvable to a space, dropped") |
| + return |
| + } |
| if nav.instanceID != inst.id { |
| nav.selectInstance(inst) |
| } |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index fa9410a..7e7f6a8 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -47,6 +47,22 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| BackgroundRefresh.register() |
| // Signposts + (consent-gated) perf recording — apps only, never the NSE. |
| Perf.install() |
| + #if DEBUG |
| + // The sim rig's notification-tap door (the LCHAT_DEV_JOIN pattern): a |
| + // thread id in the environment replays a tap's exact effect — what |
| + // didReceive below does — shortly after launch, so the cold-launch |
| + // tap route can run unattended in the simulator, where no script can |
| + // press a springboard banner. |
| + if let thread = ProcessInfo.processInfo.environment["LCHAT_TEST_TAP_THREAD"], |
| + !thread.isEmpty { |
| + let delay = Double(ProcessInfo.processInfo |
| + .environment["LCHAT_TEST_TAP_DELAY"] ?? "") ?? 0.5 |
| + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in |
| + Self.forceScrollThreadID = thread |
| + self?.pendingThreadID = thread |
| + } |
| + } |
| + #endif |
| return true |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift b/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift| new file mode 100644 |
| index 0000000..09b0af1 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift |
| @@ -0,0 +1,50 @@ |
| +import Foundation |
| + |
| +// 🤖 Resolves a tapped notification's thread id to the space that owns it. |
| +// The tap route used to hang everything on one source — the SharedThreadKeys |
| +// entry the ThreadStore folds park — and bailed SILENTLY when it was missing |
| +// or its origin no longer matched a saved instance (stale park, re-added |
| +// space, wiped keychain): the app cold-launched to the chat list and the tap |
| +// just… did nothing (the 2026-07-28 report). Resolution now falls back down |
| +// the same ladder the data actually lives in, and the caller can always tell |
| +// why a tap still couldn't route. |
| +public enum NotificationRoute { |
| + /// Where a resolution came from — the caller logs it so a stranded tap in |
| + /// the field names its failing rung instead of vanishing. |
| + public enum Resolution { |
| + case parkedKey(SavedInstance) // SharedThreadKeys entry matched |
| + case snapshot(SavedInstance) // found in a space's thread snapshot |
| + case onlySpace(SavedInstance) // one signed-in space — must be it |
| + case unresolved |
| + } |
| + |
| + /// The production ladder. `entryOrigin` and `snapshotHasThread` are seams |
| + /// so the ladder's order and edge cases stay unit-testable without a |
| + /// keychain (ThreadStoreTests pattern). |
| + public static func resolve(threadID: String, |
| + instances: [SavedInstance], |
| + entryOrigin: (String) -> String? = { |
| + SharedThreadKeys.lookup(threadID: $0)?.origin |
| + }, |
| + snapshotHasThread: (String, String) -> Bool = { origin, thread in |
| + ThreadListSnapshot.load(origin: origin)? |
| + .rows.contains { $0.id == thread } ?? false |
| + }) -> Resolution { |
| + if let origin = entryOrigin(threadID), |
| + let inst = instances.first(where: { $0.origin == origin }) { |
| + return .parkedKey(inst) |
| + } |
| + // Stale or missing park: each space's last folded thread list also |
| + // knows its thread ids (and survives the park being wiped). |
| + if let inst = instances.first(where: { snapshotHasThread($0.origin, threadID) }) { |
| + return .snapshot(inst) |
| + } |
| + // One signed-in space — the thread can only be there. A genuinely new |
| + // thread resolves inside the chat screen's own hydrate + refresh once |
| + // pushed; that beats dropping the tap on the floor. |
| + let active = instances.filter { !$0.isArchived } |
| + if active.count == 1 { return .onlySpace(active[0]) } |
| + if instances.count == 1 { return .onlySpace(instances[0]) } |
| + return .unresolved |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift b/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift| new file mode 100644 |
| index 0000000..5639ae5 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift |
| @@ -0,0 +1,79 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The notification-tap resolution ladder (NotificationRoute): parked key → |
| +// snapshot scan → only-space. The silent-drop bug this pins: a stale or |
| +// missing SharedThreadKeys park used to strand the tap on the chat list. |
| +final class NotificationRouteTests: XCTestCase { |
| + |
| + private func inst(_ origin: String, archived: Bool = false) -> SavedInstance { |
| + var i = SavedInstance(origin: origin, deviceToken: "t", screenName: "s", |
| + userPub: "pub", userPriv: "priv") |
| + i.archived = archived |
| + return i |
| + } |
| + |
| + func testParkedKeyWins() { |
| + let a = inst("https://a.example"), b = inst("https://b.example") |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", instances: [a, b], |
| + entryOrigin: { _ in "https://b.example" }, |
| + snapshotHasThread: { _, _ in XCTFail("snapshot consulted despite park"); return false }) |
| + guard case .parkedKey(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, b.origin) |
| + } |
| + |
| + func testStaleParkFallsBackToSnapshot() { |
| + // The park points at an origin that's no longer signed in (re-added |
| + // space) — the snapshot scan must still find the thread's real home. |
| + let a = inst("https://a.example"), b = inst("https://b.example") |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", instances: [a, b], |
| + entryOrigin: { _ in "https://gone.example" }, |
| + snapshotHasThread: { origin, thread in origin == "https://b.example" && thread == "th1" }) |
| + guard case .snapshot(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, b.origin) |
| + } |
| + |
| + func testMissingParkFallsBackToSnapshot() { |
| + let a = inst("https://a.example"), b = inst("https://b.example") |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", instances: [a, b], |
| + entryOrigin: { _ in nil }, |
| + snapshotHasThread: { origin, _ in origin == "https://a.example" }) |
| + guard case .snapshot(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, a.origin) |
| + } |
| + |
| + func testSingleSpaceAlwaysResolves() { |
| + // Nothing recorded anywhere (wiped keychain, brand-new thread): with |
| + // one signed-in space the tap still routes there. |
| + let a = inst("https://a.example") |
| + let r = NotificationRoute.resolve( |
| + threadID: "new-thread", instances: [a], |
| + entryOrigin: { _ in nil }, |
| + snapshotHasThread: { _, _ in false }) |
| + guard case .onlySpace(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, a.origin) |
| + } |
| + |
| + func testSingleActiveSpaceSkipsArchived() { |
| + let live = inst("https://live.example") |
| + let parked = inst("https://old.example", archived: true) |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", instances: [live, parked], |
| + entryOrigin: { _ in nil }, |
| + snapshotHasThread: { _, _ in false }) |
| + guard case .onlySpace(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, live.origin) |
| + } |
| + |
| + func testMultiSpaceUnknownThreadStaysUnresolved() { |
| + let a = inst("https://a.example"), b = inst("https://b.example") |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", instances: [a, b], |
| + entryOrigin: { _ in nil }, |
| + snapshotHasThread: { _, _ in false }) |
| + guard case .unresolved = r else { return XCTFail("\(r)") } |
| + } |
| +} |
diff --git a/ios/LChatFeedTests/RowLadderTests.swift b/ios/LChatFeedTests/RowLadderTests.swift| index beef59a..5610c35 100644 |
| --- a/ios/LChatFeedTests/RowLadderTests.swift |
| +++ b/ios/LChatFeedTests/RowLadderTests.swift |
| @@ -132,7 +132,7 @@ final class RowLadderTests: XCTestCase { |
| blocks: MessageBubble.cachedTree(m.text), |
| ctx: .init(fontSize: 15, |
| ink: m.mine ? .white : .primary, |
| - mine: m.mine))) |
| + mine: m.mine, mentions: nil))) |
| default: |
| inner = AnyView(Text(m.text).font(.system(size: 15))) |
| } |
diff --git a/ios/LChatUITests/NotificationTapUITests.swift b/ios/LChatUITests/NotificationTapUITests.swift| new file mode 100644 |
| index 0000000..e1de3f3 |
| --- /dev/null |
| +++ b/ios/LChatUITests/NotificationTapUITests.swift |
| @@ -0,0 +1,87 @@ |
| +import XCTest |
| + |
| +// The notification-tap route, end to end in the simulator (2026-07-28): a |
| +// tapped banner must land IN the chat, cold launch included. No script can |
| +// press a real springboard banner, so the app's DEBUG tap door |
| +// (LCHAT_TEST_TAP_THREAD, PushManager) replays the tap's exact effect — |
| +// forceScrollThreadID + pendingThreadID — a beat after launch, and this test |
| +// drives the two launches around it. Runs against the local dev server like |
| +// FeedMenuUITests: pass TEST_RUNNER_DEV_LINK and TEST_RUNNER_THREAD_ID (both |
| +// from .lchat-dev.json) on the xcodebuild invocation; without them it skips. |
| +final class NotificationTapUITests: XCTestCase { |
| + |
| + override func setUpWithError() throws { |
| + continueAfterFailure = false |
| + } |
| + |
| + func testColdLaunchNotificationTapLandsInThread() throws { |
| + let env = ProcessInfo.processInfo.environment |
| + guard let link = env["DEV_LINK"], !link.isEmpty, |
| + let threadID = env["THREAD_ID"], !threadID.isEmpty else { |
| + throw XCTSkip("Set TEST_RUNNER_DEV_LINK + TEST_RUNNER_THREAD_ID from .lchat-dev.json.") |
| + } |
| + |
| + // ---- run 1: sign in and visit the chat once ------------------------ |
| + // A real phone that receives pushes has been in the space: the thread |
| + // list refresh parks the SharedThreadKeys entries the tap route |
| + // resolves against, and opening the space stamps lastInstanceID. |
| + let app = XCUIApplication() |
| + app.launchEnvironment["LCHAT_DEV_JOIN"] = link |
| + app.launch() |
| + allowNotificationsIfAsked(app) |
| + |
| + // The dev-join claim lands on the spaces home screen — enter the space |
| + // (its row wears the dev server's host as its name). |
| + let devRow = app.staticTexts["Dev chat"].firstMatch |
| + if !devRow.waitForExistence(timeout: 5) { |
| + let spaceRow = app.staticTexts["127.0.0.1"].firstMatch |
| + if spaceRow.waitForExistence(timeout: 25) { spaceRow.tap() } |
| + } |
| + XCTAssertTrue(devRow.waitForExistence(timeout: 25), "Dev chat not in thread list") |
| + devRow.tap() |
| + let bubble = app.staticTexts |
| + .containing(NSPredicate(format: "label CONTAINS %@", "seeded by lchat-dev-up")) |
| + .firstMatch |
| + XCTAssertTrue(bubble.waitForExistence(timeout: 20), "seeded bubble never rendered") |
| + shot(app, "tap-01-visited") |
| + app.terminate() |
| + |
| + // ---- run 2: cold launch with the tap waiting ----------------------- |
| + // The door fires 1s after launch — didReceive's timing on a cold |
| + // launch (delivered right after the first frame). |
| + app.launchEnvironment["LCHAT_TEST_TAP_THREAD"] = threadID |
| + app.launchEnvironment["LCHAT_TEST_TAP_DELAY"] = "1.0" |
| + app.launch() |
| + shot(app, "tap-02-relaunched") |
| + |
| + // The verdict: the tapped chat's feed is on screen — and stays there. |
| + let landed = bubble.waitForExistence(timeout: 20) |
| + shot(app, landed ? "tap-03-landed" : "tap-03-STRANDED") |
| + XCTAssertTrue(landed, "notification tap never landed in the chat") |
| + |
| + // A wedged NavigationStack still shows the feed it dropped into — |
| + // prove the UI is alive by leaving the chat and coming back. |
| + let back = app.navigationBars.buttons.firstMatch |
| + XCTAssertTrue(back.waitForExistence(timeout: 5), "no back button on the chat screen") |
| + back.tap() |
| + let row = app.staticTexts["Dev chat"].firstMatch |
| + let listAlive = row.waitForExistence(timeout: 10) |
| + shot(app, listAlive ? "tap-04-back-alive" : "tap-04-back-DEAD") |
| + XCTAssertTrue(listAlive, "back from the tapped chat never reached the list") |
| + } |
| + |
| + private func allowNotificationsIfAsked(_ app: XCUIApplication) { |
| + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") |
| + let allow = springboard.alerts.buttons["Allow"].firstMatch |
| + if allow.waitForExistence(timeout: 5) { allow.tap() } |
| + // Whether or not the alert showed, poke the app so the run continues. |
| + app.activate() |
| + } |
| + |
| + private func shot(_ app: XCUIApplication, _ name: String) { |
| + let a = XCTAttachment(screenshot: app.screenshot()) |
| + a.name = name |
| + a.lifetime = .keepAlways |
| + add(a) |
| + } |
| +} |