Skip to content

Commit 2ab02bb

Browse files
authored
Fix React Native runtime visibility packaging (#4683)
Fixes React Native/Expo app lifecycle handling for ShapeStream by replacing brittle runtime `require('react-native')` probing with a Metro/Expo `react-native` package export. React Native builds now wire `AppState` automatically, while `runtimeVisibility` remains available as an explicit escape hatch for custom bundlers/runtimes. ## Root cause The previous React Native visibility detection depended on runtime access to `require('react-native')` via `globalThis.require` or `Function('return require')`. In Metro/Hermes builds, `require` is available at module scope but not necessarily on `globalThis`, and the function fallback can return `undefined`. When that happened, `runtimeVisibility` stayed unset and the AppState listener was never installed. ## Approach - Remove the brittle runtime React Native detection from `ShapeStream`. - Add a React Native-specific entrypoint (`src/react-native.ts`) that statically imports `AppState` from `react-native` and registers the default runtime visibility adapter. - Add `browser` and `react-native` conditional package exports, plus an explicit `./react-native` subpath and legacy top-level `react-native` field, so Metro/Expo resolve the correct entrypoint for web, native, and explicit React Native imports: ```ts import { ShapeStream } from '@electric-sql/client' ``` - Keep `runtimeVisibility` as the explicit override/fallback for custom runtimes or Metro configs that do not resolve the `react-native` condition. - Mark only the React Native entrypoints as side-effectful in `package.json` so the AppState registration is not tree-shaken while preserving tree-shaking for the rest of the package. - Expose both ESM and CJS targets and matching `.d.ts`/`.d.cts` declaration files under the RN conditional export to match the package's existing dual-format publishing. ## Key invariants - Browser/Expo web/Node/default imports do not statically import `react-native`. - React Native/Metro imports run the RN entrypoint and install the AppState-backed adapter. - User-provided `runtimeVisibility` still wins over the default adapter. - Hidden/background state pauses streams and aborts in-flight requests; visible/active state resumes with non-live catch-up. - Non-RN package entrypoints remain side-effect-free for tree-shaking. ## Non-goals - Do not add `react-native` as a runtime dependency of `@electric-sql/client`. - Do not rely on additional global `require` probing or Metro internals. - Do not replace the existing `runtimeVisibility` API; it remains the escape hatch. - Do not introduce an RN-specific `ShapeStream` wrapper in this PR. ## Trade-offs The RN entrypoint uses top-level registration through a small shared factory. This keeps the main `ShapeStream` implementation simple and avoids duplicating the class for React Native, but it means the RN entrypoint must be marked as side-effectful. The `sideEffects` allowlist is limited to the RN entry files so other package entrypoints remain tree-shakable. ## Verification ```bash pnpm install pnpm --dir packages/typescript-client run typecheck pnpm --dir packages/typescript-client run stylecheck cd packages/typescript-client && pnpm build ``` I also ran the targeted wake-detection unit test with a temporary Vitest config that skips the integration global setup; all 15 tests in `test/wake-detection.test.ts` passed. The normal package test command attempted to run global integration setup and timed out waiting for Electric to be active in this environment. ## Files changed - `.changeset/fresh-ravens-foreground.md` — patch changeset for `@electric-sql/client`. - `packages/typescript-client/package.json` — adds browser/RN conditional exports, explicit `./react-native` subpath, top-level RN field, CJS RN target, matching declaration targets, pack-file exclusion for the local shim, and sideEffects allowlist for RN entrypoints. - `packages/typescript-client/tsup.config.ts` — builds the RN entrypoint and externalizes `react-native`. - `packages/typescript-client/src/client.ts` — removes runtime RN probing and reads the optional default runtime visibility factory. - `packages/typescript-client/src/react-native.ts` — RN entrypoint that wires AppState lifecycle handling and re-exports the normal client API. - `packages/typescript-client/src/runtime-visibility.ts` — shared default runtime visibility factory registration. - `packages/typescript-client/src/react-native-shim.d.ts` — minimal local typing for the RN entrypoint without adding an RN dependency. - `packages/typescript-client/test/wake-detection.test.ts` — updates the RN test to exercise the RN entrypoint instead of global require detection. - `packages/typescript-client/SPEC.md` — updates the pause-lock notes to describe RN handling through package exports. - `packages/typescript-client/README.md`, `website/docs/sync/api/clients/typescript.md`, `website/docs/sync/integrations/expo.md` — document automatic Metro/Expo handling and explicit `runtimeVisibility` fallback.
1 parent 08db805 commit 2ab02bb

12 files changed

Lines changed: 201 additions & 96 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@electric-sql/client": patch
3+
---
4+
5+
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.

packages/typescript-client/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ shape.subscribe(({ rows }) => {
101101
})
102102
```
103103

104+
### React Native / Expo app lifecycle
105+
106+
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.
107+
104108
### Error Handling
105109

106110
The ShapeStream provides robust error handling with automatic retry support:

packages/typescript-client/SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ Six sites in `client.ts` recurse or loop to issue a new fetch:
445445
| `#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`. |
446446
| `#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. |
447447
| 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. |
448-
| 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. |
448+
| 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. |
449449
| Up-to-date exit | `#requestShape` entry | Returns if `!subscribe` and `isUpToDate`. Breaks loop for one-shot syncs. |
450450

451451
### Coverage gaps

packages/typescript-client/package.json

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,28 @@
4040
"exports": {
4141
"./package.json": "./package.json",
4242
".": {
43+
"browser": {
44+
"import": {
45+
"types": "./dist/index.d.ts",
46+
"default": "./dist/index.mjs"
47+
},
48+
"require": {
49+
"types": "./dist/cjs/index.d.cts",
50+
"default": "./dist/cjs/index.cjs"
51+
},
52+
"default": "./dist/index.mjs"
53+
},
54+
"react-native": {
55+
"import": {
56+
"types": "./dist/index.react-native.d.ts",
57+
"default": "./dist/index.react-native.mjs"
58+
},
59+
"require": {
60+
"types": "./dist/cjs/index.react-native.d.cts",
61+
"default": "./dist/cjs/index.react-native.cjs"
62+
},
63+
"default": "./dist/index.react-native.mjs"
64+
},
4365
"import": {
4466
"types": "./dist/index.d.ts",
4567
"default": "./dist/index.mjs"
@@ -48,6 +70,17 @@
4870
"types": "./dist/cjs/index.d.cts",
4971
"default": "./dist/cjs/index.cjs"
5072
}
73+
},
74+
"./react-native": {
75+
"import": {
76+
"types": "./dist/index.react-native.d.ts",
77+
"default": "./dist/index.react-native.mjs"
78+
},
79+
"require": {
80+
"types": "./dist/cjs/index.react-native.d.cts",
81+
"default": "./dist/cjs/index.react-native.cjs"
82+
},
83+
"default": "./dist/index.react-native.mjs"
5184
}
5285
},
5386
"bin": {
@@ -58,12 +91,14 @@
5891
"src",
5992
"skills",
6093
"bin",
61-
"!skills/_artifacts"
94+
"!skills/_artifacts",
95+
"!src/react-native-shim.d.ts"
6296
],
6397
"homepage": "https://electric-sql.com",
6498
"license": "Apache-2.0",
6599
"main": "./dist/cjs/index.cjs",
66100
"module": "./dist/index.legacy-esm.js",
101+
"react-native": "./dist/cjs/index.react-native.cjs",
67102
"types": "./dist/index.d.ts",
68103
"optionalDependencies": {
69104
"@rollup/rollup-darwin-arm64": "^4.18.1"
@@ -83,7 +118,11 @@
83118
"coverage": "pnpm exec vitest --coverage",
84119
"typecheck": "tsc -p tsconfig.json"
85120
},
86-
"sideEffects": false,
121+
"sideEffects": [
122+
"./src/react-native.ts",
123+
"./dist/index.react-native.mjs",
124+
"./dist/cjs/index.react-native.cjs"
125+
],
87126
"typesVersions": {
88127
"*": {
89128
"*": [

packages/typescript-client/src/client.ts

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
ShapeStreamState,
8989
} from './shape-stream-state'
9090
import { PauseLock } from './pause-lock'
91+
import { getDefaultRuntimeVisibilityAdapterFactory } from './runtime-visibility'
9192

9293
const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
9394
LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -290,46 +291,10 @@ export function createReactNativeRuntimeVisibilityAdapter(
290291
}
291292
}
292293

293-
function isReactNativeEnvironment(): boolean {
294-
return (
295-
typeof navigator === `object` &&
296-
`product` in navigator &&
297-
navigator.product === `ReactNative`
298-
)
299-
}
300-
301-
function getRuntimeRequire(): ((moduleName: string) => unknown) | undefined {
302-
const globalRequire = (
303-
globalThis as typeof globalThis & { require?: unknown }
304-
).require
305-
if (typeof globalRequire === `function`) {
306-
return globalRequire as (moduleName: string) => unknown
307-
}
308-
309-
try {
310-
return Function(
311-
`return typeof require === "function" ? require : undefined`
312-
)() as ((moduleName: string) => unknown) | undefined
313-
} catch {
314-
return undefined
315-
}
316-
}
317-
318-
function detectReactNativeRuntimeVisibilityAdapter():
294+
function getDefaultRuntimeVisibilityAdapter():
319295
| RuntimeVisibilityAdapter
320296
| undefined {
321-
if (!isReactNativeEnvironment()) return undefined
322-
323-
try {
324-
const runtimeRequire = getRuntimeRequire()
325-
const reactNative = runtimeRequire?.(`react-native`) as
326-
| { AppState?: ReactNativeAppStateLike }
327-
| undefined
328-
if (!reactNative?.AppState) return undefined
329-
return createReactNativeRuntimeVisibilityAdapter(reactNative.AppState)
330-
} catch {
331-
return undefined
332-
}
297+
return getDefaultRuntimeVisibilityAdapterFactory()?.()
333298
}
334299

335300
export interface ShapeStreamOptions<T = never> {
@@ -2020,8 +1985,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
20201985

20211986
#subscribeToVisibilityChanges() {
20221987
const runtimeVisibility =
2023-
this.options.runtimeVisibility ??
2024-
detectReactNativeRuntimeVisibilityAdapter()
1988+
this.options.runtimeVisibility ?? getDefaultRuntimeVisibilityAdapter()
20251989

20261990
if (runtimeVisibility) {
20271991
this.#setVisibilityPaused(
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Minimal declaration used only to type-check the conditional React Native
2+
// entrypoint without adding react-native as a dependency of the client package.
3+
declare module 'react-native' {
4+
export const AppState: {
5+
currentState: 'active' | 'background' | 'inactive' | null
6+
addEventListener: (
7+
type: 'change',
8+
listener: (state: 'active' | 'background' | 'inactive' | null) => void
9+
) => { remove: () => void }
10+
}
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { AppState } from 'react-native'
2+
3+
import { createReactNativeRuntimeVisibilityAdapter } from './client'
4+
import { setDefaultRuntimeVisibilityAdapterFactory } from './runtime-visibility'
5+
6+
setDefaultRuntimeVisibilityAdapterFactory(() =>
7+
createReactNativeRuntimeVisibilityAdapter(AppState)
8+
)
9+
10+
export * from './index'
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { RuntimeVisibilityAdapter } from './client'
2+
3+
type RuntimeVisibilityAdapterFactory = () =>
4+
| RuntimeVisibilityAdapter
5+
| undefined
6+
7+
let defaultRuntimeVisibilityAdapterFactory:
8+
| RuntimeVisibilityAdapterFactory
9+
| undefined
10+
11+
export function setDefaultRuntimeVisibilityAdapterFactory(
12+
factory: RuntimeVisibilityAdapterFactory | undefined
13+
): void {
14+
defaultRuntimeVisibilityAdapterFactory = factory
15+
}
16+
17+
export function getDefaultRuntimeVisibilityAdapterFactory():
18+
| RuntimeVisibilityAdapterFactory
19+
| undefined {
20+
return defaultRuntimeVisibilityAdapterFactory
21+
}

packages/typescript-client/test/wake-detection.test.ts

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -255,36 +255,23 @@ describe(`Wake detection`, () => {
255255
aborter.abort()
256256
})
257257

258-
it(`should auto-detect React Native AppState visibility`, async () => {
258+
it(`should use the React Native export to install AppState visibility`, async () => {
259259
vi.useFakeTimers()
260260

261261
type AppStateStatus = `active` | `background` | `inactive` | null
262262
let appStateListener: ((state: AppStateStatus) => void) | undefined
263-
const originalNavigator = globalThis.navigator
264-
const originalRequire = (globalThis as Record<string, unknown>).require
265-
Object.defineProperty(globalThis, `navigator`, {
266-
configurable: true,
267-
value: { product: `ReactNative` },
268-
})
269-
;(globalThis as Record<string, unknown>).require = vi.fn(
270-
(moduleName: string) => {
271-
if (moduleName !== `react-native`) throw new Error(`unexpected module`)
272-
return {
273-
AppState: {
274-
currentState: `active` as AppStateStatus,
275-
addEventListener: vi.fn(
276-
(
277-
_type: `change`,
278-
nextListener: (state: AppStateStatus) => void
279-
) => {
280-
appStateListener = nextListener
281-
return { remove: vi.fn() }
282-
}
283-
),
284-
},
263+
const appState = {
264+
currentState: `active` as AppStateStatus,
265+
addEventListener: vi.fn(
266+
(_type: `change`, nextListener: (state: AppStateStatus) => void) => {
267+
appStateListener = nextListener
268+
return { remove: vi.fn() }
285269
}
286-
}
287-
)
270+
),
271+
}
272+
273+
vi.resetModules()
274+
vi.doMock(`react-native`, () => ({ AppState: appState }))
288275

289276
const fetchUrls: string[] = []
290277
const fetchSignals: AbortSignal[] = []
@@ -322,7 +309,10 @@ describe(`Wake detection`, () => {
322309
}
323310

324311
try {
325-
const stream = new ShapeStream({
312+
const { ShapeStream: ReactNativeShapeStream } = await import(
313+
`../src/react-native`
314+
)
315+
const stream = new ReactNativeShapeStream({
326316
url: shapeUrl,
327317
params: { table: `foo` },
328318
signal: aborter.signal,
@@ -350,11 +340,8 @@ describe(`Wake detection`, () => {
350340
unsub()
351341
aborter.abort()
352342
} finally {
353-
Object.defineProperty(globalThis, `navigator`, {
354-
configurable: true,
355-
value: originalNavigator,
356-
})
357-
;(globalThis as Record<string, unknown>).require = originalRequire
343+
vi.doUnmock(`react-native`)
344+
vi.resetModules()
358345
}
359346
})
360347

packages/typescript-client/tsup.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ export default defineConfig((options) => {
55
const commonOptions: Partial<Options> = {
66
entry: {
77
index: `src/index.ts`,
8+
'index.react-native': `src/react-native.ts`,
89
},
910
tsconfig: `./tsconfig.build.json`,
1011
sourcemap: true,
1112
noExternal: [`@microsoft/fetch-event-source`],
13+
external: [`react-native`],
1214
...options,
1315
}
1416

0 commit comments

Comments
 (0)