Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 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.
Comment thread
Alessandro100 marked this conversation as resolved.
Outdated

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

Typo: “rarily” should be “rarely”.

Suggested change
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
When remote configs change (they rarely 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

Copilot uses AI. Check for mistakes.
Comment thread
Alessandro100 marked this conversation as resolved.
Outdated

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)
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.

The new README section states remote config is “called and set at build time” for static pages, but this PR also introduces server-side caching/revalidation (unstable_cache + revalidateTag) which updates values without a redeploy for dynamic/ISR renders. Consider clarifying that the “build time” limitation only applies to fully static routes, while server-rendered routes will revalidate per the cache settings.

Suggested change
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.
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
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)
Firebase remote configs help us toggle new features on and off. Due to the nature of static pages, there are some nuances. For fully static pages, the remote configs are fetched and set at build time and will remain the same for the lifetime of that static page cache.
When remote configs change (they rarely do), redeploying the app is recommended for fully static pages because it triggers a new build/cache that includes the updated remote config values. However, this build-time limitation does not apply to server-rendered or revalidated routes: those routes use the server-side cached remote config values and will pick up updates based on the configured cache revalidation behavior, without requiring a full redeploy.
In practice, client components can access the Firebase remote configs through Context, while server components read them on the server. Those server-side values are cached (for example, for up to 1 hour on the server), so they are not refetched on every request unless the cache is revalidated.

Copilot uses AI. Check for mistakes.

# 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