Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-ravens-foreground.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/typescript-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript-client/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion packages/typescript-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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": {
"*": {
"*": [
Expand Down
44 changes: 4 additions & 40 deletions packages/typescript-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
ShapeStreamState,
} from './shape-stream-state'
import { PauseLock } from './pause-lock'
import { getDefaultRuntimeVisibilityAdapterFactory } from './runtime-visibility'

const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
LIVE_CACHE_BUSTER_QUERY_PARAM,
Expand Down Expand Up @@ -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<T = never> {
Expand Down Expand Up @@ -2020,8 +1985,7 @@ export class ShapeStream<T extends Row<unknown> = Row>

#subscribeToVisibilityChanges() {
const runtimeVisibility =
this.options.runtimeVisibility ??
detectReactNativeRuntimeVisibilityAdapter()
this.options.runtimeVisibility ?? getDefaultRuntimeVisibilityAdapter()

if (runtimeVisibility) {
this.#setVisibilityPaused(
Expand Down
11 changes: 11 additions & 0 deletions packages/typescript-client/src/react-native-shim.d.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
}
10 changes: 10 additions & 0 deletions packages/typescript-client/src/react-native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { AppState } from 'react-native'

import { createReactNativeRuntimeVisibilityAdapter } from './client'
import { setDefaultRuntimeVisibilityAdapterFactory } from './runtime-visibility'

setDefaultRuntimeVisibilityAdapterFactory(() =>
createReactNativeRuntimeVisibilityAdapter(AppState)
)

export * from './index'
21 changes: 21 additions & 0 deletions packages/typescript-client/src/runtime-visibility.ts
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 18 additions & 31 deletions packages/typescript-client/test/wake-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).require
Object.defineProperty(globalThis, `navigator`, {
configurable: true,
value: { product: `ReactNative` },
})
;(globalThis as Record<string, unknown>).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[] = []
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -350,11 +340,8 @@ describe(`Wake detection`, () => {
unsub()
aborter.abort()
} finally {
Object.defineProperty(globalThis, `navigator`, {
configurable: true,
value: originalNavigator,
})
;(globalThis as Record<string, unknown>).require = originalRequire
vi.doUnmock(`react-native`)
vi.resetModules()
}
})

Expand Down
2 changes: 2 additions & 0 deletions packages/typescript-client/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ export default defineConfig((options) => {
const commonOptions: Partial<Options> = {
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,
}

Expand Down
49 changes: 49 additions & 0 deletions website/docs/sync/api/clients/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -324,6 +352,20 @@ export interface ShapeStreamOptions<T = never> {
*/
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
}

Expand All @@ -335,6 +377,13 @@ type RetryOpts = {
type ShapeStreamErrorHandler = (
error: Error
) => void | RetryOpts | Promise<void | RetryOpts>

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:
Expand Down
6 changes: 6 additions & 0 deletions website/docs/sync/integrations/expo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading