Skip to content

Commit d65cc06

Browse files
committed
feature bypass + static page functionality
1 parent 6daa7a8 commit d65cc06

8 files changed

Lines changed: 288 additions & 86 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ npx firebase use {project_name_or_alias}
8585
npx firebase hosting:channel:deploy {channel_name}
8686
```
8787

88+
# Firebase Remote Configs
89+
90+
Firebase remoet configs help us toggle new features on and off. Due to the nature of static pages, there are some nuances. For static pages, the remote configs are called and set at build time and will be the same for the remainder of the static page's cache.
91+
92+
When remote configs change (they rarily do), it is recommended to redploy the app as that will trigger a new cache for all pages, that will include the updated remote configs
93+
94+
What this also means is that client components will be able to access the firebase remote configs using the Context but server components will have to fetch them each time. This isn't a big deal as the firebase remote configs are cached (for 1 hour on the server)
95+
8896
# Component and E2E tests
8997

9098
Component and E2E tests are executed with [Cypress](https://docs.cypress.io/). Cypress tests are located in the cypress folder.

src/app/[locale]/layout.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { type ReactElement } from 'react';
77
import { NextIntlClientProvider, hasLocale } from 'next-intl';
88
import { getMessages, setRequestLocale } from 'next-intl/server';
99
import { notFound } from 'next/navigation';
10-
import { getRemoteConfigValuesForUser } from '../../lib/remote-config.server';
11-
import { getCurrentUserFromCookie } from '../utils/auth-server';
10+
import { getRemoteConfigValues } from '../../lib/remote-config.server';
1211
import { Mulish, IBM_Plex_Mono } from 'next/font/google';
1312
import Footer from '../components/Footer';
1413
import Header from '../components/Header';
@@ -90,11 +89,10 @@ export default async function LocaleLayout({
9089
// Enable static rendering for this locale
9190
setRequestLocale(validLocale);
9291

93-
const [messages, currentUser] = await Promise.all([
92+
const [messages, remoteConfig] = await Promise.all([
9493
getMessages(),
95-
getCurrentUserFromCookie(),
94+
getRemoteConfigValues(),
9695
]);
97-
const remoteConfig = await getRemoteConfigValuesForUser(currentUser?.email);
9896

9997
return (
10098
<html lang={validLocale}>

src/app/context/RemoteConfigProvider.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
'use client';
22

3-
import React, { createContext, type ReactNode, useContext } from 'react';
3+
import React, {
4+
createContext,
5+
useState,
6+
useEffect,
7+
type ReactNode,
8+
useContext,
9+
} from 'react';
410
import {
511
defaultRemoteConfigValues,
12+
matchesFeatureFlagBypass,
613
type RemoteConfigValues,
714
} from '../interface/RemoteConfig';
15+
import { useAuthSession } from '../components/AuthSessionProvider';
816

917
const RemoteConfigContext = createContext<{
1018
config: RemoteConfigValues;
@@ -17,16 +25,41 @@ interface RemoteConfigProviderProps {
1725
config: RemoteConfigValues;
1826
}
1927

28+
function applyAdminBypass(config: RemoteConfigValues): RemoteConfigValues {
29+
const overridden = { ...config };
30+
for (const key of Object.keys(overridden) as Array<
31+
keyof RemoteConfigValues
32+
>) {
33+
if (typeof overridden[key] === 'boolean') {
34+
(overridden as Record<string, unknown>)[key] = true;
35+
}
36+
}
37+
return overridden;
38+
}
39+
2040
/**
2141
* Client-side Remote Config provider that hydrates server-fetched config into React Context.
22-
* This provider does NOT fetch config - it receives pre-fetched values from the server.
42+
* Applies admin bypass for @mobilitydata.org users after client-side auth resolves,
43+
* which ensures correct flags even on statically generated pages.
2344
*/
2445
export const RemoteConfigProvider = ({
2546
children,
2647
config,
2748
}: RemoteConfigProviderProps): React.ReactElement => {
49+
const { email, isAuthReady } = useAuthSession();
50+
const [effectiveConfig, setEffectiveConfig] = useState(config);
51+
52+
useEffect(() => {
53+
if (!isAuthReady) return;
54+
setEffectiveConfig(
55+
matchesFeatureFlagBypass(email, config.featureFlagBypass)
56+
? applyAdminBypass(config)
57+
: config,
58+
);
59+
}, [email, isAuthReady, config]);
60+
2861
return (
29-
<RemoteConfigContext.Provider value={{ config }}>
62+
<RemoteConfigContext.Provider value={{ config: effectiveConfig }}>
3063
{children}
3164
</RemoteConfigContext.Provider>
3265
);

src/app/interface/RemoteConfig.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,23 @@ export const defaultRemoteConfigValues: RemoteConfigValues = {
4747
enableDetailedCoveredArea: false,
4848
gbfsValidator: false,
4949
};
50+
51+
/**
52+
* Returns true if the given email matches any regex pattern in the
53+
* featureFlagBypass config value (format: `{ "regex": [".+@example.org"] }`).
54+
*/
55+
export function matchesFeatureFlagBypass(
56+
email: string | null | undefined,
57+
featureFlagBypass: string,
58+
): boolean {
59+
if (email == null || email === '' || featureFlagBypass === '') return false;
60+
try {
61+
const parsed = JSON.parse(featureFlagBypass) as { regex?: unknown };
62+
if (!Array.isArray(parsed.regex)) return false;
63+
return (parsed.regex as string[]).some((pattern) =>
64+
new RegExp(pattern).test(email),
65+
);
66+
} catch {
67+
return false;
68+
}
69+
}

src/app/providers.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ export function Providers({
4444

4545
return (
4646
<ContextProviders>
47-
<RemoteConfigProvider config={remoteConfig}>
48-
<LocalizationProvider dateAdapter={AdapterDayjs}>
49-
<AuthSessionProvider>{children}</AuthSessionProvider>
50-
</LocalizationProvider>
51-
</RemoteConfigProvider>
47+
<AuthSessionProvider>
48+
<RemoteConfigProvider config={remoteConfig}>
49+
<LocalizationProvider dateAdapter={AdapterDayjs}>
50+
{children}
51+
</LocalizationProvider>
52+
</RemoteConfigProvider>
53+
</AuthSessionProvider>
5254
</ContextProviders>
5355
);
5456
}

src/app/screens/Feed/components/DataQualitySummary.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { WarningContentBox } from '../../../components/WarningContentBox';
77
import { FeedStatusChip } from '../../../components/FeedStatus';
88
import OfficialChip from '../../../components/OfficialChip';
99
import { getTranslations } from 'next-intl/server';
10-
import { getRemoteConfigValuesForUser } from '../../../../lib/remote-config.server';
11-
import { getCurrentUserFromCookie } from '../../../utils/auth-server';
10+
import { getUserRemoteConfigValues } from '../../../../lib/remote-config.server';
1211

1312
export interface DataQualitySummaryProps {
1413
feedStatus: components['schemas']['Feed']['status'];
@@ -22,12 +21,11 @@ export default async function DataQualitySummary({
2221
isOfficialFeed,
2322
latestDataset,
2423
}: DataQualitySummaryProps): Promise<React.ReactElement> {
25-
const [t, tCommon, currentUser] = await Promise.all([
24+
const [t, tCommon, config] = await Promise.all([
2625
getTranslations('feeds'),
2726
getTranslations('common'),
28-
getCurrentUserFromCookie(),
27+
getUserRemoteConfigValues(),
2928
]);
30-
const config = await getRemoteConfigValuesForUser(currentUser?.email);
3129

3230
return (
3331
<Box data-testid='data-quality-summary' sx={{ my: 2 }}>

0 commit comments

Comments
 (0)