From fae169f9ff0225a11d3e1e3415f03bcb6033cda6 Mon Sep 17 00:00:00 2001 From: Mike Chu <104384559+mikechu-optimizely@users.noreply.github.com> Date: Tue, 9 Apr 2024 08:31:31 -0400 Subject: [PATCH] [FSSDK-9984] fix: initialization and setUser errors (#255) * fix: remove successful fetch requirement for onReady * Revert "fix: remove successful fetch requirement for onReady" This reverts commit 566daaff800e349a8e7dfbb8beeef5cca7b930f4. * fix: error with OnReadyResult being undefined * fix: setUser should use VUID if possible * revert: timeout of undefined * docs: update copyright year * revert: Provider.tsx copyright since no code change * build: bump JS SDK version * refactor: `res` should never be `undefined` * docs: add clarifying comment * revert: retrieval & use of current user context * wip: partial solution; needs collab * refactor: setUser logic updated * revert: move setUser back to Provider constructor * style: remove commented code * fix: fetchQualifiedSegments under SSR/sync scenario * ci: VS Code jest settings to run via extension * test: use NotReadyReason enum * test: use NotReadyReason & add missing getUserId in mock user context * fix: add onInitStateChange for default ready result * fix: logic in Promise.all user & client readiness * refactor: isReady() to isReactClientReady() * test: fixes for uses of getUserId() in setUser() * wip: fixing tests * wip: fixed more tests * revert: refactor of isReactClientReady() * docs: Update copyrights * fix: later setUser not getting new usercontext (manual testing) * fix: PR review changes * test: add initial OptimizelyProvider tests * fix: PR review changes * docs: add clarification inline comments * build: bump underlying JS SDK version to 5.3.0 * fix: add missing getProjectConfig() from JS SDK * refactor: omit getProjectConfig instead of implementing it * style: remove unused import --------- Co-authored-by: Mike Chu --- .vscode/launch.json | 25 +++++++++ .vscode/settings.json | 5 +- package.json | 2 +- src/Feature.spec.tsx | 6 +-- src/Provider.spec.tsx | 95 ++++++++++++++++++++++++++++++++ src/Provider.tsx | 11 ++-- src/client.spec.ts | 87 ++++++++++++++++++++++-------- src/client.ts | 123 ++++++++++++++++++++++++++++-------------- src/hooks.ts | 102 ++++++++++++++++++++--------------- yarn.lock | 14 ++--- 10 files changed, 347 insertions(+), 123 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/Provider.spec.tsx diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4193108 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "name": "vscode-jest-tests.v2.react-sdk", + "request": "launch", + "args": [ + "--runInBand", + "--watchAll=false", + "--testNamePattern", + "${jest.testNamePattern}", + "--runTestsByPath", + "${jest.testFile}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "windows": { + "program": "${workspaceFolder}/node_modules/jest/bin/jest" + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 669180a..e79b4db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,4 @@ { - "jest.autoRun": { - "onStartup": ["all-tests"] - } + "jest.runMode": "on-demand", + "jest.jestCommandLine": "~/.nvm/nvm-exec yarn test" } diff --git a/package.json b/package.json index 8d55dd1..54de565 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "access": "public" }, "dependencies": { - "@optimizely/optimizely-sdk": "^5.2.0", + "@optimizely/optimizely-sdk": "^5.3.0", "hoist-non-react-statics": "^3.3.0", "prop-types": "^15.6.2", "utility-types": "^2.1.0 || ^3.0.0" diff --git a/src/Feature.spec.tsx b/src/Feature.spec.tsx index a747e80..bc2b824 100644 --- a/src/Feature.spec.tsx +++ b/src/Feature.spec.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2018-2019, 2023 Optimizely + * Copyright 2018-2019, 2023-2024 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { OptimizelyProvider } from './Provider'; -import { ReactSDKClient, VariableValuesObject } from './client'; +import { NotReadyReason, ReactSDKClient, VariableValuesObject } from './client'; import { OptimizelyFeature } from './Feature'; describe('', () => { @@ -298,7 +298,7 @@ describe('', () => { // while it's waiting for onReady() expect(container.innerHTML).toBe(''); - resolver.resolve({ success: false, reason: 'fail', dataReadyPromise: Promise.resolve() }); + resolver.resolve({ success: false, reason: NotReadyReason.TIMEOUT, dataReadyPromise: Promise.resolve() }); // Simulate config update notification firing after datafile fetched await optimizelyMock.onReady().then(res => res.dataReadyPromise); diff --git a/src/Provider.spec.tsx b/src/Provider.spec.tsx new file mode 100644 index 0000000..98739c2 --- /dev/null +++ b/src/Provider.spec.tsx @@ -0,0 +1,95 @@ +/** + * Copyright 2024 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// + +//jest.mock('./client'); + +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { OptimizelyProvider } from './Provider'; +import { DefaultUser, ReactSDKClient, createInstance } from './client'; + +describe('OptimizelyProvider', () => { + let mockReactClient: ReactSDKClient; + const config = { + datafile: {}, + }; + + beforeEach(() => { + mockReactClient = ({ + user: { + id: 'test-id', + attributes: {}, + }, + setUser: jest.fn().mockResolvedValue(undefined), + } as unknown) as ReactSDKClient; + }); + + it('should render successfully with user provided', () => { + act(() => { + render(); + }); + + expect(mockReactClient.setUser).toHaveBeenCalledWith({ + id: 'user1', + attributes: {}, + }); + }); + + it('should render successfully with userId provided', () => { + act(() => { + render(); + }); + + expect(mockReactClient.setUser).toHaveBeenCalledWith({ + id: 'user1', + attributes: {}, + }); + }); + + it('should render successfully without user or userId provided', () => { + act(() => { + render(); + }); + + expect(mockReactClient.setUser).toHaveBeenCalledWith(DefaultUser); + }); + + it('should render successfully with user id & attributes provided', () => { + act(() => { + render( + + ); + }); + + expect(mockReactClient.setUser).toHaveBeenCalledWith({ + id: 'user1', + attributes: { attr1: 'value1' }, + }); + }); + + it('should succeed just userAttributes provided', () => { + act(() => { + render(); + }); + + expect(mockReactClient.setUser).toHaveBeenCalledWith({ + id: DefaultUser.id, + attributes: { attr1: 'value1' }, + }); + }); +}); diff --git a/src/Provider.tsx b/src/Provider.tsx index 17b7fb3..a3612bc 100644 --- a/src/Provider.tsx +++ b/src/Provider.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,9 +43,7 @@ interface OptimizelyProviderState { export class OptimizelyProvider extends React.Component { constructor(props: OptimizelyProviderProps) { super(props); - } - componentDidMount(): void { this.setUserInOptimizely(); } @@ -78,12 +76,15 @@ export class OptimizelyProvider extends React.Component { import * as optimizely from '@optimizely/optimizely-sdk'; -import { createInstance, OnReadyResult, ReactSDKClient } from './client'; +import { createInstance, DefaultUser, NotReadyReason, OnReadyResult, ReactSDKClient } from './client'; import { logger } from './logger'; interface MockedReactSDKClient extends ReactSDKClient { @@ -37,6 +37,7 @@ interface MockedReactSDKClient extends ReactSDKClient { } describe('ReactSDKClient', () => { + const validVuid = 'vuid_8de3bb278fce47f6b000cadc1ac'; const config: optimizely.Config = { datafile: {}, }; @@ -51,6 +52,8 @@ describe('ReactSDKClient', () => { decideAll: jest.fn(), decideForKeys: jest.fn(), fetchQualifiedSegments: jest.fn(), + getUserId: jest.fn(), + getAttributes: jest.fn(), setForcedDecision: jest.fn(), removeForcedDecision: jest.fn(), removeAllForcedDecisions: jest.fn(), @@ -75,6 +78,7 @@ describe('ReactSDKClient', () => { getFeatureVariableInteger: jest.fn(() => null), getFeatureVariableString: jest.fn(() => null), getOptimizelyConfig: jest.fn(() => null), + getProjectConfig: jest.fn(() => null), onReady: jest.fn(() => Promise.resolve({ success: false })), close: jest.fn(), getVuid: jest.fn(), @@ -144,32 +148,41 @@ describe('ReactSDKClient', () => { }); it('fulfills the returned promise with success: true when a user is set', async () => { - jest.spyOn(instance, 'fetchQualifiedSegments').mockImplementation(async () => true); + jest.spyOn(mockInnerClient, 'onReady').mockResolvedValue({ success: true }); + const instance = createInstance(config); + jest.spyOn(instance, 'fetchQualifiedSegments').mockResolvedValue(true); + await instance.setUser({ id: 'user12345', }); + const result = await instance.onReady(); + expect(result.success).toBe(true); }); it('fulfills the returned promise with success: false when fetchqualifiedsegment is false', async () => { - jest.spyOn(instance, 'fetchQualifiedSegments').mockImplementation(async () => false); + jest.spyOn(mockInnerClient, 'onReady').mockResolvedValue({ success: true }); + const instance = createInstance(config); + jest.spyOn(instance, 'fetchQualifiedSegments').mockResolvedValue(false); await instance.setUser({ id: 'user12345', }); const result = await instance.onReady(); + expect(result.success).toBe(false); }); describe('if Optimizely client is null', () => { beforeEach(() => { - // Mocks dataReadyPromise value instead of _client = null because test initialization of instance causes dataReadyPromise to return { success: true } + // Mocks clientAndUserReadyPromise value instead of _client = null because test initialization of + // instance causes clientAndUserReadyPromise to return { success: true } // @ts-ignore - instance.dataReadyPromise = new Promise((resolve, reject) => { + instance.clientAndUserReadyPromise = new Promise(resolve => { resolve({ success: false, - reason: 'NO_CLIENT', + reason: NotReadyReason.NO_CLIENT, message: 'Optimizely client failed to initialize.', }); }); @@ -185,16 +198,20 @@ describe('ReactSDKClient', () => { it('waits for the inner client onReady to fulfill with success = false before fulfilling the returned promise', async () => { const mockInnerClientOnReady = jest.spyOn(mockInnerClient, 'onReady'); - let resolveInnerClientOnReady: (result: OnReadyResult) => void; + let resolveInnerClientOnReady: (result: OnReadyResult) => void = () => {}; const mockReadyPromise: Promise = new Promise(res => { resolveInnerClientOnReady = res; }); mockInnerClientOnReady.mockReturnValueOnce(mockReadyPromise); + const userId = 'user999'; + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); + resolveInnerClientOnReady({ success: true }); + await instance.setUser({ - id: 'user999', + id: userId, }); - resolveInnerClientOnReady!({ success: true }); const result = await instance.onReady(); + expect(result.success).toBe(false); }); }); @@ -206,6 +223,7 @@ describe('ReactSDKClient', () => { resolveInnerClientOnReady = res; }); mockInnerClientOnReady.mockReturnValueOnce(mockReadyPromise); + const instance = createInstance(config); jest.spyOn(instance, 'fetchQualifiedSegments').mockImplementation(async () => true); await instance.setUser({ id: 'user999', @@ -217,16 +235,29 @@ describe('ReactSDKClient', () => { }); describe('setUser', () => { + it('can be called with no/default user set', async () => { + const instance = createInstance(config); + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(validVuid); + + await instance.setUser(DefaultUser); + + expect(instance.user.id).toEqual(validVuid); + }); + it('updates the user object with id and attributes', async () => { + const userId = 'xxfueaojfe8&86'; + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); + const instance = createInstance(config); await instance.setUser({ - id: 'xxfueaojfe8&86', + id: userId, attributes: { foo: 'bar', }, }); + expect(instance.user).toEqual({ - id: 'xxfueaojfe8&86', + id: userId, attributes: { foo: 'bar', }, @@ -234,33 +265,39 @@ describe('ReactSDKClient', () => { }); it('adds and removes update handlers', async () => { + const userId = 'newUser'; + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); const instance = createInstance(config); const onUserUpdateListener = jest.fn(); const dispose = instance.onUserUpdate(onUserUpdateListener); + await instance.setUser({ - id: 'newUser', + id: userId, }); + expect(onUserUpdateListener).toBeCalledTimes(1); expect(onUserUpdateListener).toBeCalledWith({ - id: 'newUser', + id: userId, attributes: {}, }); + dispose(); await instance.setUser({ id: 'newUser2', }); + expect(onUserUpdateListener).toBeCalledTimes(1); }); - it('does not call fetchqualifiedsegements on setUser if onready is not calleed initially', async () => { + it('implicitly calls fetchqualifiedsegements', async () => { const instance = createInstance(config); - jest.spyOn(instance, 'fetchQualifiedSegments').mockImplementation(async () => true); + jest.spyOn(instance, 'fetchQualifiedSegments').mockResolvedValue(true); await instance.setUser({ id: 'xxfueaojfe8&86', }); - expect(instance.fetchQualifiedSegments).toBeCalledTimes(0); + expect(instance.fetchQualifiedSegments).toBeCalledTimes(1); }); it('calls fetchqualifiedsegements internally on each setuser call after onready', async () => { @@ -286,9 +323,11 @@ describe('ReactSDKClient', () => { describe('pre-set user and user overrides', () => { let instance: ReactSDKClient; beforeEach(async () => { + const userId = 'user1'; + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); instance = createInstance(config); await instance.setUser({ - id: 'user1', + id: userId, attributes: { foo: 'bar', }, @@ -1067,9 +1106,11 @@ describe('ReactSDKClient', () => { } } ); + const userId = 'user1123'; + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); const instance = createInstance(config); await instance.setUser({ - id: 'user1123', + id: userId, }); const result = instance.getFeatureVariables('feat1'); expect(result).toEqual({ @@ -1353,9 +1394,11 @@ describe('ReactSDKClient', () => { describe('setForcedDecision', () => { let instance: ReactSDKClient; beforeEach(async () => { + const userId = 'user1'; + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); instance = createInstance(config); await instance.setUser({ - id: 'user1', + id: userId, attributes: { foo: 'bar', }, @@ -1374,7 +1417,6 @@ describe('ReactSDKClient', () => { variables: {}, variationKey: 'varition1', }); - // @ts-ignore instance._client = null; @@ -1462,6 +1504,8 @@ describe('ReactSDKClient', () => { describe('removeForcedDecision', () => { let instance: ReactSDKClient; beforeEach(async () => { + const userId = 'user1'; + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); instance = createInstance(config); await instance.setUser({ id: 'user1', @@ -1640,7 +1684,6 @@ describe('ReactSDKClient', () => { }); it('should return a valid vuid', async () => { - const validVuid = 'vuid_8de3bb278fce47f6b000cadc1ac'; const mockGetVuid = mockInnerClient.getVuid as jest.Mock; mockGetVuid.mockReturnValue(validVuid); diff --git a/src/client.ts b/src/client.ts index 008df57..5c57bc7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -30,7 +30,11 @@ type OnUserUpdateHandler = (userInfo: UserInfo) => void; type OnForcedVariationsUpdateHandler = () => void; -type NotReadyReason = 'TIMEOUT' | 'NO_CLIENT' | 'USER_NOT_READY'; +export enum NotReadyReason { + TIMEOUT = 'TIMEOUT', + NO_CLIENT = 'NO_CLIENT', + USER_NOT_READY = 'USER_NOT_READY', +} type ResolveResult = { success: boolean; @@ -50,7 +54,7 @@ export const DefaultUser: UserInfo = { attributes: {}, }; -export interface ReactSDKClient extends Omit { +export interface ReactSDKClient extends Omit { user: UserInfo; onReady(opts?: { timeout?: number }): Promise; @@ -192,6 +196,12 @@ export const DEFAULT_ON_READY_TIMEOUT = 5_000; class OptimizelyReactSDKClient implements ReactSDKClient { private userContext: optimizely.OptimizelyUserContext | null = null; private onUserUpdateHandlers: OnUserUpdateHandler[] = []; + private userPromiseResolver: (resolveResult: ResolveResult) => void; + private isUserPromiseResolved = false; + // Its usually true from the beginning when user is provided as an object in the `OptimizelyProvider` + // This becomes more significant when a promise is provided instead. + private isUserReady = false; + private onForcedVariationsUpdateHandlers: OnForcedVariationsUpdateHandler[] = []; private forcedDecisionFlagKeys: Set = new Set(); @@ -199,14 +209,14 @@ class OptimizelyReactSDKClient implements ReactSDKClient { private isClientReady = false; // We need to add autoupdate listener to the hooks after the instance became fully ready to avoid redundant updates to hooks - private isReadyPromiseFulfilled = false; + private clientAndUserReadyPromiseFulfilled = false; private isUsingSdkKey = false; private readonly _client: optimizely.Client | null; // promise keeping track of async requests for initializing client instance - private dataReadyPromise: Promise; + private clientAndUserReadyPromise: Promise; public initialConfig: optimizely.Config; public user: UserInfo = { ...DefaultUser }; @@ -217,36 +227,45 @@ class OptimizelyReactSDKClient implements ReactSDKClient { */ constructor(config: optimizely.Config) { this.initialConfig = config; - const configWithClientInfo = { ...config, clientEngine: REACT_SDK_CLIENT_ENGINE, clientVersion: REACT_SDK_CLIENT_VERSION, }; + this.userPromiseResolver = () => {}; + const userReadyPromise = new Promise(resolve => { + this.userPromiseResolver = resolve; + }); + this._client = optimizely.createInstance(configWithClientInfo); this.isClientReady = !!configWithClientInfo.datafile; this.isUsingSdkKey = !!configWithClientInfo.sdkKey; if (this._client) { - this.dataReadyPromise = this._client.onReady().then((clientResult: { success: boolean }) => { - this.isReadyPromiseFulfilled = true; - this.isClientReady = true; + const clientReadyPromise = this._client.onReady(); - return { - success: true, - message: clientResult.success - ? 'Successfully resolved client datafile.' - : 'Client datafile was not not ready.', - }; - }); + this.clientAndUserReadyPromise = Promise.all([userReadyPromise, clientReadyPromise]).then( + ([userResult, clientResult]) => { + this.isClientReady = clientResult.success; + this.isUserReady = userResult.success; + const clientAndUserReady = this.isClientReady && this.isUserReady; + + this.clientAndUserReadyPromiseFulfilled = true; + + return { + success: clientAndUserReady, + message: clientAndUserReady ? 'Client and user are both ready.' : 'Client or user did not become ready.', + }; + } + ); } else { logger.warn('Unable to resolve datafile and user information because Optimizely client failed to initialize.'); - this.dataReadyPromise = new Promise(resolve => { + this.clientAndUserReadyPromise = new Promise(resolve => { resolve({ success: false, - reason: 'NO_CLIENT', + reason: NotReadyReason.NO_CLIENT, message: 'Optimizely client failed to initialize.', }); }); @@ -265,43 +284,36 @@ class OptimizelyReactSDKClient implements ReactSDKClient { } public getIsReadyPromiseFulfilled(): boolean { - return this.isReadyPromiseFulfilled; + return this.clientAndUserReadyPromiseFulfilled; } public getIsUsingSdkKey(): boolean { return this.isUsingSdkKey; } + private get odpExplicitlyOff() { + return this.initialConfig.odpOptions?.disabled; + } + public onReady(config: { timeout?: number } = {}): Promise { let timeoutId: number | undefined; let timeout: number = DEFAULT_ON_READY_TIMEOUT; if (config && config.timeout !== undefined) { timeout = config.timeout; } - const timeoutPromise = new Promise(resolve => { timeoutId = setTimeout(() => { resolve({ success: false, - reason: 'TIMEOUT', - message: 'Failed to initialize onReady before timeout, data was not set before the timeout period', - dataReadyPromise: this.dataReadyPromise, + reason: NotReadyReason.TIMEOUT, + message: 'Failed to initialize before timeout', + dataReadyPromise: this.clientAndUserReadyPromise, }); }, timeout) as any; }); - return Promise.race([this.dataReadyPromise, timeoutPromise]).then(async res => { + return Promise.race([this.clientAndUserReadyPromise, timeoutPromise]).then(async res => { clearTimeout(timeoutId); - if (res.success && !this.initialConfig.odpOptions?.disabled) { - const isSegmentsFetched = await this.fetchQualifiedSegments(); - if (!isSegmentsFetched) { - return { - success: false, - reason: 'USER_NOT_READY', - message: 'Failed to fetch qualified segments', - }; - } - } return res; }); } @@ -326,13 +338,22 @@ class OptimizelyReactSDKClient implements ReactSDKClient { return; } - if (!this.userContext || (this.userContext && !areUsersEqual(userInfo, this.user))) { + if (!this.userContext) { + this.userContext = this._client.createUserContext(userInfo.id || undefined, userInfo.attributes); + return; + } + + const currentUserContextUserInfo: UserInfo = { + id: this.userContext.getUserId(), + attributes: this.userContext.getAttributes(), + }; + if (!areUsersEqual(userInfo, currentUserContextUserInfo)) { this.userContext = this._client.createUserContext(userInfo.id || undefined, userInfo.attributes); } } private makeUserContextInstance(userInfo: UserInfo): optimizely.OptimizelyUserContext | null { - if (!this._client || !this.isReady()) { + if (!this._client) { logger.warn( `Unable to create user context for ${userInfo.id}. Optimizely client failed to initialize or not ready.` ); @@ -343,7 +364,11 @@ class OptimizelyReactSDKClient implements ReactSDKClient { } public async fetchQualifiedSegments(options?: optimizely.OptimizelySegmentOption[]): Promise { - if (!this.userContext || !this.isReady()) { + if (this.odpExplicitlyOff) { + return true; + } + + if (!this.userContext) { return false; } @@ -351,15 +376,33 @@ class OptimizelyReactSDKClient implements ReactSDKClient { } public async setUser(userInfo: UserInfo): Promise { - this.setCurrentUserContext(userInfo); - this.user = { id: userInfo.id || DefaultUser.id, attributes: userInfo.attributes || DefaultUser.attributes, }; - if (this.getIsReadyPromiseFulfilled()) { - await this.fetchQualifiedSegments(); + // if user is anonymous... + if (userInfo.id === DefaultUser.id) { + // wait for the SDK client to be ready before... + await this._client?.onReady(); + // setting the user context + this.setCurrentUserContext(userInfo); + + // (potentially) retrieve the VUID set in JS userContext or noop or to DefaultUser + this.user.id = this.userContext?.getUserId() || DefaultUser.id; + } else { + // synchronous user context setting is required including for server side rendering (SSR) + this.setCurrentUserContext(userInfo); + + // we need to wait for fetch qualified segments success for failure + await this._client?.onReady(); + } + + const fetchQualifedSegmentsSucceed = await this.fetchQualifiedSegments(); + + if (!this.isUserPromiseResolved) { + this.userPromiseResolver({ success: fetchQualifedSegmentsSucceed }); + this.isUserPromiseResolved = true; } this.onUserUpdateHandlers.forEach(handler => handler(this.user)); diff --git a/src/hooks.ts b/src/hooks.ts index f13bd30..038f3c5 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,5 +1,5 @@ /** - * Copyright 2018-2019, 2022-2023, Optimizely + * Copyright 2018-2019, 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ import { useCallback, useContext, useEffect, useState, useRef } from 'react'; import { UserAttributes, OptimizelyDecideOption, getLogger } from '@optimizely/optimizely-sdk'; import { setupAutoUpdateListeners } from './autoUpdate'; -import { ReactSDKClient, VariableValuesObject, OnReadyResult } from './client'; +import { ReactSDKClient, VariableValuesObject, OnReadyResult, NotReadyReason } from './client'; import { notifier } from './notifier'; import { OptimizelyContext } from './Context'; import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils'; @@ -66,7 +66,7 @@ interface UseExperiment { (experimentKey: string, options?: HookOptions, overrides?: HookOverrides): [ ExperimentDecisionValues['variation'], ClientReady, - DidTimeout, + DidTimeout ]; } @@ -75,7 +75,7 @@ interface UseFeature { FeatureDecisionValues['isEnabled'], FeatureDecisionValues['variables'], ClientReady, - DidTimeout, + DidTimeout ]; } @@ -83,7 +83,7 @@ interface UseDecision { (featureKey: string, options?: DecideHooksOptions, overrides?: HookOverrides): [ OptimizelyDecision, ClientReady, - DidTimeout, + DidTimeout ]; } @@ -127,7 +127,7 @@ function subscribeToInitialization( .onReady({ timeout }) .then((res: OnReadyResult) => { if (res.success) { - hooksLogger.info('Client became ready'); + hooksLogger.info('Client immediately ready'); onInitStateChange({ clientReady: true, didTimeout: false, @@ -137,13 +137,13 @@ function subscribeToInitialization( switch (res.reason) { // Optimizely client failed to initialize. - case 'NO_CLIENT': + case NotReadyReason.NO_CLIENT: hooksLogger.warn(`Client not ready, reason="${res.message}"`); onInitStateChange({ clientReady: false, didTimeout: false, }); - res.dataReadyPromise!.then(() => { + res.dataReadyPromise?.then(() => { hooksLogger.info('Client became ready.'); onInitStateChange({ clientReady: true, @@ -151,21 +151,47 @@ function subscribeToInitialization( }); }); break; - // Assume timeout for all other cases. - // TODO: Other reasons may fall into this case - need to update later to specify 'TIMEOUT' case and general fallback case. - default: - hooksLogger.info(`Client did not become ready before timeout of ${timeout}ms, reason="${res.message}"`); + case NotReadyReason.USER_NOT_READY: + hooksLogger.warn(`User was not ready, reason="${res.message}"`); + onInitStateChange({ + clientReady: false, + didTimeout: false, + }); + res.dataReadyPromise?.then(() => { + hooksLogger.info('User became ready later.'); + onInitStateChange({ + clientReady: true, + didTimeout: false, + }); + }); + break; + case NotReadyReason.TIMEOUT: + hooksLogger.info(`Client did not become ready before timeout of ${timeout} ms, reason="${res.message}"`); onInitStateChange({ clientReady: false, didTimeout: true, }); - res.dataReadyPromise!.then(() => { + res.dataReadyPromise?.then(() => { hooksLogger.info('Client became ready after timeout already elapsed'); onInitStateChange({ clientReady: true, didTimeout: true, }); }); + break; + default: + hooksLogger.warn(`Other reason client not ready, reason="${res.message}"`); + onInitStateChange({ + clientReady: false, + didTimeout: true, // assume timeout + }); + res.dataReadyPromise?.then(() => { + hooksLogger.info('Client became ready later'); + onInitStateChange({ + clientReady: true, + didTimeout: true, // assume timeout + }); + }); } }) .catch(() => { @@ -192,7 +218,9 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); if (!optimizely) { - hooksLogger.error(`Unable to use experiment ${experimentKey}. optimizely prop must be supplied via a parent `); + hooksLogger.error( + `Unable to use experiment ${experimentKey}. optimizely prop must be supplied via a parent ` + ); return [null, false, false]; } @@ -259,7 +287,7 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri })); }); } - return (): void => { }; + return (): void => {}; }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); useEffect( @@ -287,13 +315,10 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); if (!optimizely) { - hooksLogger.error(`Unable to properly use feature ${featureKey}. optimizely prop must be supplied via a parent `); - return [ - false, - {}, - false, - false, - ]; + hooksLogger.error( + `Unable to properly use feature ${featureKey}. optimizely prop must be supplied via a parent ` + ); + return [false, {}, false, false]; } const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); @@ -359,15 +384,10 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) })); }); } - return (): void => { }; + return (): void => {}; }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, featureKey, getCurrentDecision]); - return [ - state.isEnabled, - state.variables, - state.clientReady, - state.didTimeout, - ]; + return [state.isEnabled, state.variables, state.clientReady, state.didTimeout]; }; /** @@ -381,7 +401,9 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); if (!optimizely) { - hooksLogger.error(`Unable to use decision ${flagKey}. optimizely prop must be supplied via a parent `); + hooksLogger.error( + `Unable to use decision ${flagKey}. optimizely prop must be supplied via a parent ` + ); return [ createFailedDecision(flagKey, 'Optimizely SDK not configured properly yet.', { id: null, @@ -389,7 +411,7 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) }), false, false, - ] + ]; } const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); @@ -403,11 +425,11 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) const decisionState = isClientReady ? getCurrentDecision() : { - decision: createFailedDecision(flagKey, 'Optimizely SDK not configured properly yet.', { - id: overrides.overrideUserId || null, - attributes: overrideAttrs, - }), - }; + decision: createFailedDecision(flagKey, 'Optimizely SDK not configured properly yet.', { + id: overrides.overrideUserId || null, + attributes: overrideAttrs, + }), + }; return { ...decisionState, clientReady: isClientReady, @@ -473,12 +495,8 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) })); }); } - return (): void => { }; + return (): void => {}; }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, flagKey, getCurrentDecision]); - return [ - state.decision, - state.clientReady, - state.didTimeout, - ]; + return [state.decision, state.clientReady, state.didTimeout]; }; diff --git a/yarn.lock b/yarn.lock index 8f314f7..43f39ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -655,10 +655,10 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@optimizely/optimizely-sdk@^5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-5.2.0.tgz#6449336182515dd4cca9d6913e24ca8e814e6278" - integrity sha512-JxdhWHU9OiJKrKfyWe9lL+2/e28gBZnPNHQAA+heyWTAtcuxZWdSZ24bIkLwAAuIf26dh89o9LNB2L8aKPmwWw== +"@optimizely/optimizely-sdk@^5.3.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-5.3.0.tgz#dd1ca9d19287b31675d2f95c24234e074ade9a8b" + integrity sha512-PzfjcApCvcHGir8XWSG3IBaOJXvPADjqpzXypEWTfArrONA3FlmqdnwDAlxF4b557fo/UZI6ZCyj3AWrG8cprg== dependencies: decompress-response "^4.2.1" json-schema "^0.4.0" @@ -1624,7 +1624,7 @@ decode-uri-component@^0.2.0: decompress-response@^4.2.1: version "4.2.1" - resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== dependencies: mimic-response "^2.0.0" @@ -3311,7 +3311,7 @@ json-schema-traverse@^0.4.1: json-schema@^0.4.0: version "0.4.0" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== json-stable-stringify-without-jsonify@^1.0.1: @@ -3511,7 +3511,7 @@ mimic-fn@^2.1.0: mimic-response@^2.0.0: version "2.1.0" - resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== min-indent@^1.0.0: