Skip to content

Commit 0aca0d3

Browse files
[FSSDK-12298] singleton removal
1 parent dd51a39 commit 0aca0d3

7 files changed

Lines changed: 156 additions & 36 deletions

File tree

src/client/createInstance.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import { createInstance as jsCreateInstance } from '@optimizely/optimizely-sdk';
1818
import type { Config, Client } from '@optimizely/optimizely-sdk';
19+
import { getLoggerConfig } from '../logger/loggerConfigRegistry';
20+
import type { ReactLoggerConfig, ReactLogger } from '../logger/ReactLogger';
1921

2022
export const CLIENT_ENGINE = 'react-sdk';
2123
export const CLIENT_VERSION = '4.0.0';
@@ -25,6 +27,8 @@ export const REACT_CLIENT_META = Symbol('react-client-meta');
2527
export interface ReactClientMeta {
2628
hasOdpManager: boolean;
2729
hasVuidManager: boolean;
30+
loggerConfig?: ReactLoggerConfig;
31+
logger?: ReactLogger;
2832
}
2933

3034
/**
@@ -48,6 +52,7 @@ export function createInstance(config: Config): Client {
4852
reactClient[REACT_CLIENT_META] = {
4953
hasOdpManager: !!config.odpManager,
5054
hasVuidManager: !!config.vuidManager,
55+
loggerConfig: config.logger ? getLoggerConfig(config.logger) : undefined,
5156
} satisfies ReactClientMeta;
5257

5358
return reactClient;

src/logger/createLogger.spec.ts

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
import { vi, describe, it, expect, beforeEach } from 'vitest';
1818
import { DEBUG, INFO, WARN, ERROR, LogLevel } from '@optimizely/optimizely-sdk';
1919
import type { LoggerConfig, LogHandler } from '@optimizely/optimizely-sdk';
20+
import { storeLoggerConfig, getLoggerConfig } from './loggerConfigRegistry';
21+
import { createReactLogger } from './ReactLogger';
22+
import type { ReactLoggerConfig } from './ReactLogger';
2023

2124
const mockOpaqueLogger = vi.hoisted(() => ({ __opaque: true }));
2225

@@ -28,7 +31,7 @@ vi.mock('@optimizely/optimizely-sdk', async (importOriginal) => {
2831
};
2932
});
3033

31-
import { createLogger, getReactLogger } from './createLogger';
34+
import { createLogger } from './createLogger';
3235

3336
describe('createLogger', () => {
3437
beforeEach(() => {
@@ -41,24 +44,62 @@ describe('createLogger', () => {
4144
expect(result).toBe(mockOpaqueLogger);
4245
});
4346

44-
it('should make the ReactLogger available via getReactLogger', () => {
45-
const config: LoggerConfig = { level: INFO };
46-
createLogger(config);
47-
48-
const reactLogger = getReactLogger();
49-
expect(reactLogger).toBeDefined();
50-
expect(typeof reactLogger!.debug).toBe('function');
51-
expect(typeof reactLogger!.info).toBe('function');
52-
expect(typeof reactLogger!.warn).toBe('function');
53-
expect(typeof reactLogger!.error).toBe('function');
47+
it('should store the resolved config in the registry', () => {
48+
const mockHandler: LogHandler = { log: vi.fn() };
49+
createLogger({ level: INFO, logHandler: mockHandler });
50+
51+
const storedConfig = getLoggerConfig(mockOpaqueLogger);
52+
expect(storedConfig).toBeDefined();
53+
expect(storedConfig!.logLevel).toBe(LogLevel.Info);
54+
expect(storedConfig!.logHandler).toBe(mockHandler);
55+
});
56+
57+
describe('log level resolution', () => {
58+
it.each([
59+
{ preset: DEBUG, expected: LogLevel.Debug, name: 'DEBUG' },
60+
{ preset: INFO, expected: LogLevel.Info, name: 'INFO' },
61+
{ preset: WARN, expected: LogLevel.Warn, name: 'WARN' },
62+
{ preset: ERROR, expected: LogLevel.Error, name: 'ERROR' },
63+
])('should resolve $name preset to LogLevel.$name', ({ preset, expected }) => {
64+
createLogger({ level: preset });
65+
const storedConfig = getLoggerConfig(mockOpaqueLogger);
66+
expect(storedConfig!.logLevel).toBe(expected);
67+
});
5468
});
69+
});
70+
71+
describe('loggerConfigRegistry', () => {
72+
it('should return undefined for unknown logger objects', () => {
73+
expect(getLoggerConfig({})).toBeUndefined();
74+
});
75+
76+
it('should store and retrieve config for a given logger', () => {
77+
const logger = {};
78+
const config: ReactLoggerConfig = { logLevel: LogLevel.Warn };
79+
storeLoggerConfig(logger, config);
80+
expect(getLoggerConfig(logger)).toBe(config);
81+
});
82+
83+
it('should support multiple loggers with different configs', () => {
84+
const logger1 = {};
85+
const logger2 = {};
86+
const config1: ReactLoggerConfig = { logLevel: LogLevel.Debug };
87+
const config2: ReactLoggerConfig = { logLevel: LogLevel.Error };
5588

89+
storeLoggerConfig(logger1, config1);
90+
storeLoggerConfig(logger2, config2);
91+
92+
expect(getLoggerConfig(logger1)).toBe(config1);
93+
expect(getLoggerConfig(logger2)).toBe(config2);
94+
});
95+
});
96+
97+
describe('createReactLogger', () => {
5698
describe('log level filtering', () => {
5799
it('should filter messages below the configured level', () => {
58100
const mockHandler: LogHandler = { log: vi.fn() };
59-
createLogger({ level: WARN, logHandler: mockHandler });
101+
const logger = createReactLogger({ logLevel: LogLevel.Warn, logHandler: mockHandler });
60102

61-
const logger = getReactLogger()!;
62103
logger.debug('should not appear');
63104
logger.info('should not appear');
64105
logger.warn('should appear');
@@ -69,11 +110,10 @@ describe('createLogger', () => {
69110
expect(mockHandler.log).toHaveBeenCalledWith(LogLevel.Error, '[ReactSDK] should appear');
70111
});
71112

72-
it('should allow all messages when level is DEBUG', () => {
113+
it('should allow all messages when level is Debug', () => {
73114
const mockHandler: LogHandler = { log: vi.fn() };
74-
createLogger({ level: DEBUG, logHandler: mockHandler });
115+
const logger = createReactLogger({ logLevel: LogLevel.Debug, logHandler: mockHandler });
75116

76-
const logger = getReactLogger()!;
77117
logger.debug('d');
78118
logger.info('i');
79119
logger.warn('w');
@@ -82,11 +122,10 @@ describe('createLogger', () => {
82122
expect(mockHandler.log).toHaveBeenCalledTimes(4);
83123
});
84124

85-
it('should only allow error messages when level is ERROR', () => {
125+
it('should only allow error messages when level is Error', () => {
86126
const mockHandler: LogHandler = { log: vi.fn() };
87-
createLogger({ level: ERROR, logHandler: mockHandler });
127+
const logger = createReactLogger({ logLevel: LogLevel.Error, logHandler: mockHandler });
88128

89-
const logger = getReactLogger()!;
90129
logger.debug('d');
91130
logger.info('i');
92131
logger.warn('w');
@@ -97,26 +136,40 @@ describe('createLogger', () => {
97136
});
98137
});
99138

100-
describe('custom log handler', () => {
139+
describe('log handler', () => {
101140
it('should use the provided logHandler', () => {
102141
const mockHandler: LogHandler = { log: vi.fn() };
103-
createLogger({ level: INFO, logHandler: mockHandler });
142+
const logger = createReactLogger({ logLevel: LogLevel.Info, logHandler: mockHandler });
104143

105-
const logger = getReactLogger()!;
106144
logger.info('hello');
107145

108146
expect(mockHandler.log).toHaveBeenCalledWith(LogLevel.Info, '[ReactSDK] hello');
109147
});
110148

111149
it('should use default console handler when logHandler is not provided', () => {
112150
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
113-
createLogger({ level: INFO });
151+
const logger = createReactLogger({ logLevel: LogLevel.Info });
114152

115-
const logger = getReactLogger()!;
116153
logger.info('hello');
117154

118155
expect(consoleSpy).toHaveBeenCalledWith('[ReactSDK] hello');
119156
consoleSpy.mockRestore();
120157
});
121158
});
159+
160+
describe('message prefix', () => {
161+
it('should prepend [ReactSDK] to all messages', () => {
162+
const mockHandler: LogHandler = { log: vi.fn() };
163+
const logger = createReactLogger({ logLevel: LogLevel.Debug, logHandler: mockHandler });
164+
165+
logger.debug('test');
166+
logger.info('test');
167+
logger.warn('test');
168+
logger.error('test');
169+
170+
for (const call of (mockHandler.log as ReturnType<typeof vi.fn>).mock.calls) {
171+
expect(call[1]).toMatch(/^\[ReactSDK\] /);
172+
}
173+
});
174+
});
122175
});

src/logger/createLogger.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@
1616

1717
import { createLogger as jsCreateLogger, LogLevel, DEBUG, INFO, WARN, ERROR } from '@optimizely/optimizely-sdk';
1818
import type { LoggerConfig, OpaqueLevelPreset } from '@optimizely/optimizely-sdk';
19-
import { createReactLogger } from './ReactLogger';
20-
import type { ReactLogger } from './ReactLogger';
21-
22-
let reactLogger: ReactLogger | undefined;
19+
import { storeLoggerConfig } from './loggerConfigRegistry';
2320

2421
function resolveLogLevel(preset: OpaqueLevelPreset): LogLevel {
2522
if (preset === DEBUG) return LogLevel.Debug;
@@ -32,14 +29,10 @@ function resolveLogLevel(preset: OpaqueLevelPreset): LogLevel {
3229
export function createLogger(config: LoggerConfig) {
3330
const opaqueLogger = jsCreateLogger(config);
3431

35-
reactLogger = createReactLogger({
32+
storeLoggerConfig(opaqueLogger, {
3633
logLevel: resolveLogLevel(config.level),
3734
logHandler: config.logHandler,
3835
});
3936

4037
return opaqueLogger;
4138
}
42-
43-
export function getReactLogger(): ReactLogger | undefined {
44-
return reactLogger;
45-
}

src/logger/getReactLogger.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { Client } from '@optimizely/optimizely-sdk';
18+
import { REACT_CLIENT_META } from '../client/createInstance';
19+
import type { ReactClientMeta } from '../client/createInstance';
20+
import { createReactLogger } from './ReactLogger';
21+
22+
/**
23+
* Returns the cached ReactLogger instance for the given client.
24+
* Creates it lazily on first call; subsequent calls return the same instance.
25+
* Returns undefined if the client has no logger config (e.g., logger was
26+
* not created via the React SDK's createLogger wrapper).
27+
*/
28+
export function getReactLogger(client: Client) {
29+
const meta = (client as unknown as Record<symbol, ReactClientMeta>)[REACT_CLIENT_META];
30+
31+
if (meta.logger) return meta.logger;
32+
33+
if (meta.loggerConfig) {
34+
meta.logger = createReactLogger(meta.loggerConfig);
35+
return meta.logger;
36+
}
37+
38+
return undefined;
39+
}

