Mobile: persistence, outbox, sharing, and native systems
The Expo client reuses T3 Code's connection and projection semantics but owns a deliberately mobile durability layer: cached snapshots in SQLite, credentials in secure storage, drafts and commands in atomic files, a transactional share inbox, native rendering bridges, and an OTA restart gate that yields to unsaved work.
What this chapter resolves
- Locate the React Native shell, shared connection runtime, platform services, and native UI boundaries.
- Separate cached server projections, credentials, composer drafts, queued commands, incoming shares, and authoritative server state.
- Trace an offline command from optimistic enqueue through durable confirmation, shell convergence, retry, and cleanup.
- Explain why OTA application waits for persistence and a genuinely safe background transition.
The phone is a remote-native client, not a pocket-sized T3 server. It owns navigation, presentation, connection credentials, caches, unsent intent, and device integrations. The selected environment still owns projects, threads, workspaces, provider processes, terminals, Git, and authoritative orchestration state.
That distinction makes the mobile implementation easier to read. Most of its special machinery answers one of two questions:
- How can a device remain useful while its process, network, and foreground state are unstable?
- Which work belongs in a native surface without moving server authority onto the phone?
1. The application shell wraps one shared connection runtime
App.tsx mounts one Effect Atom registry, cloud-auth and appearance providers,
gesture/keyboard/safe-area hosts, the incoming-share provider, and a static native
navigation tree. Deep links wake navigation, except for Expo’s development-client
and share-extension lifecycle URLs: the latter is deliberately ignored because the
durable share inbox, not a transient URL, owns presentation.
export default function App() {
return (
<RegistryContext.Provider value={appAtomRegistry}>
<CloudAuthProvider>
<AppearancePreferencesProvider>
<AppContent />
</AppearancePreferencesProvider>
</CloudAuthProvider>
</RegistryContext.Provider>
);
}
function AppContent() {
const { themeAppearance } = useAppearancePreferences();
const statusBarBg = useThemeColor("--color-status-bar");
const navigationTheme = useMobileNavigationTheme(themeAppearance);
return (
<>
<SplashScreenCoordinator />
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
<StatusBar
barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"}
backgroundColor={statusBarBg}
translucent
/>
{/* The navigation theme drives the NATIVE header appearance: native-stack
forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
this, React Navigation defaults to its light theme and every native
header (glass buttons, title, materials) is forced light even when
the system is in dark mode. */}
{/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
<BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
</BlurTargetView>
{/* Anchored-menu overlays render here — in-window, so the
keyboard stays up while a dropdown is open. */}
<OverlayPortalHost />
</SafeAreaProvider>
</KeyboardProvider>
</GestureHandlerRootView>The connection runtime is not a mobile rewrite of Chapter 29. It merges the shared
Connection.layer and shared shell/thread snapshot loaders with mobile platform
services:
- Expo network state supplies current connectivity and change events.
- foreground transitions emit a lightweight probe or a stronger reconnect wakeup, depending on how long the app was backgrounded;
- the mobile catalog supplies targets, profiles, credentials, and DPoP tokens;
- SQLite implements the shared environment-cache interface;
- a mobile background-activity observer/reporter narrows connection work while the app is not active;
- environment removal also clears that environment’s drafts and outbox records.
import { Connection } from "@t3tools/client-runtime/connection";
import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
import * as Layer from "effect/Layer";
import { Atom } from "effect/unstable/reactivity";
import { runtimeContextLayer } from "../lib/runtime";
import {
mobileBackgroundActivityObserverLayer,
mobileBackgroundActivityReporterLayer,
} from "./background-activity";
import { connectionPlatformLayer } from "./platform";
const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
Layer.provide(runtimeContextLayer),
);
const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
type ConnectionLayerSource =
| typeof Connection.layer
| typeof snapshotLoaderLayer
| typeof runtimeContextLayer
| typeof connectionPlatformLayer
| typeof mobileBackgroundActivityObserverLayer
| typeof mobileBackgroundActivityReporterLayer;
const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
Layer.provideMerge(
Layer.mergeAll(
runtimeContextLayer,
providedConnectionPlatformLayer,
mobileBackgroundActivityObserverLayer,
),
),
);
const connectionLayer = mobileBackgroundActivityReporterLayer.pipe(
Layer.provideMerge(providedClientConnectionLayer),
);
export const connectionAtomRuntime: Atom.AtomRuntime<
Layer.Success<ConnectionLayerSource>,
Layer.Error<ConnectionLayerSource>
> = Atom.runtime(connectionLayer);The platform supplies no SSH gateway. Its SshEnvironmentGateway operations fail
with an explicit “desktop only” blocked error. Mobile can connect through supported
direct/bearer or relay targets, but it does not provision a remote CLI over SSH and
does not spawn a local provider. Chapter 34 returns to the access transports;
Chapter 35 follows relay authentication and tunneling.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
On the phone, the React Native shell uses the shared client runtime. The runtime connects to a remote environment. Device-local SQLite stores caches and preferences, secure storage stores connection material, and atomic document files store drafts, outbox messages, and incoming shares. Native terminal, diff, keyboard, and Live Activity surfaces attach to the phone UI. The remote environment remains authoritative for projects, threads, providers, terminal processes, files, and Git.
apps/mobile/src/App.tsx:61–106 ↗apps/mobile/src/Stack.tsx:338–405 ↗apps/mobile/src/connection/runtime.ts:1–45 ↗apps/mobile/src/connection/platform.ts:29–229 ↗apps/mobile/src/persistence/mobile-database.ts:242–275 ↗apps/mobile/src/connection/catalog-store.ts:1–121 ↗apps/mobile/src/state/use-composer-drafts.ts:146–280 ↗apps/mobile/src/state/thread-outbox-storage.ts:21–145 ↗apps/mobile/src/features/sharing/incoming-share-inbox.ts:31–193 ↗apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx:31–210 ↗2. “Mobile persistence” is four stores, not one database
The persistence layer merges an Expo SQLite database, Expo SecureStore, preferences, connection/cache adapters, and a few file-backed state machines. Their records have different confidentiality, failure, and authority properties.
| Store | Representative records | Write semantics | What it is not |
|---|---|---|---|
SQLite t3code-client.db |
shell/thread/server-config/VCS caches; client preferences | WAL database; keyed upserts; corrupt cache entries can be discarded | the server event store |
| SecureStore | connection targets/profiles/credentials, remote DPoP tokens, device identity/registration | serialized catalog update behind a semaphore | a workspace or transcript store |
| document files | composer drafts, one JSON file per queued command, one JSON file per incoming share | serialized and/or atomic file writes, feature-specific recovery | one general transaction spanning every feature |
| kept Effect Atoms / React state | hydrated views, optimistic queue row, route and disclosure state | process-local, sometimes seeded from a durable store | proof of server acceptance |
SQLite creates client_cache with the compound key
(environment_id, kind, cache_key) and a singleton client_preferences row. Cache
payloads remain schema-versioned envelopes. An unavailable or corrupt cache can
degrade to an empty client view; it must not become fabricated server truth.
yield* Effect.tryPromise({
try: async () => {
await database.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
const schema = await database.getFirstAsync<{ readonly user_version: number }>(
"PRAGMA user_version",
);
await database.withExclusiveTransactionAsync(async (transaction) => {
await transaction.execAsync(`
CREATE TABLE IF NOT EXISTS client_cache (
environment_id TEXT NOT NULL,
kind TEXT NOT NULL,
cache_key TEXT NOT NULL,
schema_version INTEGER NOT NULL,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (environment_id, kind, cache_key)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS client_cache_environment_updated
ON client_cache (environment_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS client_preferences (
singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1),
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
});
if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) {
const migrated = await migrateLegacyFileCaches(database);
if (migrated) {
await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`);
}
}The secure connection catalog has its own schema and lock. It migrates a legacy connection list, keeps a memory copy after decoding, and persists each transformed catalog before swapping that copy. The shared runtime sees narrow interfaces for target registration, profiles, credentials, and remote access tokens instead of an Expo-specific storage API.
3. A draft and an outbox message represent different commitments
A composer draft is editable device state: text, attachments, imported-share ids, provider/model/mode choices, and an optional workspace selection. Draft changes update a kept Atom and schedule a 200 ms serialized atomic-file write. Ordinary background persistence is best-effort so an I/O failure does not make the composer unusable. The explicit flush path is stricter: it lands every pending debounce and propagates a write failure to an OTA coordinator deciding whether teardown is safe.
/**
* Lands any debounced or in-flight draft write before the JS runtime is torn
* down (app update restart), so the freshest draft state survives it. A write
* failure propagates so the caller can decide whether the restart may proceed.
*/
export async function flushComposerDrafts(): Promise<void> {
// An edit during an awaited write schedules another debounced write, so
// keep landing snapshots until no debounce is pending after a queue drain.
do {
while (persistTimer !== null) {
clearTimeout(persistTimer);
persistTimer = null;
await persistenceQueue.run(() =>
writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)),
);
}
await persistenceQueue.run(() => Promise.resolve());
} while (persistTimer !== null);An outbox message is a stronger delivery intent. Its versioned record carries stable environment, thread, message, and command ids plus the payload and selected runtime settings. A new-thread item additionally snapshots project presentation/workspace inputs so it stays editable and deliverable even if the live project shell is not currently loaded.
Enqueue has a carefully split acknowledgment:
- publish the row synchronously into the kept Atom for immediate UI feedback;
- serialize an atomic file write for crash recovery;
- if that write fails, remove that object reference, not every value with the same message id, because a later retry may already have replaced it;
- before delivery, serialize a
confirmQueuedread behind pending mutations so an optimistically visible record whose file write failed can never reach the server.
// The queued atom drives the composer's immediate "queued" feedback, so it
// is published synchronously; the durable write happens behind it and rolls
// the message back out if it fails (durability only matters for crash
// recovery, not for the in-session queue).
const enqueue = (message: QueuedThreadMessage): Promise<void> => {
setMessages([
...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
message,
]);
return serialize(async () => {
try {
await options.storage.write(message);
} catch (cause) {
// Roll back by reference, not messageId: a retry enqueue with the same
// id may have optimistically replaced this attempt while the write was
// in flight, and its entry must survive this attempt's failure.
setMessages(currentMessages().filter((candidate) => candidate !== message));
throw new ThreadOutboxManagerError({
operation: "enqueue",
environmentId: message.environmentId,
threadId: message.threadId,
messageId: message.messageId,
cause,
});
}
});
};
// Resolves once all pending mutations (including any in-flight enqueue
// write) have settled, reporting whether the message is still queued. The
// drain awaits this before dispatching so a message whose durable write
// later fails can never have been delivered first.
const confirmQueued = (message: QueuedThreadMessage): Promise<boolean> =>
serialize(async () => currentMessages().some((candidate) => candidate === message));This is a client-side durability barrier, not a distributed transaction with the environment. Once RPC begins, the stable command/message/thread identities and the server’s command receipts/invariants take over.
4. The drain waits for synchronized evidence, then classifies failure
The root stack mounts the outbox drain in a null-rendering leaf. That placement is a performance boundary: queue, shell, project, and connection updates do not need to rerender the whole navigation layout. The drain considers only the first queued message per thread and dispatches one message globally at a time.
For a queued creation, transport connection is insufficient. It waits until the
shell is live, because a previously accepted creation whose local cleanup failed
could otherwise look absent and be issued again. A live shell that already contains
the stable thread id selects remove; a live shell without it can select send.
For an existing-thread message, a missing thread is removed only after a live shell
establishes the absence; before then it waits.
export function threadOutboxRetryDelayMs(attempt: number): number {
return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS);
}
export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";
export function resolveThreadOutboxDeliveryAction(input: {
readonly isCreation: boolean;
readonly threadExists: boolean;
readonly shellStatus: EnvironmentShellStatus;
readonly environmentConnected: boolean;
readonly threadBusy: boolean;
}): ThreadOutboxDeliveryAction {
if (input.isCreation) {
// A pending task creates its thread on delivery. If the thread already
// exists the creation command went through and only cleanup remains.
if (input.threadExists) {
return "remove";
}
// Wait for the shell to be live before sending: until the thread list has
// synchronized, a previously delivered creation whose cleanup failed would
// look missing and get re-issued, duplicating the thread.
return input.environmentConnected && input.shellStatus === "live" ? "send" : "wait";
}
if (!input.threadExists) {
return input.shellStatus === "live" ? "remove" : "wait";
}
return input.environmentConnected ? "send" : "wait";
}
/**
* A queued creation can only be dispatched once its payload would pass server
* validation; incomplete payloads stay pending until the user edits them.
*/
export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): boolean {
if (!message.creation) {
return false;
}
if (message.text.trim().length === 0 || message.modelSelection === undefined) {
return false;
}
return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch);
}
function errorMessage(error: unknown): string | null {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "object" && error !== null && "message" in error) {
return typeof error.message === "string" ? error.message : null;
}
return typeof error === "string" ? error : null;
}
export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
if (
typeof error === "object" &&
error !== null &&
"_tag" in error &&
error._tag === "ConnectionTransientError"
) {
return true;
}
return isTransportConnectionErrorMessage(errorMessage(error));
}
export type ThreadOutboxCommandStage = "settings-sync" | "start-turn";
export type ThreadOutboxFailureAction = "retry" | "discard";
export function resolveThreadOutboxFailureAction(input: {
readonly stage: ThreadOutboxCommandStage;
readonly error: unknown;
readonly interrupted: boolean;
}): ThreadOutboxFailureAction {
if (
input.stage === "settings-sync" ||
input.interrupted ||
shouldRetryThreadOutboxDelivery(input.error)
) {
return "retry";
}
return "discard";One subtle implementation fact is worth making explicit: the resolver accepts a
threadBusy input, and the hook supplies the current session status, but the pinned
decision function does not consult it. Do not describe this client outbox as an
idle-thread scheduler. It may cross RPC while a thread appears busy; current server
normalization and domain invariants still determine whether that command is legal.
Before an existing-thread startTurn, the drain can send deterministic settings
commands to reconcile model selection, runtime mode, and interaction mode with the
queued snapshot. Any settings-sync failure retries. For the final turn command,
interruptions, ConnectionTransientError, and recognized transport errors retry;
other observed failures select discard. Retry delay grows from one second and caps
at sixteen seconds. While retrying, the durable file remains.
Protect intent before crossing a lifecycle boundary
Switch tracks and failure cases, then step through the exact guard that keeps an offline command or downloaded update from outrunning its durable state.
Outbox · offline creation
The local queue becomes visible before its file write settles
The immediate queued row is optimistic. Delivery cannot begin until the serialized mutation queue confirms that the atomic file write survived.
Offline command outbox. Step 1 of 7.
Static mobile continuity ledger
| Boundary | Durable prerequisite | Safety decision | Recovery |
|---|---|---|---|
| Offline turn dispatch | Exact queued message confirmed after atomic-file write | Wait for a connected environment; creations also wait for a live shell | Transient/interrupted failures back off while the file remains |
| Creation replay | Live shell establishes whether the deterministic thread id already exists | If it exists, remove the queued creation without sending it again | A failed cleanup can be retried without duplicating the thread |
| Automatic OTA restart | Composer drafts and outbox writes flush | Restart only while truly backgrounded and no foreground handoff is active | Failed flush or unsafe handoff keeps the runtime alive and rearms the next background |
5. Incoming sharing uses a durable handoff before navigation
The system share extension supplies transient native payloads. The app converts
them into a draft with a SHA-256 content-derived handoff id, but it does not clear
the native payload first. IncomingShareInbox serializes all mutations, loads and
deduplicates persisted drafts, writes the newly built draft, cleans temporary
files, and only then acknowledges the native handoff.
If the app terminates after the durable write but before native acknowledgement, the next refresh sees the same id, cleans any replayed image payload, and clears the handoff without adding a second inbox row. Once a share is consumed and its file is removed, a later intentional share of identical content can be ingested again.
/**
* Serializes every durable inbox mutation. This prevents a stale storage load
* or a foreground refresh from restoring an item after it has been consumed.
*/
export class IncomingShareInbox {
private readonly operations = new SerializedAsyncQueue();
constructor(private readonly dependencies: IncomingShareInboxDependencies) {}
private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
return this.operations.run(operation);
}
private clearNativePayloads(): void {
try {
this.dependencies.clearPayloads();
} catch (error) {
this.dependencies.onClearError?.(error);
}
}
private async cleanup(operation: () => Promise<void>): Promise<void> {
try {
await operation();
} catch (error) {
this.dependencies.onCleanupError?.(error);
}
}
refresh(options: { readonly ingestNative: boolean }): Promise<ReadonlyArray<IncomingShareDraft>> {
return this.runExclusive(async () => {
const loaded = await this.dependencies.loadDrafts();
const persisted = sortAndDedupeIncomingShares(loaded);
if (!options.ingestNative) {
return persisted;
}
const payloads = this.dependencies.getPayloads();
if (payloads.length === 0) {
return persisted;
}
// A share extension payload remains available until the containing app
// acknowledges it. Use a content-derived id so a crash after the durable
// write but before acknowledgement reuses the same inbox item.
const shareId = await this.dependencies.idForPayloads(payloads);
if (loaded.some((draft) => draft.id === shareId)) {
if (this.dependencies.cleanupReplayedPayloads) {
await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads));
}
this.clearNativePayloads();
return persisted;
}
const built = await this.dependencies.buildDraft({
payloads,
id: shareId,
createdAt: this.dependencies.now(),
});
const { draft } = built;
if (!hasIncomingShareContent(draft)) {
// Unsupported native payloads cannot become actionable on retry and
// would otherwise reopen the project picker on every foreground.
await this.cleanup(built.cleanup);
this.clearNativePayloads();
throw new Error(
draft.warnings[0] ?? "The shared content is not supported by the composer.",
);
}
// The durable inbox write is the transaction boundary. Never clear the
// native handoff first: a process termination must leave one recoverable
// copy on one side of the boundary.
await this.dependencies.writeDraft(draft);
await this.cleanup(built.cleanup);
this.clearNativePayloads();
return sortAndDedupeIncomingShares([draft, ...persisted]);
});
}Presentation adds another durable state: reserve(shareId, destination) binds a
share to one environment/project draft. Re-reserving for the same destination is
idempotent; a different destination is rejected. Conditional release refuses to
erase a reservation that changed underneath the caller. The root navigation layout
observes the oldest pending presentation candidate and opens the new-task sheet,
but that route is only a view over the inbox record.
6. Native systems are presentation and OS-integration edges
Mobile is not “the web UI in a WebView.” It uses a native-stack route tree and an adaptive workspace layout. The thread feed is a keyboard-aware virtualized LegendList with fixed-size hints for compact rows, per-type estimation for variable messages, visible-content preservation, and end-follow controls. Those choices are implementation strategies, not a benchmark claim.
| Edge | JavaScript responsibility | Native responsibility | Authority retained elsewhere |
|---|---|---|---|
| Terminal | attach to server terminal stream; replay buffer; translate input/resize | Ghostty-backed view renders and encodes device input | server owns PTY/process/history boundary |
| Review diff | parse/categorize change data; flatten rows/comments; cache mapping | Swift/Kotlin canvas renders large code surfaces | server/Git services own diff inputs and mutation |
| Selectable Markdown/composer | derive structured spans/chips and callbacks | native text/editor views handle selection and keyboard behavior | thread message remains server state; draft remains client state |
| Hardware keyboard | most-recently-mounted scoped handler dispatch | iOS native module exposes command events; platform shortcuts wake routes | command target still resolves through current screen/runtime |
| Agent Activity | derive a compact activity projection and registration intent | iOS widget/Live Activity renders lock-screen/Dynamic Island families | relay/environment activity remains the remote source |
The native terminal wrapper checks whether its Expo view config exists, caches a successful resolution, permanently remembers a failed resolution for the process, and exposes a compiled hardware-key revision. That revision is a version-skew diagnostic: JavaScript can detect an older binary instead of assuming every OTA bundle has the expected native code.
export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminalSurfaceProps> | null {
if (cachedNativeTerminalSurfaceView) {
return cachedNativeTerminalSurfaceView;
}
if (nativeTerminalSurfaceViewResolutionFailed) {
return null;
}
if (getExpoViewConfig(NATIVE_TERMINAL_MODULE_NAME) == null) {
return null;
}
try {
cachedNativeTerminalSurfaceView = requireNativeView<NativeTerminalSurfaceProps>(
NATIVE_TERMINAL_MODULE_NAME,
);
} catch (cause) {
nativeTerminalSurfaceViewResolutionFailed = true;
console.error(
new NativeViewResolutionError({
nativeModuleName: NATIVE_TERMINAL_MODULE_NAME,
cause,
}),
);
return null;
}
return cachedNativeTerminalSurfaceView ?? null;
}
/**
* Revision of the native hardware-keyboard handling compiled into the installed binary,
* or `null` when the binary predates the revision constant (or the module is missing).
* Used in terminal debug logs to detect stale native builds.
*/
export function getNativeTerminalHardwareKeyRevision(): number | null {
try {
if (typeof requireOptionalNativeModule !== "function") {
return null;
}
const module = requireOptionalNativeModule<{ readonly hardwareKeyRevision?: number }>(
NATIVE_TERMINAL_MODULE_NAME,
);
return module?.hardwareKeyRevision ?? null;
} catch {
return null;
}
}
export function hasNativeTerminalSurface() {
return resolveNativeTerminalSurfaceView() !== null;
}The review adapter similarly converts parsed files, hunk/line/comment rows, themes, and word-diff ranges into a native-friendly flattened model; a WeakMap reuses that expensive mapping until comment identity changes. Native rendering avoids moving Git authority to the device.
Agent-awareness settings are explicitly presented as iOS only at this revision. Release iOS builds configure an Agent Activity widget/Live Activity and push capability; personal-team builds remove capabilities they cannot sign. Android has an Expo notification resource configuration, so the precise claim is about the inspected product capability gate and Live Activity path—not that no Android notification API could exist in the binary. Chapter 36 traces notification and multi-environment reconciliation in full.
7. OTA updates coordinate binary compatibility and volatile work
Expo Updates uses a fingerprint runtime-version policy rather than the marketing app version. Native dependencies, config plugins, and patches therefore participate in compatibility: a JavaScript bundle should not land on a binary missing the native surface it expects.
After download, normal application is deferred. The next background transition tries to flush composer drafts and outbox writes, then rechecks that the app is still backgrounded and that no app-initiated foreground handoff is active. Android can report background while an image picker, auth tab, or share surface covers the activity; a small reference-counted handoff marker prevents the update coordinator from tearing down that live flow.
async function applyDeferredAppUpdateInstall(
client: AppUpdateClient,
environment: AppUpdateEnvironment,
deferral: AppUpdateDeferral,
): Promise<void> {
if (!deferral.pendingInstall || deferral.installInProgress) return;
deferral.installInProgress = true;
const flushed = await settlePromise(() => environment.flushPendingWrites());
const safe = await settlePromise(() => environment.isSafeToRestartInBackground());
if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) {
if (flushed._tag === "Failure") {
// Nothing is lost yet: keep the state-bearing runtime alive and retry
// the flush at the next backgrounding instead of restarting over it.
reportUpdateFailure(flushed, "Could not save pending state.", undefined);
}
deferral.installInProgress = false;
// This attempt already ran in the current background session; retrying
// before a fresh transition would just loop over the same failure.
scheduleDeferredAppUpdateInstall(client, environment, deferral, false);
return;
}
const reloaded = await settlePromise(() => client.reloadAsync());
if (reloaded._tag === "Failure") {
reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined);
deferral.installInProgress = false;
// Let later checks re-arm the install; the downloaded update still
// applies at the next cold start regardless.
deferral.pendingInstall = false;
}
}
async function defaultConfirmInstallNow(): Promise<boolean> {
const { Alert } = await import("react-native");
return new Promise<boolean>((resolve) => {
Alert.alert(
"Update ready",
"A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?",
[
{ onPress: () => resolve(false), style: "cancel", text: "Later" },
{ onPress: () => resolve(true), text: "Install Now" },
],
{ cancelable: true, onDismiss: () => resolve(false) },
);
});
}
async function defaultFlushPendingWrites(): Promise<void> {
// Attempt every flush before surfacing the first failure, so one broken
// store cannot keep the others from landing.
const results = await Promise.allSettled([
import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()),
import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()),
]);
const failed = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failed) throw failed.reason;
}
async function defaultIsSafeToRestartInBackground(): Promise<boolean> {
const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff");
if (isForegroundHandoffActive()) return false;
const { AppState } = await import("react-native");
return AppState.currentState === "background";If a flush fails or the lifecycle check is unsafe, the install stays pending and is rearmed for a future background rather than polling inside the same background session. If the app remains foregrounded for thirty minutes with a ready update, it asks whether to install now. An explicit user-requested install may proceed after a failed flush once the failure is reported; automatic application does not. A remote rollback directive also takes the immediate path, though an automatic rollback whose flush fails is retained for deferred retry.
8. The design lesson is selective durability
T3 Code does not place every mobile value in one “offline database.” It gives each kind of state the smallest recovery contract it needs:
- server projections are schema-versioned, replaceable cache;
- credentials and connection profiles use secure device storage;
- drafts favor responsive editing plus best-effort persistence, with a strict flush only at a teardown boundary;
- queued commands add stable ids, atomic files, serialized confirmation, replay guards, and retry classification;
- incoming shares make the file write the native-to-app transaction boundary and add destination reservation;
- OTA restart waits for those state-bearing stores instead of assuming
backgroundmeans “safe now.”
For a meta-harness, the transferable pattern is not “add an outbox everywhere.” It is: identify which intent must survive client death, bind it to stable remote identities, make replay ambiguity observable through synchronized server state, and keep caches visibly subordinate to authority.