Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/every-goats-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vercel/flags-core": patch
---

Fixed an issue where concurrent flag evaluations (e.g. `Promise.all([client.evaluate('a'), client.evaluate('b')])`) would each trigger a separate initialization, causing a flood of network requests to the flags service. Also fixed stream disconnect during initialization from starting a duplicate polling cycle.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ npm-debug.log
**/playwright-report/
**/blob-report/
**/playwright/.cache/
examples/shirt-shop-vercel/

.source
.next
next-env.d.ts
next-env.d.ts
96 changes: 80 additions & 16 deletions packages/vercel-flags-core/src/client-fns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ describe('client-fns', () => {
describe('initialize', () => {
it('should call dataSource.initialize()', async () => {
const dataSource = createMockDataSource();
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

await initialize(CLIENT_ID);

Expand All @@ -88,7 +92,11 @@ describe('client-fns', () => {
const dataSource = createMockDataSource({
initialize: vi.fn().mockResolvedValue('init-result'),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const result = await initialize(CLIENT_ID);

Expand All @@ -103,7 +111,11 @@ describe('client-fns', () => {
describe('shutdown', () => {
it('should call dataSource.shutdown()', async () => {
const dataSource = createMockDataSource();
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

await shutdown(CLIENT_ID);

Expand All @@ -114,7 +126,11 @@ describe('client-fns', () => {
const dataSource = createMockDataSource({
shutdown: vi.fn().mockResolvedValue('shutdown-result'),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const result = await shutdown(CLIENT_ID);

Expand All @@ -140,7 +156,11 @@ describe('client-fns', () => {
const dataSource = createMockDataSource({
getFallbackDatafile: getFallbackDatafileFn,
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

await getFallbackDatafile(CLIENT_ID);

Expand All @@ -159,7 +179,11 @@ describe('client-fns', () => {
const dataSource = createMockDataSource({
getFallbackDatafile: vi.fn().mockResolvedValue(mockFallback),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const result = await getFallbackDatafile(CLIENT_ID);

Expand All @@ -170,7 +194,11 @@ describe('client-fns', () => {
const dataSource = createMockDataSource();
// Remove getFallbackDatafile
delete (dataSource as Partial<DataSource>).getFallbackDatafile;
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

expect(() => getFallbackDatafile(CLIENT_ID)).toThrow(
'flags: This data source does not support fallbacks',
Expand All @@ -194,7 +222,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const result = await evaluate(CLIENT_ID, 'nonexistent-flag', 'default');

Expand All @@ -219,7 +251,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const result = await evaluate(CLIENT_ID, 'missing', { fallback: true });

Expand All @@ -242,7 +278,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const result = await evaluate(CLIENT_ID, 'my-flag', false);

Expand All @@ -267,7 +307,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

await evaluate(CLIENT_ID, 'my-flag', 'default');

Expand Down Expand Up @@ -297,7 +341,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

await evaluate(CLIENT_ID, 'my-flag');

Expand All @@ -315,7 +363,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

await evaluate(CLIENT_ID, 'nonexistent');

Expand Down Expand Up @@ -345,7 +397,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const result = await evaluate(CLIENT_ID, 'targeted-flag', 'default', {
user: { id: 'user-123' },
Expand Down Expand Up @@ -374,7 +430,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

// Call without entities
const result = await evaluate(CLIENT_ID, 'my-flag');
Expand Down Expand Up @@ -414,7 +474,11 @@ describe('client-fns', () => {
}),
),
});
clientMap.set(CLIENT_ID, { dataSource, initialized: false });
clientMap.set(CLIENT_ID, {
dataSource,
initialized: false,
initPromise: null,
});

const boolResult = await evaluate<boolean>(CLIENT_ID, 'bool-flag');
expect(boolResult.value).toBe(true);
Expand Down
11 changes: 7 additions & 4 deletions packages/vercel-flags-core/src/client-map.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { DataSource } from './types';

export const clientMap = new Map<
number,
{ dataSource: DataSource; initialized: boolean }
>();
export type ClientInstance = {
dataSource: DataSource;
initialized: boolean;
initPromise: Promise<void> | null;
};

export const clientMap = new Map<number, ClientInstance>();
56 changes: 56 additions & 0 deletions packages/vercel-flags-core/src/create-raw-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,62 @@ describe('createCreateRawClient', () => {

expect(clientMap.size).toBe(1);
});

it('should deduplicate concurrent initialize() calls', async () => {
const fns = createMockFns();
// Make initialize take some time so concurrent calls overlap
fns.initialize.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 50)),
);
const createRawClient = createCreateRawClient(fns);
const dataSource = createMockDataSource();

const client = createRawClient({ dataSource });

await Promise.all([
client.initialize(),
client.initialize(),
client.initialize(),
]);

expect(fns.initialize).toHaveBeenCalledTimes(1);
});

it('should deduplicate concurrent evaluate() calls that trigger initialize()', async () => {
const fns = createMockFns();
fns.initialize.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 50)),
);
const createRawClient = createCreateRawClient(fns);
const dataSource = createMockDataSource();

const client = createRawClient({ dataSource });

await Promise.all([
client.evaluate('flag-a'),
client.evaluate('flag-b'),
client.evaluate('flag-c'),
]);

expect(fns.initialize).toHaveBeenCalledTimes(1);
expect(fns.evaluate).toHaveBeenCalledTimes(3);
});

it('should allow re-initialization after failure', async () => {
const fns = createMockFns();
fns.initialize
.mockRejectedValueOnce(new Error('init failed'))
.mockResolvedValueOnce(undefined);
const createRawClient = createCreateRawClient(fns);
const dataSource = createMockDataSource();

const client = createRawClient({ dataSource });

await expect(client.initialize()).rejects.toThrow('init failed');
await client.initialize();

expect(fns.initialize).toHaveBeenCalledTimes(2);
});
});

describe('shutdown', () => {
Expand Down
34 changes: 26 additions & 8 deletions packages/vercel-flags-core/src/create-raw-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
initialize,
shutdown,
} from './client-fns';
import { clientMap } from './client-map';
import { type ClientInstance, clientMap } from './client-map';
import type {
BundledDefinitions,
DataSource,
Expand All @@ -16,6 +16,20 @@ import type {

let idCount = 0;

async function performInitialize(
instance: ClientInstance,
initFn: () => Promise<void>,
): Promise<void> {
try {
await initFn();
instance.initialized = true;
} catch (error) {
// Clear so next call can retry
instance.initPromise = null;
throw error;
}
}

export function createCreateRawClient(fns: {
initialize: typeof initialize;
shutdown: typeof shutdown;
Expand All @@ -31,23 +45,27 @@ export function createCreateRawClient(fns: {
origin?: { provider: string; sdkKey: string };
}): FlagsClient {
const id = idCount++;
clientMap.set(id, { dataSource, initialized: false });
clientMap.set(id, { dataSource, initialized: false, initPromise: null });

const api = {
origin,
initialize: async () => {
let instance = clientMap.get(id);
if (!instance) {
instance = { dataSource, initialized: false };
instance = { dataSource, initialized: false, initPromise: null };
clientMap.set(id, instance);
}

// skip promise if already initialized
// skip if already initialized
if (instance.initialized) return;
const promise = fns.initialize(id);
await promise;
instance.initialized = true;
return promise;

if (!instance.initPromise) {
instance.initPromise = performInitialize(instance, () =>
fns.initialize(id),
);
}

return instance.initPromise;
},
shutdown: async () => {
await fns.shutdown(id);
Expand Down
Loading
Loading