Skip to content

Commit dff2347

Browse files
committed
refactor(featureFlags): centralize ?ff= reading with the other query-state readers
The session override module read window.location.search through its own URLSearchParams on every flag read, which is the one query parameter in the app not read where all the others are. Capture now happens in the router guard next to captureOAuthRequestId, taking the LocationQuery the guard already has, and the new readCurrentLocationQuery covers the reads that land before the router's first navigation - api.init(), the auth store's sign-in path, and the router's own guards. Fixes FE-1551
1 parent b9964c6 commit dff2347

5 files changed

Lines changed: 156 additions & 41 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { parseQuery } from 'vue-router'
2+
import type { LocationQuery } from 'vue-router'
3+
4+
/**
5+
* The query of the URL as it stands right now, in the shape a router guard
6+
* receives, for query-state readers that also have to answer before the
7+
* router's first navigation.
8+
*
9+
* Going through the router's own parser rather than `URLSearchParams` is what
10+
* makes the two paths agree: a bare `?flag` arrives as null from both, and a
11+
* repeated parameter as an array from both.
12+
*/
13+
export function readCurrentLocationQuery(): LocationQuery {
14+
return parseQuery(window.location.search)
15+
}

src/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { installDesktopLoginRedemption } from '@/platform/cloud/onboarding/deskt
2020
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
2121
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
2222
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
23+
import { captureFeatureFlagOverrides } from '@/utils/sessionFeatureFlagOverride'
2324

2425
const cloudOnboardingRoutes = isCloud
2526
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
@@ -133,6 +134,7 @@ installPreservedQueryTracker(router, [
133134
])
134135

135136
router.beforeEach((to, _from, next) => {
137+
captureFeatureFlagOverrides(to.query)
136138
captureOAuthRequestId(to.query)
137139
next()
138140
})

src/scripts/api.sessionOverride.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22

3+
import { readCurrentLocationQuery } from '@/platform/navigation/currentLocationQuery'
34
import { api } from '@/scripts/api'
5+
import { captureFeatureFlagOverrides } from '@/utils/sessionFeatureFlagOverride'
46

