feature-flags.tsx289 lines · main
1'use client'
2
3import { components } from 'api-types'
4import { FlagValues } from 'flags/react'
5import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'
6
7import { useAuth } from './auth'
8import { getFlags as getDefaultConfigCatFlags } from './configcat'
9import { hasConsented } from './consent-state'
10import { get, post } from './fetchWrappers'
11import { ensurePlatformSuffix } from './helpers'
12import { useParams } from './hooks'
13
14type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBody']
15export type CallFeatureFlagsResponse = components['schemas']['TelemetryCallFeatureFlagsResponse']
16
17export async function getFeatureFlags(
18 API_URL: string,
19 options: { organizationSlug?: string; projectRef?: string } = {}
20) {
21 try {
22 const url = new URL(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`)
23
24 if (options.organizationSlug) {
25 url.searchParams.set('organization_slug', options.organizationSlug)
26 }
27
28 if (options.projectRef) {
29 url.searchParams.set('project_ref', options.projectRef)
30 }
31
32 const data = await get(url.toString())
33 return data as CallFeatureFlagsResponse
34 } catch (error: any) {
35 if (error.message.includes('Failed to fetch')) {
36 console.error('Failed to fetch PH flags: API is not available')
37 }
38 throw error
39 }
40}
41
42export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) {
43 const consent = hasConsented()
44
45 if (!consent) return undefined
46 await post(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags/track`, { body })
47}
48
49export type FeatureFlagContextType = {
50 API_URL?: string
51 configcat: { [key: string]: boolean | number | string | null }
52 posthog: CallFeatureFlagsResponse
53 hasLoaded?: boolean
54}
55
56export const FeatureFlagContext = createContext<FeatureFlagContextType>({
57 API_URL: undefined,
58 configcat: {},
59 posthog: {},
60 hasLoaded: false,
61})
62
63function getCookies() {
64 const pairs = document.cookie.split(';')
65 let cookies: Record<string, string> = {}
66 for (var i = 0; i < pairs.length; i++) {
67 var [t_key, value] = pairs[i].split('=')
68 const key = t_key.trim()
69
70 cookies[key] = unescape(value)
71 }
72 return cookies
73}
74
75export const FeatureFlagProvider = ({
76 API_URL,
77 enabled = true,
78 organizationSlug,
79 projectRef,
80 getConfigCatFlags,
81 children,
82}: PropsWithChildren<{
83 API_URL?: string
84 /** Accepts either `boolean` which controls all feature flags or `{ cc: boolean, ph: boolean }` for individual providers */
85 enabled?: boolean | { cc: boolean; ph: boolean }
86 organizationSlug?: string
87 projectRef?: string
88 /** Custom fetcher for ConfigCat flags if passing in custom attributes */
89 getConfigCatFlags?: (
90 userEmail?: string
91 ) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]>
92}>) => {
93 const { session, isLoading } = useAuth()
94 const userEmail = session?.user?.email
95 const params = useParams()
96 const resolvedOrganizationSlug = organizationSlug ?? params.slug
97 const resolvedProjectRef = projectRef ?? params.ref
98 const lastSentGroupContextRef = useRef<string | null>(null)
99
100 const [store, setStore] = useState<FeatureFlagContextType>({
101 API_URL,
102 configcat: {},
103 posthog: {},
104 hasLoaded: false,
105 })
106
107 useEffect(() => {
108 let mounted = true
109
110 async function ensureGroupContext() {
111 if (!API_URL) return
112
113 const userId = session?.user?.id
114 if (!userId) return
115 if (!hasConsented()) return
116
117 if (!resolvedOrganizationSlug && !resolvedProjectRef) return
118
119 const contextKey = [userId, resolvedOrganizationSlug ?? '', resolvedProjectRef ?? ''].join(
120 '|'
121 )
122 if (lastSentGroupContextRef.current === contextKey) return
123
124 try {
125 await post(
126 `${ensurePlatformSuffix(API_URL)}/telemetry/identify`,
127 {
128 user_id: userId,
129 ...(resolvedOrganizationSlug && { organization_slug: resolvedOrganizationSlug }),
130 ...(resolvedProjectRef && { project_ref: resolvedProjectRef }),
131 },
132 { headers: { Version: '2' } }
133 )
134
135 lastSentGroupContextRef.current = contextKey
136 } catch {}
137 }
138
139 async function processFlags() {
140 if (!enabled || isLoading) return
141
142 const loadPHFlags =
143 (enabled === true || (typeof enabled === 'object' && enabled.ph)) && !!API_URL
144 const loadCCFlags = enabled === true || (typeof enabled === 'object' && enabled.cc)
145
146 let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} }
147
148 // Run both async operations in parallel — allSettled so a failure in one doesn't block the other
149 const [phResult, ccResult] = await Promise.allSettled([
150 loadPHFlags
151 ? (async () => {
152 await ensureGroupContext()
153 return getFeatureFlags(API_URL, {
154 organizationSlug: resolvedOrganizationSlug,
155 projectRef: resolvedProjectRef,
156 })
157 })()
158 : Promise.resolve({}),
159 loadCCFlags
160 ? typeof getConfigCatFlags === 'function'
161 ? getConfigCatFlags(userEmail)
162 : getDefaultConfigCatFlags(userEmail)
163 : Promise.resolve([]),
164 ])
165
166 const flags = phResult.status === 'fulfilled' ? phResult.value : {}
167 if (phResult.status === 'rejected') {
168 console.warn('[FeatureFlags] PostHog flags failed', phResult.reason)
169 }
170 const flagValues = ccResult.status === 'fulfilled' ? ccResult.value : []
171
172 // Dev toolbar flag overrides are available in local dev and staging.
173 // Duplicated for tree-shaking — bundler must see literal process.env reference.
174 // Keep in sync: dev-tools/index.ts, DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx
175 const env = process.env.NEXT_PUBLIC_ENVIRONMENT
176 const isDevToolsEnabled = env === 'local' || env === 'staging'
177
178 const safeParse = (value: string | undefined): Record<string, boolean | number | string> => {
179 if (!value) return {}
180 try {
181 return JSON.parse(value)
182 } catch {
183 return {}
184 }
185 }
186
187 // Process PostHog flags if loaded
188 if (Object.keys(flags).length > 0) {
189 // Apply dev toolbar overrides for PostHog flags
190 if (isDevToolsEnabled) {
191 try {
192 const cookies = getCookies()
193 const phOverrides = safeParse(cookies['x-ph-flag-overrides'])
194 flagStore.posthog = { ...flags, ...phOverrides }
195 } catch {
196 flagStore.posthog = flags
197 }
198 } else {
199 flagStore.posthog = flags
200 }
201 }
202
203 // Process ConfigCat flags if loaded
204 if (flagValues.length > 0) {
205 let overridesCookieValue: Record<string, boolean | number | string> = {}
206
207 try {
208 const cookies = getCookies()
209 // Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (dev toolbar only)
210 // x-cc-flag-overrides takes precedence when dev tools are enabled
211 const vercelOverrides = safeParse(cookies['vercel-flag-overrides'])
212 const ccOverrides = isDevToolsEnabled ? safeParse(cookies['x-cc-flag-overrides']) : {}
213
214 overridesCookieValue = {
215 ...vercelOverrides,
216 ...ccOverrides, // local overrides take precedence
217 }
218 } catch {}
219
220 flagValues.forEach((item) => {
221 flagStore['configcat'][item.settingKey] =
222 overridesCookieValue[item.settingKey] ??
223 (item.settingValue === null ? null : (item.settingValue ?? false))
224 })
225 }
226
227 flagStore.hasLoaded = true
228
229 if (mounted) {
230 setStore(flagStore)
231 }
232 }
233
234 // [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay
235 // as per https://configcat.com/docs/sdk-reference/js/#polling-modes:
236 // The polling downloads the config.json at the set interval and are stored in the internal cache
237 // which subsequently all getValueAsync() calls are served from there
238 processFlags()
239
240 return () => {
241 mounted = false
242 }
243 }, [
244 enabled,
245 isLoading,
246 userEmail,
247 API_URL,
248 session?.user?.id,
249 resolvedOrganizationSlug,
250 resolvedProjectRef,
251 getConfigCatFlags,
252 ])
253
254 return (
255 <FeatureFlagContext.Provider value={store}>
256 {/*
257 [Joshen] Just support configcat flags in Vercel flags for now for simplicity
258 although I think it should be fairly simply to support PH too
259 */}
260 <FlagValues values={store.configcat} />
261 {children}
262 </FeatureFlagContext.Provider>
263 )
264}
265
266export const useFeatureFlags = () => {
267 return useContext(FeatureFlagContext)
268}
269
270const isObjectEmpty = (obj: Object) => {
271 return Object.keys(obj).length === 0
272}
273
274export function useFlag<T = boolean>(name: string) {
275 const flagStore = useFeatureFlags()
276 const store = flagStore.configcat
277
278 // Flag store is empty means config cat is not loaded yet, return false
279 if (isObjectEmpty(store)) {
280 return false
281 }
282
283 if (store[name] === undefined) {
284 console.error(`Flag key "${name}" does not exist in ConfigCat flag store`)
285 return false
286 }
287
288 return store[name] as T
289}