1+ import { stringifyQuery } from 'vue-router'
2+ import type { LocationQuery } from 'vue-router'
13import { useCurrentUser } from 'vuefire'
24
35import { isCloud } from '@/platform/distribution/types'
6+ import { readCurrentLocationQuery } from '@/platform/navigation/currentLocationQuery'
47
58const STORAGE_KEY = 'Comfy.FeatureFlagOverride'
69const 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
5559function emptyState ( ) : StoredState {
56- return { search : '' , overrides : { } }
60+ return { request : '' , overrides : { } }
5761}
5862
5963function 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