Skip to content

Commit 08c765c

Browse files
authored
Merge pull request #206 from temisan0x/feature/191-frontend-feature-flags-rollout-control
feat(frontend): introduce feature flags for incremental UI rollout control
2 parents bc37d0d + 8b3902f commit 08c765c

7 files changed

Lines changed: 238 additions & 8 deletions

File tree

frontend/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
VITE_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
22
VITE_STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
33
VITE_VAULT_CONTRACT_ID=
4+
VITE_FF_ANALYTICS_PAGE=true

frontend/FEATURE_FLAGS.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Feature Flags (Vite)
2+
3+
This app uses environment-based flags to toggle frontend features safely. We use a **fail-closed** approach: if a variable is not exactly `"true"`, the feature is treated as disabled.
4+
5+
> [!IMPORTANT]
6+
> Vite injects `import.meta.env` values at build/dev-server startup time.
7+
> Restart the dev server after changing `.env` values so flag updates take effect.
8+
9+
## Registered Flags
10+
11+
| Key | Env Var | Notes |
12+
| :--- | :--- | :--- |
13+
| `ANALYTICS_PAGE` | `VITE_FF_ANALYTICS_PAGE` | Gates the analytics route until rollout is approved. |
14+
15+
## How to Add a Flag
16+
17+
1. Register the key in `FLAG_KEYS` in `src/context/FeatureFlagContext.tsx`.
18+
2. Add the env variable to `.env.example` (`VITE_FF_<FLAG_KEY>`).
19+
3. Use `useFeatureFlag` (evaluation utility) or `<FeatureGate>` where needed.
20+
21+
## Usage Patterns
22+
23+
### 1) Component wrapper (preferred for full sections/routes)
24+
25+
```tsx
26+
<FeatureGate flag="ANALYTICS_PAGE" fallback={<Skeleton />}>
27+
<AnalyticsDashboard />
28+
</FeatureGate>
29+
```
30+
31+
### 2) Evaluation utility hook (preferred for inline conditions)
32+
33+
```tsx
34+
const analyticsEnabled = useFeatureFlag("ANALYTICS_PAGE");
35+
36+
return analyticsEnabled ? <AnalyticsDashboard /> : <EmptyState />;
37+
```

frontend/src/App.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import "./index.css";
1515
import { KeyboardShortcutProvider } from "./context/KeyboardShortcutContext";
1616
import Navbar from "./components/Navbar";
1717
import ShortcutHelpModal from "./components/ShortcutHelpModal";
18+
import { FeatureGate } from "./components/FeatureGate";
19+
import { FeatureFlagProvider } from "./context/FeatureFlagContext";
1820
import "./index.css";
1921

