diff --git a/packages/react/src/context/use-context-mutator.ts b/packages/react/src/context/use-context-mutator.ts index e089c871a..2229a23e8 100644 --- a/packages/react/src/context/use-context-mutator.ts +++ b/packages/react/src/context/use-context-mutator.ts @@ -39,6 +39,7 @@ export function useContextMutator(options: ContextMutationOptions = { defaultCon async ( updatedContext: EvaluationContext | ((currentContext: EvaluationContext) => EvaluationContext), ): Promise => { + // TODO: Needs to handle `isolated` option like OpenFeatureProvider const previousContext = OpenFeature.getContext(options?.defaultContext ? undefined : domain); const resolvedContext = typeof updatedContext === 'function' ? updatedContext(previousContext) : updatedContext; diff --git a/packages/react/src/provider/provider.tsx b/packages/react/src/provider/provider.tsx index 21bfada99..dadd10d21 100644 --- a/packages/react/src/provider/provider.tsx +++ b/packages/react/src/provider/provider.tsx @@ -11,6 +11,11 @@ type ClientOrDomain = * @see OpenFeature.setProvider() and overloads. */ domain?: string; + /** + * If the package-local isolated OpenFeature singleton should be used + * @see OpenFeature.isolated for more details. + */ + isolated?: boolean; client?: never; } | { @@ -19,6 +24,7 @@ type ClientOrDomain = */ client?: Client; domain?: never; + isolated?: never; }; type ProviderProps = { @@ -31,8 +37,11 @@ type ProviderProps = { * @param {ProviderProps} properties props for the context provider * @returns {OpenFeatureProvider} context provider */ -export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) { - const stableClient = React.useMemo(() => client || OpenFeature.getClient(domain), [client, domain]); +export function OpenFeatureProvider({ client, domain, isolated, children, ...options }: ProviderProps) { + const stableClient = React.useMemo( + () => client || (isolated ? OpenFeature.isolated : OpenFeature).getClient(domain), + [client, domain], + ); return {children}; } diff --git a/packages/react/src/provider/test-provider.tsx b/packages/react/src/provider/test-provider.tsx index 82bb6f045..28b1b6a1e 100644 --- a/packages/react/src/provider/test-provider.tsx +++ b/packages/react/src/provider/test-provider.tsx @@ -87,6 +87,7 @@ export function OpenFeatureTestProvider(testProviderOptions: TestProviderProps) const effectiveProvider = ( flagValueMap ? new TestProvider(flagValueMap, testProviderOptions.delayMs) : mixInNoop(provider) || NOOP_PROVIDER ) as Provider; + // TODO: Needs to handle `isolated` option like OpenFeatureProvider testProviderOptions.domain ? OpenFeature.setProvider(testProviderOptions.domain, effectiveProvider) : OpenFeature.setProvider(effectiveProvider); diff --git a/packages/react/src/provider/use-open-feature-provider.ts b/packages/react/src/provider/use-open-feature-provider.ts index f15d0321e..e2d3e6655 100644 --- a/packages/react/src/provider/use-open-feature-provider.ts +++ b/packages/react/src/provider/use-open-feature-provider.ts @@ -17,5 +17,6 @@ export function useOpenFeatureProvider(): Provider { throw new MissingContextError('No OpenFeature context available'); } + // TODO: Needs to handle `isolated` option like OpenFeatureProvider return OpenFeature.getProvider(openFeatureContext.domain); } diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 131f8c273..39ad74d0c 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -19,6 +19,7 @@ type DomainRecord = { }; const _globalThis = globalThis as OpenFeatureGlobal; +const _localThis = {} as OpenFeatureGlobal; export class OpenFeatureAPI extends OpenFeatureCommonAPI @@ -41,16 +42,19 @@ export class OpenFeatureAPI /** * Gets a singleton instance of the OpenFeature API. * @ignore + * @param {boolean} global Whether to get the global (window) singleton instance or a package-local singleton instance. * @returns {OpenFeatureAPI} OpenFeature API */ - static getInstance(): OpenFeatureAPI { - const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY]; + static getInstance(global = true): OpenFeatureAPI { + const store = global ? _globalThis : _localThis; + + const globalApi = store[GLOBAL_OPENFEATURE_API_KEY]; if (globalApi) { return globalApi; } const instance = new OpenFeatureAPI(); - _globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance; + store[GLOBAL_OPENFEATURE_API_KEY] = instance; return instance; } @@ -421,8 +425,60 @@ export class OpenFeatureAPI } } +interface OpenFeatureAPIWithIsolated extends OpenFeatureAPI { + /** + * A package-local singleton instance of the OpenFeature API. + * + * By default, the OpenFeature API is exposed as a global singleton instance (stored on `window` in browsers). + * While this can be very convenient as domains, providers, etc., are shared across an entire application, + * this can mean that in multi-frontend architectures (e.g. micro-frontends) different parts of an application + * can think they're loading different versions of OpenFeature, when they're actually all sharing the same instance. + * + * The `isolated` property provides access to a package-local singleton instance of the OpenFeature API, + * which is not shared globally, isolated from the global singleton. As such, it will not share domains, providers, + * etc., with the global singleton instance, and uses its own version of the SDK. + * + * The `isolated` property allows different parts of a multi-frontend application to have their own isolated + * OpenFeature API instances, avoiding potential conflicts and ensuring they're using the expected version of the SDK. + * However, it is still a singleton within the package though, so it will share state with other uses of the + * `isolated` instance imported from the same package within the same micro-frontend. + * @example + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * OpenFeature.setProvider(new MyGlobalProvider()); // Sets the provider for the default domain on the global instance + * OpenFeature.isolated.setProvider(new MyIsolatedProvider()); // Sets the provider for the default domain on the isolated instance + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * const isolatedClient = OpenFeature.isolated.getClient(); // Uses MyIsolatedProvider, the provider for the default domain on the isolated instance + * + * // In the same micro-frontend, in a different file ... + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * const isolatedClient = OpenFeature.isolated.getClient(); // Uses MyIsolatedProvider, the provider for the default domain on the isolated instance + * + * // In another micro-frontend, after the above has executed ... + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * const isolatedClient = OpenFeature.isolated.getClient(); // Returns the NOOP provider, as this is a different isolated instance + */ + readonly isolated: OpenFeatureAPI; +} + +const createOpenFeatureAPI = (): OpenFeatureAPIWithIsolated => { + const globalInstance = OpenFeatureAPI.getInstance(); + const localInstance = OpenFeatureAPI.getInstance(false); + + return Object.assign(globalInstance, { + get isolated() { + return localInstance; + }, + }); +}; + /** * A singleton instance of the OpenFeature API. - * @returns {OpenFeatureAPI} OpenFeature API + * @returns {OpenFeatureAPIWithIsolated} OpenFeature API */ -export const OpenFeature = OpenFeatureAPI.getInstance(); +export const OpenFeature = createOpenFeatureAPI(); diff --git a/packages/web/test/isolated.spec.ts b/packages/web/test/isolated.spec.ts new file mode 100644 index 000000000..fbd4ea44e --- /dev/null +++ b/packages/web/test/isolated.spec.ts @@ -0,0 +1,94 @@ +import type { JsonValue, OpenFeatureAPI, Provider, ProviderMetadata, ResolutionDetails } from '../src'; + +const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api'); + +class MockProvider implements Provider { + readonly metadata: ProviderMetadata; + + constructor(options?: { name?: string }) { + this.metadata = { name: options?.name ?? 'mock-provider' }; + } + + resolveBooleanEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveStringEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } +} + +const _globalThis = globalThis as { + [GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI; +}; + +describe('OpenFeature', () => { + beforeEach(() => { + Reflect.deleteProperty(_globalThis, GLOBAL_OPENFEATURE_API_KEY); + expect(_globalThis[GLOBAL_OPENFEATURE_API_KEY]).toBeUndefined(); + jest.resetModules(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should persist via globalThis (window in browsers)', async () => { + const firstInstance = (await import('../src')).OpenFeature; + + jest.resetModules(); + const secondInstance = (await import('../src')).OpenFeature; + + expect(firstInstance).toBe(secondInstance); + expect(_globalThis[GLOBAL_OPENFEATURE_API_KEY]).toBe(firstInstance); + }); + + describe('OpenFeature.isolated', () => { + it('should not be the same instance as the global singleton', async () => { + const { OpenFeature } = await import('../src'); + + expect(OpenFeature.isolated).not.toBe(OpenFeature); + }); + + it('should not share state between global and isolated instances', async () => { + const { OpenFeature, NOOP_PROVIDER } = await import('../src'); + const isolatedInstance = OpenFeature.isolated; + + const globalProvider = new MockProvider({ name: 'global-provider' }); + OpenFeature.setProvider(globalProvider); + + expect(OpenFeature.getProvider()).toBe(globalProvider); + expect(isolatedInstance.getProvider()).toBe(NOOP_PROVIDER); + + const isolatedProvider = new MockProvider({ name: 'isolated-provider' }); + isolatedInstance.setProvider(isolatedProvider); + + expect(OpenFeature.getProvider()).toBe(globalProvider); + expect(isolatedInstance.getProvider()).toBe(isolatedProvider); + }); + + it('should persist when imported multiple times', async () => { + const firstIsolatedInstance = (await import('../src')).OpenFeature.isolated; + const secondIsolatedInstance = (await import('../src')).OpenFeature.isolated; + + expect(firstIsolatedInstance).toBe(secondIsolatedInstance); + }); + + it('should not persist via globalThis (window in browsers)', async () => { + const firstIsolatedInstance = (await import('../src')).OpenFeature.isolated; + + jest.resetModules(); + const secondIsolatedInstance = (await import('../src')).OpenFeature.isolated; + + expect(firstIsolatedInstance).not.toBe(secondIsolatedInstance); + }); + }); +}); diff --git a/packages/web/test/tsconfig.json b/packages/web/test/tsconfig.json index 379a994d8..146c1b0de 100644 --- a/packages/web/test/tsconfig.json +++ b/packages/web/test/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../tsconfig.json", - "include": ["."] + "include": ["."], + "compilerOptions": { + "module": "es2020" + } }