57
const mockDistribution = vi.hoisted(() => ({
68
isCloud: true,
@@ -15,6 +17,11 @@ vi.mock('vuefire', () => ({
1517
useCurrentUser: vi.fn(() => mockCurrentUser)
1618
}))
1719

20+
function visit(search: string) {
21+
window.history.replaceState({}, '', search)
22+
captureFeatureFlagOverrides(readCurrentLocationQuery())
23+
}
24+
1825
/**
1926
* Every call here happens at plain module scope — no component, no `setup()`,
2027
* no active Vue instance — which is how `api.getServerFeature` is reached from
@@ -44,29 +51,29 @@ describe('api.getServerFeature session override outside component setup', () =>
4451

4552
it('applies a numeric override to a flag that never routes through resolveFlag', () => {
4653
api.serverFeatureFlags.value = { max_upload_size: 100 }
47-
window.history.replaceState({}, '', '/?ff=max_upload_size:209715200')
54+
visit('/?ff=max_upload_size:209715200')
4855

4956
expect(api.getServerFeature('max_upload_size')).toBe(209715200)
5057
})
5158

5259
it('does not throw when no Vue instance is active', () => {
53-
window.history.replaceState({}, '', '/?ff=some_flag:enforce')
60+
visit('/?ff=some_flag:enforce')
5461

5562
expect(() => api.getServerFeature('some_flag')).not.toThrow()
5663
expect(api.getServerFeature('some_flag')).toBe('enforce')
5764
})
5865

5966
it('beats the dev localStorage override', () => {
6067
localStorage.setItem('ff:some_flag', '"from_local_storage"')
61-
window.history.replaceState({}, '', '/?ff=some_flag:from_url')
68+
visit('/?ff=some_flag:from_url')
6269

6370
expect(api.getServerFeature('some_flag')).toBe('from_url')
6471
})
6572

6673
it('withholds the override from a non-employee', () => {
6774
mockCurrentUser.value = { email: 'someone@gmail.com', emailVerified: true }
6875
api.serverFeatureFlags.value = { max_upload_size: 100 }
69-
window.history.replaceState({}, '', '/?ff=max_upload_size:209715200')
76+
visit('/?ff=max_upload_size:209715200')
7077

7178
expect(api.getServerFeature('max_upload_size')).toBe(100)
7279
})
@@ -80,14 +87,14 @@ describe('api.getServerFeature session override outside component setup', () =>
8087

8188
it('reports the override through serverSupportsFeature too', () => {
8289
api.serverFeatureFlags.value = { some_flag: false }
83-
window.history.replaceState({}, '', '/?ff=some_flag')
90+
visit('/?ff=some_flag')
8491

8592
expect(api.serverSupportsFeature('some_flag')).toBe(true)
8693
})
8794

8895
it('turns a supported feature off through an explicit false override', () => {
8996
api.serverFeatureFlags.value = { some_flag: true }
90-
window.history.replaceState({}, '', '/?ff=some_flag:false')
97+
visit('/?ff=some_flag:false')
9198

9299
expect(api.serverSupportsFeature('some_flag')).toBe(false)
93100
expect(api.getServerFeature('some_flag')).toBe(false)

src/utils/sessionFeatureFlagOverride.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22

3-
import { getSessionOverride } from '@/utils/sessionFeatureFlagOverride'
3+
import { readCurrentLocationQuery } from '@/platform/navigation/currentLocationQuery'
4+
import {
5+
captureFeatureFlagOverrides,
6+
getSessionOverride
7+
} from '@/utils/sessionFeatureFlagOverride'
48

59
const mockDistribution = vi.hoisted(() => ({
610
isCloud: true,
@@ -20,8 +24,10 @@ vi.mock('vuefire', () => ({
2024
const COMFY_EMPLOYEE = { email: 'dev@comfy.org', emailVerified: true }
2125
const STORAGE_KEY = 'Comfy.FeatureFlagOverride'
2226

27+
/** Stands in for a router navigation: URL change plus the guard's capture. */
2328
function visit(search: string) {
2429
window.history.replaceState({}, '', search)
30+
captureFeatureFlagOverrides(readCurrentLocationQuery())
2531
}
2632

2733
describe('getSessionOverride', () => {
@@ -89,6 +95,22 @@ describe('getSessionOverride', () => {
8995
expect(getSessionOverride('onboarding_tour_enabled')).toBeUndefined()
9096
})
9197

98+
it('clears the session on a bare ?ff with no equals sign', () => {
99+
visit('/?ff=onboarding_tour_enabled')
100+
expect(getSessionOverride('onboarding_tour_enabled')).toBe(true)
101+
102+
visit('/?ff')
103+
expect(getSessionOverride('onboarding_tour_enabled')).toBeUndefined()
104+
})
105+
106+
it('accumulates overrides across separate ?ff= visits', () => {
107+
visit('/?ff=onboarding_tour_enabled')
108+
visit('/?ff=signup_turnstile:enforce')
109+
110+
expect(getSessionOverride('onboarding_tour_enabled')).toBe(true)
111+
expect(getSessionOverride('signup_turnstile')).toBe('enforce')
112+
})
113+
92114
it('overrides any flag, with no opt-in registry to join', () => {
93115
visit('/?ff=unified_cloud_auth&ff=some_flag_invented_tomorrow:42')
94116

@@ -214,4 +236,40 @@ describe('getSessionOverride', () => {
214236
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull()
215237
})
216238
})
239+
240+
describe('capture from a router query', () => {
241+
it('takes the query the guard hands it, without reading the URL', () => {
242+
captureFeatureFlagOverrides({ ff: 'signup_turnstile:enforce' })
243+
244+
expect(window.location.search).toBe('')
245+
expect(getSessionOverride('signup_turnstile')).toBe('enforce')
246+
})
247+
248+
it('applies every value of a repeated parameter', () => {
249+
captureFeatureFlagOverrides({
250+
ff: ['workflow_sharing_enabled', 'signup_turnstile:shadow']
251+
})
252+
253+
expect(getSessionOverride('workflow_sharing_enabled')).toBe(true)
254+
expect(getSessionOverride('signup_turnstile')).toBe('shadow')
255+
})
256+
257+
it('leaves an established session alone on a query without ff', () => {
258+
visit('/?ff=onboarding_tour_enabled')
259+
260+
captureFeatureFlagOverrides({ workflowId: 'abc' })
261+
262+
expect(getSessionOverride('onboarding_tour_enabled')).toBe(true)
263+
})
264+
265+
it('captures the URL for flags read before the router navigates', async () => {
266+
window.history.replaceState({}, '', '/?ff=onboarding_tour_enabled')
267+
vi.resetModules()
268+
269+
const { getSessionOverride: readWithoutRouter } =
270+
await import('@/utils/sessionFeatureFlagOverride')
271+
272+
expect(readWithoutRouter('onboarding_tour_enabled')).toBe(true)
273+
})
274+
})
217275
})

src/utils/sessionFeatureFlagOverride.ts

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { stringifyQuery } from 'vue-router'
2+
import type { LocationQuery } from 'vue-router'
13
import { useCurrentUser } from 'vuefire'
24

35
import { isCloud } from '@/platform/distribution/types'
6+
import { readCurrentLocationQuery } from '@/platform/navigation/currentLocationQuery'
47

58
const STORAGE_KEY = 'Comfy.FeatureFlagOverride'
69
const QUERY_PARAM = 'ff'
@@ -46,14 +49,15 @@ function parseOverrideValue(rawValue: string | undefined): unknown {
4649
}
4750

4851
/**
49-
* The captured overrides plus the query string they came from. Remembering the
50-
* source makes capture idempotent: re-reading the same URL does not rewrite
51-
* storage, so a flag can be read as often as a render needs.
52+
* The captured overrides plus the `ff` request they came from. Remembering the
53+
* request is what makes capture idempotent across the two entry points: the
54+
* lazy first read and the router guard that follows it see the same URL, and
55+
* the second one leaves storage alone.
5256
*/
53-
type StoredState = { search: string; overrides: OverrideMap }
57+
type StoredState = { request: string; overrides: OverrideMap }
5458

5559
function emptyState(): StoredState {
56-
return { search: '', overrides: {} }
60+
return { request: '', overrides: {} }
5761
}
5862

5963
function coerceStoredOverrides(value: unknown): OverrideMap {
@@ -80,10 +84,10 @@ function readStoredState(): StoredState {
8084
}
8185
if (typeof parsed !== 'object' || parsed === null) return emptyState()
8286

83-
const search = 'search' in parsed ? parsed.search : undefined
87+
const request = 'request' in parsed ? parsed.request : undefined
8488
const overrides = 'overrides' in parsed ? parsed.overrides : undefined
8589
return {
86-
search: typeof search === 'string' ? search : '',
90+
request: typeof request === 'string' ? request : '',
8791
overrides: coerceStoredOverrides(overrides)
8892
}
8993
}
@@ -102,58 +106,87 @@ function splitRequest(request: string): [name: string, value?: string] {
102106
return [request.slice(0, separator), request.slice(separator + 1)]
103107
}
104108

105-
function readOverrideRequests(search: string): string[] {
106-
try {
107-
return new URLSearchParams(search).getAll(QUERY_PARAM)
108-
} catch {
109-
return []
110-
}
109+
/**
110+
* The `ff` requests in a router query, or undefined when the parameter is
111+
* absent — an unrelated URL has to leave an established session alone.
112+
*
113+
* The router reports a bare `?ff` as null and `?ff=` as an empty string. Both
114+
* are the nameless request that clears the session.
115+
*/
116+
function readOverrideRequests(query: LocationQuery): string[] | undefined {
117+
const raw = query[QUERY_PARAM]
118+
if (raw === undefined) return undefined
119+
120+
const values = Array.isArray(raw) ? raw : [raw]
121+
return values.map((value) => value ?? '')
111122
}
112123

113124
/**
114-
* Merges `?ff=` requests from the current URL into the overrides already held
115-
* for this tab. An `?ff=` with no name clears every override in the session.
125+
* Merges `?ff=` requests into the overrides already held for this tab. An
126+
* `?ff=` with no name clears every override in the session.
116127
*/
117-
function captureRequests(
128+
function resolveOverrides(
118129
requests: string[],
119-
stored: OverrideMap,
120-
search: string
130+
stored: OverrideMap
121131
): OverrideMap {
122-
if (requests.includes('')) {
123-
writeStoredState({ search, overrides: {} })
124-
return {}
125-
}
132+
if (requests.includes('')) return {}
126133

127134
const overrides: OverrideMap = { ...stored }
128135
for (const request of requests) {
129136
const [name, rawValue] = splitRequest(request)
130137
overrides[name] = parseOverrideValue(rawValue)
131138
}
132-
133-
writeStoredState({ search, overrides })
134139
return overrides
135140
}
136141

137-
function loadSessionOverrides(): OverrideMap {
142+
/**
143+
* Captures the `?ff=` request carried by a router query into this tab's
144+
* session. The router guard calls this on every navigation, alongside the
145+
* other query-state readers, so the URL is read where all query state is read
146+
* rather than on each of the hundreds of flag reads a session performs.
147+
*/
148+
export function captureFeatureFlagOverrides(query: LocationQuery): void {
149+
if (!isCloud) return
150+
151+
const requests = readOverrideRequests(query)
152+
if (!requests) return
153+
154+
const request = stringifyQuery({ [QUERY_PARAM]: query[QUERY_PARAM] })
138155
const stored = readStoredState()
139-
const search = window.location.search
140-
if (search === stored.search) return stored.overrides
156+
if (request === stored.request) return
157+
158+
writeStoredState({
159+
request,
160+
overrides: resolveOverrides(requests, stored.overrides)
161+
})
162+
}
141163

142-
const requests = readOverrideRequests(search)
143-
if (requests.length === 0) return stored.overrides
164+
/**
165+
* Flags are read during bootstrap — from `api.init()`, from the auth store's
166+
* sign-in path, and from the router's own guards — all of which can run before
167+
* the first navigation reaches the guard. The first read therefore captures
168+
* the URL itself; every later change of it arrives through the router.
169+
*/
170+
let hasCapturedInitialQuery = false
171+
172+
function loadSessionOverrides(): OverrideMap {
173+
if (!hasCapturedInitialQuery) {
174+
hasCapturedInitialQuery = true
175+
captureFeatureFlagOverrides(readCurrentLocationQuery())
176+
}
144177

145-
return captureRequests(requests, stored.overrides, search)
178+
return readStoredState().overrides
146179
}
147180

148181
/**
149182
* Gets a session override for any feature flag, requested via `?ff=name` to
150183
* turn it on or `?ff=name:value` for a specific value, repeatable to override
151184
* several flags at once. A nameless `?ff=` clears the session.
152185
*
153-
* The request is captured into `sessionStorage` on the first read, so it
154-
* survives reloads and in-app navigation but dies when the tab closes. Capture
155-
* happens before authentication resolves; the employee check is applied here on
156-
* every read instead, so a flag flips as soon as the user is known.
186+
* The request is captured into `sessionStorage`, so it survives reloads and
187+
* in-app navigation but dies when the tab closes. Capture happens before
188+
* authentication resolves; the employee check is applied here on every read
189+
* instead, so a flag flips as soon as the user is known.
157190
*
158191
* Returns undefined (not null) as the "no override" sentinel, matching
159192
* `getDevOverride`.

0 commit comments

Comments
 (0)