2022
import * as Sentry from "@sentry/react";
@@ -174,6 +176,14 @@ function AppContent() {
174176
/>
175177
}
176178
/>
179+
<Route
180+
path="/analytics"
181+
element={
182+
<FeatureGate flag="ANALYTICS_PAGE">
183+
<Analytics />
184+
</FeatureGate>
185+
}
186+
/>
177187
<Route path="/analytics" element={<Analytics />} />
178188
<Route path="/settings" element={<div>Settings Page</div>} />
179189
<Route path="/ui-kit" element={<UIPreview />} />
@@ -190,13 +200,15 @@ function AppContent() {
190200
function App() {
191201
return (
192202
<Sentry.ErrorBoundary fallback={<AppErrorFallback />} showDialog>
193-
<ThemeProvider>
194-
<VaultProvider>
195-
<Router>
196-
<AppContent />
197-
</Router>
198-
</VaultProvider>
199-
</ThemeProvider>
203+
<FeatureFlagProvider>
204+
<ThemeProvider>
205+
<VaultProvider>
206+
<Router>
207+
<AppContent />
208+
</Router>
209+
</VaultProvider>
210+
</ThemeProvider>
211+
</FeatureFlagProvider>
200212
</Sentry.ErrorBoundary>
201213
);
202214
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from "react";
2+
import { ShieldOff } from "lucide-react";
3+
import { useFeatureFlag } from "../context/FeatureFlagContext";
4+
import type { FeatureFlagKey } from "../context/FeatureFlagContext";
5+
6+
interface FeatureGateProps {
7+
flag: FeatureFlagKey;
8+
children: React.ReactNode;
9+
fallback?: React.ReactNode;
10+
}
11+
const DefaultFallback: React.FC = () => (
12+
<div className="glass-panel feature-gate-fallback" role="status" aria-live="polite">
13+
<div className="feature-gate-icon" aria-hidden="true">
14+
<ShieldOff size={18} />
15+
</div>
16+
<span className="tag cyan feature-gate-tag">Controlled Rollout</span>
17+
<div className="text-gradient feature-gate-title">
18+
Feature Unavailable
19+
</div>
20+
<div className="feature-gate-description">
21+
This section is currently disabled for this environment. It will be
22+
enabled as rollout progresses.
23+
</div>
24+
</div>
25+
);
26+
27+
export const FeatureGate: React.FC<FeatureGateProps> = ({ flag, children, fallback }) => {
28+
const enabled = useFeatureFlag(flag);
29+
if (!enabled) return <>{fallback ?? <DefaultFallback />}</>;
30+
return <>{children}</>;
31+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React, { createContext, useContext } from "react";
2+
const FLAG_KEYS = ["ANALYTICS_PAGE"] as const;
3+
export type FeatureFlagKey = (typeof FLAG_KEYS)[number];
4+
5+
type FeatureFlagMap = Record<FeatureFlagKey, boolean>;
6+
7+
const resolveFlags = (): FeatureFlagMap => {
8+
return FLAG_KEYS.reduce((acc, key) => {
9+
acc[key] = import.meta.env[`VITE_FF_${key}`] === "true";
10+
return acc;
11+
}, {} as FeatureFlagMap);
12+
}
13+
14+
const FeatureFlagContext = createContext<FeatureFlagMap | null>(null);
15+
16+
export const FeatureFlagProvider: React.FC<{ children: React.ReactNode }> = ({
17+
children,
18+
}) => {
19+
const flags = resolveFlags();
20+
return (
21+
<FeatureFlagContext.Provider value={flags}>
22+
{children}
23+
</FeatureFlagContext.Provider>
24+
);
25+
}
26+
27+
// eslint-disable-next-line react-refresh/only-export-components
28+
export const useFeatureFlag = (flag: FeatureFlagKey): boolean => {
29+
const ctx = useContext(FeatureFlagContext);
30+
if (!ctx) throw new Error("useFeatureFlag must be used within FeatureFlagProvider");
31+
return ctx[flag];
32+
}

frontend/src/index.css

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,49 @@ button {
508508
border: 1px solid rgba(0, 240, 255, 0.3);
509509
}
510510

511+
/* Feature Gate */
512+
.feature-gate-fallback {
513+
max-width: 720px;
514+
margin: 0 auto;
515+
padding: 40px 44px;
516+
text-align: center;
517+
background:
518+
radial-gradient(circle at 85% 10%, var(--bg-gradient-2), transparent 45%),
519+
radial-gradient(circle at 10% 100%, var(--bg-gradient-1), transparent 45%),
520+
var(--bg-surface);
521+
}
522+
523+
.feature-gate-icon {
524+
width: 40px;
525+
height: 40px;
526+
margin: 0 auto 12px;
527+
border-radius: 9999px;
528+
display: flex;
529+
align-items: center;
530+
justify-content: center;
531+
color: var(--accent-cyan);
532+
background: var(--accent-cyan-dim);
533+
border: 1px solid rgba(0, 240, 255, 0.28);
534+
}
535+
536+
.feature-gate-tag {
537+
margin-bottom: 12px;
538+
}
539+
540+
.feature-gate-title {
541+
font-size: clamp(var(--text-2xl), 4vw, var(--text-3xl));
542+
margin-bottom: 10px;
543+
line-height: var(--leading-tight);
544+
}
545+
546+
.feature-gate-description {
547+
color: var(--text-secondary);
548+
font-size: var(--text-base);
549+
line-height: var(--leading-relaxed);
550+
max-width: 56ch;
551+
margin: 0 auto;
552+
}
553+
511554
/* Base button styles */
512555
.btn {
513556
display: inline-flex;
@@ -1187,6 +1230,18 @@ button {
11871230

11881231
/* Mobile responsiveness for PageHeader */
11891232
@media (max-width: 768px) {
1233+
.feature-gate-fallback {
1234+
padding: 28px 20px;
1235+
}
1236+
1237+
.feature-gate-title {
1238+
font-size: var(--text-2xl);
1239+
}
1240+
1241+
.feature-gate-description {
1242+
font-size: var(--text-sm);
1243+
}
1244+
11901245
.page-header {
11911246
text-align: left !important;
11921247
}
@@ -1260,4 +1315,3 @@ button {
12601315
background: var(--border-glass);
12611316
margin: 16px 0;
12621317
}
1263-
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { FeatureGate } from "../components/FeatureGate";
4+
import {
5+
FeatureFlagProvider,
6+
useFeatureFlag,
7+
} from "../context/FeatureFlagContext";
8+
9+
function FlagProbe() {
10+
const enabled = useFeatureFlag("ANALYTICS_PAGE");
11+
return <div data-testid="flag-value">{String(enabled)}</div>;
12+
}
13+
14+
describe("feature flags", () => {
15+
beforeEach(() => {
16+
vi.unstubAllEnvs();
17+
});
18+
19+
it("fails closed for non-true values", () => {
20+
vi.stubEnv("VITE_FF_ANALYTICS_PAGE", "false");
21+
22+
render(
23+
<FeatureFlagProvider>
24+
<FlagProbe />
25+
</FeatureFlagProvider>,
26+
);
27+
28+
expect(screen.getByTestId("flag-value")).toHaveTextContent("false");
29+
});
30+
31+
it("renders fallback when a feature is disabled", () => {
32+
vi.stubEnv("VITE_FF_ANALYTICS_PAGE", "false");
33+
34+
render(
35+
<FeatureFlagProvider>
36+
<FeatureGate
37+
flag="ANALYTICS_PAGE"
38+
fallback={<div>analytics fallback</div>}
39+
>
40+
<div>analytics enabled</div>
41+
</FeatureGate>
42+
</FeatureFlagProvider>,
43+
);
44+
45+
expect(screen.queryByText("analytics enabled")).not.toBeInTheDocument();
46+
expect(screen.getByText("analytics fallback")).toBeInTheDocument();
47+
});
48+
49+
it("renders gated content when the feature is enabled", () => {
50+
vi.stubEnv("VITE_FF_ANALYTICS_PAGE", "true");
51+
52+
render(
53+
<FeatureFlagProvider>
54+
<FeatureGate flag="ANALYTICS_PAGE">
55+
<div>analytics enabled</div>
56+
</FeatureGate>
57+
</FeatureFlagProvider>,
58+
);
59+
60+
expect(screen.getByText("analytics enabled")).toBeInTheDocument();
61+
expect(screen.queryByText("Feature Unavailable")).not.toBeInTheDocument();
62+
});
63+
});

0 commit comments

Comments
 (0)