diff --git a/packages/react/src/provider/context.ts b/packages/react/src/provider/context.ts index ca53cb1e3..ce95f5a24 100644 --- a/packages/react/src/provider/context.ts +++ b/packages/react/src/provider/context.ts @@ -8,7 +8,7 @@ import { normalizeOptions } from '../common/options'; * DO NOT EXPORT PUBLICLY * @internal */ -export const Context = React.createContext<{ client: Client; options: ReactFlagEvaluationOptions } | undefined>(undefined); +export const Context = React.createContext<{ client: Client; domain?: string; options: ReactFlagEvaluationOptions } | undefined>(undefined); /** * Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}. diff --git a/packages/react/src/provider/index.ts b/packages/react/src/provider/index.ts index 8e12f357b..a4ee0392b 100644 --- a/packages/react/src/provider/index.ts +++ b/packages/react/src/provider/index.ts @@ -1,4 +1,5 @@ export * from './provider'; export * from './use-open-feature-client'; export * from './use-when-provider-ready'; -export * from './test-provider'; \ No newline at end of file +export * from './test-provider'; +export * from './use-context-mutator'; \ No newline at end of file diff --git a/packages/react/src/provider/provider.tsx b/packages/react/src/provider/provider.tsx index 31cefcb84..64f641649 100644 --- a/packages/react/src/provider/provider.tsx +++ b/packages/react/src/provider/provider.tsx @@ -36,5 +36,5 @@ export function OpenFeatureProvider({ client, domain, children, ...options }: Pr client = OpenFeature.getClient(domain); } - return {children}; + return {children}; } diff --git a/packages/react/src/provider/use-context-mutator.ts b/packages/react/src/provider/use-context-mutator.ts new file mode 100644 index 000000000..86dd242d3 --- /dev/null +++ b/packages/react/src/provider/use-context-mutator.ts @@ -0,0 +1,36 @@ +import { useCallback, useContext, useRef } from 'react'; +import type { EvaluationContext } from '@openfeature/web-sdk'; +import { OpenFeature } from '@openfeature/web-sdk'; +import { Context } from './context'; + +/** + * + * A hook for accessing context mutating functions. + * + */ +export function useContextMutator({ + setGlobal +}: { + /** + * Apply changes to the global context instead of the domain scoped context applied at the React Provider + */ + setGlobal?: boolean; +} = {}) { + const { domain } = useContext(Context) || {}; + const previousContext = useRef(null); + + const setContext = useCallback(async (updatedContext: EvaluationContext) => { + if (previousContext.current !== updatedContext) { + if (!domain || setGlobal) { + OpenFeature.setContext(updatedContext); + } else { + OpenFeature.setContext(domain, updatedContext); + } + previousContext.current = updatedContext; + } + }, [domain]); + + return { + setContext, + }; +} diff --git a/packages/react/test/provider.spec.tsx b/packages/react/test/provider.spec.tsx index 7edfec817..0c8bd3581 100644 --- a/packages/react/test/provider.spec.tsx +++ b/packages/react/test/provider.spec.tsx @@ -1,9 +1,15 @@ import type { EvaluationContext} from '@openfeature/web-sdk'; -import { OpenFeature } from '@openfeature/web-sdk'; +import { InMemoryProvider, OpenFeature } from '@openfeature/web-sdk'; import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup -import { render, renderHook, screen, waitFor } from '@testing-library/react'; +import { render, renderHook, screen, waitFor, fireEvent, act } from '@testing-library/react'; import * as React from 'react'; -import { OpenFeatureProvider, useOpenFeatureClient, useWhenProviderReady } from '../src'; +import { + OpenFeatureProvider, + useOpenFeatureClient, + useWhenProviderReady, + useContextMutator, + useStringFlagValue, +} from '../src'; import { TestingProvider } from './test.utils'; describe('OpenFeatureProvider', () => { @@ -34,6 +40,9 @@ describe('OpenFeatureProvider', () => { if (context.user == 'bob@flags.com') { return 'both'; } + if (context.done === true) { + return 'parting'; + } return 'greeting'; }, }, @@ -138,4 +147,148 @@ describe('OpenFeatureProvider', () => { }); }); }); + describe('useMutateContext', () => { + const MutateButton = () => { + const { setContext } = useContextMutator(); + + return setContext({ user: 'bob@flags.com' })}>Update Context; + }; + const TestComponent = ({ name }: { name: string }) => { + const flagValue = useStringFlagValue<'hi' | 'bye' | 'aloha'>(SUSPENSE_FLAG_KEY, 'hi'); + + return ( + + + {`${name} says ${flagValue}`} + + ); + }; + + it('should update context when a domain is set', async () => { + const DOMAIN = 'mutate-context-tests'; + OpenFeature.setProvider(DOMAIN, suspendingProvider()); + render( + + {FALLBACK}}> + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Will says hi')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(screen.getByText('Update Context')); + }); + await waitFor( + () => { + expect(screen.getByText('Will says aloha')).toBeInTheDocument(); + }, + { timeout: DELAY * 4 }, + ); + }); + + it('should update nested contexts', async () => { + const DOMAIN1 = 'Wills Domain'; + const DOMAIN2 = 'Todds Domain'; + OpenFeature.setProvider(DOMAIN1, suspendingProvider()); + OpenFeature.setProvider(DOMAIN2, suspendingProvider()); + render( + + {FALLBACK}}> + + + {FALLBACK}}> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Todd says hi')).toBeInTheDocument(); + }); + + act(() => { + // Click the Update context button in Todds domain + fireEvent.click(screen.getAllByText('Update Context')[1]); + }); + await waitFor( + () => { + expect(screen.getByText('Todd says aloha')).toBeInTheDocument(); + }, + { timeout: DELAY * 4 }, + ); + await waitFor( + () => { + expect(screen.getByText('Will says hi')).toBeInTheDocument(); + }, + { timeout: DELAY * 4 }, + ); + }); + + it('should update nested global contexts', async () => { + const DOMAIN1 = 'Wills Domain'; + OpenFeature.setProvider(DOMAIN1, suspendingProvider()); + OpenFeature.setProvider(new InMemoryProvider({ + globalFlagsHere: { + defaultVariant: 'a', + variants: { + a: 'Smile', + b: 'Frown', + }, + disabled: false, + contextEvaluator: (ctx: EvaluationContext) => { + if (ctx.user === 'bob@flags.com') { + return 'b'; + } + + return 'a'; + }, + } + })); + const GlobalComponent = ({ name }: { name: string }) => { + const flagValue = useStringFlagValue<'b' | 'a'>('globalFlagsHere', 'a'); + + return ( + + + {`${name} likes to ${flagValue}`} + + ); + }; + render( + + {FALLBACK}}> + + + {FALLBACK}}> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Todd likes to Smile')).toBeInTheDocument(); + }); + + act(() => { + // Click the Update context button in Todds domain + fireEvent.click(screen.getAllByText('Update Context')[1]); + }); + await waitFor( + () => { + expect(screen.getByText('Todd likes to Frown')).toBeInTheDocument(); + }, + { timeout: DELAY * 4 }, + ); + + expect(screen.getByText('Will says hi')).toBeInTheDocument(); + }); + }); });