From 48048729bd6079188539133d81181f54ff50b474 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Sat, 14 Nov 2026 01:34:45 +0100 Subject: [PATCH] Keep the event stream open in the iOS app switcher iOS reports "inactive" while the app switcher, Control Center, Notification Center, or a system prompt covers the app. The transport treated every state other than "active" as background, so swiping up to switch apps closed the event stream and showed the stale-request and saved-data notices on the workspace card. Only "background" now closes the stream and pauses query focus. Leaving for another app still reaches "background" and reconnects on return. --- .../state/connection-runtime-context.test.tsx | 2 ++ .../src/state/connection-runtime-context.tsx | 8 ++++++-- .../connection-runtime-transport.test.ts | 13 +++++++++++++ .../src/state/connection-runtime-transport.ts | 12 ++++++++++-- docs/COMPATIBILITY.md | 19 +++++++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/state/connection-runtime-context.test.tsx b/apps/mobile/src/state/connection-runtime-context.test.tsx index e3da588..1a7c714 100644 --- a/apps/mobile/src/state/connection-runtime-context.test.tsx +++ b/apps/mobile/src/state/connection-runtime-context.test.tsx @@ -279,6 +279,8 @@ test("forwards app state and network changes and drops stale cache metadata", as ?.value as NativeEventSubscription; const removeAppState = jest.spyOn(appStateSubscription, "remove"); + act(() => appStateListener("inactive")); + expect(setFocused).toHaveBeenLastCalledWith(true); act(() => appStateListener("background")); expect(setFocused).toHaveBeenLastCalledWith(false); act(() => mockNetworkListener?.({ isConnected: false })); diff --git a/apps/mobile/src/state/connection-runtime-context.tsx b/apps/mobile/src/state/connection-runtime-context.tsx index 2702b3f..3c3c0d7 100644 --- a/apps/mobile/src/state/connection-runtime-context.tsx +++ b/apps/mobile/src/state/connection-runtime-context.tsx @@ -23,7 +23,11 @@ import { type ConnectionCacheMetadata, readConnectionCacheMetadata, } from "./connection-cache-metadata"; -import { appendEventLocation, startConnectionTransport } from "./connection-runtime-transport"; +import { + appendEventLocation, + isTransportForeground, + startConnectionTransport, +} from "./connection-runtime-transport"; import type { ConnectionTransportCoordinator, ConnectionTransportStatus, @@ -196,7 +200,7 @@ function useConnectionTransport( }); const appStateSubscription = AppState.addEventListener("change", (nextState) => { - const foreground = nextState === "active"; + const foreground = isTransportForeground(nextState); focusManager.setFocused(foreground); coordinator?.setForeground(foreground); }); diff --git a/apps/mobile/src/state/connection-runtime-transport.test.ts b/apps/mobile/src/state/connection-runtime-transport.test.ts index ca6e91e..5392df9 100644 --- a/apps/mobile/src/state/connection-runtime-transport.test.ts +++ b/apps/mobile/src/state/connection-runtime-transport.test.ts @@ -12,6 +12,7 @@ import { appendEventLocation, type ConnectionRuntimeTransportSink, connectionTransportCallbacks, + isTransportForeground, startConnectionTransport, } from "./connection-runtime-transport"; import type { ConnectionSnapshot } from "./connection-transport-coordinator"; @@ -147,6 +148,18 @@ test("omits authorization without a credential and starts in the background", as expect(mockCoordinators[0]?.start).toHaveBeenCalledTimes(1); }); +test("keeps the transport in the foreground while iOS reports inactive", () => { + Object.defineProperty(AppState, "currentState", { configurable: true, value: "inactive" }); + mockGetNetworkState.mockResolvedValue({}); + + startConnectionTransport(profile, undefined, createQueryClient(), createSink()); + + expect(mockCoordinators[0]?.setForeground).toHaveBeenCalledWith(true); + expect(isTransportForeground("active")).toBe(true); + expect(isTransportForeground("inactive")).toBe(true); + expect(isTransportForeground("background")).toBe(false); +}); + test("encodes basic credentials", () => { mockGetNetworkState.mockResolvedValue({ isConnected: true }); diff --git a/apps/mobile/src/state/connection-runtime-transport.ts b/apps/mobile/src/state/connection-runtime-transport.ts index b251751..14a98b2 100644 --- a/apps/mobile/src/state/connection-runtime-transport.ts +++ b/apps/mobile/src/state/connection-runtime-transport.ts @@ -7,7 +7,7 @@ import { import { focusManager, onlineManager, type QueryClient } from "@tanstack/react-query"; import * as Network from "expo-network"; import type { Dispatch, SetStateAction } from "react"; -import { AppState } from "react-native"; +import { AppState, type AppStateStatus } from "react-native"; import { connectionAuthorizationHeader } from "../connections/connection-authorization"; import type { ConnectionCredential, ConnectionProfile } from "../connections/connection-profile"; @@ -77,7 +77,7 @@ export function startConnectionTransport( shouldReconcileEvent: eventRequiresConnectionSnapshot, }); - const foreground = AppState.currentState === "active"; + const foreground = isTransportForeground(AppState.currentState); coordinator.setForeground(foreground); focusManager.setFocused(foreground); void Network.getNetworkStateAsync() @@ -92,6 +92,14 @@ export function startConnectionTransport( return coordinator; } +// iOS reports "inactive" while the app switcher, Control Center, Notification Center, +// or a system prompt covers the app. The app is still on screen and not suspended, +// so only "background" closes the event stream. Closing it on "inactive" marked the +// connection stale every time the user swiped up to switch apps. +export function isTransportForeground(state: AppStateStatus) { + return state !== "background"; +} + export function connectionTransportCallbacks( profile: ConnectionTransportProfile, queryClient: QueryClient, diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 013af5f..a3fe0da 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,25 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-11-14: event stream stays open in the iOS app switcher, pending device verification + +On an iPhone, swiping up to the app switcher showed "Request status is stale" +and "Showing saved data until the server reconnects" on the workspace card. iOS +reports `inactive` while the app switcher, Control Center, Notification Center, +or a system prompt covers the app. The transport treated every state other than +`active` as background, so it closed the event stream and marked the connection +stale. Only `background` now closes the stream and pauses query focus; `inactive` +keeps both. Leaving for another app still reaches `background`, closes the +stream, and reconnects on return. Android does not report `inactive`. + +The full lint, typecheck, test, and build sequence passed, including 1533 mobile +tests and both Hermes exports. Expo Doctor passed 18 of 18 checks. Both native +fingerprints and runtime matched the installed 0.1.6 baseline under the preview +EAS environment. Local EAS Update publication then succeeded for iOS and Android +on preview. Publication does not establish installation or device behavior. +Still to verify on the iPhone: no notice while hovering in the app switcher, and +a clean reconnect after switching to another app and back. + ## 2026-11-10: transcript image tiles and swipeable viewer, pending device verification Transcript images now form justified rows of tiles that keep each image's shape.