Skip to content

Commit c7e86ae

Browse files
authored
Merge pull request #19776 from mozilla/fxa-12738
fix(metrics): Add `useLocalStorageSync` hook for reactive changes to localstorage
2 parents d8e0aee + 33631f7 commit c7e86ae

7 files changed

Lines changed: 231 additions & 52 deletions

File tree

packages/fxa-settings/src/lib/cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function getStoredAccountData({
4545

4646
type LocalAccounts = Record<hexstring, StoredAccountData>;
4747

48-
function accounts(accounts?: LocalAccounts) {
48+
export function accounts(accounts?: LocalAccounts) {
4949
if (accounts) {
5050
storage.set('accounts', accounts);
5151
return accounts;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { renderHook, act } from '@testing-library/react';
6+
import { useLocalStorageSync } from './useLocalStorageSync';
7+
import Storage from '../storage';
8+
9+
jest.mock('../storage');
10+
11+
const mockStorage = {
12+
get: jest.fn(),
13+
set: jest.fn(),
14+
remove: jest.fn(),
15+
clear: jest.fn(),
16+
};
17+
18+
describe('useLocalStorageSync', () => {
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
(Storage.factory as jest.Mock) = jest.fn(() => mockStorage);
22+
});
23+
24+
it('returns value from storage', () => {
25+
mockStorage.get.mockReturnValue('test-value');
26+
const { result } = renderHook(() => useLocalStorageSync('test-key'));
27+
28+
expect(result.current).toBe('test-value');
29+
expect(mockStorage.get).toHaveBeenCalledWith('test-key');
30+
});
31+
32+
it('returns undefined when key does not exist', () => {
33+
mockStorage.get.mockReturnValue(undefined);
34+
const { result } = renderHook(() => useLocalStorageSync('non-existent'));
35+
36+
expect(result.current).toBeUndefined();
37+
});
38+
39+
it('subscribes to localStorageChange events', () => {
40+
mockStorage.get.mockReturnValue('value');
41+
const { result } = renderHook(() => useLocalStorageSync('test-key'));
42+
43+
expect(result.current).toBe('value');
44+
45+
act(() => {
46+
window.dispatchEvent(
47+
new CustomEvent('localStorageChange', {
48+
detail: { key: 'test-key' },
49+
})
50+
);
51+
});
52+
53+
expect(mockStorage.get).toHaveBeenCalled();
54+
});
55+
56+
it('cleans up event listener on unmount', () => {
57+
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
58+
const { unmount } = renderHook(() => useLocalStorageSync('test-key'));
59+
60+
unmount();
61+
62+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
63+
'localStorageChange',
64+
expect.any(Function)
65+
);
66+
67+
removeEventListenerSpy.mockRestore();
68+
});
69+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { useSyncExternalStore } from 'react';
6+
import Storage from '../storage';
7+
8+
/**
9+
* Hook to monitor a specific localStorage key and reactively update when it changes.
10+
* Works for both same-tab and cross-tab changes.
11+
*
12+
* @param key - The localStorage key to monitor (without the '__fxa_storage.' prefix)
13+
* @returns The current value from localStorage, or undefined if not set
14+
*
15+
* @example
16+
* ```tsx
17+
* const currentAccountUid = useLocalStorageSync('currentAccountUid');
18+
*
19+
* useEffect(() => {
20+
* if (currentAccountUid) {
21+
* console.log('Account UID changed:', currentAccountUid);
22+
* }
23+
* }, [currentAccountUid]);
24+
* ```
25+
*/
26+
export function useLocalStorageSync(key: string) {
27+
const storage = Storage.factory('localStorage');
28+
29+
const subscribe = (callback: () => void) => {
30+
// Listen for custom events (same-tab changes)
31+
// We'll dispatch this when we write to localStorage
32+
const handleCustomStorage = (e: CustomEvent) => {
33+
if (e.detail?.key === key) {
34+
callback();
35+
}
36+
};
37+
38+
window.addEventListener('localStorageChange' as any, handleCustomStorage as EventListener);
39+
40+
return () => {
41+
window.removeEventListener('localStorageChange' as any, handleCustomStorage as EventListener);
42+
};
43+
};
44+
45+
// Cache snapshot to prevent infinite loops - compare by value, not reference
46+
let cachedSnapshot: any;
47+
let cachedSnapshotString: string | undefined;
48+
49+
const getSnapshot = () => {
50+
const snapshot = storage.get(key);
51+
const snapshotString = JSON.stringify(snapshot);
52+
53+
// Only update cached snapshot if the value actually changed
54+
if (snapshotString !== cachedSnapshotString) {
55+
cachedSnapshot = snapshot;
56+
cachedSnapshotString = snapshotString;
57+
}
58+
59+
return cachedSnapshot;
60+
};
61+
62+
return useSyncExternalStore(subscribe, getSnapshot);
63+
}

packages/fxa-settings/src/lib/storage.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,27 @@ class Storage {
5858

5959
set(key: string, val: any): void {
6060
this._backend.setItem(fullKey(key), JSON.stringify(val));
61+
62+
// This allows useLocalStorageSync to detect changes in the same tab
63+
if (this._backend === window.localStorage) {
64+
window.dispatchEvent(
65+
new CustomEvent('localStorageChange', {
66+
detail: { key },
67+
})
68+
);
69+
}
6170
}
6271

6372
remove(key: string) {
6473
this._backend.removeItem(fullKey(key));
74+
75+
if (this._backend === window.localStorage) {
76+
window.dispatchEvent(
77+
new CustomEvent('localStorageChange', {
78+
detail: { key },
79+
})
80+
);
81+
}
6582
}
6683

6784
clear() {

packages/fxa-settings/src/models/contexts/NimbusContext.test.tsx

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,23 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import React from 'react';
6-
import { render, screen } from '@testing-library/react';
6+
import { render, screen, waitFor } from '@testing-library/react';
77
import { NimbusProvider, useNimbusContext } from './NimbusContext';
88
import { AppContext, AppContextValue } from './AppContext';
99
import { useDynamicLocalization } from '../../contexts/DynamicLocalizationContext';
1010
import { initializeNimbus, NimbusResult } from '../../lib/nimbus';
1111
import * as Sentry from '@sentry/react';
12-
import { currentAccount } from '../../lib/cache';
13-
import { StoredAccountData } from '../../lib/storage-utils';
12+
import { useLocalStorageSync } from '../../lib/hooks/useLocalStorageSync';
1413

1514
jest.mock('../../contexts/DynamicLocalizationContext');
1615
jest.mock('../../lib/nimbus');
1716
jest.mock('@sentry/react');
18-
jest.mock('../../lib/cache', () => ({
19-
currentAccount: jest.fn(),
20-
}));
17+
jest.mock('../../lib/hooks/useLocalStorageSync');
2118

2219
const mockUseDynamicLocalization = useDynamicLocalization as jest.MockedFunction<typeof useDynamicLocalization>;
2320
const mockInitializeNimbus = initializeNimbus as jest.MockedFunction<typeof initializeNimbus>;
2421
const mockSentryCaptureException = Sentry.captureException as jest.MockedFunction<typeof Sentry.captureException>;
25-
const mockCurrentAccount = currentAccount as jest.MockedFunction<typeof currentAccount>;
22+
const mockUseLocalStorageSync = useLocalStorageSync as jest.MockedFunction<typeof useLocalStorageSync>;
2623

2724
const TestComponent = () => {
2825
const { experiments, loading, error } = useNimbusContext();
@@ -61,9 +58,16 @@ describe('NimbusContext', () => {
6158
clearLanguagePreference: jest.fn(),
6259
isLoading: false
6360
});
64-
mockCurrentAccount.mockReturnValue({
65-
metricsEnabled: true,
66-
} as StoredAccountData);
61+
// Mock useLocalStorageSync to return undefined by default (no account)
62+
mockUseLocalStorageSync.mockImplementation((key: string) => {
63+
if (key === 'currentAccountUid') {
64+
return undefined;
65+
}
66+
if (key === 'accounts') {
67+
return undefined;
68+
}
69+
return undefined;
70+
});
6771
Object.defineProperty(window, 'location', {
6872
value: { search: '' },
6973
writable: true
@@ -121,32 +125,39 @@ describe('NimbusContext', () => {
121125
expect(screen.getByTestId('loading')).toHaveTextContent('false');
122126
});
123127

124-
it('does not fetch when metrics are disabled', async () => {
125-
mockCurrentAccount.mockReturnValue({
126-
metricsEnabled: false,
127-
} as StoredAccountData);
128+
it('does not fetch when metrics are disabled via localStorage', async () => {
129+
const accountUid = 'test-account-uid';
130+
mockUseLocalStorageSync.mockImplementation((key: string) => {
131+
if (key === 'currentAccountUid') return accountUid;
132+
if (key === 'accounts') return { [accountUid]: { metricsEnabled: false } };
133+
return undefined;
134+
});
128135

129136
renderWithProviders();
130137

131-
expect(mockInitializeNimbus).not.toHaveBeenCalled();
132-
expect(screen.getByTestId('loading')).toHaveTextContent('false');
138+
await waitFor(() => {
139+
expect(mockInitializeNimbus).not.toHaveBeenCalled();
140+
});
133141
expect(screen.getByTestId('experiments')).toHaveTextContent('no-experiments');
134142
});
135143

136-
it('fetches when metrics are enabled', async () => {
137-
mockCurrentAccount.mockReturnValue({
138-
metricsEnabled: true,
139-
} as StoredAccountData);
140-
const mockExperiments: NimbusResult = {
144+
it('fetches when metrics are enabled via localStorage', async () => {
145+
const accountUid = 'test-account-uid';
146+
mockUseLocalStorageSync.mockImplementation((key: string) => {
147+
if (key === 'currentAccountUid') return accountUid;
148+
if (key === 'accounts') return { [accountUid]: { metricsEnabled: true } };
149+
return undefined;
150+
});
151+
mockInitializeNimbus.mockResolvedValue({
141152
features: { 'test-feature': { enabled: true } },
142153
nimbusUserId: 'test-user-id'
143-
};
144-
mockInitializeNimbus.mockResolvedValue(mockExperiments);
154+
});
145155

146156
renderWithProviders();
147157

148-
expect(mockInitializeNimbus).toHaveBeenCalled();
149-
await screen.findByTestId('experiments');
158+
await waitFor(() => {
159+
expect(mockInitializeNimbus).toHaveBeenCalled();
160+
});
150161
expect(screen.getByTestId('experiments')).toHaveTextContent('has-experiments');
151162
});
152163

@@ -191,17 +202,15 @@ describe('NimbusContext', () => {
191202
expect(screen.getByTestId('experiments')).toHaveTextContent('no-experiments');
192203
});
193204

194-
it('handles fetch error', async () => {
205+
it('handles fetch error without duplicate error handling', async () => {
195206
const error = new Error('Network error');
196207
mockInitializeNimbus.mockRejectedValue(error);
197208

198209
renderWithProviders();
199210

200211
await screen.findByTestId('error');
201212
expect(screen.getByTestId('error')).toHaveTextContent('Network error');
202-
expect(mockSentryCaptureException).toHaveBeenCalledWith(error, expect.objectContaining({
203-
tags: { area: 'NimbusProvider', component: 'NimbusContext' }
204-
}));
213+
expect(mockSentryCaptureException).toHaveBeenCalledTimes(1);
205214
});
206215

207216
it('handles preview mode from config', async () => {
@@ -234,13 +243,19 @@ describe('NimbusContext', () => {
234243
);
235244
});
236245

237-
it('cleans up on unmount', () => {
246+
it('cleans up on unmount', async () => {
247+
mockInitializeNimbus.mockResolvedValue({
248+
features: { 'test-feature': { enabled: true } },
249+
nimbusUserId: 'test-user-id'
250+
});
251+
238252
const { unmount } = renderWithProviders();
239253

240-
unmount();
254+
await waitFor(() => {
255+
expect(mockInitializeNimbus).toHaveBeenCalled();
256+
});
241257

242-
// Should not throw or cause memory leaks
243-
expect(mockInitializeNimbus).toHaveBeenCalled();
258+
expect(() => unmount()).not.toThrow();
244259
});
245260
});
246261
});

packages/fxa-settings/src/models/contexts/NimbusContext.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { useDynamicLocalization } from '../../contexts/DynamicLocalizationContex
2121
import { parseAcceptLanguage } from '@fxa/shared/l10n';
2222
import { searchParams } from '../../lib/utilities';
2323
import * as Sentry from '@sentry/react';
24-
import { currentAccount } from '../../lib/cache';
24+
import { useLocalStorageSync } from '../../lib/hooks/useLocalStorageSync';
2525

2626
interface NimbusApiResponse {
2727
Features?: Record<string, any>;
@@ -45,7 +45,7 @@ const NimbusContext = createContext<NimbusContextValue | undefined>(undefined);
4545

4646
export function useNimbusContext() {
4747
const context = useContext(NimbusContext);
48-
if (context === undefined ) {
48+
if (context === undefined) {
4949
// Return default values when no NimbusProvider is present
5050
return {
5151
experiments: null,
@@ -73,21 +73,30 @@ export function NimbusProvider({ children }: NimbusProviderProps) {
7373
const [loading, setLoading] = useState<boolean>(false);
7474
const [error, setError] = useState<Error | undefined>(undefined);
7575

76-
useEffect(() => {
76+
// Monitor currentAccountUid changes in localStorage (reacts to same-tab and cross-tab changes)
77+
const currentAccountUid = useLocalStorageSync('currentAccountUid');
78+
const accounts = useLocalStorageSync('accounts');
79+
80+
// Get metricsEnabled from accounts object, safely handling undefined values
81+
const accountMetricsEnabled = useMemo(() => {
82+
if (!currentAccountUid || !accounts || accounts[currentAccountUid]?.metricsEnabled === undefined) {
83+
return true;
84+
}
85+
86+
return accounts[currentAccountUid]?.metricsEnabled;
87+
}, [currentAccountUid, accounts]);
7788

78-
// Disable Nimbus if metrics are opted out, note that we specifically check
79-
// the local storage account because the account stored in the AppContext is
80-
// not available at this point
81-
const legacyLocalStorageAccount = currentAccount();
82-
if (legacyLocalStorageAccount && legacyLocalStorageAccount.metricsEnabled === false) {
89+
90+
useEffect(() => {
91+
// Disable Nimbus if metrics are opted out
92+
const nimbusUserId = currentAccountUid || uniqueUserId;
93+
if (accountMetricsEnabled === false) {
8394
setExperiments(null);
8495
setLoading(false);
8596
setError(undefined);
8697
return;
8798
}
8899

89-
const nimbusUserId = legacyLocalStorageAccount?.uid || uniqueUserId;
90-
91100
if (!config?.nimbus.enabled || !nimbusUserId) {
92101
setExperiments(null);
93102
setLoading(false);
@@ -160,7 +169,9 @@ export function NimbusProvider({ children }: NimbusProviderProps) {
160169
config?.nimbus.enabled,
161170
config?.nimbus.preview,
162171
uniqueUserId,
163-
currentLocale
172+
currentLocale,
173+
currentAccountUid,
174+
accountMetricsEnabled,
164175
]);
165176

166177
const value: NimbusContextValue = useMemo(() => ({

0 commit comments

Comments
 (0)