← reports

#3132 · Mobile push routing drops project scope

Bug Medium Effort: Low mobile remote open on GitHub 2026-09-05 · base 5498be1f7

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

ClaimStatusEvidence
A notification for a normal project thread opens a route that cannot display that thread.VerifiedThe 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.VerifiedThe 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.RefutedThe 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 codeThe 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.UnverifiedNo 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

4. Minimal reproduction

  1. Check out and build the trusted base:
    git checkout 5498be1f7854fc8f5678b740e3a984ea07f86d13
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Add the focused test below as apps/mobile/src/notifications/PushNotificationsHost.test.ts.
  3. Run it through the repository orchestrator:
    pnpm exec turbo run test --filter=@bb/mobile --force -- --run src/notifications/PushNotificationsHost.test.ts
  4. Expected: the notification response opens /projects/proj_1/threads/thr_1 on the resolved profile.
  5. 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.