From 4232b6dce3ad30b4990694e584984d41c2f260c0 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 23 Jan 2025 15:22:08 +0200 Subject: [PATCH 1/9] throw when decide returns undefined --- packages/flags/src/index.ts | 2 + packages/flags/src/next/index.test.ts | 166 +++++++++++++++++++++++--- packages/flags/src/next/index.ts | 44 ++++--- packages/flags/src/types.ts | 4 +- 4 files changed, 182 insertions(+), 34 deletions(-) diff --git a/packages/flags/src/index.ts b/packages/flags/src/index.ts index 2f35f9c..3266163 100644 --- a/packages/flags/src/index.ts +++ b/packages/flags/src/index.ts @@ -12,6 +12,8 @@ export type { FlagOverridesType, FlagDeclaration, GenerousOption, + Identify, + Decide, } from './types'; export { safeJsonStringify } from './lib/safe-json-stringify'; export { encrypt, decrypt } from './lib/crypto'; diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 75c3465..6bb2879 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -3,7 +3,7 @@ import { flag, precompute } from '.'; import { IncomingMessage } from 'node:http'; import { NextApiRequestCookies } from 'next/dist/server/api-utils'; import { Readable } from 'node:stream'; -import { encrypt } from '..'; +import { type Adapter, encrypt } from '..'; const mocks = vi.hoisted(() => { return { @@ -50,7 +50,7 @@ describe('flag on app router', () => { it('allows declaring a flag', async () => { mocks.headers.mockReturnValueOnce(new Headers()); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: () => false, }); @@ -62,7 +62,7 @@ describe('flag on app router', () => { it('caches for the duration of a request', async () => { let i = 0; const decide = vi.fn(() => i++); - const f = await flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); // first request using the flag twice const headersOfFirstRequest = new Headers(); @@ -94,7 +94,7 @@ describe('flag on app router', () => { const mockDecide = vi.fn(() => promise); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, }); @@ -119,7 +119,7 @@ describe('flag on app router', () => { it('respects overrides', async () => { const decide = vi.fn(() => false); - const f = await flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); // first request using the flag twice const headersOfFirstRequest = new Headers(); @@ -166,7 +166,7 @@ describe('flag on app router', () => { const mockDecide = vi.fn(() => promise); const catchFn = vi.fn(); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, defaultValue: false, @@ -185,6 +185,48 @@ describe('flag on app router', () => { expect(catchFn).not.toHaveBeenCalled(); expect(mockDecide).toHaveBeenCalledTimes(1); }); + + it('falls back to the defaultValue when a decide function returns undefined', async () => { + const syncFlag = flag({ + key: 'sync-flag', + // @ts-expect-error this is the case we are testing + decide: () => undefined, + defaultValue: true, + }); + + await expect(syncFlag()).resolves.toEqual(true); + + const asyncFlag = flag({ + key: 'async-flag', + // @ts-expect-error this is the case we are testing + decide: async () => undefined, + defaultValue: true, + }); + + await expect(asyncFlag()).resolves.toEqual(true); + }); + + it('throws an error when the decide function returns undefined and no defaultValue is provided (sync)', async () => { + const syncFlag = flag({ + key: 'sync-flag', + // @ts-expect-error this is the case we are testing + decide: () => undefined, + }); + + await expect(syncFlag()).rejects.toThrow( + '@vercel/flags: Flag "sync-flag" must have a defaultValue or a decide function that returns a value', + ); + + const asyncFlag = flag({ + key: 'async-flag', + // @ts-expect-error this is the case we are testing + decide: async () => undefined, + }); + + await expect(asyncFlag()).rejects.toThrow( + '@vercel/flags: Flag "async-flag" must have a defaultValue or a decide function that returns a value', + ); + }); }); describe('flag on pages router', () => { @@ -196,7 +238,7 @@ describe('flag on pages router', () => { it('allows declaring a flag', async () => { mocks.headers.mockReturnValueOnce(new Headers()); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: () => false, }); @@ -214,7 +256,7 @@ describe('flag on pages router', () => { it('caches for the duration of a request', async () => { let i = 0; const decide = vi.fn(() => i++); - const f = await flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); const [firstRequest, socket1] = createRequest(); const [secondRequest, socket2] = createRequest(); @@ -246,7 +288,7 @@ describe('flag on pages router', () => { const mockDecide = vi.fn(() => promise); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, }); @@ -272,18 +314,69 @@ describe('flag on pages router', () => { const mockDecide = vi.fn(() => { throw new Error('custom error'); }); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, }); + + const [firstRequest, socket1] = createRequest(); expect(mockDecide).toHaveBeenCalledTimes(0); - await expect(() => f()).rejects.toThrow('custom error'); + await expect(() => f(firstRequest)).rejects.toThrow('custom error'); expect(mockDecide).toHaveBeenCalledTimes(1); + socket1.destroy(); + }); + + it('falls back to the defaultValue when a decide function returns undefined', async () => { + const [firstRequest, socket1] = createRequest(); + const syncFlag = flag({ + key: 'sync-flag', + // @ts-expect-error this is the case we are testing + decide: () => undefined, + defaultValue: true, + }); + + await expect(syncFlag(firstRequest)).resolves.toEqual(true); + + const asyncFlag = flag({ + key: 'async-flag', + // @ts-expect-error this is the case we are testing + decide: async () => undefined, + defaultValue: true, + }); + + await expect(asyncFlag(firstRequest)).resolves.toEqual(true); + + socket1.destroy(); + }); + + it('throws an error when the decide function returns undefined and no defaultValue is provided (sync)', async () => { + const [firstRequest, socket1] = createRequest(); + const syncFlag = flag({ + key: 'sync-flag', + // @ts-expect-error this is the case we are testing + decide: () => undefined, + }); + + await expect(syncFlag(firstRequest)).rejects.toThrow( + '@vercel/flags: Flag "sync-flag" must have a defaultValue or a decide function that returns a value', + ); + + const asyncFlag = flag({ + key: 'async-flag', + // @ts-expect-error this is the case we are testing + decide: async () => undefined, + }); + + await expect(asyncFlag(firstRequest)).rejects.toThrow( + '@vercel/flags: Flag "async-flag" must have a defaultValue or a decide function that returns a value', + ); + + socket1.destroy(); }); it('respects overrides', async () => { const decide = vi.fn(() => false); - const f = await flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); const override = await encrypt({ 'first-flag': true }); const [firstRequest, socket1] = createRequest({ @@ -313,7 +406,7 @@ describe('flag on pages router', () => { const mockDecide = vi.fn(() => promise); const catchFn = vi.fn(); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, defaultValue: false, @@ -341,7 +434,7 @@ describe('dynamic io', () => { (error as any).digest = 'DYNAMIC_SERVER_USAGE;dynamic usage error'; throw error; }); - const f = await flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, defaultValue: false, @@ -351,3 +444,48 @@ describe('dynamic io', () => { expect(mockDecide).toHaveBeenCalledTimes(1); }); }); + +describe('adapters', () => { + function createTestAdapter() { + return function testAdapter( + value: ValueType, + ): Adapter { + return { + decide: () => value, + origin: (key) => `fake-origin#${key}`, + }; + }; + } + + it("should use the adapter's decide function when provided", async () => { + const testAdapter = createTestAdapter(); + + mocks.headers.mockReturnValueOnce(new Headers()); + + const f = flag({ + key: 'adapter-flag', + adapter: testAdapter(5), + }); + + expect(f).toHaveProperty('key', 'adapter-flag'); + await expect(f()).resolves.toEqual(5); + expect(f).toHaveProperty('origin', 'fake-origin#adapter-flag'); + }); + + it("should throw when an adapter's decide function returns undefined", async () => { + const testAdapter = createTestAdapter(); + + mocks.headers.mockReturnValueOnce(new Headers()); + + const f = flag({ + key: 'adapter-flag', + adapter: testAdapter(undefined), + }); + + expect(f).toHaveProperty('key', 'adapter-flag'); + await expect(f()).rejects.toThrow( + '@vercel/flags: Flag "adapter-flag" must have a defaultValue or a decide function that returns a value', + ); + expect(f).toHaveProperty('origin', 'fake-origin#adapter-flag'); + }); +}); diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index d743e17..cf01939 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -2,7 +2,6 @@ import { RequestCookies } from '@edge-runtime/cookies'; import { type FlagDefinitionType, type ProviderData, - type JsonValue, reportValue, type FlagDefinitionsType, } from '..'; @@ -290,22 +289,32 @@ function getRun( }), ) // catch errors in async "decide" functions - .catch((error: Error) => { - if (isInternalNextError(error)) throw error; - - // try to recover if defaultValue is set - if (definition.defaultValue !== undefined) { + .then( + (value) => { + if (value !== undefined) return value; + if (definition.defaultValue !== undefined) + return definition.defaultValue; + throw new Error( + `@vercel/flags: Flag "${definition.key}" must have a defaultValue or a decide function that returns a value`, + ); + }, + (error: Error) => { + if (isInternalNextError(error)) throw error; + + // try to recover if defaultValue is set + if (definition.defaultValue !== undefined) { + console.warn( + `@vercel/flags: Flag "${definition.key}" is falling back to the defaultValue after catching the following error`, + error, + ); + return definition.defaultValue; + } console.warn( - `@vercel/flags: Flag "${definition.key}" is falling back to the defaultValue after catching the following error`, - error, + `@vercel/flags: Flag "${definition.key}" could not be evaluated`, ); - return definition.defaultValue; - } - console.warn( - `@vercel/flags: Flag "${definition.key}" could not be evaluated`, - ); - throw error; - }); + throw error; + }, + ); } catch (error) { if (isInternalNextError(error)) throw error; @@ -366,10 +375,7 @@ function getOrigin( * @param definition - Information about the feature flag. * @returns - A feature flag declaration */ -export function flag< - ValueType extends JsonValue = boolean | string | number, - EntitiesType = any, ->( +export function flag( definition: FlagDeclaration, ): Flag { const decide = getDecide(definition); diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index bf9ad8e..0c71df6 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -127,11 +127,13 @@ export type GenerousOption = boolean extends T ? T | FlagOption : FlagOption; +type NonUndefined = T extends undefined | Promise ? never : T; + export type Decide = ( params: FlagParamsType & { entities?: EntitiesType; }, -) => Promise | ValueType; +) => Promise> | NonUndefined; export type Identify = ( params: FlagParamsType, From 28c4d2d8720c65e6629b1778f543a5da4677421e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 23 Jan 2025 15:26:29 +0200 Subject: [PATCH 2/9] throw on undefined unless defaultValue is defined --- packages/flags/src/next/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 6bb2879..cc9edd4 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -206,7 +206,7 @@ describe('flag on app router', () => { await expect(asyncFlag()).resolves.toEqual(true); }); - it('throws an error when the decide function returns undefined and no defaultValue is provided (sync)', async () => { + it('throws an error when the decide function returns undefined and no defaultValue is provided', async () => { const syncFlag = flag({ key: 'sync-flag', // @ts-expect-error this is the case we are testing @@ -349,7 +349,7 @@ describe('flag on pages router', () => { socket1.destroy(); }); - it('throws an error when the decide function returns undefined and no defaultValue is provided (sync)', async () => { + it('throws an error when the decide function returns undefined and no defaultValue is provided', async () => { const [firstRequest, socket1] = createRequest(); const syncFlag = flag({ key: 'sync-flag', From 097e3e19e61512dc2b7442ac5435dc743bd6e7dc Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 23 Jan 2025 17:20:34 +0200 Subject: [PATCH 3/9] wip --- packages/flags/src/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index 0c71df6..bf9ad8e 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -127,13 +127,11 @@ export type GenerousOption = boolean extends T ? T | FlagOption : FlagOption; -type NonUndefined = T extends undefined | Promise ? never : T; - export type Decide = ( params: FlagParamsType & { entities?: EntitiesType; }, -) => Promise> | NonUndefined; +) => Promise | ValueType; export type Identify = ( params: FlagParamsType, From cbeb229087356938ee3d1a2e54d1d626a181fb02 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 23 Jan 2025 20:19:45 +0200 Subject: [PATCH 4/9] bring back JsonValue --- packages/flags/src/next/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index cf01939..74b350b 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -10,6 +10,7 @@ import type { FlagDeclaration, FlagParamsType, Identify, + JsonValue, Origin, } from '../types'; import type { Flag, PrecomputedFlag, PagesRouterFlag } from './types'; @@ -375,7 +376,10 @@ function getOrigin( * @param definition - Information about the feature flag. * @returns - A feature flag declaration */ -export function flag( +export function flag< + ValueType extends JsonValue = boolean | string | number, + EntitiesType = any, +>( definition: FlagDeclaration, ): Flag { const decide = getDecide(definition); From 0bdb934050ccbb9568744e82166bd6fd18724bc4 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 23 Jan 2025 20:43:56 +0200 Subject: [PATCH 5/9] do not enforce on typescript level for now --- packages/flags/src/next/index.test.ts | 6 ------ packages/flags/src/next/precompute.ts | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index cc9edd4..58ce273 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -189,7 +189,6 @@ describe('flag on app router', () => { it('falls back to the defaultValue when a decide function returns undefined', async () => { const syncFlag = flag({ key: 'sync-flag', - // @ts-expect-error this is the case we are testing decide: () => undefined, defaultValue: true, }); @@ -198,7 +197,6 @@ describe('flag on app router', () => { const asyncFlag = flag({ key: 'async-flag', - // @ts-expect-error this is the case we are testing decide: async () => undefined, defaultValue: true, }); @@ -209,7 +207,6 @@ describe('flag on app router', () => { it('throws an error when the decide function returns undefined and no defaultValue is provided', async () => { const syncFlag = flag({ key: 'sync-flag', - // @ts-expect-error this is the case we are testing decide: () => undefined, }); @@ -330,7 +327,6 @@ describe('flag on pages router', () => { const [firstRequest, socket1] = createRequest(); const syncFlag = flag({ key: 'sync-flag', - // @ts-expect-error this is the case we are testing decide: () => undefined, defaultValue: true, }); @@ -339,7 +335,6 @@ describe('flag on pages router', () => { const asyncFlag = flag({ key: 'async-flag', - // @ts-expect-error this is the case we are testing decide: async () => undefined, defaultValue: true, }); @@ -353,7 +348,6 @@ describe('flag on pages router', () => { const [firstRequest, socket1] = createRequest(); const syncFlag = flag({ key: 'sync-flag', - // @ts-expect-error this is the case we are testing decide: () => undefined, }); diff --git a/packages/flags/src/next/precompute.ts b/packages/flags/src/next/precompute.ts index 0304208..d8671ea 100644 --- a/packages/flags/src/next/precompute.ts +++ b/packages/flags/src/next/precompute.ts @@ -94,7 +94,7 @@ export async function deserialize( * @param code - The code returned from `serialize` * @param secret - The secret to use for verifying the signature */ -export async function getPrecomputed( +export async function getPrecomputed( flag: Flag, precomputeFlags: FlagsArray, code: string, From ab832bbd9b052e62b4c853d01c89c947d2c7b7c2 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 24 Jan 2025 14:09:33 +0200 Subject: [PATCH 6/9] add changeset --- .changeset/few-wombats-reply.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/few-wombats-reply.md diff --git a/.changeset/few-wombats-reply.md b/.changeset/few-wombats-reply.md new file mode 100644 index 0000000..ee8f729 --- /dev/null +++ b/.changeset/few-wombats-reply.md @@ -0,0 +1,8 @@ +--- +'@vercel/flags': patch +--- + +- fix: Fall back to `defaultValue` when a feature flag returns `undefined` +- fix: Throw error when a flag resolves to `undefined` and no `defaultValue` is present + +The value `undefined` can not be serialized so feature flags should never resolve to `undefined`. Use `null` instead. From 1775370ca5b3486ded2700aa577592a73653555c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 24 Jan 2025 14:10:14 +0200 Subject: [PATCH 7/9] mention exported types --- .changeset/few-wombats-reply.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/few-wombats-reply.md b/.changeset/few-wombats-reply.md index ee8f729..e8483c7 100644 --- a/.changeset/few-wombats-reply.md +++ b/.changeset/few-wombats-reply.md @@ -4,5 +4,6 @@ - fix: Fall back to `defaultValue` when a feature flag returns `undefined` - fix: Throw error when a flag resolves to `undefined` and no `defaultValue` is present +- fix: Export `Identify` and `Decide` types The value `undefined` can not be serialized so feature flags should never resolve to `undefined`. Use `null` instead. From 5bca4c4c2e171dc67aceed2ad09a9e7459b8f48b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 24 Jan 2025 14:16:17 +0200 Subject: [PATCH 8/9] add precise types --- .../precompute/automatic/[code]/flags.tsx | 4 +- .../feature-flags-in-edge-middleware/flags.ts | 2 +- .../app/getting-started/overview/page.tsx | 2 +- examples/snippets/flags.ts | 2 +- .../lib/pages-router-precomputed/flags.ts | 2 +- examples/summer-sale/flags.ts | 2 +- packages/flags/README.md | 2 +- packages/flags/src/next/index.test.ts | 61 ++++++++++++------- packages/flags/src/next/precompute.test.ts | 28 +++++---- packages/flags/src/sveltekit/index.test.ts | 2 +- 10 files changed, 63 insertions(+), 44 deletions(-) diff --git a/examples/snippets/app/concepts/precompute/automatic/[code]/flags.tsx b/examples/snippets/app/concepts/precompute/automatic/[code]/flags.tsx index 5fffb9f..9f08dbe 100644 --- a/examples/snippets/app/concepts/precompute/automatic/[code]/flags.tsx +++ b/examples/snippets/app/concepts/precompute/automatic/[code]/flags.tsx @@ -1,11 +1,11 @@ import { flag } from '@vercel/flags/next'; -export const firstPrecomputedFlag = flag({ +export const firstPrecomputedFlag = flag({ key: 'first-precomputed-flag', decide: () => Math.random() > 0.5, }); -export const secondPrecomputedFlag = flag({ +export const secondPrecomputedFlag = flag({ key: 'second-precomputed-flag', decide: () => Date.now() % 2 === 0, }); diff --git a/examples/snippets/app/examples/feature-flags-in-edge-middleware/flags.ts b/examples/snippets/app/examples/feature-flags-in-edge-middleware/flags.ts index d4605ff..3dee36d 100644 --- a/examples/snippets/app/examples/feature-flags-in-edge-middleware/flags.ts +++ b/examples/snippets/app/examples/feature-flags-in-edge-middleware/flags.ts @@ -1,6 +1,6 @@ import { flag } from '@vercel/flags/next'; -export const basicEdgeMiddlewareFlag = flag({ +export const basicEdgeMiddlewareFlag = flag({ key: 'basic-edge-middleware-flag', decide({ cookies }) { return cookies.get('basic-edge-middleware-flag')?.value === '1'; diff --git a/examples/snippets/app/getting-started/overview/page.tsx b/examples/snippets/app/getting-started/overview/page.tsx index 8df55e5..dd588cd 100644 --- a/examples/snippets/app/getting-started/overview/page.tsx +++ b/examples/snippets/app/getting-started/overview/page.tsx @@ -3,7 +3,7 @@ import { DemoFlag } from '@/components/demo-flag'; import { ReloadButton } from './reload-button'; // declare a feature flag -const randomFlag = flag({ +const randomFlag = flag({ key: 'random-flag', decide() { // this flag will be on for 50% of visitors diff --git a/examples/snippets/flags.ts b/examples/snippets/flags.ts index b027b80..dbacde4 100644 --- a/examples/snippets/flags.ts +++ b/examples/snippets/flags.ts @@ -1,6 +1,6 @@ import { flag } from '@vercel/flags/next'; -export const exampleFlag = flag({ +export const exampleFlag = flag({ key: 'example-flag', decide() { return true; diff --git a/examples/snippets/lib/pages-router-precomputed/flags.ts b/examples/snippets/lib/pages-router-precomputed/flags.ts index cedfa3b..7550f96 100644 --- a/examples/snippets/lib/pages-router-precomputed/flags.ts +++ b/examples/snippets/lib/pages-router-precomputed/flags.ts @@ -1,6 +1,6 @@ import { flag } from '@vercel/flags/next'; -export const exampleFlag = flag({ +export const exampleFlag = flag({ key: 'pages-router-precomputed-example-flag', decide() { return true; diff --git a/examples/summer-sale/flags.ts b/examples/summer-sale/flags.ts index 734e22b..43e5f8b 100644 --- a/examples/summer-sale/flags.ts +++ b/examples/summer-sale/flags.ts @@ -37,7 +37,7 @@ export const showFreeDeliveryBannerFlag = flag({ ], }); -export const countryFlag = flag({ +export const countryFlag = flag({ key: 'country', async decide({ headers }) { return headers.get('accept-language') || 'no accept-lanugage header'; diff --git a/packages/flags/README.md b/packages/flags/README.md index e0723c6..79f67f4 100644 --- a/packages/flags/README.md +++ b/packages/flags/README.md @@ -40,7 +40,7 @@ Create a file called flags.ts in your project and declare your first feature fla // app/flags.tsx import { flag } from '@vercel/flags/next'; -export const exampleFlag = flag({ +export const exampleFlag = flag({ key: 'example-flag', decide() { return true; diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 58ce273..9396746 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -50,7 +50,7 @@ describe('flag on app router', () => { it('allows declaring a flag', async () => { mocks.headers.mockReturnValueOnce(new Headers()); - const f = flag({ + const f = flag({ key: 'first-flag', decide: () => false, }); @@ -62,7 +62,7 @@ describe('flag on app router', () => { it('caches for the duration of a request', async () => { let i = 0; const decide = vi.fn(() => i++); - const f = flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); // first request using the flag twice const headersOfFirstRequest = new Headers(); @@ -94,7 +94,7 @@ describe('flag on app router', () => { const mockDecide = vi.fn(() => promise); - const f = flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, }); @@ -119,7 +119,7 @@ describe('flag on app router', () => { it('respects overrides', async () => { const decide = vi.fn(() => false); - const f = flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); // first request using the flag twice const headersOfFirstRequest = new Headers(); @@ -139,7 +139,11 @@ describe('flag on app router', () => { it('uses precomputed values', async () => { const decide = vi.fn(() => true); - const f = flag({ key: 'first-flag', decide, options: [false, true] }); + const f = flag({ + key: 'first-flag', + decide, + options: [false, true], + }); const flagGroup = [f]; const code = await precompute(flagGroup); expect(decide).toHaveBeenCalledTimes(1); @@ -149,7 +153,7 @@ describe('flag on app router', () => { it('uses precomputed values even when options are inferred', async () => { const decide = vi.fn(() => true); - const f = flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); const flagGroup = [f]; const code = await precompute(flagGroup); expect(decide).toHaveBeenCalledTimes(1); @@ -166,7 +170,7 @@ describe('flag on app router', () => { const mockDecide = vi.fn(() => promise); const catchFn = vi.fn(); - const f = flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, defaultValue: false, @@ -187,16 +191,18 @@ describe('flag on app router', () => { }); it('falls back to the defaultValue when a decide function returns undefined', async () => { - const syncFlag = flag({ + const syncFlag = flag({ key: 'sync-flag', + // @ts-expect-error this is the case we are testing decide: () => undefined, defaultValue: true, }); await expect(syncFlag()).resolves.toEqual(true); - const asyncFlag = flag({ + const asyncFlag = flag({ key: 'async-flag', + // @ts-expect-error this is the case we are testing decide: async () => undefined, defaultValue: true, }); @@ -205,8 +211,9 @@ describe('flag on app router', () => { }); it('throws an error when the decide function returns undefined and no defaultValue is provided', async () => { - const syncFlag = flag({ + const syncFlag = flag({ key: 'sync-flag', + // @ts-expect-error this is the case we are testing decide: () => undefined, }); @@ -235,7 +242,7 @@ describe('flag on pages router', () => { it('allows declaring a flag', async () => { mocks.headers.mockReturnValueOnce(new Headers()); - const f = flag({ + const f = flag({ key: 'first-flag', decide: () => false, }); @@ -253,7 +260,7 @@ describe('flag on pages router', () => { it('caches for the duration of a request', async () => { let i = 0; const decide = vi.fn(() => i++); - const f = flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); const [firstRequest, socket1] = createRequest(); const [secondRequest, socket2] = createRequest(); @@ -285,7 +292,7 @@ describe('flag on pages router', () => { const mockDecide = vi.fn(() => promise); - const f = flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, }); @@ -311,7 +318,7 @@ describe('flag on pages router', () => { const mockDecide = vi.fn(() => { throw new Error('custom error'); }); - const f = flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, }); @@ -325,16 +332,18 @@ describe('flag on pages router', () => { it('falls back to the defaultValue when a decide function returns undefined', async () => { const [firstRequest, socket1] = createRequest(); - const syncFlag = flag({ + const syncFlag = flag({ key: 'sync-flag', + // @ts-expect-error this is the case we are testing decide: () => undefined, defaultValue: true, }); await expect(syncFlag(firstRequest)).resolves.toEqual(true); - const asyncFlag = flag({ + const asyncFlag = flag({ key: 'async-flag', + // @ts-expect-error this is the case we are testing decide: async () => undefined, defaultValue: true, }); @@ -346,8 +355,9 @@ describe('flag on pages router', () => { it('throws an error when the decide function returns undefined and no defaultValue is provided', async () => { const [firstRequest, socket1] = createRequest(); - const syncFlag = flag({ + const syncFlag = flag({ key: 'sync-flag', + // @ts-expect-error this is the case we are testing decide: () => undefined, }); @@ -370,7 +380,7 @@ describe('flag on pages router', () => { it('respects overrides', async () => { const decide = vi.fn(() => false); - const f = flag({ key: 'first-flag', decide }); + const f = flag({ key: 'first-flag', decide }); const override = await encrypt({ 'first-flag': true }); const [firstRequest, socket1] = createRequest({ @@ -383,7 +393,11 @@ describe('flag on pages router', () => { it('uses precomputed values', async () => { const decide = vi.fn(() => true); - const f = flag({ key: 'first-flag', decide, options: [false, true] }); + const f = flag({ + key: 'first-flag', + decide, + options: [false, true], + }); const flagGroup = [f]; const code = await precompute(flagGroup); expect(decide).toHaveBeenCalledTimes(1); @@ -400,7 +414,7 @@ describe('flag on pages router', () => { const mockDecide = vi.fn(() => promise); const catchFn = vi.fn(); - const f = flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, defaultValue: false, @@ -428,7 +442,7 @@ describe('dynamic io', () => { (error as any).digest = 'DYNAMIC_SERVER_USAGE;dynamic usage error'; throw error; }); - const f = flag({ + const f = flag({ key: 'first-flag', decide: mockDecide, defaultValue: false, @@ -456,7 +470,7 @@ describe('adapters', () => { mocks.headers.mockReturnValueOnce(new Headers()); - const f = flag({ + const f = flag({ key: 'adapter-flag', adapter: testAdapter(5), }); @@ -471,8 +485,9 @@ describe('adapters', () => { mocks.headers.mockReturnValueOnce(new Headers()); - const f = flag({ + const f = flag({ key: 'adapter-flag', + // @ts-expect-error this is the case we are testing adapter: testAdapter(undefined), }); diff --git a/packages/flags/src/next/precompute.test.ts b/packages/flags/src/next/precompute.test.ts index 7b602f1..7dbcdb6 100644 --- a/packages/flags/src/next/precompute.test.ts +++ b/packages/flags/src/next/precompute.test.ts @@ -35,7 +35,7 @@ describe('generatePermutations', () => { it('should infer boolean options', async () => { process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); - const flagA = flag({ key: 'a', decide: () => false }); + const flagA = flag({ key: 'a', decide: () => false }); await expectPermutations([flagA], [{ a: false }, { a: true }]); }); }); @@ -44,7 +44,11 @@ describe('generatePermutations', () => { it('should not infer any options', async () => { process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); - const flagA = flag({ key: 'a', decide: () => false, options: [] }); + const flagA = flag({ + key: 'a', + decide: () => false, + options: [], + }); await expectPermutations([flagA], []); }); }); @@ -89,12 +93,12 @@ describe('generatePermutations', () => { it('should generate permutations', async () => { process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); - const flagA = flag({ + const flagA = flag({ key: 'a', decide: () => false, }); - const flagB = flag({ + const flagB = flag({ key: 'b', decide: () => false, }); @@ -115,17 +119,17 @@ describe('generatePermutations', () => { it('should generate permutations', async () => { process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); - const flagA = flag({ + const flagA = flag({ key: 'a', decide: () => false, }); - const flagB = flag({ + const flagB = flag({ key: 'b', decide: () => false, }); - const flagC = flag({ + const flagC = flag({ key: 'c', decide: () => 'two', options: ['one', 'two', 'three'], @@ -157,17 +161,17 @@ describe('generatePermutations', () => { it('should generate permutations', async () => { process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); - const flagA = flag({ + const flagA = flag({ key: 'a', decide: () => false, }); - const flagB = flag({ + const flagB = flag({ key: 'b', decide: () => false, }); - const flagC = flag({ + const flagC = flag({ key: 'c', decide: () => 'two', options: ['one', 'two', 'three'], @@ -189,8 +193,8 @@ describe('getPrecomputed', () => { it('should return the precomputed value', async () => { process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); - const flagA = flag({ key: 'a', decide: () => true }); - const flagB = flag({ key: 'b', decide: () => false }); + const flagA = flag({ key: 'a', decide: () => true }); + const flagB = flag({ key: 'b', decide: () => false }); const group = [flagA, flagB]; const code = await serialize(group, [true, false]); diff --git a/packages/flags/src/sveltekit/index.test.ts b/packages/flags/src/sveltekit/index.test.ts index f1f8e09..0ae76b6 100644 --- a/packages/flags/src/sveltekit/index.test.ts +++ b/packages/flags/src/sveltekit/index.test.ts @@ -9,7 +9,7 @@ describe('getProviderData', () => { describe('flag', () => { it('defines a key', async () => { - const f = flag({ key: 'first-flag', decide: () => false }); + const f = flag({ key: 'first-flag', decide: () => false }); expect(f).toHaveProperty('key', 'first-flag'); }); }); From 222c6a0309790247693a477c34ba23ffa0a8dbd0 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 24 Jan 2025 14:18:59 +0200 Subject: [PATCH 9/9] fix summer-sale example --- examples/summer-sale/flags.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/summer-sale/flags.ts b/examples/summer-sale/flags.ts index 43e5f8b..ecec494 100644 --- a/examples/summer-sale/flags.ts +++ b/examples/summer-sale/flags.ts @@ -37,10 +37,11 @@ export const showFreeDeliveryBannerFlag = flag({ ], }); -export const countryFlag = flag({ +export const countryFlag = flag({ key: 'country', + defaultValue: 'no accept-lanugage header', async decide({ headers }) { - return headers.get('accept-language') || 'no accept-lanugage header'; + return headers.get('accept-language') ?? this.defaultValue!; }, });