src/logger/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
export { createLogger, getReactLogger } from './createLogger';
17+
export { createLogger } from './createLogger';
18+
export { getReactLogger } from './getReactLogger';
1819
export { createReactLogger } from './ReactLogger';
1920
export type { ReactLogger, ReactLoggerConfig } from './ReactLogger';

src/logger/loggerConfigRegistry.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { ReactLoggerConfig } from './ReactLogger';
18+
19+
// WeakMap keyed by OpaqueLogger objects. Bridges the gap between
20+
// createLogger() and createInstance() — supports multiple clients with
21+
// different configs and automatically releases entries when the logger is GC'd.
22+
const registry = new WeakMap<object, ReactLoggerConfig>();
23+
24+
export function storeLoggerConfig(logger: object, config: ReactLoggerConfig): void {
25+
registry.set(logger, config);
26+
}
27+
28+
export function getLoggerConfig(logger: object): ReactLoggerConfig | undefined {
29+
return registry.get(logger);
30+
}

src/provider/OptimizelyProvider.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { NOTIFICATION_TYPES } from '@optimizely/optimizely-sdk';
1919

2020
import { ProviderStateStore } from './ProviderStateStore';
2121
import { UserContextManager } from '../utils/UserContextManager';
22-
import { getReactLogger } from '../logger/index';
2322
import type { OptimizelyProviderProps, OptimizelyContextValue } from './types';
2423
import type { Client } from '@optimizely/optimizely-sdk';
2524

@@ -75,7 +74,7 @@ export function OptimizelyProvider({
7574
// Readiness is derived from userContext + getOptimizelyConfig() by hooks.
7675
useEffect(() => {
7776
if (!client) {
78-
getReactLogger()?.error('OptimizelyProvider must be passed an Optimizely client instance');
77+
console.error('[ReactSDK] OptimizelyProvider must be passed an Optimizely client instance');
7978
store.setError(new Error('Optimizely client is required'));
8079
return;
8180
}

0 commit comments

Comments
 (0)