Skip to content
2 changes: 1 addition & 1 deletion packages/react/src/provider/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { NormalizedOptions, ReactFlagEvaluationOptions, normalizeOptions } from
* 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}.
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/provider/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './provider';
export * from './use-open-feature-client';
export * from './use-when-provider-ready';
export * from './test-provider';
export * from './test-provider';
export * from './use-context-mutator';
2 changes: 1 addition & 1 deletion packages/react/src/provider/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@ export function OpenFeatureProvider({ client, domain, children, ...options }: Pr
client = OpenFeature.getClient(domain);
}

return <Context.Provider value={{ client, options }}>{children}</Context.Provider>;
return <Context.Provider value={{ client, options, domain }}>{children}</Context.Provider>;
}
28 changes: 28 additions & 0 deletions packages/react/src/provider/use-context-mutator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useCallback, useContext, useRef } from 'react';
import { OpenFeature, EvaluationContext } from '@openfeature/web-sdk';
import { Context } from './context';

/**
*
* A hook for accessing context mutating functions.
*
*/
export function useContextMutator() {
const { domain } = useContext(Context) || {};
const previousContext = useRef<null | EvaluationContext>(null);

const mutateContext = useCallback(async (updatedContext: EvaluationContext) => {
if (previousContext.current !== updatedContext) {
if (!domain) {
OpenFeature.setContext(updatedContext);
} else {
OpenFeature.setContext(domain, updatedContext);
}
previousContext.current = updatedContext;
}
}, [domain]);

return {
mutateContext,
};
}
76 changes: 74 additions & 2 deletions packages/react/test/provider.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { EvaluationContext, 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', () => {
Expand Down Expand Up @@ -33,6 +33,9 @@ describe('OpenFeatureProvider', () => {
if (context.user == '[email protected]') {
return 'both';
}
if (context.done === true) {
return 'parting';
}
return 'greeting';
},
},
Expand Down Expand Up @@ -136,5 +139,74 @@ describe('OpenFeatureProvider', () => {
await waitFor(() => expect(screen.queryByText('👍')).toBeInTheDocument(), { timeout: DELAY * 2 });
});
});

describe('useMutateContext', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests look great but could you please add one more for the following setup?

<OpenFeatureProvider domain={DOMAIN1}>
  <React.Suspense fallback={<div>{FALLBACK}</div>}>
    <TestComponent name="Will" />
    <OpenFeatureProvider>
      <React.Suspense fallback={<div>{FALLBACK}</div>}>
        <TestComponent name="Todd" />
      </React.Suspense>
    </OpenFeatureProvider>
  </React.Suspense>
</OpenFeatureProvider>

The nested component should update the global context.

Copy link
Member Author

@wichopy wichopy Oct 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beeme1mr I have set this up. In this scenario, it will update both <TestComponents />. Is that the expected behaviour?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like a test that ensures that updating context in the nested component updates the global context and not the domain scoped context. The other component probably isn't necessary in the test.

Copy link
Member Author

@wichopy wichopy Oct 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I'm asking is it seems like something is wrong, shouldn't domain scoped children be unchanged even if the global context changes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're correct. Is there something wrong with my example?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

@wichopy wichopy Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@toddbaert adding a property to set global or not doesn't solve the issue I brought up though.

I've added a test with the global context having a different flag key and variants than the domain scoped one. The domain and global provider context evaluators both listen to user === '[email protected]', but now only the global context flag variant changes instead of both global and domain scoped.

When the providers are the same (same flag keys and variants) they both update when global context is nested under domain context. Did I find a bug or is this desired behaviour?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure I understand your last comment. But I can confirm what @beeme1mr said here:

Basically, domain-scoped context is only managed separately if it's explicitly set for the domain.

Basically, if you don't set a context for domain X, it will use the default context. If you DO set a context for domain X, it uses that context (this is analogous to how providers are bound to domains as well. Does that clear things up?

My point about the option was somewhat separate: just that users might want to update either of these from their component, and either is valid.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think you cleared it up! i think testing is all set then unless we need more cases.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, looks that way to me.

const MutateButton = () => {
const { mutateContext } = useContextMutator();

return <button onClick={() => mutateContext({ user: '[email protected]' })}>Update Context</button>;
};
const TestComponent = ({ name }: { name: string}) => {
const flagValue = useStringFlagValue<'hi' | 'bye' | 'aloha'>(SUSPENSE_FLAG_KEY, 'hi');

return <div>
<MutateButton />
<div>{`${name} says ${flagValue}`}</div>
</div>;
};

it('should update context when a domain is set', async () => {
const DOMAIN = 'mutate-context-tests';
OpenFeature.setProvider(DOMAIN, suspendingProvider());
render(<OpenFeatureProvider domain={DOMAIN}>
<React.Suspense fallback={<div>{FALLBACK}</div>}>
<TestComponent name="Will"/>
</React.Suspense>
</OpenFeatureProvider>,);

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(<OpenFeatureProvider domain={DOMAIN1}>
<React.Suspense fallback={<div>{FALLBACK}</div>}>
<TestComponent name="Will"/>
<OpenFeatureProvider domain={DOMAIN2}>
<React.Suspense fallback={<div>{FALLBACK}</div>}>
<TestComponent name="Todd"/>
</React.Suspense>
</OpenFeatureProvider>
</React.Suspense>
</OpenFeatureProvider>,);

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 });
});
});
});
});