Lifecycle-aware streaming: pause on background/offline, resume + resync on foreground#7
Conversation
…on foreground) WebSockets don't survive iOS background suspension or Android doze, and the OS may never surface a clean close. The default transport then fires pings into a dead socket and, on resume, walks a blind reconnect-backoff schedule while serving stale flags. Add a LifecycleAwareWebSocket that wraps the SDK's BrowserWebSocket and drives it from AppState (and optional NetInfo): the stream is dropped shortly after the app backgrounds or goes offline and re-established with an immediate resync on foreground/online. Transitions are debounced (default 20s) so the transient iOS 'inactive' state and momentary network blips don't churn the connection. Defaults to always-on, so behaviour is unchanged if no lifecycle signal fires; inert in polling mode. NetInfo is an optional peer dependency, loaded the same conditional way as async-storage. Closes featbit#4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers LifecycleController (debounce, foreground+online gating, dispose) and LifecycleAwareWebSocket (deferred connect while backgrounded, drop-on-background / fresh-socket-on-foreground, transient iOS 'inactive' debounce, listener survival across a pause/resume cycle via the shared emitter, and subscription teardown on close). Mocks react-native AppState and the SDK's BrowserWebSocket. 14 tests, run with `npm test`. Production build excludes src/__tests__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the app supplies no logger, options.logger is undefined, so the store was constructed without one and would NPE on its first debug/error log path (e.g. when persisting the user), hanging initialization. Pass the resolved fallback logger to the store. Found via the e2e suite when running with no app-supplied logger. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drives a real client built from the RN SDK's buildConfig (ReactNativePlatform + LifecycleAwareWebSocket + ReactNativeStore) against a real FeatBit stack (Postgres + api-server + evaluation-server) spun up with Testcontainers. Ported from the Fluent Health Android SDK's e2e harness. Three tests: polling (evaluate seeded flag, observe server-side change, identify); streaming (receive a server-side change as a real-time push); and streaming + lifecycle (background pauses the stream so an update is NOT received, foreground reconnects and resyncs). Gated by FEATBIT_E2E=1 (`npm run test:e2e`) so the default `npm test` never needs Docker. Node env mocks: controllable react-native (AppState/Platform) and in-memory async-storage. Postgres init SQL under e2e/initdb/ is vendored from featbit/featbit (Apache-2.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs build + unit tests on every push/PR, and the FEATBIT_E2E e2e suite on a GitHub-hosted runner (which provides Docker for Testcontainers). Pre-pulls the FeatBit stack images before running. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hi @alexheidl, I'm reviewing the PR and I would like to push some commit into your branch, is it possible to give me the permission? |
| } | ||
|
|
||
| private resume(): void { | ||
| if (!this.started) { |
There was a problem hiding this comment.
shouldn't this be?
if (this.started)
There was a problem hiding this comment.
Thanks for the catch — this one is intentional. resume() is the lifecycle hook that fires on foreground/online, and it should only rebuild the socket if the synchronizer was actually started. So the early-return guard is:
private resume(): void {
if (!this.started) {
return; // never started (or already closed) → nothing to resume
}
// rebuild socket + connect
}Inverting it to if (this.started) while keeping the return would bail out exactly when we do want to reconnect, which breaks resume. pause() just above uses the same !this.started guard for symmetry. Happy to wrap the body in if (this.started) { ... } instead if you prefer that style, but the behavior would be identical.
There was a problem hiding this comment.
Thanks for explaining
|
@cosmos-explorer done — I've added you as a collaborator, so you should have push access to the branch now. Looking forward to your commits! |
Thank you, I just push the code. The main change is: removed the initd folder and fetch the postgres migration scripts at runtime. I also resolved the conflicts |
The previous lockfile was generated with optional dependencies omitted, so it was missing fsevents, cpu-features, nan and buildcheck. `npm ci` is strict about the lockfile matching package.json's full dependency closure and bailed before installing, failing both CI jobs (build/unit and e2e) in ~6s. Regenerated with a plain `npm install`; `npm ci` now resolves cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hello @alexheidl It seems good to me, if you have no further questions, I'll merge it |
Closes #4.
Summary
WebSockets don't survive iOS background suspension or Android doze, and the OS may never deliver a clean
close. The current transport (BrowserWebSocket, via@featbit/js-client-sdk) keeps firing pings into a dead socket and, on resume, walks a blind reconnect-backoff schedule while serving stale flags.This adds lifecycle-aware streaming: the stream is dropped shortly after the app backgrounds or goes offline, and re-established with an immediate resync on foreground / when connectivity returns. It's driven by React Native's
AppState(and optionalNetInfo), enabled automatically, and defaults to always-on, so behaviour is unchanged if no lifecycle signal ever fires. Inert in polling mode. The design mirrors a proven implementation in our native Android port.What's included
LifecycleController— foreground-AND-online gating with a debounce (default 20s) so the transient iOSinactivestate (app switcher, etc.) and momentary network blips don't churn the connection.LifecycleAwareWebSocket— wrapsBrowserWebSocket. On pause it drops the inner socket; on resume it builds a fresh one (becauseBrowserWebSocket.close()permanently sets its internalclosedflag), reusing a single captured emitter so the data synchronizer's listeners survive the swap. No changes tojs-client-sdkrequired.ConditionalNetInfo— optional@react-native-community/netinfo, loaded the same conditional way as async-storage;AppStatealone works without it.buildConfigconstructedReactNativeStorewith the pre-resolved options, so when an app supplies no logger the store's logger wasundefinedand NPE'd on its first log path, hanging initialization. It now passes the resolved fallback logger. (Surfaced by the e2e suite below.)Tests
inactivedebounce, listener survival across pause/resume, teardown).npm testruns the unit tests (no Docker).npm run test:e2eruns the e2e (gated byFEATBIT_E2E=1).Notes
.github/workflows/ci.ymlis optional — happy to drop that commit if you have your own CI conventions.