Skip to content

Commit bcf8c69

Browse files
chore: persist startup feature toggles across reloads and navigation
Startup feature toggles (`?enabled-features=...`) only survived while the query parameter stayed in the URL. Persist the enabled set in localStorage so toggles stay on/off across page reloads, in-app navigation, and tabs. - Boot treats the URL as source of truth when the parameter is present and syncs it to localStorage; otherwise it falls back to localStorage. - Toggling from the Developer Menu updates both the URL and localStorage, so disabling the last toggle clears the stored set instead of letting it resurrect on the next load. - The write side derives the enabled set with the same URL-or-localStorage precedence as boot, fixing enable/disable dropping toggles that were restored from localStorage.
1 parent ac39c81 commit bcf8c69

6 files changed

Lines changed: 306 additions & 9 deletions

File tree

apps/webapp/src/script/components/configToolbar/configToolbar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ export function ConfigToolbar() {
353353
locationSearch,
354354
featureToggleName,
355355
shouldEnableFeatureToggle,
356+
localStorage: globalThis.localStorage,
356357
});
357358
const locationPathname = applicationNavigation.currentPathname;
358359
const locationHash = applicationNavigation.currentHash;

apps/webapp/src/script/featureToggles/startupFeatureToggleQueryParameters.test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,35 @@ import {
2121
applockRefactoredFeatureToggleName,
2222
conversationListCollapseFeatureToggleName,
2323
} from './startupFeatureToggleNames';
24-
import {startupFeatureToggleQueryParameterName} from './startupFeatureToggles';
24+
import {startupFeatureToggleQueryParameterName, startupFeatureToggleLocalStorageKey} from './startupFeatureToggles';
2525
import {updateLocationSearchForStartupFeatureToggle} from './startupFeatureToggleQueryParameters';
2626

27+
type LocalStorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
28+
29+
function createInMemoryLocalStorage(initialValue?: string): {
30+
localStorage: LocalStorageLike;
31+
getStoredValue: () => string | null;
32+
} {
33+
let storedValue: string | null = initialValue ?? null;
34+
35+
return {
36+
localStorage: {
37+
getItem: (key: string) => (key === startupFeatureToggleLocalStorageKey ? storedValue : null),
38+
setItem: (key: string, value: string) => {
39+
if (key === startupFeatureToggleLocalStorageKey) {
40+
storedValue = value;
41+
}
42+
},
43+
removeItem: (key: string) => {
44+
if (key === startupFeatureToggleLocalStorageKey) {
45+
storedValue = null;
46+
}
47+
},
48+
},
49+
getStoredValue: () => storedValue,
50+
};
51+
}
52+
2753
describe('updateLocationSearchForStartupFeatureToggle', () => {
2854
it('adds a feature toggle to an existing query string and preserves unrelated parameters', () => {
2955
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
@@ -105,3 +131,108 @@ describe('updateLocationSearchForStartupFeatureToggle', () => {
105131
);
106132
});
107133
});
134+
135+
describe('updateLocationSearchForStartupFeatureToggle session persistence', () => {
136+
it('persists the updated enabled toggles to local storage when enabling a toggle', () => {
137+
const {localStorage, getStoredValue} = createInMemoryLocalStorage();
138+
139+
updateLocationSearchForStartupFeatureToggle({
140+
locationSearch: '?foo=bar',
141+
featureToggleName: applockRefactoredFeatureToggleName,
142+
shouldEnableFeatureToggle: true,
143+
localStorage,
144+
});
145+
146+
expect(getStoredValue()).toBe(applockRefactoredFeatureToggleName);
147+
});
148+
149+
it('clears local storage when the last feature toggle is disabled', () => {
150+
const {localStorage, getStoredValue} = createInMemoryLocalStorage(applockRefactoredFeatureToggleName);
151+
152+
updateLocationSearchForStartupFeatureToggle({
153+
locationSearch: `?${startupFeatureToggleQueryParameterName}=${applockRefactoredFeatureToggleName}`,
154+
featureToggleName: applockRefactoredFeatureToggleName,
155+
shouldEnableFeatureToggle: false,
156+
localStorage,
157+
});
158+
159+
expect(getStoredValue()).toBeNull();
160+
});
161+
162+
it('keeps the remaining enabled toggles in local storage when one is disabled', () => {
163+
const {localStorage, getStoredValue} = createInMemoryLocalStorage(
164+
`${applockRefactoredFeatureToggleName},${conversationListCollapseFeatureToggleName}`,
165+
);
166+
167+
updateLocationSearchForStartupFeatureToggle({
168+
locationSearch: `?${startupFeatureToggleQueryParameterName}=${applockRefactoredFeatureToggleName},${conversationListCollapseFeatureToggleName}`,
169+
featureToggleName: applockRefactoredFeatureToggleName,
170+
shouldEnableFeatureToggle: false,
171+
localStorage,
172+
});
173+
174+
expect(getStoredValue()).toBe(conversationListCollapseFeatureToggleName);
175+
});
176+
177+
it('does not touch local storage when no local storage is provided', () => {
178+
const {localStorage, getStoredValue} = createInMemoryLocalStorage();
179+
180+
updateLocationSearchForStartupFeatureToggle({
181+
locationSearch: '?foo=bar',
182+
featureToggleName: applockRefactoredFeatureToggleName,
183+
shouldEnableFeatureToggle: true,
184+
});
185+
186+
expect(getStoredValue()).toBeNull();
187+
});
188+
189+
it('keeps localStorage-enabled toggles when enabling a toggle from a search without the query parameter', () => {
190+
const {localStorage, getStoredValue} = createInMemoryLocalStorage(conversationListCollapseFeatureToggleName);
191+
192+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
193+
locationSearch: '?foo=bar',
194+
featureToggleName: applockRefactoredFeatureToggleName,
195+
shouldEnableFeatureToggle: true,
196+
localStorage,
197+
});
198+
199+
expect(updatedLocationSearch).toBe(
200+
`?foo=bar&${startupFeatureToggleQueryParameterName}=${applockRefactoredFeatureToggleName}%2C${conversationListCollapseFeatureToggleName}`,
201+
);
202+
expect(getStoredValue()).toBe(
203+
`${applockRefactoredFeatureToggleName},${conversationListCollapseFeatureToggleName}`,
204+
);
205+
});
206+
207+
it('keeps other localStorage-enabled toggles when disabling one from a search without the query parameter', () => {
208+
const {localStorage, getStoredValue} = createInMemoryLocalStorage(
209+
`${applockRefactoredFeatureToggleName},${conversationListCollapseFeatureToggleName}`,
210+
);
211+
212+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
213+
locationSearch: '?foo=bar',
214+
featureToggleName: applockRefactoredFeatureToggleName,
215+
shouldEnableFeatureToggle: false,
216+
localStorage,
217+
});
218+
219+
expect(updatedLocationSearch).toBe(
220+
`?foo=bar&${startupFeatureToggleQueryParameterName}=${conversationListCollapseFeatureToggleName}`,
221+
);
222+
expect(getStoredValue()).toBe(conversationListCollapseFeatureToggleName);
223+
});
224+
225+
it('clears local storage when disabling the last toggle from a search without the query parameter', () => {
226+
const {localStorage, getStoredValue} = createInMemoryLocalStorage(applockRefactoredFeatureToggleName);
227+
228+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
229+
locationSearch: '?foo=bar',
230+
featureToggleName: applockRefactoredFeatureToggleName,
231+
shouldEnableFeatureToggle: false,
232+
localStorage,
233+
});
234+
235+
expect(updatedLocationSearch).toBe('?foo=bar');
236+
expect(getStoredValue()).toBeNull();
237+
});
238+
});

