Skip to content

Commit d4108c8

Browse files
authored
allow clearing the dedupe cache (#138)
* allow clearing the dedupe cache * add changeset * expose clearCacheForCurrentRequest * reword changeset * avoid adding prop to deduped fn
1 parent 0249e78 commit d4108c8

File tree

5 files changed

+115
-5
lines changed

5 files changed

+115
-5
lines changed

.changeset/twelve-feet-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'flags': patch
3+
---
4+
5+
- expose `clearDedupeCacheForCurrentRequest` to allow clearing the cache of a deduped function for the current request

packages/flags/src/next/dedupe.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, Mock, vitest, vi } from 'vitest';
2-
import { dedupe } from './dedupe';
2+
import { clearDedupeCacheForCurrentRequest, dedupe } from './dedupe';
33

44
const mocks = vi.hoisted(() => {
55
return {
@@ -190,6 +190,60 @@ describe('dedupe', () => {
190190
});
191191
});
192192

193+
describe('clearCurrent', () => {
194+
it('should allow dedupe to be cleared within same request', async () => {
195+
const fn = vitest.fn();
196+
const deduped = dedupe(fn);
197+
const same = new Headers();
198+
const headersMock = await getHeadersMock();
199+
headersMock.mockReturnValue(same);
200+
201+
await deduped();
202+
await deduped();
203+
await clearDedupeCacheForCurrentRequest(deduped);
204+
await deduped();
205+
await deduped();
206+
207+
expect(fn).toHaveBeenCalledTimes(2);
208+
});
209+
210+
it('should not affect the cache of a different request', async () => {
211+
const fn = vitest.fn();
212+
const deduped = dedupe(fn);
213+
const same = new Headers();
214+
const other = new Headers();
215+
const headersMock = await getHeadersMock();
216+
217+
// fill both caches and call once each, interleaved
218+
headersMock.mockReturnValue(same);
219+
await deduped();
220+
221+
headersMock.mockReturnValue(other);
222+
await deduped();
223+
224+
headersMock.mockReturnValue(same);
225+
await deduped();
226+
227+
headersMock.mockReturnValue(other);
228+
await deduped();
229+
230+
// check the fn was only called twice (once for each request)
231+
expect(fn).toHaveBeenCalledTimes(2);
232+
233+
// now clear one cache but not the other, and call once for each request
234+
headersMock.mockReturnValue(same);
235+
await clearDedupeCacheForCurrentRequest(deduped);
236+
await deduped();
237+
238+
headersMock.mockReturnValue(other);
239+
await deduped();
240+
241+
// it should go from 2 → 3 as the one request gets a cache hit, but
242+
// the other request gets a cache miss
243+
expect(fn).toHaveBeenCalledTimes(3);
244+
});
245+
});
246+
193247
describe('promises', () => {
194248
it('should dedupe even when the promise has not resolved yet', async () => {
195249
let resolvePromise: (value: unknown) => void = () => {

packages/flags/src/next/dedupe.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ enum Status {
44
ERRORED = 2,
55
}
66

7+
type RequestStore<T> = WeakMap<Headers, CacheNode<T>>;
8+
79
type CacheNode<T> = {
810
s: Status;
911
v: T | undefined | unknown;
@@ -23,6 +25,15 @@ function createCacheNode<T>(): CacheNode<T> {
2325
};
2426
}
2527

28+
/**
29+
* We use a registry to store the request store for each deduped function.
30+
*
31+
* This is necessary as we don't want to attach the request store to the deduped
32+
* to retain its original type, and we need to be able to clear the cache for the
33+
* current request.
34+
*/
35+
const cacheRegistry = new WeakMap<Function, RequestStore<unknown>>();
36+
2637
/**
2738
* A middleware-friendly version of React.cache.
2839
*
@@ -40,9 +51,9 @@ function createCacheNode<T>(): CacheNode<T> {
4051
export function dedupe<A extends Array<unknown>, T>(
4152
fn: (...args: A) => T | Promise<T>,
4253
): (...args: A) => Promise<T> {
43-
const requestStore = new WeakMap<Headers, CacheNode<T>>();
54+
const requestStore: RequestStore<T> = new WeakMap<Headers, CacheNode<T>>();
4455

45-
return async function (this: unknown, ...args: A): Promise<T> {
56+
const dedupedFn = async function (this: unknown, ...args: A): Promise<T> {
4657
// async import required as turbopack errors in Pages Router
4758
// when next/headers is imported at the top-level
4859
const { headers } = await import('next/headers');
@@ -105,4 +116,29 @@ export function dedupe<A extends Array<unknown>, T>(
105116
throw error;
106117
}
107118
};
119+
120+
cacheRegistry.set(dedupedFn, requestStore);
121+
return dedupedFn;
122+
}
123+
124+
/**
125+
* Clears the cached value of a deduped function for the current request.
126+
*
127+
* This function is useful for resetting the cache after making changes to
128+
* the underlying cached information.
129+
*/
130+
export async function clearDedupeCacheForCurrentRequest(
131+
dedupedFn: (...args: unknown[]) => unknown,
132+
) {
133+
if (typeof dedupedFn !== 'function') {
134+
throw new Error('dedupe: not a function');
135+
}
136+
const requestStore = cacheRegistry.get(dedupedFn);
137+
138+
if (!requestStore) {
139+
throw new Error('dedupe: cache not found');
140+
}
141+
const { headers } = await import('next/headers');
142+
const h = await headers();
143+
return requestStore.delete(h);
108144
}

packages/flags/src/next/index.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, it, describe, vi, beforeAll } from 'vitest';
2-
import { flag, precompute } from '.';
2+
import { flag, precompute, dedupe, clearDedupeCacheForCurrentRequest } from '.';
33
import { IncomingMessage } from 'node:http';
44
import { NextApiRequestCookies } from 'next/dist/server/api-utils';
55
import { Readable } from 'node:stream';
@@ -42,6 +42,21 @@ function createRequest(cookies = {}): [
4242
return [request, socket];
4343
}
4444

45+
describe('exports', () => {
46+
it('should export flag', () => {
47+
expect(typeof flag).toBe('function');
48+
});
49+
it('should export precompute', () => {
50+
expect(typeof precompute).toBe('function');
51+
});
52+
it('should export dedupe', () => {
53+
expect(typeof dedupe).toBe('function');
54+
});
55+
it('should export clearDedupeCacheForCurrentRequest', () => {
56+
expect(typeof clearDedupeCacheForCurrentRequest).toBe('function');
57+
});
58+
});
59+
4560
describe('flag on app router', () => {
4661
beforeAll(() => {
4762
// a random secret for testing purposes

packages/flags/src/next/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,5 +483,5 @@ export function getProviderData(
483483
return { definitions, hints: [] };
484484
}
485485

486-
export { dedupe } from './dedupe';
486+
export { dedupe, clearDedupeCacheForCurrentRequest } from './dedupe';
487487
export { createFlagsDiscoveryEndpoint } from './create-flags-discovery-endpoint';

0 commit comments

Comments
 (0)