Skip to content
Merged
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 root `react-native` condition, use the explicit `@electric-sql/client/react-native` entrypoint or 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
43 changes: 41 additions & 2 deletions packages/typescript-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@
"exports": {
"./package.json": "./package.json",
".": {
"browser": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
},
"default": "./dist/index.mjs"
},
"react-native": {
"import": {
"types": "./dist/index.react-native.d.ts",
"default": "./dist/index.react-native.mjs"
},
"require": {
"types": "./dist/cjs/index.react-native.d.cts",
"default": "./dist/cjs/index.react-native.cjs"
},
"default": "./dist/index.react-native.mjs"
},
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
Expand All @@ -48,6 +70,17 @@
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./react-native": {
"import": {
"types": "./dist/index.react-native.d.ts",
"default": "./dist/index.react-native.mjs"
},
"require": {
"types": "./dist/cjs/index.react-native.d.cts",
"default": "./dist/cjs/index.react-native.cjs"
},
"default": "./dist/index.react-native.mjs"
}
},
"bin": {
Expand All @@ -58,12 +91,14 @@
"src",
"skills",
"bin",
"!skills/_artifacts"
"!skills/_artifacts",
"!src/react-native-shim.d.ts"
],
"homepage": "https://electric-sql.com",
"license": "Apache-2.0",
"main": "./dist/cjs/index.cjs",
"module": "./dist/index.legacy-esm.js",
"react-native": "./dist/cjs/index.react-native.cjs",
"types": "./dist/index.d.ts",
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "^4.18.1"
Expand All @@ -83,7 +118,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
Loading
Loading