From e781db1e0567948bee28baba9b6dee49a29500e1 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 23 Mar 2026 19:17:46 -0500 Subject: [PATCH 1/7] feat: adding isomorphic provider to bridge client and server This commit will also: - modify the server-only example to demonstrate how to use the isomorphic provider - modify the bundling so client and server can still bundle correcly with isomorphic components - refactor the client side NOOP client to be able to execute well during SSR --- .../server-only/app/BootstrapClient.tsx | 18 ++++++++++++++++++ .../sdk/react/src/client/LDReactClient.tsx | 4 ++++ packages/sdk/react/src/client/index.ts | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/react/examples/server-only/app/BootstrapClient.tsx diff --git a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx new file mode 100644 index 000000000..5b8b56d37 --- /dev/null +++ b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useBoolVariation } from '@launchdarkly/react-sdk'; + +/** + * Client component that evaluates a flag via the bootstrapped browser SDK. + * The LDIsomorphicProvider evaluates all flags on the server and passes them + * to the browser SDK as bootstrap data. + */ +export default function BootstrapClient({ flagKey }: { flagKey: string }) { + const flagValue = useBoolVariation(flagKey, false); + + return ( +

+ Client: The {flagKey} feature flag evaluates to {String(flagValue)}. +

+ ); +} diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index f2b5c1eda..5ab44175b 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -45,9 +45,12 @@ export function createClient( context: LDContext, options: LDReactClientOptions = {}, ): LDReactClient { +<<<<<<< HEAD // This should not happen during runtime, but some frameworks such as Next.js supports // static rendering which will attempt to render client code during build time. In these cases, // we will need to use the noop client to avoid errors. +======= +>>>>>>> c5eda353d (feat: adding isomorphic provider to bridge client and server) if (typeof window === 'undefined') { return createNoopClient(); } @@ -84,6 +87,7 @@ export function createClient( if (startCalled) { return baseClient.start(startOptions); } + initializationState = 'initializing'; startCalled = true; if (startOptions?.bootstrap) { hasBootstrap = true; diff --git a/packages/sdk/react/src/client/index.ts b/packages/sdk/react/src/client/index.ts index cf211e3db..4299c4525 100644 --- a/packages/sdk/react/src/client/index.ts +++ b/packages/sdk/react/src/client/index.ts @@ -2,7 +2,7 @@ export type * from '@launchdarkly/js-client-sdk'; export * from './LDClient'; export * from './LDOptions'; -export { LDReactContext, initLDReactContext } from './provider/LDReactContext'; +export * from './provider/LDReactContext'; export { createLDReactProvider, createLDReactProviderWithClient } from './provider/LDReactProvider'; export { LDIsomorphicClientProvider } from './provider/LDIsomorphicClientProvider'; export type { LDIsomorphicClientProviderProps } from './provider/LDIsomorphicClientProvider'; From a290d3aa86898484be427b70febbe06a24852e22 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 24 Mar 2026 10:18:17 -0500 Subject: [PATCH 2/7] chore: fixing example application --- packages/sdk/react/examples/server-only/app/BootstrapClient.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx index 5b8b56d37..33ba7384c 100644 --- a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx +++ b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx @@ -12,7 +12,7 @@ export default function BootstrapClient({ flagKey }: { flagKey: string }) { return (

- Client: The {flagKey} feature flag evaluates to {String(flagValue)}. + Client: feature flag evaluates to {String(flagValue)} (bootstrapped).

); } From e7611ec310a79faa91edf44714963c446d4e4943 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 24 Mar 2026 10:43:05 -0500 Subject: [PATCH 3/7] chore: pr comments --- .../server-only/app/BootstrapClient.tsx | 18 ------------------ .../sdk/react/src/client/LDReactClient.tsx | 2 +- packages/sdk/react/src/client/index.ts | 2 +- 3 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 packages/sdk/react/examples/server-only/app/BootstrapClient.tsx diff --git a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx deleted file mode 100644 index 33ba7384c..000000000 --- a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { useBoolVariation } from '@launchdarkly/react-sdk'; - -/** - * Client component that evaluates a flag via the bootstrapped browser SDK. - * The LDIsomorphicProvider evaluates all flags on the server and passes them - * to the browser SDK as bootstrap data. - */ -export default function BootstrapClient({ flagKey }: { flagKey: string }) { - const flagValue = useBoolVariation(flagKey, false); - - return ( -

- Client: feature flag evaluates to {String(flagValue)} (bootstrapped). -

- ); -} diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index 5ab44175b..a8518a8ea 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -55,6 +55,7 @@ export function createClient( return createNoopClient(); } + const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options; const baseClientOptions: LDOptions = { @@ -87,7 +88,6 @@ export function createClient( if (startCalled) { return baseClient.start(startOptions); } - initializationState = 'initializing'; startCalled = true; if (startOptions?.bootstrap) { hasBootstrap = true; diff --git a/packages/sdk/react/src/client/index.ts b/packages/sdk/react/src/client/index.ts index 4299c4525..cf211e3db 100644 --- a/packages/sdk/react/src/client/index.ts +++ b/packages/sdk/react/src/client/index.ts @@ -2,7 +2,7 @@ export type * from '@launchdarkly/js-client-sdk'; export * from './LDClient'; export * from './LDOptions'; -export * from './provider/LDReactContext'; +export { LDReactContext, initLDReactContext } from './provider/LDReactContext'; export { createLDReactProvider, createLDReactProviderWithClient } from './provider/LDReactProvider'; export { LDIsomorphicClientProvider } from './provider/LDIsomorphicClientProvider'; export type { LDIsomorphicClientProviderProps } from './provider/LDIsomorphicClientProvider'; From b416b2f85462cb7ce767c542e172af3fc60709b1 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 25 Mar 2026 16:20:10 -0500 Subject: [PATCH 4/7] refactor: adding noop on client side --- packages/sdk/react/__tests__/client/createNoopClient.test.ts | 2 +- packages/sdk/react/src/client/LDReactClient.tsx | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/sdk/react/__tests__/client/createNoopClient.test.ts b/packages/sdk/react/__tests__/client/createNoopClient.test.ts index b0cc03985..ec66c0e68 100644 --- a/packages/sdk/react/__tests__/client/createNoopClient.test.ts +++ b/packages/sdk/react/__tests__/client/createNoopClient.test.ts @@ -213,7 +213,7 @@ describe('handles edge cases gracefully', () => { expect(createNoopClient().getInitializationState()).toBe('initializing'); }); - it('isReady returns true when bootstrap is provided', () => { + it('isReady returns true when bootstrap is provided', () => { const client = createNoopClient({}); expect(client.isReady()).toBe(true); }); diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index a8518a8ea..f2b5c1eda 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -45,17 +45,13 @@ export function createClient( context: LDContext, options: LDReactClientOptions = {}, ): LDReactClient { -<<<<<<< HEAD // This should not happen during runtime, but some frameworks such as Next.js supports // static rendering which will attempt to render client code during build time. In these cases, // we will need to use the noop client to avoid errors. -======= ->>>>>>> c5eda353d (feat: adding isomorphic provider to bridge client and server) if (typeof window === 'undefined') { return createNoopClient(); } - const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options; const baseClientOptions: LDOptions = { From 715bcd04df65615b7dce09c28d3bfa261d302937 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 27 Mar 2026 12:51:38 -0500 Subject: [PATCH 5/7] docs: adding clarification to example --- packages/sdk/react/__tests__/client/createNoopClient.test.ts | 2 +- packages/sdk/react/examples/server-only/next.config.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/sdk/react/__tests__/client/createNoopClient.test.ts b/packages/sdk/react/__tests__/client/createNoopClient.test.ts index ec66c0e68..b0cc03985 100644 --- a/packages/sdk/react/__tests__/client/createNoopClient.test.ts +++ b/packages/sdk/react/__tests__/client/createNoopClient.test.ts @@ -213,7 +213,7 @@ describe('handles edge cases gracefully', () => { expect(createNoopClient().getInitializationState()).toBe('initializing'); }); - it('isReady returns true when bootstrap is provided', () => { + it('isReady returns true when bootstrap is provided', () => { const client = createNoopClient({}); expect(client.isReady()).toBe(true); }); diff --git a/packages/sdk/react/examples/server-only/next.config.ts b/packages/sdk/react/examples/server-only/next.config.ts index 4c8addcba..1af3e01c2 100644 --- a/packages/sdk/react/examples/server-only/next.config.ts +++ b/packages/sdk/react/examples/server-only/next.config.ts @@ -2,6 +2,10 @@ import type { NextConfig } from 'next'; import path from 'path'; const nextConfig: NextConfig = { + // We suppress strict mode for this example to make the render log only one + // evaluation. While it is correct to double evaluate with strict mode on, that + // behavior is not immediately obvious to some users. + reactStrictMode: false, turbopack: { root: path.resolve(__dirname, '../../../../..'), }, From 6e30c0ea49a4a861ae848b9916975e239da70625 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 27 Mar 2026 13:59:14 -0500 Subject: [PATCH 6/7] test: adding tests for new providers --- .../LDIsomorphicClientProvider.test.tsx | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx diff --git a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx b/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx new file mode 100644 index 000000000..a7cbc6c3c --- /dev/null +++ b/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { createNoopClient } from '../../src/client/createNoopClient'; +import { LDIsomorphicClientProvider } from '../../src/client/provider/LDIsomorphicClientProvider'; +import { + createLDReactProvider, + createLDReactProviderWithClient, +} from '../../src/client/provider/LDReactProvider'; + +const mockNoopClient = { noop: true }; +const MockProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => + React.createElement('div', { 'data-testid': 'mock-provider' }, children); + +jest.mock('../../src/client/createNoopClient', () => ({ + createNoopClient: jest.fn(() => mockNoopClient), +})); + +jest.mock('../../src/client/provider/LDReactProvider', () => ({ + createLDReactProvider: jest.fn(() => MockProvider), + createLDReactProviderWithClient: jest.fn(() => MockProvider), +})); + +// Mock useRef to work outside React's render context. +let refStore: { current: unknown } = { current: null }; + +const defaultProps = { + clientSideId: 'client-id-123', + context: { kind: 'user' as const, key: 'user-1' }, + bootstrap: { 'my-flag': true, $flagsState: {}, $valid: true }, + children: React.createElement('span', null, 'child'), +}; + +beforeEach(() => { + jest.clearAllMocks(); + refStore = { current: null }; + jest.spyOn(React, 'useRef').mockImplementation(() => refStore); +}); + +// The test environment is node (no window), so SSR path is always taken. +it('creates a noop client with bootstrap on the server', () => { + LDIsomorphicClientProvider(defaultProps); + + expect(createNoopClient).toHaveBeenCalledWith(defaultProps.bootstrap); + expect(createLDReactProviderWithClient).toHaveBeenCalledWith(mockNoopClient); + expect(createLDReactProvider).not.toHaveBeenCalled(); +}); + +it('does not re-initialize the provider on subsequent renders', () => { + LDIsomorphicClientProvider(defaultProps); + expect(createLDReactProviderWithClient).toHaveBeenCalledTimes(1); + + // Second render — provider ref is already populated, so factories should not be called again. + jest.clearAllMocks(); + LDIsomorphicClientProvider(defaultProps); + expect(createNoopClient).not.toHaveBeenCalled(); + expect(createLDReactProviderWithClient).not.toHaveBeenCalled(); +}); + +describe('given a browser environment (window defined)', () => { + let originalWindow: typeof globalThis.window; + + beforeEach(() => { + originalWindow = globalThis.window; + // @ts-ignore — simulate browser + globalThis.window = {}; + }); + + afterEach(() => { + // @ts-ignore + globalThis.window = originalWindow; + }); + + it('creates a real provider with bootstrap on the client', () => { + LDIsomorphicClientProvider(defaultProps); + + expect(createLDReactProvider).toHaveBeenCalledWith( + defaultProps.clientSideId, + defaultProps.context, + { bootstrap: defaultProps.bootstrap }, + ); + expect(createNoopClient).not.toHaveBeenCalled(); + }); + + it('forwards options merged with bootstrap to createLDReactProvider', () => { + const options = { deferInitialization: true }; + + LDIsomorphicClientProvider({ ...defaultProps, options }); + + expect(createLDReactProvider).toHaveBeenCalledWith( + defaultProps.clientSideId, + defaultProps.context, + { deferInitialization: true, bootstrap: defaultProps.bootstrap }, + ); + }); +}); From 58f5bd79c679b70fff852a267b88f34ad52e3846 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 3 Apr 2026 10:49:39 -0500 Subject: [PATCH 7/7] chore(react-sdk): adding vercel edge example --- package.json | 1 + .../LDIsomorphicClientProvider.test.tsx | 95 ------------------- packages/sdk/react/examples/README.md | 1 + .../react/examples/server-only/next.config.ts | 4 - .../sdk/react/examples/vercel-edge/.gitignore | 43 +++++++++ .../sdk/react/examples/vercel-edge/README.md | 84 ++++++++++++++++ .../react/examples/vercel-edge/app/App.tsx | 41 ++++++++ .../vercel-edge/app/BootstrappedClient.tsx | 18 ++++ .../react/examples/vercel-edge/app/layout.tsx | 13 +++ .../react/examples/vercel-edge/app/page.tsx | 81 ++++++++++++++++ .../react/examples/vercel-edge/app/styles.css | 50 ++++++++++ .../examples/vercel-edge/e2e/verify.spec.ts | 9 ++ .../react/examples/vercel-edge/next.config.ts | 14 +++ .../react/examples/vercel-edge/package.json | 26 +++++ .../examples/vercel-edge/playwright.config.ts | 17 ++++ .../react/examples/vercel-edge/tsconfig.json | 33 +++++++ 16 files changed, 431 insertions(+), 99 deletions(-) delete mode 100644 packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx create mode 100644 packages/sdk/react/examples/vercel-edge/.gitignore create mode 100644 packages/sdk/react/examples/vercel-edge/README.md create mode 100644 packages/sdk/react/examples/vercel-edge/app/App.tsx create mode 100644 packages/sdk/react/examples/vercel-edge/app/BootstrappedClient.tsx create mode 100644 packages/sdk/react/examples/vercel-edge/app/layout.tsx create mode 100644 packages/sdk/react/examples/vercel-edge/app/page.tsx create mode 100644 packages/sdk/react/examples/vercel-edge/app/styles.css create mode 100644 packages/sdk/react/examples/vercel-edge/e2e/verify.spec.ts create mode 100644 packages/sdk/react/examples/vercel-edge/next.config.ts create mode 100644 packages/sdk/react/examples/vercel-edge/package.json create mode 100644 packages/sdk/react/examples/vercel-edge/playwright.config.ts create mode 100644 packages/sdk/react/examples/vercel-edge/tsconfig.json diff --git a/package.json b/package.json index aaedd7dfc..4fc0b4a9f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "packages/sdk/react/contract-tests", "packages/sdk/react/examples/hello-react", "packages/sdk/react/examples/server-only", + "packages/sdk/react/examples/vercel-edge", "packages/sdk/react-native", "packages/sdk/react-native/example", "packages/sdk/react-native/contract-tests/entity", diff --git a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx b/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx deleted file mode 100644 index a7cbc6c3c..000000000 --- a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; - -import { createNoopClient } from '../../src/client/createNoopClient'; -import { LDIsomorphicClientProvider } from '../../src/client/provider/LDIsomorphicClientProvider'; -import { - createLDReactProvider, - createLDReactProviderWithClient, -} from '../../src/client/provider/LDReactProvider'; - -const mockNoopClient = { noop: true }; -const MockProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => - React.createElement('div', { 'data-testid': 'mock-provider' }, children); - -jest.mock('../../src/client/createNoopClient', () => ({ - createNoopClient: jest.fn(() => mockNoopClient), -})); - -jest.mock('../../src/client/provider/LDReactProvider', () => ({ - createLDReactProvider: jest.fn(() => MockProvider), - createLDReactProviderWithClient: jest.fn(() => MockProvider), -})); - -// Mock useRef to work outside React's render context. -let refStore: { current: unknown } = { current: null }; - -const defaultProps = { - clientSideId: 'client-id-123', - context: { kind: 'user' as const, key: 'user-1' }, - bootstrap: { 'my-flag': true, $flagsState: {}, $valid: true }, - children: React.createElement('span', null, 'child'), -}; - -beforeEach(() => { - jest.clearAllMocks(); - refStore = { current: null }; - jest.spyOn(React, 'useRef').mockImplementation(() => refStore); -}); - -// The test environment is node (no window), so SSR path is always taken. -it('creates a noop client with bootstrap on the server', () => { - LDIsomorphicClientProvider(defaultProps); - - expect(createNoopClient).toHaveBeenCalledWith(defaultProps.bootstrap); - expect(createLDReactProviderWithClient).toHaveBeenCalledWith(mockNoopClient); - expect(createLDReactProvider).not.toHaveBeenCalled(); -}); - -it('does not re-initialize the provider on subsequent renders', () => { - LDIsomorphicClientProvider(defaultProps); - expect(createLDReactProviderWithClient).toHaveBeenCalledTimes(1); - - // Second render — provider ref is already populated, so factories should not be called again. - jest.clearAllMocks(); - LDIsomorphicClientProvider(defaultProps); - expect(createNoopClient).not.toHaveBeenCalled(); - expect(createLDReactProviderWithClient).not.toHaveBeenCalled(); -}); - -describe('given a browser environment (window defined)', () => { - let originalWindow: typeof globalThis.window; - - beforeEach(() => { - originalWindow = globalThis.window; - // @ts-ignore — simulate browser - globalThis.window = {}; - }); - - afterEach(() => { - // @ts-ignore - globalThis.window = originalWindow; - }); - - it('creates a real provider with bootstrap on the client', () => { - LDIsomorphicClientProvider(defaultProps); - - expect(createLDReactProvider).toHaveBeenCalledWith( - defaultProps.clientSideId, - defaultProps.context, - { bootstrap: defaultProps.bootstrap }, - ); - expect(createNoopClient).not.toHaveBeenCalled(); - }); - - it('forwards options merged with bootstrap to createLDReactProvider', () => { - const options = { deferInitialization: true }; - - LDIsomorphicClientProvider({ ...defaultProps, options }); - - expect(createLDReactProvider).toHaveBeenCalledWith( - defaultProps.clientSideId, - defaultProps.context, - { deferInitialization: true, bootstrap: defaultProps.bootstrap }, - ); - }); -}); diff --git a/packages/sdk/react/examples/README.md b/packages/sdk/react/examples/README.md index 0d75b91da..e36894ed1 100644 --- a/packages/sdk/react/examples/README.md +++ b/packages/sdk/react/examples/README.md @@ -6,3 +6,4 @@ This directory contains example applications demonstrating the LaunchDarkly Reac |---------|-------------| | [hello-react](./hello-react/) | Minimal Vite + React app that evaluates a boolean feature flag and displays the result with real-time updates. This is the recommended starting point. | | [server-only](./server-only/) | Next.js App Router example demonstrating server-side flag evaluation with React Server Components. | +| [vercel-edge](./vercel-edge/) | Next.js App Router example using the Vercel Edge SDK to evaluate flags from Vercel Edge Config, with server-to-client bootstrap via `LDIsomorphicProvider`. | diff --git a/packages/sdk/react/examples/server-only/next.config.ts b/packages/sdk/react/examples/server-only/next.config.ts index 1af3e01c2..4c8addcba 100644 --- a/packages/sdk/react/examples/server-only/next.config.ts +++ b/packages/sdk/react/examples/server-only/next.config.ts @@ -2,10 +2,6 @@ import type { NextConfig } from 'next'; import path from 'path'; const nextConfig: NextConfig = { - // We suppress strict mode for this example to make the render log only one - // evaluation. While it is correct to double evaluate with strict mode on, that - // behavior is not immediately obvious to some users. - reactStrictMode: false, turbopack: { root: path.resolve(__dirname, '../../../../..'), }, diff --git a/packages/sdk/react/examples/vercel-edge/.gitignore b/packages/sdk/react/examples/vercel-edge/.gitignore new file mode 100644 index 000000000..077d13c7b --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +/test-results + +# next.js +/.next/ +/out/ +/.swc/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/sdk/react/examples/vercel-edge/README.md b/packages/sdk/react/examples/vercel-edge/README.md new file mode 100644 index 000000000..0274cbc94 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/README.md @@ -0,0 +1,84 @@ +# LaunchDarkly sample React + Vercel Edge application + +We've built a simple web application that demonstrates how the LaunchDarkly React SDK works with +the Vercel Edge SDK. The app evaluates feature flags using data stored in +[Vercel Edge Config](https://vercel.com/docs/edge-config/overview) and renders the result using +React Server Components. + +The Vercel SDK reads flag data from Edge Config instead of connecting to LaunchDarkly servers +directly, providing ultra-low latency flag evaluation at the edge. + +The demo shows 2 ways to use React server-side rendering: + +1. Using `createLDServerSession` and `useLDServerSession` to provide +per-request session isolation: Nested Server Components access the session through React's `cache()` +without any prop drilling. + +2. Using the `LDIsomorphicProvider` to bootstrap the browser SDK with server-evaluated flag values. +This allows the browser SDK to start immediately with real values. + +Below, you'll find the build procedure. For more comprehensive instructions, you can visit your +[Quickstart page](https://app.launchdarkly.com/quickstart#/) or the +[React SDK reference guide](https://docs.launchdarkly.com/sdk/client-side/react/react-web). + +This demo requires Node.js 18 or higher. + +## Prerequisites + +This example requires the [LaunchDarkly Vercel integration](https://vercel.com/integrations/launchdarkly) +to be configured. The integration syncs your LaunchDarkly flag data to Vercel Edge Config so that +the Vercel SDK can read it without connecting to LaunchDarkly servers. + +## Build instructions + +1. Set the `VERCEL_EDGE_CONFIG` environment variable to your Vercel Edge Config connection string. + You can find this in your Vercel project settings under Edge Config. + + ```bash + export VERCEL_EDGE_CONFIG="https://edge-config.vercel.com/ecfg_..." + ``` + +2. Set the `LD_CLIENT_SIDE_ID` environment variable to your LaunchDarkly client-side ID. + The Vercel SDK uses this to look up flag data in Edge Config, and the same value is used + to bootstrap the browser SDK. + + ```bash + export LD_CLIENT_SIDE_ID="my-client-side-id" + ``` + +3. If there is an existing boolean feature flag in your LaunchDarkly project that you want to + evaluate, set `LAUNCHDARKLY_FLAG_KEY`: + + ```bash + export LAUNCHDARKLY_FLAG_KEY="my-flag-key" + ``` + + Otherwise, `sample-feature` will be used by default. + +## Running + +On the command line, run: + +```bash +yarn dev +``` + +Then open [http://localhost:3000](http://localhost:3000) in your browser. You will see the +spec message, current context name, and a full-page background: green when the flag is on, +or grey when off. + +To simulate a different user, append the `?context=` query parameter. Each tab gets a +completely independent `LDServerSession` with its own context: + +| URL | Context | +|-----|---------| +| `http://localhost:3000/` | Sandy (example-user-key) — default | +| `http://localhost:3000/?context=sandy` | Sandy (example-user-key) | +| `http://localhost:3000/?context=jamie` | Jamie (user-jamie) | +| `http://localhost:3000/?context=alex` | Alex (user-alex) | + +If you have targeting rules in LaunchDarkly that serve different values to different user keys, +you will see different flag results for each context. + +In a production app, the user identity would come from auth tokens, cookies, or session data +instead of query parameters. diff --git a/packages/sdk/react/examples/vercel-edge/app/App.tsx b/packages/sdk/react/examples/vercel-edge/app/App.tsx new file mode 100644 index 000000000..a7fe2b2a9 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/App.tsx @@ -0,0 +1,41 @@ +import { useLDServerSession } from '@launchdarkly/react-sdk/server'; + +import BootstrappedClient from './BootstrappedClient'; + +// The flag key to evaluate. Override with the LAUNCHDARKLY_FLAG_KEY environment variable. +const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY || 'sample-feature'; + +export default async function App() { + // The session was stored here by createLDServerSession() in the parent page. + const session = useLDServerSession(); + + if (!session) { + return ( +

+ No LaunchDarkly session found. Ensure createLDServerSession() is called before rendering + this component. +

+ ); + } + + const flagValue = await session.boolVariation(flagKey, false); + const ctx = session.getContext() as { name?: string; key: string }; + + console.log('[LaunchDarkly] Flag evaluation:', { + flagKey, + flagValue, + context: session.getContext(), + }); + + return ( +
+

Feature flag: {flagKey}

+

Context: {ctx.name ?? ctx.key}

+

+ Server: feature flag evaluates to {String(flagValue)} (server-side + rendered). +

+ +
+ ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/BootstrappedClient.tsx b/packages/sdk/react/examples/vercel-edge/app/BootstrappedClient.tsx new file mode 100644 index 000000000..2bd3b635a --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/BootstrappedClient.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useBoolVariation } from '@launchdarkly/react-sdk'; + +/** + * Client component that evaluates a flag via the bootstrapped react clientSDK. + * The LDIsomorphicProvider evaluates all flags on the server and passes them + * to the react client SDK as bootstrap data. + */ +export default function BootstrappedClient({ flagKey }: { flagKey: string }) { + const flagValue = useBoolVariation(flagKey, false); + + return ( +

+ Client: feature flag evaluates to {String(flagValue)} (bootstrapped). +

+ ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/layout.tsx b/packages/sdk/react/examples/vercel-edge/app/layout.tsx new file mode 100644 index 000000000..e3006e4d8 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/layout.tsx @@ -0,0 +1,13 @@ +import './styles.css'; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/page.tsx b/packages/sdk/react/examples/vercel-edge/app/page.tsx new file mode 100644 index 000000000..97bedb44a --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/page.tsx @@ -0,0 +1,81 @@ +import { createClient } from '@vercel/edge-config'; + +import { createLDServerSession, LDIsomorphicProvider } from '@launchdarkly/react-sdk/server'; +import { init } from '@launchdarkly/vercel-server-sdk'; + +import App from './App'; + +// The Vercel SDK reads flag data from Vercel Edge Config instead of connecting +// to LaunchDarkly servers directly, so it uses the client-side ID — not the +// server SDK key. +const clientSideId = process.env.LD_CLIENT_SIDE_ID || ''; +const edgeConfig = process.env.VERCEL_EDGE_CONFIG; +const edgeConfigClient = edgeConfig ? createClient(edgeConfig) : null; +const ldBaseClient = clientSideId && edgeConfigClient ? init(clientSideId, edgeConfigClient) : null; + +// Select via ?context=sandy|jamie|alex (defaults to sandy). +const PRESET_CONTEXTS = { + sandy: { kind: 'user' as const, key: 'example-user-key', name: 'Sandy' }, + jamie: { kind: 'user' as const, key: 'user-jamie', name: 'Jamie' }, + alex: { kind: 'user' as const, key: 'user-alex', name: 'Alex' }, +}; + +export default async function Home({ + searchParams, +}: { + searchParams: Promise<{ context?: string }>; +}) { + if (!edgeConfigClient) { + return ( +
+

+ Vercel Edge Config is required: set the VERCEL_EDGE_CONFIG environment variable and try + again. +

+
+ ); + } + + if (!ldBaseClient) { + return ( +
+

+ LaunchDarkly client-side ID is required: set the LD_CLIENT_SIDE_ID environment variable + and try again. +

+
+ ); + } + + try { + await ldBaseClient.waitForInitialization(); + } catch { + return ( +
+

+ SDK failed to initialize. Please check your Edge Config connection and LaunchDarkly + client-side ID for any issues. +

+
+ ); + } + + // Resolve the evaluation context from the ?context= query parameter. + // In a real app this would come from authentication tokens, cookies, or session data. + const { context: contextKey = 'sandy' } = await searchParams; + const context = + PRESET_CONTEXTS[contextKey as keyof typeof PRESET_CONTEXTS] ?? PRESET_CONTEXTS.sandy; + + // Create a per-request session bound to this user's context. + // createLDServerSession also stores the session in React's cache() so any Server Component + // in this render tree can retrieve it via useLDServerSession(). + const session = createLDServerSession(ldBaseClient, context); + + // Wrap the app with LDIsomorphicProvider to bootstrap the browser SDK with + // server-evaluated flag values. + return ( + + + + ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/styles.css b/packages/sdk/react/examples/vercel-edge/app/styles.css new file mode 100644 index 000000000..699ead1da --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/styles.css @@ -0,0 +1,50 @@ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* ── Error states (page.tsx) ───────────────────────── */ +.error { + min-height: 100vh; + background-color: #373841; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +/* ── App ──────────────────────────────────────────── */ +.no-session { + color: #ff6b6b; + font-family: monospace; +} + +.app { + min-height: 100vh; + color: #ffffff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: calc(10px + 2vmin); + padding: 2rem; + gap: 1rem; +} + +.app--on { + background-color: #00844b; +} +.app--off { + background-color: #373841; +} + +.context, +.flag-key { + font-size: 0.7em; + opacity: 0.75; +} diff --git a/packages/sdk/react/examples/vercel-edge/e2e/verify.spec.ts b/packages/sdk/react/examples/vercel-edge/e2e/verify.spec.ts new file mode 100644 index 000000000..e11ffd5bb --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/e2e/verify.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +test('feature flag evaluates to true', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('feature flag evaluates to true', { exact: false })).toHaveCount(2, { + timeout: 10000, + }); +}); diff --git a/packages/sdk/react/examples/vercel-edge/next.config.ts b/packages/sdk/react/examples/vercel-edge/next.config.ts new file mode 100644 index 000000000..1af3e01c2 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from 'next'; +import path from 'path'; + +const nextConfig: NextConfig = { + // We suppress strict mode for this example to make the render log only one + // evaluation. While it is correct to double evaluate with strict mode on, that + // behavior is not immediately obvious to some users. + reactStrictMode: false, + turbopack: { + root: path.resolve(__dirname, '../../../../..'), + }, +}; + +export default nextConfig; diff --git a/packages/sdk/react/examples/vercel-edge/package.json b/packages/sdk/react/examples/vercel-edge/package.json new file mode 100644 index 000000000..b15651b95 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/package.json @@ -0,0 +1,26 @@ +{ + "name": "@internal/react-sdk-example-vercel-edge", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "test": "playwright test" + }, + "dependencies": { + "@launchdarkly/react-sdk": "0.1.1", + "@launchdarkly/vercel-server-sdk": "workspace:^", + "@vercel/edge-config": "^1.1.0", + "next": "^16", + "react": "^19", + "react-dom": "^19" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + } +} diff --git a/packages/sdk/react/examples/vercel-edge/playwright.config.ts b/packages/sdk/react/examples/vercel-edge/playwright.config.ts new file mode 100644 index 000000000..23e75b9d1 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/playwright.config.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + reporter: [['list']], + use: { + baseURL: 'http://localhost:3000', + }, + webServer: { + command: 'yarn start', + port: 3000, + timeout: 30_000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/sdk/react/examples/vercel-edge/tsconfig.json b/packages/sdk/react/examples/vercel-edge/tsconfig.json new file mode 100644 index 000000000..d45e92c43 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules", "e2e", "playwright.config.ts"] +}