apps/webapp/src/script/featureToggles/startupFeatureToggleQueryParameters.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@ import {Maybe} from 'true-myth';
2222
import {StartupFeatureToggleName, startupFeatureToggleNames} from './startupFeatureToggleNames';
2323
import {
2424
createStartupFeatureTogglesFromLocationSearch,
25+
persistEnabledFeatureToggleNamesInLocalStorage,
2526
startupFeatureToggleQueryParameterName,
2627
} from './startupFeatureToggles';
2728

29+
type StartupFeatureToggleLocalStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
30+
2831
type UpdateStartupFeatureToggleLocationSearchInput = {
2932
readonly locationSearch: string;
3033
readonly featureToggleName: StartupFeatureToggleName;
3134
readonly shouldEnableFeatureToggle: boolean;
35+
readonly localStorage?: StartupFeatureToggleLocalStorage;
3236
};
3337

3438
function toOrderedEnabledFeatureToggleNames(
@@ -41,8 +45,9 @@ function toOrderedEnabledFeatureToggleNames(
4145

4246
function readEnabledFeatureToggleNameSetFromLocationSearch(
4347
locationSearch: string,
48+
localStorage?: StartupFeatureToggleLocalStorage,
4449
): ReadonlySet<StartupFeatureToggleName> {
45-
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch(locationSearch);
50+
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch(locationSearch, localStorage);
4651
return new Set(startupFeatureToggles.enabledFeatureToggleNames);
4752
}
4853

@@ -77,14 +82,21 @@ function serializeEnabledFeatureToggleNames(
7782
export function updateLocationSearchForStartupFeatureToggle(
7883
input: UpdateStartupFeatureToggleLocationSearchInput,
7984
): string {
80-
const {locationSearch, featureToggleName, shouldEnableFeatureToggle} = input;
81-
const enabledFeatureToggleNameSet = readEnabledFeatureToggleNameSetFromLocationSearch(locationSearch);
85+
const {locationSearch, featureToggleName, shouldEnableFeatureToggle, localStorage} = input;
86+
const enabledFeatureToggleNameSet = readEnabledFeatureToggleNameSetFromLocationSearch(locationSearch, localStorage);
8287
const updatedEnabledFeatureToggleNameSet = toUpdatedEnabledFeatureToggleNameSet(
8388
enabledFeatureToggleNameSet,
8489
featureToggleName,
8590
shouldEnableFeatureToggle,
8691
);
8792

93+
if (localStorage !== undefined) {
94+
persistEnabledFeatureToggleNamesInLocalStorage(
95+
toOrderedEnabledFeatureToggleNames(updatedEnabledFeatureToggleNameSet),
96+
localStorage,
97+
);
98+
}
99+
88100
const queryParameters = new URLSearchParams(locationSearch);
89101
const serializedEnabledFeatureToggleNames = serializeEnabledFeatureToggleNames(updatedEnabledFeatureToggleNameSet);
90102

apps/webapp/src/script/featureToggles/startupFeatureToggles.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
allowedStartupFeatureToggleNames,
2222
createStartupFeatureTogglesFromLocationSearch,
2323
startupFeatureToggleQueryParameterName,
24+
startupFeatureToggleLocalStorageKey,
2425
} from './startupFeatureToggles';
2526
import {
2627
applockRefactoredFeatureToggleName,
@@ -165,3 +166,93 @@ describe('startupFeatureToggles', function () {
165166
expect('enabledFeatureToggleNameSet' in startupFeatureToggles).toBe(false);
166167
});
167168
});
169+
170+
type LocalStorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
171+
172+
function createInMemoryLocalStorage(initialValue?: string): {
173+
localStorage: LocalStorageLike;
174+
getStoredValue: () => string | null;
175+
} {
176+
let storedValue: string | null = initialValue ?? null;
177+
178+
return {
179+
localStorage: {
180+
getItem: (key: string) => (key === startupFeatureToggleLocalStorageKey ? storedValue : null),
181+
setItem: (key: string, value: string) => {
182+
if (key === startupFeatureToggleLocalStorageKey) {
183+
storedValue = value;
184+
}
185+
},
186+
removeItem: (key: string) => {
187+
if (key === startupFeatureToggleLocalStorageKey) {
188+
storedValue = null;
189+
}
190+
},
191+
},
192+
getStoredValue: () => storedValue,
193+
};
194+
}
195+
196+
describe('startupFeatureToggles session persistence', () => {
197+
it('falls back to local storage when the query parameter is missing', () => {
198+
const {localStorage} = createInMemoryLocalStorage(applockRefactoredFeatureToggleName);
199+
200+
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch('?foo=bar', localStorage);
201+
202+
expect(startupFeatureToggles.isFeatureToggleEnabled(applockRefactoredFeatureToggleName)).toBe(true);
203+
expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([applockRefactoredFeatureToggleName]);
204+
});
205+
206+
it('prefers the query parameter over local storage', () => {
207+
const {localStorage} = createInMemoryLocalStorage(applockRefactoredFeatureToggleName);
208+
209+
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch(
210+
`?${startupFeatureToggleQueryParameterName}=${viewerPermissionFeatureToggleName}`,
211+
localStorage,
212+
);
213+
214+
expect(startupFeatureToggles.isFeatureToggleEnabled(applockRefactoredFeatureToggleName)).toBe(false);
215+
expect(startupFeatureToggles.isFeatureToggleEnabled(viewerPermissionFeatureToggleName)).toBe(true);
216+
});
217+
218+
it('persists query parameter toggles to local storage', () => {
219+
const {localStorage, getStoredValue} = createInMemoryLocalStorage();
220+
221+
createStartupFeatureTogglesFromLocationSearch(
222+
`?${startupFeatureToggleQueryParameterName}=${applockRefactoredFeatureToggleName}`,
223+
localStorage,
224+
);
225+
226+
expect(getStoredValue()).toBe(applockRefactoredFeatureToggleName);
227+
});
228+
229+
it('clears local storage when the query parameter is present but empty', () => {
230+
const {localStorage, getStoredValue} = createInMemoryLocalStorage(applockRefactoredFeatureToggleName);
231+
232+
createStartupFeatureTogglesFromLocationSearch(`?${startupFeatureToggleQueryParameterName}=`, localStorage);
233+
234+
expect(getStoredValue()).toBeNull();
235+
});
236+
237+
it('does not read from local storage when no local storage is provided', () => {
238+
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch('?foo=bar');
239+
240+
expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([]);
241+
});
242+
243+
it('ignores unknown feature toggles from local storage', () => {
244+
const {localStorage} = createInMemoryLocalStorage('unknown-feature');
245+
246+
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch('?foo=bar', localStorage);
247+
248+
expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([]);
249+
});
250+
251+
it('trims whitespace around feature toggle names from local storage', () => {
252+
const {localStorage} = createInMemoryLocalStorage(` ${applockRefactoredFeatureToggleName} `);
253+
254+
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch('?foo=bar', localStorage);
255+
256+
expect(startupFeatureToggles.isFeatureToggleEnabled(applockRefactoredFeatureToggleName)).toBe(true);
257+
});
258+
});

apps/webapp/src/script/featureToggles/startupFeatureToggles.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ export type {StartupFeatureToggleName} from './startupFeatureToggleNames';
2424

2525
export const startupFeatureToggleQueryParameterName = 'enabled-features';
2626

27+
export const startupFeatureToggleLocalStorageKey = 'startup-feature-toggles';
28+
2729
export const allowedStartupFeatureToggleNames = startupFeatureToggleNames;
2830

31+
type StartupFeatureToggleLocalStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
32+
2933
const allowedStartupFeatureToggleNameSet = new Set<StartupFeatureToggleName>(allowedStartupFeatureToggleNames);
3034

3135
export type StartupFeatureToggles = {
@@ -62,10 +66,65 @@ function readEnabledFeatureToggleNameListFromLocationSearch(
6266
return Maybe.of(enabledFeatureTogglesParameterValue).map(toEnabledFeatureToggleNameList).unwrapOr([]);
6367
}
6468

65-
export function createStartupFeatureTogglesFromLocationSearch(locationSearch: string): StartupFeatureToggles {
66-
const enabledFeatureToggleNameSet = new Set<StartupFeatureToggleName>(
67-
readEnabledFeatureToggleNameListFromLocationSearch(locationSearch),
68-
);
69+
function hasStartupFeatureToggleQueryParameter(locationSearch: string): boolean {
70+
return new URLSearchParams(locationSearch).has(startupFeatureToggleQueryParameterName);
71+
}
72+
73+
function readEnabledFeatureToggleNameListFromLocalStorage(
74+
localStorage: StartupFeatureToggleLocalStorage,
75+
): readonly StartupFeatureToggleName[] {
76+
return Maybe.of(localStorage.getItem(startupFeatureToggleLocalStorageKey))
77+
.map(toEnabledFeatureToggleNameList)
78+
.unwrapOr([]);
79+
}
80+
81+
export function persistEnabledFeatureToggleNamesInLocalStorage(
82+
enabledFeatureToggleNames: readonly StartupFeatureToggleName[],
83+
localStorage: StartupFeatureToggleLocalStorage,
84+
): void {
85+
if (enabledFeatureToggleNames.length === 0) {
86+
localStorage.removeItem(startupFeatureToggleLocalStorageKey);
87+
return;
88+
}
89+
90+
localStorage.setItem(startupFeatureToggleLocalStorageKey, enabledFeatureToggleNames.join(','));
91+
}
92+
93+
function readEnabledFeatureToggleNameList(
94+
locationSearch: string,
95+
localStorage: StartupFeatureToggleLocalStorage | undefined,
96+
): readonly StartupFeatureToggleName[] {
97+
if (hasStartupFeatureToggleQueryParameter(locationSearch)) {
98+
return readEnabledFeatureToggleNameListFromLocationSearch(locationSearch);
99+
}
100+
101+
if (localStorage !== undefined) {
102+
return readEnabledFeatureToggleNameListFromLocalStorage(localStorage);
103+
}
104+
105+
return [];
106+
}
107+
108+
/**
109+
* Creates the startup feature toggles for the application boot.
110+
*
111+
* The URL query parameter is the source of truth whenever it is present:
112+
* - If `enabled-features` is in the URL, its value is used and synced to local storage.
113+
* - If the parameter is missing and local storage is provided, the previously persisted
114+
* toggles are used so they survive page reloads and in-app navigation.
115+
* - If neither is available, all toggles are disabled.
116+
*/
117+
export function createStartupFeatureTogglesFromLocationSearch(
118+
locationSearch: string,
119+
localStorage?: StartupFeatureToggleLocalStorage,
120+
): StartupFeatureToggles {
121+
const enabledFeatureToggleNames = readEnabledFeatureToggleNameList(locationSearch, localStorage);
122+
123+
if (localStorage !== undefined) {
124+
persistEnabledFeatureToggleNamesInLocalStorage(enabledFeatureToggleNames, localStorage);
125+
}
126+
127+
const enabledFeatureToggleNameSet = new Set<StartupFeatureToggleName>(enabledFeatureToggleNames);
69128

70129
return {
71130
isFeatureToggleEnabled(featureToggleName) {

0 commit comments

Comments
 (0)