-
Notifications
You must be signed in to change notification settings - Fork 35
feat: adding isomorphic provider to bridge client and server #1218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+540
−44
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
051ff34
feat: adding isomorphic provider to bridge client and server
joker23 7e3d8b8
chore: fixing example application
joker23 29ea0c6
chore: pr comments
joker23 82caf05
refactor: adding noop on client side
joker23 e4dd3ec
docs: adding clarification to example
joker23 cd2c876
test: adding tests for new providers
joker23 05ee4d0
chore: error handling
joker23 85fe0cf
chore: bot comment
joker23 4fd4177
chore: bot comments
joker23 71795a3
chore: more bot comments
joker23 0f76c59
chore: docs pass
joker23 9fa80fd
chore: bot comment
joker23 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
packages/sdk/react/__tests__/client/provider/LDIsomorphicClientProvider.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, undefined); | ||
| 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, reactContext: undefined }, | ||
| ); | ||
| 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, reactContext: undefined }, | ||
| ); | ||
| }); | ||
| }); | ||
132 changes: 132 additions & 0 deletions
132
packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| import React from 'react'; | ||
|
|
||
| import type { LDServerSession } from '../../src/server/LDClient'; | ||
| import { LDIsomorphicProvider } from '../../src/server/LDIsomorphicProvider'; | ||
|
|
||
| function makeMockSession(overrides?: Partial<LDServerSession>): LDServerSession { | ||
| const bootstrapJson = { 'my-flag': true, $flagsState: {}, $valid: true }; | ||
|
|
||
| return { | ||
| initialized: jest.fn(() => true), | ||
| getContext: jest.fn(() => ({ kind: 'user', key: 'test-user' })), | ||
| boolVariation: jest.fn(), | ||
| numberVariation: jest.fn(), | ||
| stringVariation: jest.fn(), | ||
| jsonVariation: jest.fn(), | ||
| boolVariationDetail: jest.fn(), | ||
| numberVariationDetail: jest.fn(), | ||
| stringVariationDetail: jest.fn(), | ||
| jsonVariationDetail: jest.fn(), | ||
| allFlagsState: jest.fn(() => | ||
| Promise.resolve({ | ||
| valid: true, | ||
| getFlagValue: jest.fn(), | ||
| getFlagReason: jest.fn(), | ||
| allValues: jest.fn(() => ({})), | ||
| toJSON: jest.fn(() => bootstrapJson), | ||
| }), | ||
| ), | ||
| ...overrides, | ||
| } as unknown as LDServerSession; | ||
| } | ||
|
|
||
| it('calls allFlagsState with clientSideOnly and passes toJSON as bootstrap', async () => { | ||
| const session = makeMockSession(); | ||
|
|
||
| const result = await LDIsomorphicProvider({ | ||
| session, | ||
| clientSideId: 'client-id-123', | ||
| children: React.createElement('div'), | ||
| }); | ||
|
|
||
| expect(session.allFlagsState).toHaveBeenCalledWith({ clientSideOnly: true }); | ||
|
|
||
| // The async component returns a React element whose props contain the bootstrap data. | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const element = result as any; | ||
| expect(element.props.bootstrap).toEqual({ | ||
| 'my-flag': true, | ||
| $flagsState: {}, | ||
| $valid: true, | ||
| }); | ||
| }); | ||
|
|
||
| it('passes session context to the client provider', async () => { | ||
| const context = { kind: 'user' as const, key: 'ctx-abc' }; | ||
| const session = makeMockSession({ | ||
| getContext: jest.fn(() => context), | ||
| }); | ||
|
|
||
| const result = await LDIsomorphicProvider({ | ||
| session, | ||
| clientSideId: 'client-id-123', | ||
| children: React.createElement('div'), | ||
| }); | ||
|
|
||
| expect(session.getContext).toHaveBeenCalled(); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const element = result as any; | ||
| expect(element.props.context).toEqual(context); | ||
| }); | ||
|
|
||
| it('forwards clientSideId to the client provider', async () => { | ||
| const session = makeMockSession(); | ||
|
|
||
| const result = await LDIsomorphicProvider({ | ||
| session, | ||
| clientSideId: 'my-client-side-id', | ||
| children: React.createElement('div'), | ||
| }); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const element = result as any; | ||
| expect(element.props.clientSideId).toBe('my-client-side-id'); | ||
| }); | ||
|
|
||
| it('forwards options to the client provider', async () => { | ||
| const session = makeMockSession(); | ||
| const options = { deferInitialization: true }; | ||
|
|
||
| const result = await LDIsomorphicProvider({ | ||
| session, | ||
| clientSideId: 'client-id-123', | ||
| // @ts-ignore — minimal options mock | ||
| options, | ||
| children: React.createElement('div'), | ||
| }); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const element = result as any; | ||
| expect(element.props.options).toEqual(options); | ||
| }); | ||
|
|
||
| it('falls back to undefined bootstrap when allFlagsState throws', async () => { | ||
| const session = makeMockSession({ | ||
| allFlagsState: jest.fn(() => Promise.reject(new Error('client not initialized'))), | ||
| }); | ||
|
|
||
| const result = await LDIsomorphicProvider({ | ||
| session, | ||
| clientSideId: 'client-id-123', | ||
| children: React.createElement('div'), | ||
| }); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const element = result as any; | ||
| expect(element.props.bootstrap).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('passes children to the client provider', async () => { | ||
| const session = makeMockSession(); | ||
| const child = React.createElement('span', null, 'hello'); | ||
|
|
||
| const result = await LDIsomorphicProvider({ | ||
| session, | ||
| clientSideId: 'client-id-123', | ||
| children: child, | ||
| }); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const element = result as any; | ||
| expect(element.props.children).toEqual(child); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In general, I think this example name should be changed to something like |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
18 changes: 18 additions & 0 deletions
18
packages/sdk/react/examples/server-only/app/BootstrappedClient.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <p> | ||
| <strong>Client:</strong> feature flag evaluates to {String(flagValue)} (bootstrapped). | ||
| </p> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.