Skip to content

Commit 6ee9c51

Browse files
authored
feat: Prepare FDv2 EAP for browser and React Native SDKs (#1419)
## Summary Prepares the FDv2 data system for Early Access on the browser and React Native client SDKs, bringing them in line with the server EAP. - **Make FDv2 configurable.** Removes the `@internal` annotation from the user-facing FDv2 config surface -- the `dataSystem` option (sdk-client, browser, React Native), `setConnectionMode`, and the FDv2 `getConnectionMode` overload -- so applications can opt in. FDv2 implementation internals (initializers, synchronizers, bases, etc.) stay `@internal`, matching the server SDK. - **EAP wording.** Replaces the "experimental / UNSUPPORTED" notices on those members with the standard EAP wording already used by the shared FDv2 interfaces. - **Examples.** Removes the now-unnecessary `@ts-ignore` comments from the browser and React Native FDv2 example apps (the fields they suppressed are now public). - **CI contract tests.** Runs the FDv2 contract tests alongside the existing FDv1 runs for both SDKs, using a floating `v3` test-harness version: - Browser: a second `contract-tests` run (`version: v3`) reusing the existing `suppressions_datamode_changes.txt` baseline; the FDv1 run no longer stops the service so both runs share one instance. - React Native: downloads both the v2 and latest v3 harness binaries and runs both passes; adds `dataSystem` translation to the React Native contract-test entity (mirroring the browser entity, which already had it) and a placeholder `suppressions-fdv2.txt`. Follows the server-node FDv2 CI pattern and the android-client-sdk client-side FDv2 work (PR #369). The `v3` harness resolves to the latest `v3.x` release at run time (currently `v3.1.0-alpha.6`, the first with client-side FDv2 support). The React Native FDv2 suppression list starts empty and may need tuning once CI reports which cases to skip. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Exposes unstable FDv2 configuration on public TypeScript surfaces and adds CI that depends on floating v3 harness releases; runtime flag-delivery behavior is unchanged unless customers opt into `dataSystem`. > > **Overview** > Prepares **FDv2 (data-saving mode)** for Early Access on the browser and React Native client SDKs by making the opt-in API public and aligning docs with the shared EAP wording. > > **Public API & docs:** `@internal` / “experimental unsupported” notices are removed from user-facing `dataSystem`, `setConnectionMode`, and FDv2 `getConnectionMode` overloads in shared `LDOptions`, browser, and React Native types. Those members now document **early access** (no semver guarantees) and link to the data-saving-mode docs. FDv2 example apps drop `@ts-ignore` comments that were only hiding those options. > > **CI contract tests:** Browser workflow runs **FDv1** then **FDv2** harness passes on one test service (`stop_service: false` on v1; v3 harness with `suppressions_datamode_changes.txt`). React Native CI downloads **v2 and latest v3** harness binaries, runs both from `run-ci-contract-tests.sh`, and adds **`dataSystem` harness → SDK config translation** in the RN contract-test entity (same pattern as browser). An empty `suppressions-fdv2.txt` placeholder is included for future skips. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fcd9a0c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 40c0e6c commit 6ee9c51

14 files changed

Lines changed: 205 additions & 70 deletions

File tree

.github/workflows/browser.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,21 @@ jobs:
102102
echo $! > /tmp/playwright.pid
103103
sleep 5 # Give the browser time to initialize and connect via WebSocket
104104
105-
- name: Run contract tests
105+
- name: Run contract tests (FDv1)
106106
uses: launchdarkly/gh-actions/actions/contract-tests@d271978e893b5b9facb9f000414e9fcd62e1f78b
107107
with:
108108
test_service_port: 8000
109109
token: ${{ secrets.GITHUB_TOKEN }}
110-
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/browser/contract-tests/suppressions.txt --stop-service-at-end'
110+
stop_service: 'false'
111+
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/browser/contract-tests/suppressions.txt'
112+
113+
- name: Run contract tests (FDv2)
114+
uses: launchdarkly/gh-actions/actions/contract-tests@d271978e893b5b9facb9f000414e9fcd62e1f78b
115+
with:
116+
test_service_port: 8000
117+
token: ${{ secrets.GITHUB_TOKEN }}
118+
version: v3
119+
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt'
111120

112121
- name: Print logs on failure
113122
if: failure()

.github/workflows/react-native-contract-tests.yml

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,13 @@ jobs:
8787
sleep 1
8888
done
8989
90-
- name: Download contract test harness
91-
run: |
92-
# https://github.com/launchdarkly/sdk-test-harness/releases/tag/v2.34.0
93-
curl -sL -o sdk-test-harness.tar.gz "https://github.com/launchdarkly/sdk-test-harness/releases/download/v2.34.0/sdk-test-harness_Linux_x86_64.tar.gz"
94-
tar -xzf sdk-test-harness.tar.gz sdk-test-harness
95-
chmod +x sdk-test-harness
96-
9790
- name: Run contract tests on Android emulator
9891
# https://github.com/ReactiveCircus/android-emulator-runner/releases/tag/v2.34.0
9992
uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # v2
93+
env:
94+
# The contract-test runner script downloads the harness via the
95+
# official downloader; the token avoids GitHub API rate limits.
96+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10097
with:
10198
api-level: 31
10299
arch: x86_64

packages/sdk/browser/example-fdv2/src/app.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,6 @@ const main = async () => {
232232
buildUI();
233233

234234
const client = createClient(clientSideID, contexts[currentContextIndex], {
235-
// @ts-ignore dataSystem is @internal — experimental FDv2 opt-in
236235
dataSystem: {},
237236
logger: basicLogger({ level: 'debug' }),
238237
});
@@ -284,14 +283,12 @@ const main = async () => {
284283
];
285284
connectionModes.forEach((mode) => {
286285
document.getElementById(`btn-mode-${mode}`)!.addEventListener('click', () => {
287-
// @ts-ignore setConnectionMode is @internal — experimental FDv2 opt-in
288286
client.setConnectionMode(mode);
289287
updateModeStatus(mode);
290288
log(`setConnectionMode('${mode}')`);
291289
});
292290
});
293291
document.getElementById('btn-mode-clear')!.addEventListener('click', () => {
294-
// @ts-ignore setConnectionMode is @internal — experimental FDv2 opt-in
295292
client.setConnectionMode(undefined);
296293
updateModeStatus(undefined);
297294
log('setConnectionMode(undefined)');

packages/sdk/browser/src/LDClient.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,6 @@ export type LDClient = Omit<CommonClient, 'getConnectionMode' | 'getOffline' | '
3535
* from the interface.
3636
*/
3737
/**
38-
* @internal
39-
*
40-
* This feature is experimental and should NOT be considered ready for
41-
* production use. It may change or be removed without notice and is not
42-
* subject to backwards compatibility guarantees.
43-
*
4438
* Sets the connection mode for the SDK's data system.
4539
*
4640
* When set, this mode is used exclusively, overriding all automatic mode
@@ -54,6 +48,11 @@ export type LDClient = Omit<CommonClient, 'getConnectionMode' | 'getOffline' | '
5448
* This method requires the FDv2 data system (`dataSystem` option). If
5549
* FDv2 is not enabled, the call logs a warning and has no effect.
5650
*
51+
* This method is not stable, and not subject to any backwards compatibility
52+
* guarantees or semantic versioning. It is in early access. If you want access
53+
* to this feature please join the EAP.
54+
* https://launchdarkly.com/docs/sdk/features/data-saving-mode
55+
*
5756
* @param mode The connection mode to use, or `undefined` to clear the override.
5857
*/
5958
setConnectionMode(mode?: FDv2ConnectionMode): void;

packages/sdk/browser/src/options.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,18 @@ export interface BrowserDataSystemOptions extends Omit<
3737
*/
3838
export interface BrowserOptions extends Omit<LDOptionsBase, 'initialConnectionMode'> {
3939
/**
40-
* @internal
41-
*
42-
* This feature is experimental and should NOT be considered ready for
43-
* production use. It may change or be removed without notice and is not
44-
* subject to backwards compatibility guarantees.
45-
*
4640
* Configuration for the FDv2 data system. When present, the SDK uses
4741
* the FDv2 protocol for flag delivery instead of the default FDv1
4842
* protocol.
4943
*
5044
* The browser SDK restricts `automaticModeSwitching` to `false` or
51-
* {@link ManualModeSwitching} only automatic switching has no effect
45+
* {@link ManualModeSwitching} only -- automatic switching has no effect
5246
* in browser environments.
47+
*
48+
* This option is not stable, and not subject to any backwards compatibility
49+
* guarantees or semantic versioning. It is in early access. If you want access
50+
* to this feature please join the EAP.
51+
* https://launchdarkly.com/docs/sdk/features/data-saving-mode
5352
*/
5453
dataSystem?: BrowserDataSystemOptions;
5554
/**

packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts

Lines changed: 127 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import {
33
CommandType,
44
CreateInstanceParams,
55
makeLogger,
6+
SDKConfigDataInitializer,
7+
SDKConfigDataSynchronizer,
8+
SDKConfigModeDefinition,
69
SDKConfigParams,
710
ClientSideTestHook as TestHook,
811
ValueType,
@@ -16,6 +19,59 @@ import {
1619
export const badCommandError = new Error('unsupported command');
1720
export const malformedCommand = new Error('command was malformed');
1821

22+
function translateInitializer(init: SDKConfigDataInitializer): any | undefined {
23+
if (init.polling) {
24+
return {
25+
type: 'polling',
26+
...(init.polling.pollIntervalMs !== undefined && {
27+
pollInterval: init.polling.pollIntervalMs / 1000,
28+
}),
29+
...(init.polling.baseUri && {
30+
endpoints: { pollingBaseUri: init.polling.baseUri },
31+
}),
32+
};
33+
}
34+
return undefined;
35+
}
36+
37+
function translateSynchronizer(sync: SDKConfigDataSynchronizer): any | undefined {
38+
if (sync.streaming) {
39+
return {
40+
type: 'streaming',
41+
...(sync.streaming.initialRetryDelayMs !== undefined && {
42+
initialReconnectDelay: sync.streaming.initialRetryDelayMs / 1000,
43+
}),
44+
...(sync.streaming.baseUri && {
45+
endpoints: { streamingBaseUri: sync.streaming.baseUri },
46+
}),
47+
};
48+
}
49+
if (sync.polling) {
50+
return {
51+
type: 'polling',
52+
...(sync.polling.pollIntervalMs !== undefined && {
53+
pollInterval: sync.polling.pollIntervalMs / 1000,
54+
}),
55+
...(sync.polling.baseUri && {
56+
endpoints: { pollingBaseUri: sync.polling.baseUri },
57+
}),
58+
};
59+
}
60+
return undefined;
61+
}
62+
63+
function translateModeDefinition(modeDef: SDKConfigModeDefinition): any {
64+
const initializers = (modeDef.initializers ?? [])
65+
.map(translateInitializer)
66+
.filter((x) => x !== undefined);
67+
68+
const synchronizers = (modeDef.synchronizers ?? [])
69+
.map(translateSynchronizer)
70+
.filter((x) => x !== undefined);
71+
72+
return { initializers, synchronizers };
73+
}
74+
1975
function makeSdkConfig(options: SDKConfigParams, tag: string) {
2076
if (!options.clientSide) {
2177
throw new Error('configuration did not include clientSide options');
@@ -39,21 +95,80 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) {
3995
cf.eventsUri = options.serviceEndpoints.events;
4096
}
4197

42-
if (options.polling) {
43-
if (options.polling.baseUri) {
44-
cf.baseUri = options.polling.baseUri;
45-
}
46-
cf.initialConnectionMode = 'polling';
98+
if (options.dataSystem?.payloadFilter) {
99+
cf.payloadFilterKey = options.dataSystem.payloadFilter;
47100
}
48101

49-
// Can contain streaming and polling, if streaming is set override the initial connection
50-
// mode.
51-
if (options.streaming) {
52-
if (options.streaming.baseUri) {
53-
cf.streamUri = options.streaming.baseUri;
102+
if (options.dataSystem) {
103+
const dataSystem: any = {};
104+
105+
// Helper to apply endpoint overrides from a mode definition to global URIs.
106+
const applyEndpointOverrides = (modeDef: SDKConfigModeDefinition) => {
107+
(modeDef.synchronizers ?? []).forEach((sync) => {
108+
if (sync.streaming?.baseUri) {
109+
cf.streamUri = sync.streaming.baseUri;
110+
cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs);
111+
}
112+
if (sync.polling?.baseUri) {
113+
cf.baseUri = sync.polling.baseUri;
114+
}
115+
});
116+
(modeDef.initializers ?? []).forEach((init) => {
117+
if (init.polling?.baseUri) {
118+
cf.baseUri = init.polling.baseUri;
119+
}
120+
});
121+
};
122+
123+
if (options.dataSystem.connectionModeConfig) {
124+
const connMode = options.dataSystem.connectionModeConfig;
125+
dataSystem.automaticModeSwitching = connMode.initialConnectionMode
126+
? { type: 'manual', initialConnectionMode: connMode.initialConnectionMode }
127+
: false;
128+
129+
if (connMode.customConnectionModes) {
130+
const connectionModes: Record<string, any> = {};
131+
Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => {
132+
connectionModes[modeName] = translateModeDefinition(modeDef);
133+
applyEndpointOverrides(modeDef);
134+
});
135+
dataSystem.connectionModes = connectionModes;
136+
}
137+
} else if (options.dataSystem.initializers || options.dataSystem.synchronizers) {
138+
// Top-level initializers/synchronizers (no connection modes). Wrap them
139+
// into a single 'streaming' connection mode.
140+
const modeDef: SDKConfigModeDefinition = {
141+
initializers: options.dataSystem.initializers,
142+
synchronizers: options.dataSystem.synchronizers,
143+
};
144+
dataSystem.automaticModeSwitching = {
145+
type: 'manual',
146+
initialConnectionMode: 'streaming',
147+
};
148+
dataSystem.connectionModes = {
149+
streaming: translateModeDefinition(modeDef),
150+
};
151+
applyEndpointOverrides(modeDef);
152+
}
153+
154+
cf.dataSystem = dataSystem;
155+
} else {
156+
if (options.polling) {
157+
if (options.polling.baseUri) {
158+
cf.baseUri = options.polling.baseUri;
159+
}
160+
cf.initialConnectionMode = 'polling';
161+
}
162+
163+
// Can contain streaming and polling, if streaming is set override the initial connection
164+
// mode.
165+
if (options.streaming) {
166+
if (options.streaming.baseUri) {
167+
cf.streamUri = options.streaming.baseUri;
168+
}
169+
cf.initialConnectionMode = 'streaming';
170+
cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs);
54171
}
55-
cf.initialConnectionMode = 'streaming';
56-
cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs);
57172
}
58173

59174
if (options.events) {

packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,32 @@ while [ "$i" -lt 30 ]; do
5656
sleep 2
5757
done
5858

59-
# Run the contract test harness
60-
SUPPRESSIONS_FILE="$SCRIPT_DIR/suppressions.txt"
61-
EXTRA_ARGS=""
62-
if [ -s "$SUPPRESSIONS_FILE" ]; then
63-
EXTRA_ARGS="--skip-from=$SUPPRESSIONS_FILE"
59+
# Fetch the official contract-test-harness runner once. This is the same
60+
# downloader the launchdarkly/gh-actions contract-tests action uses; VERSION
61+
# selects the harness release (v2 -> latest v2.x for FDv1, v3 -> latest v3.x
62+
# for FDv2), and GITHUB_TOKEN (from the workflow env) avoids API rate limits.
63+
# This mirrors the android-client-sdk contract-test setup.
64+
HARNESS_RUNNER=/tmp/run-test-harness.sh
65+
curl -sf \
66+
https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \
67+
-o "$HARNESS_RUNNER"
68+
69+
# FDv1 (v2 harness).
70+
FDV1_SUPPRESSIONS="$SCRIPT_DIR/suppressions.txt"
71+
FDV1_SKIP=""
72+
if [ -s "$FDV1_SUPPRESSIONS" ]; then
73+
FDV1_SKIP="--skip-from=$FDV1_SUPPRESSIONS"
74+
fi
75+
76+
echo "=== Running FDv1 contract tests ==="
77+
VERSION=v2 PARAMS="-url http://localhost:8000 -debug $FDV1_SKIP" sh "$HARNESS_RUNNER"
78+
79+
# FDv2 (v3 harness). Only the final run stops the test service.
80+
FDV2_SUPPRESSIONS="$SCRIPT_DIR/suppressions-fdv2.txt"
81+
FDV2_SKIP=""
82+
if [ -s "$FDV2_SUPPRESSIONS" ]; then
83+
FDV2_SKIP="--skip-from=$FDV2_SUPPRESSIONS"
6484
fi
6585

66-
"$REPO_ROOT/sdk-test-harness" \
67-
-url http://localhost:8000 \
68-
-debug \
69-
$EXTRA_ARGS
86+
echo "=== Running FDv2 contract tests ==="
87+
VERSION=v3 PARAMS="-url http://localhost:8000 -debug $FDV2_SKIP -stop-service-at-end" sh "$HARNESS_RUNNER"

packages/sdk/react-native/contract-tests/run-contract-tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ echo "Port forwarding configured."
3737
echo ""
3838
echo "=== Starting adapter ==="
3939
cd "$REPO_ROOT"
40-
yarn workspace react-native-contract-test-entity run start:adapter > /tmp/rn-adapter.log 2>&1 &
40+
yarn workspace @launchdarkly/react-native-contract-test-entity run start:adapter > /tmp/rn-adapter.log 2>&1 &
4141
ADAPTER_PID=$!
4242
echo "Adapter started (PID: $ADAPTER_PID)"
4343

packages/sdk/react-native/contract-tests/suppressions-fdv2.txt

Whitespace-only changes.

packages/sdk/react-native/example-fdv2/App.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ const featureClient = new ReactNativeLDClient(LAUNCHDARKLY_MOBILE_KEY, AutoEnvAt
1414
id: 'ld-rn-fdv2-test-app',
1515
version: '0.0.1',
1616
},
17-
// @ts-ignore dataSystem is @internal
1817
dataSystem: {},
1918
});
2019

0 commit comments

Comments
 (0)