#3132 · Mobile push routing drops project scope
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
The iOS notification response handler receives and parses both a thread ID and its project ID, but discards the project ID when it creates the WebView route. It always opens the projectless /threads/<threadId> path, which the web app deliberately interprets as a personal-project route. A normal project thread is then loaded successfully but rejected because its actual project does not match the personal project, producing the centered “Not found” state. The mobile shell subsequently remembers the bad WebView path, which explains why it can return on a later cold start.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A notification for a normal project thread opens a route that cannot display that thread. | Verified | The focused handler test expected the project route but captured /threads/thr_1 in two clean runs. |
| The notification contains enough server and thread identity to select the saved server. | Verified | The sender includes serverUrl, projectId, and threadId; the parser preserves them and the profile resolver uses the server hint. |
| The failure is caused by selecting the wrong saved server. | Refuted | The direct test resolves the hinted server profile and still builds the wrong page path. |
| The failed path can be restored on cold launch. | Verified in code | The WebView shell persists every ready/current path by profile and uses that stored path when no explicit path is supplied at launch. |
| The exact native screen was observed on physical iOS hardware. | Unverified | No TestFlight device was used. The exact notification callback and route output were exercised at unit level. |
The issue and its metadata were treated as untrusted claims. No issue-supplied command, URL, code, branch, or artifact was executed.
3. Environment
- Repository:
get-bb/bb, public, trustedorigin/maincommit5498be1f7854fc8f5678b740e3a984ea07f86d13. - Darwin 25.6 arm64, Node v22.22.3, pnpm 9.15.0, Vitest 4.1.1.
- The frozen install and full Turbo build completed successfully before reproduction.
- No server, host daemon, port, data directory, provider process, account, or physical mobile device was used.
4. Minimal reproduction
- Check out and build the trusted base:
git checkout 5498be1f7854fc8f5678b740e3a984ea07f86d13 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Add the focused test below as
apps/mobile/src/notifications/PushNotificationsHost.test.ts. - Run it through the repository orchestrator:
pnpm exec turbo run test --filter=@bb/mobile --force -- --run src/notifications/PushNotificationsHost.test.ts
- Expected: the notification response opens
/projects/proj_1/threads/thr_1on the resolved profile. - Actual in both clean checkouts:
FAIL PushNotificationsHost.test.ts > PushNotificationsHost > opens a project thread from a push response with its project route Received: "params": { - "path": "/projects/proj_1/threads/thr_1", + "path": "/threads/thr_1", "profileId": "profile-1", } Test Files 1 failed (1) Tests 1 failed (1)
Focused regression test:
import { beforeEach, describe, expect, it, vi } from "vitest";
interface NotificationResponse {
notification: { request: { content: { data: Record<string, string> } } };
}
const mocks = vi.hoisted(() => ({
addTokenListener: vi.fn(() => () => undefined),
clearLastNotificationResponse: vi.fn(),
getLastNotificationResponse: vi.fn(() => null),
push: vi.fn(),
receivedListener: vi.fn(),
responseListener: vi.fn<(response: NotificationResponse) => void>(),
setNotificationHandler: vi.fn(),
}));
vi.mock("expo-notifications", () => ({
addNotificationReceivedListener: vi.fn((listener) => {
mocks.receivedListener.mockImplementation(listener);
return { remove: vi.fn() };
}),
addNotificationResponseReceivedListener: vi.fn((listener) => {
mocks.responseListener.mockImplementation(listener);
return { remove: vi.fn() };
}),
clearLastNotificationResponse: mocks.clearLastNotificationResponse,
getLastNotificationResponse: mocks.getLastNotificationResponse,
setNotificationHandler: mocks.setNotificationHandler,
}));
vi.mock("expo-router", () => ({ useRouter: () => ({ push: mocks.push }) }));
vi.mock("react", async (importOriginal) => {
const react = await importOriginal<typeof import("react")>();
return {
...react,
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => void | (() => void)) => effect(),
useMemo: (factory: () => unknown) => factory(),
useRef: (current: unknown) => ({ current }),
};
});
vi.mock("react-native", () => ({
AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) },
}));
vi.mock("@/app-shell", () => {
const profile = {
credential: "credential", handle: "profile", id: "profile-1",
label: "Profile", mode: "connect", serverUrl: "https://bb.example.test",
};
return {
useProfiles: () => ({
activeProfile: profile, connection: null, profiles: [profile], status: "ready",
}),
useRealtimeConnectionState: () => "disconnected",
};
});
vi.mock("@/ui", () => ({
ActionSheet: () => null,
toast: { error: vi.fn(), message: vi.fn() },
useSheet: () => ({ present: vi.fn() }),
}));
vi.mock("./AppBadgeSync", () => ({ AppBadgeSync: () => null }));
vi.mock("./expo-push-module", () => ({
getPushNotificationsModule: () => ({
addTokenListener: mocks.addTokenListener,
getPermission: vi.fn(async () => "denied"),
projectId: null,
}),
}));
vi.mock("./push-controller", () => ({
getPushRegistrationController: () => ({
handleTokenRolled: vi.fn(), reconcileRemovedProfiles: vi.fn(async () => undefined),
refreshPermission: vi.fn(async () => "denied"), setEnabled: vi.fn(), sync: vi.fn(),
}),
}));
vi.mock("./push-storage", () => ({
getPushStore: () => ({ hasPrompted: vi.fn(() => true) }),
}));
vi.mock("./use-push-store", () => ({
usePushStoreSnapshot: () => ({ enabledProfileIds: [], prompted: true }),
}));
import { PushNotificationsHost } from "./PushNotificationsHost";
describe("PushNotificationsHost", () => {
beforeEach(() => { vi.clearAllMocks(); PushNotificationsHost(); });
it("opens a project thread from a push response with its project route", async () => {
mocks.responseListener({ notification: { request: { content: { data: {
projectId: "proj_1",
serverUrl: "https://bb.example.test",
threadId: "thr_1",
} } } } });
await vi.waitFor(() =>
expect(mocks.push).toHaveBeenCalledWith({
pathname: "/webview",
params: {
path: "/projects/proj_1/threads/thr_1",
profileId: "profile-1",
},
}),
);
});
});
5. Root cause
The push sender includes the required project identity alongside the thread and optional server hint. See the trusted sender payload. The mobile parser also preserves all three fields; see push data parsing.
The loss happens in the response handler. After resolving the correct profile, it hard-codes a projectless path and never reads target.projectId. See notification target opening.
webViewShellHref({
profileId: profile.id,
path: `/threads/${target.threadId}`,
})
The web app intentionally maps /threads/:threadId to PERSONAL_PROJECT_ID; see route-state derivation. Once the real thread loads, the detail view compares its project with that derived project and renders “Not found” on mismatch; see the mismatch guard.
The cold-start symptom follows from the shell’s persistence behavior: the WebView screen stores the current path per profile and uses it as the next initial path when no explicit route is supplied.
6. Proposed fix (first principles)
Use the existing @bb/client-core thread-route helper when the parsed payload has a project ID, while retaining the projectless path only for legacy payloads that omit it. This keeps personal-project behavior centralized, changes no protocol, and lets the focused handler test cover the complete notification callback. Run the full mobile test suite and typecheck afterward.
7. Related issues
The behavior was introduced with the mobile push-notification implementation in merged pull request #2881. No related open issue or linked open pull request was present in GitHub metadata at investigation time.
8. Verification
The same agent repeated the focused reproduction in a second clean detached checkout at 5498be1f7854fc8f5678b740e3a984ea07f86d13 after a separate frozen install. The second run captured the identical route mismatch and failed the same assertion. No report claim required correction.
9. Appendix
Commands executed against trusted code:
git fetch origin main git checkout 5498be1f7854fc8f5678b740e3a984ea07f86d13 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/mobile --force -- --run src/notifications/PushNotificationsHost.test.ts
The first and verification runs used separate clean checkouts. GitHub was read only during investigation. No linked open pull request existed, so there is no PR-review section. Raw build and test logs remain outside this public repository.