diff --git a/package.json b/package.json index e3946e05cb..188ab88eaa 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "packages/sdk/react/examples/vercel-edge", "packages/sdk/react-native", "packages/sdk/react-native/example", + "packages/sdk/react-native/example-fdv2", "packages/sdk/react-native/contract-tests/entity", "packages/sdk/vercel", "packages/sdk/svelte", diff --git a/packages/sdk/react-native/example-fdv2/.env.example b/packages/sdk/react-native/example-fdv2/.env.example new file mode 100644 index 0000000000..476c2b4e79 --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/.env.example @@ -0,0 +1 @@ +LAUNCHDARKLY_MOBILE_KEY= diff --git a/packages/sdk/react-native/example-fdv2/.gitignore b/packages/sdk/react-native/example-fdv2/.gitignore new file mode 100644 index 0000000000..017aa78aa2 --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/.gitignore @@ -0,0 +1,44 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files +.env + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +ios +android + +!yarn.lock + +# detox +artifacts diff --git a/packages/sdk/react-native/example-fdv2/App.tsx b/packages/sdk/react-native/example-fdv2/App.tsx new file mode 100644 index 0000000000..b7d9afefec --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/App.tsx @@ -0,0 +1,27 @@ +import { LAUNCHDARKLY_MOBILE_KEY } from '@env'; + +import { + AutoEnvAttributes, + LDProvider, + ReactNativeLDClient, +} from '@launchdarkly/react-native-client-sdk'; + +import Welcome from './src/welcome'; + +const featureClient = new ReactNativeLDClient(LAUNCHDARKLY_MOBILE_KEY, AutoEnvAttributes.Enabled, { + debug: true, + applicationInfo: { + id: 'ld-rn-fdv2-test-app', + version: '0.0.1', + }, + // @ts-ignore dataSystem is @internal + dataSystem: {}, +}); + +const App = () => ( + + + +); + +export default App; diff --git a/packages/sdk/react-native/example-fdv2/README.md b/packages/sdk/react-native/example-fdv2/README.md new file mode 100644 index 0000000000..6df96da2ea --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/README.md @@ -0,0 +1,57 @@ +# LaunchDarkly React Native SDK - FDv2 Example App + +This is a minimal example app that demonstrates the experimental FDv2 data system +for the React Native SDK. + +> **Note:** FDv2 support is `@internal` and experimental. It is not ready for +> production use and may change or be removed without notice. + +## Features Demonstrated + +- SDK initialization with the `dataSystem` option (FDv2 protocol) +- Connection mode switching for all FDv2 modes: + - **Streaming** - real-time flag updates with polling fallback + - **Polling** - periodic polling only + - **Offline** - cached flags only, no network + - **One-Shot** - initialize then stop (no persistent synchronizer) + - **Background** - low-frequency polling for background state + - **Automatic** - clear the override and use automatic mode selection +- Context identification +- Boolean flag evaluation + +## Quickstart + +1. At the js-core repo root, install dependencies and build: + +```shell +yarn && yarn build +``` + +2. Create an `.env` file in this directory (`example-fdv2/`) with your mobile key: + +```shell +LAUNCHDARKLY_MOBILE_KEY=mob-your-mobile-key-here +``` + +3. Update the flag key in `src/welcome.tsx` if needed (defaults to `sample-feature`). + +4. Run the app: + +```shell +# iOS +yarn ios + +# Android +yarn android +``` + +> **Note:** You may need to run `npx expo prebuild` before the first iOS or +> Android build. + +## Caveats + +- **Network-based automatic mode switching** is not yet implemented. The wiring + is in place, but `RNStateDetector` does not yet emit network state changes. + Lifecycle-based switching (foreground/background) works. +- The `dataSystem` option and `setConnectionMode()` are marked `@internal` and + require `@ts-ignore` to use from TypeScript. diff --git a/packages/sdk/react-native/example-fdv2/app.json b/packages/sdk/react-native/example-fdv2/app.json new file mode 100644 index 0000000000..293e8da78f --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/app.json @@ -0,0 +1,20 @@ +{ + "expo": { + "name": "react-native-example-fdv2", + "slug": "react-native-example-fdv2", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "light", + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.reactnativeexamplefdv2" + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#ffffff" + }, + "package": "com.anonymous.reactnativeexamplefdv2" + } + } +} diff --git a/packages/sdk/react-native/example-fdv2/babel.config.js b/packages/sdk/react-native/example-fdv2/babel.config.js new file mode 100644 index 0000000000..2b897a7d65 --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/babel.config.js @@ -0,0 +1,15 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + [ + 'module:react-native-dotenv', + { + safe: true, + allowUndefined: false, + }, + ], + ], + }; +}; diff --git a/packages/sdk/react-native/example-fdv2/index.js b/packages/sdk/react-native/example-fdv2/index.js new file mode 100644 index 0000000000..202e3f47d8 --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/index.js @@ -0,0 +1,10 @@ +// We have to use a custom entrypoint for monorepo workspaces to work. +// https://docs.expo.dev/guides/monorepos/#change-default-entrypoint +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/packages/sdk/react-native/example-fdv2/metro.config.js b/packages/sdk/react-native/example-fdv2/metro.config.js new file mode 100644 index 0000000000..9d52aa2665 --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/metro.config.js @@ -0,0 +1,26 @@ +// We need to use a custom metro config for monorepo workspaces to work. +// https://docs.expo.dev/guides/monorepos/#modify-the-metro-config +/** + * @type {import('expo/metro-config')} + */ +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +// Find the project and workspace directories +const projectRoot = __dirname; +// This can be replaced with `find-yarn-workspace-root` +const workspaceRoot = path.resolve(projectRoot, '../../../..'); + +const config = getDefaultConfig(projectRoot); + +// 1. Watch all files within the monorepo +config.watchFolders = [workspaceRoot]; +// 2. Let Metro know where to resolve packages and in what order +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; +// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` +config.resolver.disableHierarchicalLookup = true; + +module.exports = config; diff --git a/packages/sdk/react-native/example-fdv2/package.json b/packages/sdk/react-native/example-fdv2/package.json new file mode 100644 index 0000000000..f4f36c32da --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/package.json @@ -0,0 +1,31 @@ +{ + "name": "@launchdarkly/react-native-example-fdv2", + "private": true, + "version": "0.0.1", + "main": "index.js", + "scripts": { + "start": "expo start --reset-cache", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web --clear" + }, + "dependencies": { + "@launchdarkly/react-native-client-sdk": "10.16.0", + "@react-native-async-storage/async-storage": "^2.0.0", + "expo": "52.0.14", + "expo-status-bar": "~1.11.1", + "react": "18.3.1", + "react-native": "0.76.3", + "react-native-dotenv": "^3.4.9" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@types/react": "~18.2.55", + "@types/react-native-dotenv": "^0.2.1", + "typescript": "^5.2.2" + }, + "packageManager": "yarn@3.4.1", + "installConfig": { + "hoistingLimits": "workspaces" + } +} diff --git a/packages/sdk/react-native/example-fdv2/src/welcome.tsx b/packages/sdk/react-native/example-fdv2/src/welcome.tsx new file mode 100644 index 0000000000..e142655631 --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/src/welcome.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import { + type FDv2ConnectionMode, + useBoolVariation, + useLDClient, +} from '@launchdarkly/react-native-client-sdk'; + +const connectionModes: { label: string; mode?: FDv2ConnectionMode }[] = [ + { label: 'Streaming', mode: 'streaming' }, + { label: 'Polling', mode: 'polling' }, + { label: 'Offline', mode: 'offline' }, + { label: 'One-Shot', mode: 'one-shot' }, + { label: 'Background', mode: 'background' }, + { label: 'Automatic', mode: undefined }, +]; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + title: { + fontSize: 22, + fontWeight: 'bold', + marginBottom: 10, + }, + sectionTitle: { + fontSize: 16, + fontWeight: 'bold', + marginTop: 10, + marginBottom: 5, + }, + modeText: { + fontSize: 16, + marginBottom: 10, + fontStyle: 'italic', + }, + connectionModeContainer: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 8, + }, + logBox: { + flexGrow: 0, + backgroundColor: '#1a1a1a', + maxHeight: 150, + width: '100%', + borderRadius: 8, + padding: 10, + marginVertical: 10, + }, + logText: { + color: '#ffa500', + fontFamily: 'monospace', + }, + input: { + height: 40, + width: '80%', + margin: 8, + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + padding: 10, + }, + buttonContainer: { + elevation: 4, + backgroundColor: '#009688', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 14, + marginBottom: 8, + }, + activeButton: { + backgroundColor: '#00695c', + borderWidth: 2, + borderColor: '#ffffff', + }, + buttonText: { + fontSize: 14, + color: '#fff', + fontWeight: 'bold', + alignSelf: 'center', + textTransform: 'uppercase', + }, +}); + +export default function Welcome() { + const [flagKey, setFlagKey] = useState('sample-feature'); + const [userKey, setUserKey] = useState(''); + const [currentMode, setCurrentMode] = useState('streaming'); + const flagValue = useBoolVariation(flagKey, false); + const ldc = useLDClient(); + + const onIdentify = () => { + ldc + .identify({ kind: 'user', key: userKey }, { timeout: 5 }) + // eslint-disable-next-line no-console + .catch((e: any) => console.error(`error identifying ${userKey}: ${e}`)); + }; + + const onSetConnectionMode = (mode?: FDv2ConnectionMode) => { + // @ts-ignore setConnectionMode is @internal - experimental FDv2 opt-in + ldc.setConnectionMode(mode); + setCurrentMode(mode ?? 'automatic'); + }; + + const context = ldc.getContext() ?? 'No context identified.'; + + return ( + + LaunchDarkly FDv2 Demo + Mode: {currentMode} + + {flagKey}: {`${flagValue}`} + + + Context: {JSON.stringify(context, null, 2)} + + + + Identify + + + Connection Modes + + {connectionModes.map(({ label, mode }) => ( + onSetConnectionMode(mode)} + > + {label} + + ))} + + + ); +} diff --git a/packages/sdk/react-native/example-fdv2/tsconfig.json b/packages/sdk/react-native/example-fdv2/tsconfig.json new file mode 100644 index 0000000000..cfbd925faf --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "jsx": "react-jsx", + "strict": true, + "typeRoots": ["./types"] + } +} diff --git a/packages/sdk/react-native/example-fdv2/types/env.d.ts b/packages/sdk/react-native/example-fdv2/types/env.d.ts new file mode 100644 index 0000000000..0d39ec4bf4 --- /dev/null +++ b/packages/sdk/react-native/example-fdv2/types/env.d.ts @@ -0,0 +1,4 @@ +declare module '@env' { + // eslint-disable-next-line import/prefer-default-export + export const LAUNCHDARKLY_MOBILE_KEY: string; +} diff --git a/packages/sdk/react-native/tsconfig.json b/packages/sdk/react-native/tsconfig.json index afbca6d198..c63ab18317 100644 --- a/packages/sdk/react-native/tsconfig.json +++ b/packages/sdk/react-native/tsconfig.json @@ -27,6 +27,7 @@ "dist", "docs", "example", + "example-fdv2", "node_modules", "babel.config.js", "jest.config.ts", diff --git a/release-please-config.json b/release-please-config.json index 8aada61d70..75cfd1a6d9 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -122,6 +122,11 @@ "type": "json", "path": "example/package.json", "jsonpath": "$.dependencies['@launchdarkly/react-native-client-sdk']" + }, + { + "type": "json", + "path": "example-fdv2/package.json", + "jsonpath": "$.dependencies['@launchdarkly/react-native-client-sdk']" } ] },