Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ npx firebase use {project_name_or_alias}
npx firebase hosting:channel:deploy {channel_name}
```

# Firebase Remote Configs

Firebase remote 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.

When remote configs change (they rarily do), it is recommended to redeploy the app as that will trigger a new cache for all pages, that will include the updated remote configs

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)

# Component and E2E tests

Component and E2E tests are executed with [Cypress](https://docs.cypress.io/). Cypress tests are located in the cypress folder.
Expand Down
39 changes: 36 additions & 3 deletions src/app/context/RemoteConfigProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
'use client';

import React, { createContext, type ReactNode, useContext } from 'react';
import React, {
createContext,
useState,
useEffect,
type ReactNode,
useContext,
} from 'react';
import {
defaultRemoteConfigValues,
matchesFeatureFlagBypass,
type RemoteConfigValues,
} from '../interface/RemoteConfig';
import { useAuthSession } from '../components/AuthSessionProvider';

const RemoteConfigContext = createContext<{
config: RemoteConfigValues;
Expand All @@ -17,16 +25,41 @@ interface RemoteConfigProviderProps {
config: RemoteConfigValues;
}

function applyAdminBypass(config: RemoteConfigValues): RemoteConfigValues {
const overridden = { ...config };
for (const key of Object.keys(overridden) as Array<
keyof RemoteConfigValues
>) {
if (typeof overridden[key] === 'boolean') {
(overridden as Record<string, unknown>)[key] = true;
}
}
return overridden;
}
Comment on lines +28 to +38
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyAdminBypass is implemented both here and in src/lib/remote-config.server.ts. This duplication risks the server/client behavior drifting over time (e.g., if new flag types are introduced). Consider extracting a shared helper (in a non-server-only module) that both server and client can import.

Copilot uses AI. Check for mistakes.

/**
* Client-side Remote Config provider that hydrates server-fetched config into React Context.
* This provider does NOT fetch config - it receives pre-fetched values from the server.
* Applies admin bypass for @mobilitydata.org users after client-side auth resolves,
* which ensures correct flags even on statically generated pages.
*/
export const RemoteConfigProvider = ({
children,
config,
}: RemoteConfigProviderProps): React.ReactElement => {
const { email, isAuthReady } = useAuthSession();
const [effectiveConfig, setEffectiveConfig] = useState(config);

useEffect(() => {
if (!isAuthReady) return;
setEffectiveConfig(
matchesFeatureFlagBypass(email, config.featureFlagBypass)
? applyAdminBypass(config)
: config,
);
}, [email, isAuthReady, config]);

return (
<RemoteConfigContext.Provider value={{ config }}>
<RemoteConfigContext.Provider value={{ config: effectiveConfig }}>
{children}
</RemoteConfigContext.Provider>
);
Expand Down
20 changes: 20 additions & 0 deletions src/app/interface/RemoteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,23 @@ export const defaultRemoteConfigValues: RemoteConfigValues = {
enableDetailedCoveredArea: false,
gbfsValidator: false,
};

/**
* Returns true if the given email matches any regex pattern in the
* featureFlagBypass config value (format: `{ "regex": [".+@example.org"] }`).
*/
export function matchesFeatureFlagBypass(
email: string | null | undefined,
featureFlagBypass: string,
): boolean {
if (email == null || email === '' || featureFlagBypass === '') return false;
try {
const parsed = JSON.parse(featureFlagBypass) as { regex?: unknown };
if (!Array.isArray(parsed.regex)) return false;
return (parsed.regex as string[]).some((pattern) =>
new RegExp(pattern).test(email),
);
} catch {
return false;
}
}
12 changes: 7 additions & 5 deletions src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ export function Providers({

return (
<ContextProviders>
<RemoteConfigProvider config={remoteConfig}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<AuthSessionProvider>{children}</AuthSessionProvider>
</LocalizationProvider>
</RemoteConfigProvider>
<AuthSessionProvider>
<RemoteConfigProvider config={remoteConfig}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
{children}
</LocalizationProvider>
</RemoteConfigProvider>
</AuthSessionProvider>
</ContextProviders>
);
}
4 changes: 2 additions & 2 deletions src/app/screens/Feed/components/DataQualitySummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { WarningContentBox } from '../../../components/WarningContentBox';
import { FeedStatusChip } from '../../../components/FeedStatus';
import OfficialChip from '../../../components/OfficialChip';
import { getTranslations } from 'next-intl/server';
import { getRemoteConfigValues } from '../../../../lib/remote-config.server';
import { getUserRemoteConfigValues } from '../../../../lib/remote-config.server';

export interface DataQualitySummaryProps {
feedStatus: components['schemas']['Feed']['status'];
Expand All @@ -24,7 +24,7 @@ export default async function DataQualitySummary({
const [t, tCommon, config] = await Promise.all([
getTranslations('feeds'),
getTranslations('common'),
getRemoteConfigValues(),
getUserRemoteConfigValues(),
]);

return (
Expand Down
Loading
Loading