From a8bbf89109437020ab518ab46cc1eb35c4e21f92 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 13 Aug 2026 12:28:05 -0400 Subject: [PATCH 1/4] feat: wire fdv1-fallback capability into node-client, browser, and react-native contract-test entities --- .../contract-tests/entity/src/ClientEntity.ts | 21 +++++++++++++++++-- .../entity/src/TestHarnessWebSocket.ts | 2 ++ .../node-client/contract-tests/src/index.ts | 4 +--- .../contract-tests/src/sdkClientEntity.ts | 21 +++++++++++++++++-- .../contract-tests/entity/src/ClientEntity.ts | 21 +++++++++++++++++-- .../entity/src/TestHarnessWebSocket.ts | 2 ++ .../src/types/ConfigParams.ts | 1 + 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index dca627157e..0fbb9245c6 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -16,6 +16,7 @@ import { SDKConfigDataSynchronizer, SDKConfigModeDefinition, SDKConfigParams, + SDKConfigPollingParams, ClientSideTestHook as TestHook, ValueType, } from '@launchdarkly/js-contract-test-utils/client'; @@ -64,7 +65,10 @@ function translateSynchronizer(sync: SDKConfigDataSynchronizer): SynchronizerEnt return undefined; } -function translateModeDefinition(modeDef: SDKConfigModeDefinition): ModeDefinition { +function translateModeDefinition( + modeDef: SDKConfigModeDefinition, + fdv1Fallback?: SDKConfigPollingParams | null, +): ModeDefinition { const initializers: InitializerEntry[] = (modeDef.initializers ?? []) .map(translateInitializer) .filter((x): x is InitializerEntry => x !== undefined); @@ -73,6 +77,19 @@ function translateModeDefinition(modeDef: SDKConfigModeDefinition): ModeDefiniti .map(translateSynchronizer) .filter((x): x is SynchronizerEntry => x !== undefined); + if (fdv1Fallback?.baseUri) { + return { + initializers, + synchronizers, + fdv1Fallback: { + ...(fdv1Fallback.pollIntervalMs != null && { + pollInterval: fdv1Fallback.pollIntervalMs / 1000, + }), + endpoints: { pollingBaseUri: fdv1Fallback.baseUri }, + }, + }; + } + return { initializers, synchronizers }; } @@ -147,7 +164,7 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { initialConnectionMode: 'streaming', }; dataSystem.connectionModes = { - streaming: translateModeDefinition(modeDef), + streaming: translateModeDefinition(modeDef, options.dataSystem.fdv1Fallback), }; applyEndpointOverrides(modeDef); } diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index 2dfaee44ba..8e51ba4a57 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -45,6 +45,8 @@ export default class TestHarnessWebSocket { 'client-prereq-cycle-detection', 'client-per-context-summaries', 'track-hooks', + 'fdv1-fallback', + 'client-event-source-http-errors', ]; break; diff --git a/packages/sdk/node-client/contract-tests/src/index.ts b/packages/sdk/node-client/contract-tests/src/index.ts index da2ee2b77a..64e19c7fc9 100644 --- a/packages/sdk/node-client/contract-tests/src/index.ts +++ b/packages/sdk/node-client/contract-tests/src/index.ts @@ -41,10 +41,8 @@ app.get('/', (req: Request, res: Response) => { 'tls:skip-verify-peer', 'tls:custom-ca', 'wrapper', + 'fdv1-fallback', 'client-event-source-http-errors', - // NOTE: this needs additional fixes to the shared - // SDK code to support - // 'fdv1-fallback', ], }); }); diff --git a/packages/sdk/node-client/contract-tests/src/sdkClientEntity.ts b/packages/sdk/node-client/contract-tests/src/sdkClientEntity.ts index c3266a7aee..003b3d07d3 100644 --- a/packages/sdk/node-client/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/node-client/contract-tests/src/sdkClientEntity.ts @@ -8,6 +8,7 @@ import { SDKConfigDataSynchronizer, SDKConfigModeDefinition, SDKConfigParams, + SDKConfigPollingParams, ValueType, } from '@launchdarkly/js-contract-test-utils'; import { ClientSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils/client'; @@ -68,7 +69,10 @@ function translateSynchronizer(sync: SDKConfigDataSynchronizer): SynchronizerEnt return undefined; } -function translateModeDefinition(modeDef: SDKConfigModeDefinition): ModeDefinition { +function translateModeDefinition( + modeDef: SDKConfigModeDefinition, + fdv1Fallback?: SDKConfigPollingParams | null, +): ModeDefinition { const initializers: InitializerEntry[] = (modeDef.initializers ?? []) .map(translateInitializer) .filter((x): x is InitializerEntry => x !== undefined); @@ -77,6 +81,19 @@ function translateModeDefinition(modeDef: SDKConfigModeDefinition): ModeDefiniti .map(translateSynchronizer) .filter((x): x is SynchronizerEntry => x !== undefined); + if (fdv1Fallback?.baseUri) { + return { + initializers, + synchronizers, + fdv1Fallback: { + ...(fdv1Fallback.pollIntervalMs != null && { + pollInterval: fdv1Fallback.pollIntervalMs / 1000, + }), + endpoints: { pollingBaseUri: fdv1Fallback.baseUri }, + }, + }; + } + return { initializers, synchronizers }; } @@ -151,7 +168,7 @@ function makeSdkConfig(options: SDKConfigParams, tag: string): LDOptions { initialConnectionMode: 'streaming', }; dataSystem.connectionModes = { - streaming: translateModeDefinition(modeDef), + streaming: translateModeDefinition(modeDef, options.dataSystem.fdv1Fallback), }; applyEndpointOverrides(modeDef); } diff --git a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts index 5ce17f0429..7c3179214c 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts @@ -7,6 +7,7 @@ import { SDKConfigDataSynchronizer, SDKConfigModeDefinition, SDKConfigParams, + SDKConfigPollingParams, ClientSideTestHook as TestHook, ValueType, } from '@launchdarkly/js-contract-test-utils/client'; @@ -60,7 +61,10 @@ function translateSynchronizer(sync: SDKConfigDataSynchronizer): any | undefined return undefined; } -function translateModeDefinition(modeDef: SDKConfigModeDefinition): any { +function translateModeDefinition( + modeDef: SDKConfigModeDefinition, + fdv1Fallback?: SDKConfigPollingParams | null, +): any { const initializers = (modeDef.initializers ?? []) .map(translateInitializer) .filter((x) => x !== undefined); @@ -69,6 +73,19 @@ function translateModeDefinition(modeDef: SDKConfigModeDefinition): any { .map(translateSynchronizer) .filter((x) => x !== undefined); + if (fdv1Fallback?.baseUri) { + return { + initializers, + synchronizers, + fdv1Fallback: { + ...(fdv1Fallback.pollIntervalMs != null && { + pollInterval: fdv1Fallback.pollIntervalMs / 1000, + }), + endpoints: { pollingBaseUri: fdv1Fallback.baseUri }, + }, + }; + } + return { initializers, synchronizers }; } @@ -146,7 +163,7 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { initialConnectionMode: 'streaming', }; dataSystem.connectionModes = { - streaming: translateModeDefinition(modeDef), + streaming: translateModeDefinition(modeDef, options.dataSystem.fdv1Fallback), }; applyEndpointOverrides(modeDef); } diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts index b6a9c687f7..979eb816ed 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -58,6 +58,8 @@ export default class TestHarnessWebSocket { 'client-prereq-cycle-detection', 'client-per-context-summaries', 'track-hooks', + 'fdv1-fallback', + 'client-event-source-http-errors', ]; break; diff --git a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts index 3d642f65b8..848fdde91f 100644 --- a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts @@ -27,6 +27,7 @@ export interface SDKConfigDataSystem { useDefaultDataSystem?: boolean; initializers?: SDKConfigDataInitializer[]; synchronizers?: SDKConfigDataSynchronizer[]; + fdv1Fallback?: SDKConfigPollingParams; payloadFilter?: string; connectionModeConfig?: SDKConfigConnectionModeConfig; } From 504515c0aa0a40f5986802b4a1ce1824974f9434 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 13 Aug 2026 12:52:55 -0400 Subject: [PATCH 2/4] fix: remove client-event-source-http-errors capability from browser contract-test entity --- .../browser/contract-tests/entity/src/TestHarnessWebSocket.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index 8e51ba4a57..de8f5f5c6d 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -46,7 +46,6 @@ export default class TestHarnessWebSocket { 'client-per-context-summaries', 'track-hooks', 'fdv1-fallback', - 'client-event-source-http-errors', ]; break; From fb4dcc57c06b8ee6a18b992965d64df2d3b44897 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 13 Aug 2026 13:11:03 -0400 Subject: [PATCH 3/4] test: more suppressions due to browser eventsource not handling headers --- .../browser/contract-tests/suppressions_datamode_changes.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt index 37a64e0802..3294f99fbb 100644 --- a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt +++ b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt @@ -1,3 +1,7 @@ +# Browser's native EventSource cannot read response headers/status on the streaming +# connection, so it can never observe the directive this test relies on. +tags/FDv1 fallback directive requests + streaming/requests/method and headers/REPORT/http streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT From 8a0e71d4d3a012cc550132ef980df1adc28dd2ed Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 13 Aug 2026 13:30:36 -0400 Subject: [PATCH 4/4] test: suppress RN eventsource gaps --- .../contract-tests/suppressions-fdv2.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/sdk/react-native/contract-tests/suppressions-fdv2.txt b/packages/sdk/react-native/contract-tests/suppressions-fdv2.txt index e69de29bb2..ceb1a3fc58 100644 --- a/packages/sdk/react-native/contract-tests/suppressions-fdv2.txt +++ b/packages/sdk/react-native/contract-tests/suppressions-fdv2.txt @@ -0,0 +1,12 @@ +# Tests in this file will be skipped by the LaunchDarkly SDK test harness running +# the FDv2-feature branch against the React Native client-side SDK. Add a path per line. +# Lines beginning with '#' are comments. + +# RN's forked EventSource (react-native-sse) fires `open` too late (from +# onreadystatechange at readyState DONE, not from onprogress at LOADING) and never +# populates headers on error events, so FDv1 fallback directives carried on stream +# response headers don't reach the SDK in time (or at all, on the error path). +streaming/fdv2/FDv1 fallback directive/directive on streaming error engages FDv1 fallback +streaming/fdv2/FDv1 fallback directive/directive on streaming success applies payload then engages FDv1 +streaming/fdv2/FDv1 fallback directive/directive without FDv1 fallback configured halts the data system +tags/FDv1 fallback directive requests