'use client' import { components } from 'api-types' import { FlagValues } from 'flags/react' import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react' import { useAuth } from './auth' import { getFlags as getDefaultConfigCatFlags } from './configcat' import { hasConsented } from './consent-state' import { get, post } from './fetchWrappers' import { ensurePlatformSuffix } from './helpers' import { useParams } from './hooks' type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBody'] export type CallFeatureFlagsResponse = components['schemas']['TelemetryCallFeatureFlagsResponse'] export async function getFeatureFlags( API_URL: string, options: { organizationSlug?: string; projectRef?: string } = {} ) { try { const url = new URL(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`) if (options.organizationSlug) { url.searchParams.set('organization_slug', options.organizationSlug) } if (options.projectRef) { url.searchParams.set('project_ref', options.projectRef) } const data = await get(url.toString()) return data as CallFeatureFlagsResponse } catch (error: any) { if (error.message.includes('Failed to fetch')) { console.error('Failed to fetch PH flags: API is not available') } throw error } } export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) { const consent = hasConsented() if (!consent) return undefined await post(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags/track`, { body }) } export type FeatureFlagContextType = { API_URL?: string configcat: { [key: string]: boolean | number | string | null } posthog: CallFeatureFlagsResponse hasLoaded?: boolean } export const FeatureFlagContext = createContext({ API_URL: undefined, configcat: {}, posthog: {}, hasLoaded: false, }) function getCookies() { const pairs = document.cookie.split(';') let cookies: Record = {} for (var i = 0; i < pairs.length; i++) { var [t_key, value] = pairs[i].split('=') const key = t_key.trim() cookies[key] = unescape(value) } return cookies } export const FeatureFlagProvider = ({ API_URL, enabled = true, organizationSlug, projectRef, getConfigCatFlags, children, }: PropsWithChildren<{ API_URL?: string /** Accepts either `boolean` which controls all feature flags or `{ cc: boolean, ph: boolean }` for individual providers */ enabled?: boolean | { cc: boolean; ph: boolean } organizationSlug?: string projectRef?: string /** Custom fetcher for ConfigCat flags if passing in custom attributes */ getConfigCatFlags?: ( userEmail?: string ) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]> }>) => { const { session, isLoading } = useAuth() const userEmail = session?.user?.email const params = useParams() const resolvedOrganizationSlug = organizationSlug ?? params.slug const resolvedProjectRef = projectRef ?? params.ref const lastSentGroupContextRef = useRef(null) const [store, setStore] = useState({ API_URL, configcat: {}, posthog: {}, hasLoaded: false, }) useEffect(() => { let mounted = true async function ensureGroupContext() { if (!API_URL) return const userId = session?.user?.id if (!userId) return if (!hasConsented()) return if (!resolvedOrganizationSlug && !resolvedProjectRef) return const contextKey = [userId, resolvedOrganizationSlug ?? '', resolvedProjectRef ?? ''].join( '|' ) if (lastSentGroupContextRef.current === contextKey) return try { await post( `${ensurePlatformSuffix(API_URL)}/telemetry/identify`, { user_id: userId, ...(resolvedOrganizationSlug && { organization_slug: resolvedOrganizationSlug }), ...(resolvedProjectRef && { project_ref: resolvedProjectRef }), }, { headers: { Version: '2' } } ) lastSentGroupContextRef.current = contextKey } catch {} } async function processFlags() { if (!enabled || isLoading) return const loadPHFlags = (enabled === true || (typeof enabled === 'object' && enabled.ph)) && !!API_URL const loadCCFlags = enabled === true || (typeof enabled === 'object' && enabled.cc) let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} } // Run both async operations in parallel — allSettled so a failure in one doesn't block the other const [phResult, ccResult] = await Promise.allSettled([ loadPHFlags ? (async () => { await ensureGroupContext() return getFeatureFlags(API_URL, { organizationSlug: resolvedOrganizationSlug, projectRef: resolvedProjectRef, }) })() : Promise.resolve({}), loadCCFlags ? typeof getConfigCatFlags === 'function' ? getConfigCatFlags(userEmail) : getDefaultConfigCatFlags(userEmail) : Promise.resolve([]), ]) const flags = phResult.status === 'fulfilled' ? phResult.value : {} if (phResult.status === 'rejected') { console.warn('[FeatureFlags] PostHog flags failed', phResult.reason) } const flagValues = ccResult.status === 'fulfilled' ? ccResult.value : [] // Dev toolbar flag overrides are available in local dev and staging. // Duplicated for tree-shaking — bundler must see literal process.env reference. // Keep in sync: dev-tools/index.ts, DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx const env = process.env.NEXT_PUBLIC_ENVIRONMENT const isDevToolsEnabled = env === 'local' || env === 'staging' const safeParse = (value: string | undefined): Record => { if (!value) return {} try { return JSON.parse(value) } catch { return {} } } // Process PostHog flags if loaded if (Object.keys(flags).length > 0) { // Apply dev toolbar overrides for PostHog flags if (isDevToolsEnabled) { try { const cookies = getCookies() const phOverrides = safeParse(cookies['x-ph-flag-overrides']) flagStore.posthog = { ...flags, ...phOverrides } } catch { flagStore.posthog = flags } } else { flagStore.posthog = flags } } // Process ConfigCat flags if loaded if (flagValues.length > 0) { let overridesCookieValue: Record = {} try { const cookies = getCookies() // Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (dev toolbar only) // x-cc-flag-overrides takes precedence when dev tools are enabled const vercelOverrides = safeParse(cookies['vercel-flag-overrides']) const ccOverrides = isDevToolsEnabled ? safeParse(cookies['x-cc-flag-overrides']) : {} overridesCookieValue = { ...vercelOverrides, ...ccOverrides, // local overrides take precedence } } catch {} flagValues.forEach((item) => { flagStore['configcat'][item.settingKey] = overridesCookieValue[item.settingKey] ?? (item.settingValue === null ? null : (item.settingValue ?? false)) }) } flagStore.hasLoaded = true if (mounted) { setStore(flagStore) } } // [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay // as per https://configcat.com/docs/sdk-reference/js/#polling-modes: // The polling downloads the config.json at the set interval and are stored in the internal cache // which subsequently all getValueAsync() calls are served from there processFlags() return () => { mounted = false } }, [ enabled, isLoading, userEmail, API_URL, session?.user?.id, resolvedOrganizationSlug, resolvedProjectRef, getConfigCatFlags, ]) return ( {/* [Joshen] Just support configcat flags in Vercel flags for now for simplicity although I think it should be fairly simply to support PH too */} {children} ) } export const useFeatureFlags = () => { return useContext(FeatureFlagContext) } const isObjectEmpty = (obj: Object) => { return Object.keys(obj).length === 0 } export function useFlag(name: string) { const flagStore = useFeatureFlags() const store = flagStore.configcat // Flag store is empty means config cat is not loaded yet, return false if (isObjectEmpty(store)) { return false } if (store[name] === undefined) { console.error(`Flag key "${name}" does not exist in ConfigCat flag store`) return false } return store[name] as T }