Skip to content

Commit 441e654

Browse files
committed
fix: feature flag undefined until evaluated
1 parent 3077d81 commit 441e654

File tree

7 files changed

+160
-46
lines changed

7 files changed

+160
-46
lines changed

apps/api/src/routes/public/flags.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,4 +731,114 @@ describe('Flag Evaluation System', () => {
731731
expect(totalEnabled).toBeGreaterThan(0);
732732
});
733733
});
734+
735+
describe('Comprehensive User Consistency', () => {
736+
it('should always return the same result for the same user across 1000 evaluations', () => {
737+
const testUserIds = [
738+
'user_001',
739+
'user_002',
740+
'user_003',
741+
'user_004',
742+
'user_005',
743+
'user_006',
744+
'user_007',
745+
'user_008',
746+
'user_009',
747+
'user_010',
748+
];
749+
750+
const flags = [
751+
{
752+
key: 'boolean-flag',
753+
type: 'boolean',
754+
defaultValue: false,
755+
payload: { feature: 'test' },
756+
rules: [
757+
{
758+
type: 'user_id',
759+
operator: 'equals',
760+
value: 'user_001',
761+
enabled: true,
762+
batch: false,
763+
},
764+
{
765+
type: 'email',
766+
operator: 'ends_with',
767+
value: '@test.com',
768+
enabled: true,
769+
batch: false,
770+
},
771+
],
772+
},
773+
{
774+
key: 'rollout-flag',
775+
type: 'rollout',
776+
defaultValue: false,
777+
rolloutPercentage: 50,
778+
payload: { rollout: true },
779+
rules: [],
780+
},
781+
{
782+
key: 'percentage-flag',
783+
type: 'boolean',
784+
defaultValue: false,
785+
payload: { percentage: true },
786+
rules: [
787+
{
788+
type: 'percentage',
789+
operator: 'equals',
790+
value: 25,
791+
enabled: true,
792+
batch: false,
793+
},
794+
],
795+
},
796+
{
797+
key: 'property-flag',
798+
type: 'boolean',
799+
defaultValue: false,
800+
payload: { property: true },
801+
rules: [
802+
{
803+
type: 'property',
804+
field: 'plan',
805+
operator: 'equals',
806+
value: 'premium',
807+
enabled: true,
808+
batch: false,
809+
},
810+
],
811+
},
812+
];
813+
814+
const contexts: UserContext[] = testUserIds.map((userId) => ({
815+
userId,
816+
email: `${userId}@test.com`,
817+
properties: { plan: userId === 'user_001' ? 'premium' : 'free' },
818+
}));
819+
820+
const baselineResults: Record<string, boolean> = {};
821+
822+
// First evaluation - establish baseline
823+
for (const userId of testUserIds) {
824+
for (const flag of flags) {
825+
const context = contexts.find((c) => c.userId === userId);
826+
const result = evaluateFlag(flag, context);
827+
baselineResults[`${userId}-${flag.key}`] = result.enabled;
828+
}
829+
}
830+
831+
for (let evaluation = 0; evaluation < 1000; evaluation++) {
832+
for (const userId of testUserIds) {
833+
for (const flag of flags) {
834+
const context = contexts.find((c) => c.userId === userId);
835+
const result = evaluateFlag(flag, context);
836+
const key = `${userId}-${flag.key}`;
837+
838+
expect(result.enabled).toBe(baselineResults[key]);
839+
}
840+
}
841+
}
842+
});
843+
});
734844
});

apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function WebsitePageHeader({
9191
</Link>
9292
</Button>
9393
)}
94-
<div className="rounded-xl border border-primary/20 bg-primary/10 p-3">
94+
<div className="rounded-xl border border-primary/10 bg-primary/5 p-3">
9595
{icon}
9696
</div>
9797
</div>
@@ -105,7 +105,7 @@ export function WebsitePageHeader({
105105
<div className="flex items-center gap-3">
106106
{onRefresh && (
107107
<Button
108-
className="gap-2"
108+
className="cursor-pointer gap-2 transition-all duration-300 hover:border-primary/50 hover:bg-primary/5"
109109
disabled={isRefreshing}
110110
onClick={onRefresh}
111111
variant="outline"
@@ -137,7 +137,7 @@ export function WebsitePageHeader({
137137
</Link>
138138
</Button>
139139
)}
140-
<div className="rounded-xl border border-primary/20 bg-gradient-to-br from-primary/10 to-primary/5 p-3">
140+
<div className="rounded-xl border border-primary/10 bg-gradient-to-br from-primary/5 to-primary/10 p-3">
141141
{icon}
142142
</div>
143143
<div>
@@ -151,7 +151,7 @@ export function WebsitePageHeader({
151151
<div className="flex items-center gap-3">
152152
{onRefresh && (
153153
<Button
154-
className="gap-2 border-border/50 transition-all duration-200 hover:border-primary/50 hover:bg-primary/5"
154+
className="cursor-pointer select-none gap-2 border-border/50"
155155
disabled={isRefreshing}
156156
onClick={onRefresh}
157157
variant="outline"
@@ -165,7 +165,7 @@ export function WebsitePageHeader({
165165
)}
166166
{onCreateAction && (
167167
<Button
168-
className="gap-2 bg-gradient-to-r from-primary to-primary/90 shadow-lg transition-all duration-200 hover:from-primary/90 hover:to-primary hover:shadow-xl"
168+
className="group relative cursor-pointer select-none gap-2 overflow-hidden bg-gradient-to-r from-primary to-primary/90 px-8 py-4 font-medium text-sm transition-all duration-300 hover:from-primary/90 hover:to-primary"
169169
onClick={onCreateAction}
170170
>
171171
<PlusIcon size={16} />
@@ -181,7 +181,7 @@ export function WebsitePageHeader({
181181
<Card className="rounded-xl border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
182182
<CardContent className="pt-6">
183183
<div className="flex flex-col items-center space-y-3 text-center">
184-
<div className="rounded-full border border-destructive/20 bg-destructive/10 p-3">
184+
<div className="rounded-full border border-destructive/10 bg-destructive/5 p-3">
185185
{icon}
186186
</div>
187187
<div>
@@ -195,7 +195,7 @@ export function WebsitePageHeader({
195195
</div>
196196
{onRefresh && (
197197
<Button
198-
className="gap-2 rounded-lg"
198+
className="cursor-pointer select-none gap-2 rounded transition-all duration-300 hover:border-primary/20 hover:bg-primary/10"
199199
onClick={onRefresh}
200200
size="sm"
201201
variant="outline"

apps/dashboard/app/(main)/websites/[id]/flags/page.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,21 @@ export default function FlagsPage() {
144144
websiteId={websiteId}
145145
websiteName={website?.name || undefined}
146146
/>
147-
<div className="flex items-center gap-2">
148-
<FlagIcon
149-
className="h-5 w-5"
150-
color={isExperimentEnabled ? 'red' : 'blue'}
151-
size={16}
152-
weight="fill"
153-
/>
154-
{isExperimentEnabled ? (
155-
<Badge className="bg-red-500 text-white">Red Team</Badge>
156-
) : (
157-
<Badge className="bg-blue-500 text-white">Blue Team</Badge>
158-
)}
159-
</div>
147+
{isExperimentEnabled !== undefined && (
148+
<div className="flex items-center gap-2">
149+
<FlagIcon
150+
className="h-5 w-5"
151+
color={isExperimentEnabled ? 'red' : 'blue'}
152+
size={16}
153+
weight="fill"
154+
/>
155+
{isExperimentEnabled ? (
156+
<Badge className="bg-red-500 text-white">Red Team</Badge>
157+
) : (
158+
<Badge className="bg-blue-500 text-white">Blue Team</Badge>
159+
)}
160+
</div>
161+
)}
160162
<Suspense fallback={<FlagsListSkeleton />}>
161163
<FlagsList
162164
flags={flags || []}

apps/dashboard/components/empty-state.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,21 @@ export function EmptyState({
6060
const renderIcon = () => {
6161
if (variant === 'simple' || variant === 'minimal') {
6262
return (
63-
<div className="mb-4 rounded-full border border-muted bg-muted/20 p-6">
63+
<div className="mb-4 rounded-full border border-muted bg-muted/10 p-6">
6464
{icon}
6565
</div>
6666
);
6767
}
6868

6969
return (
7070
<div className="group relative mb-8">
71-
<div className="rounded-full border-2 border-primary/20 bg-primary/10 p-6">
71+
<div className="rounded-full border-2 border-primary/10 bg-primary/5 p-6">
7272
{icon}
7373
</div>
7474
{showPlusBadge && (
7575
<div
7676
aria-label="Create new item"
77-
className="-top-2 -right-2 absolute cursor-pointer rounded-full border-2 border-primary/20 bg-background p-2 shadow-sm"
77+
className="-top-2 -right-2 absolute cursor-pointer select-none rounded-full border-2 border-primary/10 bg-background p-2"
7878
onClick={(e) => {
7979
e.stopPropagation();
8080
action?.onClick();
@@ -98,7 +98,7 @@ export function EmptyState({
9898
const renderCard = () => {
9999
const cardClasses = cn(
100100
variant === 'default' &&
101-
'rounded-xl border-2 border-dashed bg-gradient-to-br from-background to-muted/20',
101+
'rounded-xl border-2 border-dashed bg-gradient-to-br from-background to-muted/10',
102102
variant === 'simple' && 'rounded border-dashed bg-muted/10',
103103
variant === 'minimal' && 'rounded border-none bg-transparent shadow-none',
104104
className
@@ -147,9 +147,11 @@ export function EmptyState({
147147
<Button
148148
className={cn(
149149
variant === 'default' &&
150-
'group relative cursor-pointer gap-2 overflow-hidden rounded-lg bg-gradient-to-r from-primary to-primary/90 px-8 py-4 font-medium text-base transition-all duration-300 hover:from-primary/90 hover:to-primary',
151-
variant === 'simple' && 'gap-2',
152-
variant === 'minimal' && 'gap-2'
150+
'group relative cursor-pointer select-none gap-2 overflow-hidden rounded-lg bg-gradient-to-r from-primary to-primary/90 px-8 py-4 font-medium text-base transition-all duration-300 hover:from-primary/90 hover:to-primary',
151+
variant === 'simple' &&
152+
'cursor-pointer select-none gap-2',
153+
variant === 'minimal' &&
154+
'cursor-pointer select-none gap-2'
153155
)}
154156
onClick={action.onClick}
155157
size="lg"

apps/docs/content/docs/features/feature-flags.mdx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ function MyComponent() {
5757
5858
return (
5959
<div className={darkModeEnabled ? 'dark' : ''}>
60-
{showNewFeature && <NewDashboard />}
61-
{showBetaFeatures && <BetaFeatures />}
60+
{showNewFeature !== undefined && showNewFeature && <NewDashboard />}
61+
{showBetaFeatures !== undefined && showBetaFeatures && <BetaFeatures />}
6262
<button>Click me</button>
6363
</div>
6464
);
@@ -94,7 +94,7 @@ function MyComponent() {
9494
9595
return (
9696
<div>
97-
{showNewFeature && <NewFeature />}
97+
{showNewFeature !== undefined && showNewFeature && <NewFeature />}
9898
<button>Click me</button>
9999
</div>
100100
);
@@ -168,10 +168,15 @@ const showNewUI = isEnabled('new-ui-rollout');`}
168168
refresh
169169
} = useFlags();
170170
171-
// Check if flag is enabled
172-
const showFeature = isEnabled('my-feature');
171+
// Check if flag is enabled (returns boolean | undefined)
172+
const showFeature = isEnabled('my-feature'); // undefined if not evaluated yet
173173
const darkModeDefault = getValue('dark-mode', false);
174174
175+
// Only render when flag is evaluated
176+
{showFeature !== undefined && (
177+
showFeature ? <NewFeature /> : <OldFeature />
178+
)}
179+
175180
// Refresh all flags
176181
await fetchAllFlags();`}
177182
</CodeBlock>
@@ -251,24 +256,19 @@ Enable debug mode to see flag evaluation in the browser console.
251256
1. **Use `isPending`** with authentication to prevent race conditions
252257
2. **Pass Custom Properties** for granular targeting and A/B testing
253258
3. **Enable Debug Mode** during development to monitor flag evaluation
254-
4. **Handle Flag Changes** gracefully with loading states
259+
4. **Handle Flag Evaluation States** properly - flags return `undefined` until evaluated
255260
5. **Update User Context** after profile changes to refresh flag evaluation
256261

257262
<CodeBlock language="tsx">
258-
{`// Best practice: Handle loading states
263+
{`// Best practice: Wait for flag evaluation before rendering
259264
function FeatureComponent() {
260265
const { isEnabled } = useFlags();
261-
const [isLoading, setIsLoading] = useState(true);
262-
263-
useEffect(() => {
264-
// Simulate loading or wait for flags
265-
const timer = setTimeout(() => setIsLoading(false), 100);
266-
return () => clearTimeout(timer);
267-
}, []);
266+
const showNewFeature = isEnabled('new-feature');
268267
269-
if (isLoading) return <Skeleton />;
268+
// Don't render until flag is evaluated
269+
if (showNewFeature === undefined) return <Skeleton />;
270270
271-
return isEnabled('new-feature') ? <NewFeature /> : <OldFeature />;
271+
return showNewFeature ? <NewFeature /> : <OldFeature />;
272272
}`}
273273
</CodeBlock>
274274

packages/sdk/src/react/flags-provider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,12 +352,12 @@ export function useFlags() {
352352
return fetchFlag(key);
353353
};
354354

355-
const isEnabled = (key: string): boolean => {
355+
const isEnabled = (key: string): boolean | undefined => {
356356
if (memoryFlags[key]) {
357357
return memoryFlags[key].enabled;
358358
}
359359
getFlag(key);
360-
return false;
360+
return;
361361
};
362362

363363
const getValue = (key: string, defaultValue = false): boolean => {

packages/sdk/src/react/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface FlagsConfig {
2828
}
2929

3030
export interface FlagsContext {
31-
isEnabled: (key: string) => boolean;
31+
isEnabled: (key: string) => boolean | undefined;
3232
getValue: (key: string, defaultValue?: boolean) => boolean;
3333
fetchAllFlags: () => Promise<void>;
3434
updateUser: (user: FlagsConfig['user']) => void;

0 commit comments

Comments
 (0)