From b3b322259f536a52235a5f34c7d9eb371a426035 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 2 Jul 2026 13:26:01 -0600 Subject: [PATCH] Fix React Native runtime visibility packaging --- .changeset/fresh-ravens-foreground.md | 5 ++ packages/typescript-client/README.md | 4 ++ packages/typescript-client/SPEC.md | 2 +- packages/typescript-client/package.json | 13 ++++- packages/typescript-client/src/client.ts | 44 ++--------------- .../src/react-native-shim.d.ts | 11 +++++ .../typescript-client/src/react-native.ts | 10 ++++ .../src/runtime-visibility.ts | 21 ++++++++ .../test/wake-detection.test.ts | 49 +++++++------------ packages/typescript-client/tsup.config.ts | 2 + website/docs/sync/api/clients/typescript.md | 49 +++++++++++++++++++ website/docs/sync/integrations/expo.md | 6 +++ 12 files changed, 143 insertions(+), 73 deletions(-) create mode 100644 .changeset/fresh-ravens-foreground.md create mode 100644 packages/typescript-client/src/react-native-shim.d.ts create mode 100644 packages/typescript-client/src/react-native.ts create mode 100644 packages/typescript-client/src/runtime-visibility.ts diff --git a/.changeset/fresh-ravens-foreground.md b/.changeset/fresh-ravens-foreground.md new file mode 100644 index 0000000000..6cda57549f --- /dev/null +++ b/.changeset/fresh-ravens-foreground.md @@ -0,0 +1,5 @@ +--- +"@electric-sql/client": patch +--- + +Use the React Native package export to wire AppState lifecycle handling in Metro/Expo builds, replacing brittle runtime `require('react-native')` auto-detection while keeping `runtimeVisibility` as an explicit escape hatch. diff --git a/packages/typescript-client/README.md b/packages/typescript-client/README.md index 420ec50e1d..f5701f5b5b 100644 --- a/packages/typescript-client/README.md +++ b/packages/typescript-client/README.md @@ -101,6 +101,10 @@ shape.subscribe(({ rows }) => { }) ``` +### React Native / Expo app lifecycle + +Metro and Expo resolve the package's `react-native` export for native builds, so standard imports automatically wire React Native `AppState` into `ShapeStream`. If your bundler setup does not resolve the `react-native` export, pass `runtimeVisibility` explicitly. See the [TypeScript client docs](https://electric-sql.com/docs/sync/api/clients/typescript#react-native-and-expo-lifecycle-handling) for details. + ### Error Handling The ShapeStream provides robust error handling with automatic retry support: diff --git a/packages/typescript-client/SPEC.md b/packages/typescript-client/SPEC.md index 1131e5e20c..b9b3ea5217 100644 --- a/packages/typescript-client/SPEC.md +++ b/packages/typescript-client/SPEC.md @@ -445,7 +445,7 @@ Six sites in `client.ts` recurse or loop to issue a new fetch: | `#maxSnapshotRetries` | Snapshot 409 path (L6) | Counts consecutive snapshot 409s. Unconditional cache buster on every retry. Throws FetchError(502) after 5. Runtime-enforced by `Shape #fetchSnapshotWithRetry 409 loop PBT` in `test/pbt-micro.test.ts`. | | `#maxConsecutiveErrorRetries` | `#start` onError retry (L5) | Counts consecutive error retries. Before each retry, waits with abort-aware full-jitter exponential backoff using `backoffOptions` (`initialDelay`, `multiplier`, `maxDelay`). Sends error to subscribers and tears down after 50. Reset on successful message batch or accepted 204. | | Request watchdog | Live long-poll and refresh catch-up requests | Aborts with `live-request-timeout` and marks the request for restart after `liveRequestTimeoutMs` (default 45s; positive finite number or `false` to disable), then rejects independently of platform fetch abort behavior so runtimes with stuck fetch promises cannot block the request loop forever. Applies to non-live refresh catch-up requests because mobile runtimes can also wedge after the wake abort has already sent catch-up requests. | -| Pause lock | `#requestShape` entry | Returns immediately if paused. Prevents fetches during snapshots and hidden/background runtime states. Browser `document.visibilitychange` is used when available; React Native AppState is auto-detected when available; other runtimes can pass `runtimeVisibility` to pause while hidden/backgrounded and resume with a non-live catch-up request after foregrounding. | +| Pause lock | `#requestShape` entry | Returns immediately if paused. Prevents fetches during snapshots and hidden/background runtime states. Browser `document.visibilitychange` is used when available; React Native AppState is wired through the React Native package export when Metro/Expo resolves the `react-native` condition; other runtimes can pass `runtimeVisibility` to pause while hidden/backgrounded and resume with a non-live catch-up request after foregrounding. | | Up-to-date exit | `#requestShape` entry | Returns if `!subscribe` and `isUpToDate`. Breaks loop for one-shot syncs. | ### Coverage gaps diff --git a/packages/typescript-client/package.json b/packages/typescript-client/package.json index 26df7dd7bb..cacaf4092b 100644 --- a/packages/typescript-client/package.json +++ b/packages/typescript-client/package.json @@ -40,6 +40,12 @@ "exports": { "./package.json": "./package.json", ".": { + "react-native": { + "types": "./dist/index.react-native.d.ts", + "import": "./dist/index.react-native.mjs", + "require": "./dist/cjs/index.react-native.cjs", + "default": "./dist/index.react-native.mjs" + }, "import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" @@ -64,6 +70,7 @@ "license": "Apache-2.0", "main": "./dist/cjs/index.cjs", "module": "./dist/index.legacy-esm.js", + "react-native": "./dist/index.react-native.mjs", "types": "./dist/index.d.ts", "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" @@ -83,7 +90,11 @@ "coverage": "pnpm exec vitest --coverage", "typecheck": "tsc -p tsconfig.json" }, - "sideEffects": false, + "sideEffects": [ + "./src/react-native.ts", + "./dist/index.react-native.mjs", + "./dist/cjs/index.react-native.cjs" + ], "typesVersions": { "*": { "*": [ diff --git a/packages/typescript-client/src/client.ts b/packages/typescript-client/src/client.ts index 41c84b66d7..4e2504c9e9 100644 --- a/packages/typescript-client/src/client.ts +++ b/packages/typescript-client/src/client.ts @@ -88,6 +88,7 @@ import { ShapeStreamState, } from './shape-stream-state' import { PauseLock } from './pause-lock' +import { getDefaultRuntimeVisibilityAdapterFactory } from './runtime-visibility' const RESERVED_PARAMS: Set = new Set([ LIVE_CACHE_BUSTER_QUERY_PARAM, @@ -290,46 +291,10 @@ export function createReactNativeRuntimeVisibilityAdapter( } } -function isReactNativeEnvironment(): boolean { - return ( - typeof navigator === `object` && - `product` in navigator && - navigator.product === `ReactNative` - ) -} - -function getRuntimeRequire(): ((moduleName: string) => unknown) | undefined { - const globalRequire = ( - globalThis as typeof globalThis & { require?: unknown } - ).require - if (typeof globalRequire === `function`) { - return globalRequire as (moduleName: string) => unknown - } - - try { - return Function( - `return typeof require === "function" ? require : undefined` - )() as ((moduleName: string) => unknown) | undefined - } catch { - return undefined - } -} - -function detectReactNativeRuntimeVisibilityAdapter(): +function getDefaultRuntimeVisibilityAdapter(): | RuntimeVisibilityAdapter | undefined { - if (!isReactNativeEnvironment()) return undefined - - try { - const runtimeRequire = getRuntimeRequire() - const reactNative = runtimeRequire?.(`react-native`) as - | { AppState?: ReactNativeAppStateLike } - | undefined - if (!reactNative?.AppState) return undefined - return createReactNativeRuntimeVisibilityAdapter(reactNative.AppState) - } catch { - return undefined - } + return getDefaultRuntimeVisibilityAdapterFactory()?.() } export interface ShapeStreamOptions { @@ -2020,8 +1985,7 @@ export class ShapeStream = Row> #subscribeToVisibilityChanges() { const runtimeVisibility = - this.options.runtimeVisibility ?? - detectReactNativeRuntimeVisibilityAdapter() + this.options.runtimeVisibility ?? getDefaultRuntimeVisibilityAdapter() if (runtimeVisibility) { this.#setVisibilityPaused( diff --git a/packages/typescript-client/src/react-native-shim.d.ts b/packages/typescript-client/src/react-native-shim.d.ts new file mode 100644 index 0000000000..cf0424b12a --- /dev/null +++ b/packages/typescript-client/src/react-native-shim.d.ts @@ -0,0 +1,11 @@ +// Minimal declaration used only to type-check the conditional React Native +// entrypoint without adding react-native as a dependency of the client package. +declare module 'react-native' { + export const AppState: { + currentState: 'active' | 'background' | 'inactive' | null + addEventListener: ( + type: 'change', + listener: (state: 'active' | 'background' | 'inactive' | null) => void + ) => { remove: () => void } + } +} diff --git a/packages/typescript-client/src/react-native.ts b/packages/typescript-client/src/react-native.ts new file mode 100644 index 0000000000..4eeb26b93d --- /dev/null +++ b/packages/typescript-client/src/react-native.ts @@ -0,0 +1,10 @@ +import { AppState } from 'react-native' + +import { createReactNativeRuntimeVisibilityAdapter } from './client' +import { setDefaultRuntimeVisibilityAdapterFactory } from './runtime-visibility' + +setDefaultRuntimeVisibilityAdapterFactory(() => + createReactNativeRuntimeVisibilityAdapter(AppState) +) + +export * from './index' diff --git a/packages/typescript-client/src/runtime-visibility.ts b/packages/typescript-client/src/runtime-visibility.ts new file mode 100644 index 0000000000..56adeddf50 --- /dev/null +++ b/packages/typescript-client/src/runtime-visibility.ts @@ -0,0 +1,21 @@ +import type { RuntimeVisibilityAdapter } from './client' + +type RuntimeVisibilityAdapterFactory = () => + | RuntimeVisibilityAdapter + | undefined + +let defaultRuntimeVisibilityAdapterFactory: + | RuntimeVisibilityAdapterFactory + | undefined + +export function setDefaultRuntimeVisibilityAdapterFactory( + factory: RuntimeVisibilityAdapterFactory | undefined +): void { + defaultRuntimeVisibilityAdapterFactory = factory +} + +export function getDefaultRuntimeVisibilityAdapterFactory(): + | RuntimeVisibilityAdapterFactory + | undefined { + return defaultRuntimeVisibilityAdapterFactory +} diff --git a/packages/typescript-client/test/wake-detection.test.ts b/packages/typescript-client/test/wake-detection.test.ts index 34e9e6d23e..1dafda2b68 100644 --- a/packages/typescript-client/test/wake-detection.test.ts +++ b/packages/typescript-client/test/wake-detection.test.ts @@ -255,36 +255,23 @@ describe(`Wake detection`, () => { aborter.abort() }) - it(`should auto-detect React Native AppState visibility`, async () => { + it(`should use the React Native export to install AppState visibility`, async () => { vi.useFakeTimers() type AppStateStatus = `active` | `background` | `inactive` | null let appStateListener: ((state: AppStateStatus) => void) | undefined - const originalNavigator = globalThis.navigator - const originalRequire = (globalThis as Record).require - Object.defineProperty(globalThis, `navigator`, { - configurable: true, - value: { product: `ReactNative` }, - }) - ;(globalThis as Record).require = vi.fn( - (moduleName: string) => { - if (moduleName !== `react-native`) throw new Error(`unexpected module`) - return { - AppState: { - currentState: `active` as AppStateStatus, - addEventListener: vi.fn( - ( - _type: `change`, - nextListener: (state: AppStateStatus) => void - ) => { - appStateListener = nextListener - return { remove: vi.fn() } - } - ), - }, + const appState = { + currentState: `active` as AppStateStatus, + addEventListener: vi.fn( + (_type: `change`, nextListener: (state: AppStateStatus) => void) => { + appStateListener = nextListener + return { remove: vi.fn() } } - } - ) + ), + } + + vi.resetModules() + vi.doMock(`react-native`, () => ({ AppState: appState })) const fetchUrls: string[] = [] const fetchSignals: AbortSignal[] = [] @@ -322,7 +309,10 @@ describe(`Wake detection`, () => { } try { - const stream = new ShapeStream({ + const { ShapeStream: ReactNativeShapeStream } = await import( + `../src/react-native` + ) + const stream = new ReactNativeShapeStream({ url: shapeUrl, params: { table: `foo` }, signal: aborter.signal, @@ -350,11 +340,8 @@ describe(`Wake detection`, () => { unsub() aborter.abort() } finally { - Object.defineProperty(globalThis, `navigator`, { - configurable: true, - value: originalNavigator, - }) - ;(globalThis as Record).require = originalRequire + vi.doUnmock(`react-native`) + vi.resetModules() } }) diff --git a/packages/typescript-client/tsup.config.ts b/packages/typescript-client/tsup.config.ts index 5a29f28752..4a4d939eab 100644 --- a/packages/typescript-client/tsup.config.ts +++ b/packages/typescript-client/tsup.config.ts @@ -5,10 +5,12 @@ export default defineConfig((options) => { const commonOptions: Partial = { entry: { index: `src/index.ts`, + 'index.react-native': `src/react-native.ts`, }, tsconfig: `./tsconfig.build.json`, sourcemap: true, noExternal: [`@microsoft/fetch-event-source`], + external: [`react-native`], ...options, } diff --git a/website/docs/sync/api/clients/typescript.md b/website/docs/sync/api/clients/typescript.md index a3de27cd97..cfc143ee0c 100644 --- a/website/docs/sync/api/clients/typescript.md +++ b/website/docs/sync/api/clients/typescript.md @@ -166,6 +166,34 @@ The client automatically detects when SSE is not working properly (e.g., due to If your reverse proxy or CDN is buffering responses, you may need to configure it to support streaming. See the [HTTP API SSE documentation](/docs/sync/api/http#server-sent-events-sse) for proxy configuration examples. +#### React Native and Expo lifecycle handling + +React Native apps should pause active long-polls while the app is backgrounded and resume with a non-live catch-up request when the app returns to the foreground. The package includes a React Native conditional export for Metro/Expo, so standard imports automatically install an `AppState`-based runtime visibility adapter in React Native builds: + +```ts +import { ShapeStream } from '@electric-sql/client' +``` + +If your Metro configuration disables package exports or you use another non-browser runtime, pass `runtimeVisibility` explicitly: + +```ts +import { AppState } from 'react-native' +import { + ShapeStream, + createReactNativeRuntimeVisibilityAdapter, +} from '@electric-sql/client' + +const stream = new ShapeStream({ + url: `http://localhost:3000/v1/shape`, + params: { table: `foo` }, + runtimeVisibility: createReactNativeRuntimeVisibilityAdapter(AppState), +}) +``` + +`runtimeVisibility` is also the escape hatch for custom runtimes. Return `hidden` to pause and abort in-flight requests, and `visible` to resume with a catch-up request. + +The client also includes a live request watchdog (`liveRequestTimeoutMs`, default `45_000`) that reconnects if a mobile fetch implementation leaves a request hanging across lifecycle or network transitions. Set it to `false` only if you want to disable that protection. + #### Options The `ShapeStream` constructor takes [the following options](https://github.com/electric-sql/electric/blob/main/packages/typescript-client/src/client.ts#L39): @@ -324,6 +352,20 @@ export interface ShapeStreamOptions { */ onError?: ShapeStreamErrorHandler + /** + * Runtime lifecycle adapter for environments without `document.visibilitychange`. + * Hidden state pauses the stream and aborts in-flight requests; visible state + * resumes with a non-live catch-up request. + */ + runtimeVisibility?: RuntimeVisibilityAdapter + + /** + * Maximum time in milliseconds to wait for a live long-poll request or refresh + * catch-up request before aborting it and reconnecting. Defaults to 45s. Set + * to `false` to disable. + */ + liveRequestTimeoutMs?: number | false + backoffOptions?: BackoffOptions } @@ -335,6 +377,13 @@ type RetryOpts = { type ShapeStreamErrorHandler = ( error: Error ) => void | RetryOpts | Promise + +type RuntimeVisibilityState = 'visible' | 'hidden' + +type RuntimeVisibilityAdapter = { + getCurrentState?: () => RuntimeVisibilityState | undefined + subscribe: (callback: (state: RuntimeVisibilityState) => void) => () => void +} ``` Note that certain parameter names are reserved for Electric's internal use and cannot be used in custom params: diff --git a/website/docs/sync/integrations/expo.md b/website/docs/sync/integrations/expo.md index 4930fa426b..9cda5d0bbd 100644 --- a/website/docs/sync/integrations/expo.md +++ b/website/docs/sync/integrations/expo.md @@ -62,6 +62,12 @@ npm run web If there's data in the `items` table of your Postgres, you should see it syncing into your app. +## App lifecycle handling + +Expo uses Metro, which resolves the `react-native` condition in `@electric-sql/client`'s package exports for native builds. This means normal imports automatically use React Native's `AppState` to pause ShapeStreams while the app is backgrounded and resume with a catch-up request when the app becomes active again. + +If you've disabled Metro package exports or use a custom bundler setup, pass `runtimeVisibility` explicitly. See the [TypeScript client docs](/docs/sync/api/clients/typescript#react-native-and-expo-lifecycle-handling) for details and an example. + ## PGlite [PGlite](https://pglite.dev) doesn't _yet_ work in React Native.