Skip to content

Commit 8f8bd30

Browse files
committed
fix: rare useEffect cycle with multiple tabs
1 parent d77cd2f commit 8f8bd30

File tree

2 files changed

+124
-6
lines changed

2 files changed

+124
-6
lines changed

apps/meteor/client/providers/UserProvider/UserProvider.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,14 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => {
7373
});
7474

7575
const previousUserId = useRef(userId);
76-
const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', '');
76+
const previousUserLanguage = useRef(user?.language);
77+
const syncedLoginPreference = useRef(false);
78+
79+
if (previousUserId.current !== userId) {
80+
previousUserId.current = userId;
81+
syncedLoginPreference.current = false;
82+
}
83+
const [, setUserLanguage] = useLocalStorage('userLanguage', '');
7784
const [preferedLanguage, setPreferedLanguage] = useLocalStorage('preferedLanguage', '');
7885
const [, setSamlInviteToken] = useSamlInviteToken();
7986
const samlCredentialToken = useSearchParameter('saml_idp_credentialToken');
@@ -157,16 +164,22 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => {
157164
);
158165

159166
useEffect(() => {
160-
if (!!userId && preferedLanguage !== userLanguage) {
161-
setUserPreferences({ data: { language: preferedLanguage } });
162-
setUserLanguage(preferedLanguage);
167+
if (!!userId && !syncedLoginPreference.current) {
168+
if (preferedLanguage) {
169+
if (user?.language !== preferedLanguage) {
170+
setUserPreferences({ data: { language: preferedLanguage } });
171+
}
172+
setUserLanguage(preferedLanguage);
173+
}
174+
syncedLoginPreference.current = true;
163175
}
164176

165-
if (user?.language !== undefined && user.language !== userLanguage) {
177+
if (user?.language !== undefined && user.language !== previousUserLanguage.current) {
178+
previousUserLanguage.current = user.language;
166179
setUserLanguage(user.language);
167180
setPreferedLanguage(user.language);
168181
}
169-
}, [preferedLanguage, setPreferedLanguage, setUserLanguage, user?.language, userLanguage, userId, setUserPreferences]);
182+
}, [preferedLanguage, setPreferedLanguage, setUserLanguage, user?.language, userId, setUserPreferences]);
170183

171184
useEffect(() => {
172185
if (!samlCredentialToken && !inviteTokenHash) {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Request } from '@playwright/test';
2+
3+
import { DEFAULT_USER_CREDENTIALS } from './config/constants';
4+
import { Users } from './fixtures/userStates';
5+
import { HomeChannel, Registration } from './page-objects';
6+
import { setUserPreferences } from './utils/setUserPreferences';
7+
import { test, expect } from './utils/test';
8+
9+
test.describe('User Preferences Flood', () => {
10+
test.use({ storageState: Users.user1.state });
11+
12+
test('should not flood users.setPreferences calls', async ({ browser, api }) => {
13+
await test.step('Reset user language to "en"', async () => {
14+
await api.login({ username: 'user1', password: DEFAULT_USER_CREDENTIALS.password });
15+
const resetResponse = await setUserPreferences(api, { language: 'en' });
16+
expect(resetResponse.status()).toBe(200);
17+
});
18+
19+
const context = await browser.newContext();
20+
const requests: Request[] = [];
21+
22+
await test.step('Setup request interception', async () => {
23+
await context.route('**/users.setPreferences', async (route) => {
24+
requests.push(route.request());
25+
await route.continue();
26+
});
27+
});
28+
29+
const page1 = await context.newPage();
30+
const page2 = await context.newPage();
31+
const page3 = await context.newPage();
32+
const pages = [page1, page2, page3];
33+
34+
await test.step('Open three tabs of the workspace', async () => {
35+
await Promise.all(pages.map((page) => page.goto('/home')));
36+
});
37+
38+
await test.step('Log in to the workspace on one of the tabs', async () => {
39+
const poHomeChannel1 = new HomeChannel(page1);
40+
await poHomeChannel1.sidenav.logout();
41+
const poRegistration1 = new Registration(page1);
42+
await poRegistration1.username.fill('user1');
43+
await poRegistration1.inputPassword.fill(DEFAULT_USER_CREDENTIALS.password);
44+
await poRegistration1.btnLogin.click();
45+
await expect(page1.locator('#main-content')).toBeVisible({ timeout: 30000 });
46+
});
47+
48+
await test.step('Ensure other pages are logged in', async () => {
49+
await expect(page2.locator('#main-content')).toBeVisible({ timeout: 30000 });
50+
});
51+
52+
await test.step('Inject conflicting state to simulate the bug conditions', async () => {
53+
// We set preferedLanguage to 'pt-BR' on page1.
54+
// This triggers a storage event in page2 and page3.
55+
// The key needs to be prefixed with 'fuselage-localStorage-' as per fuselage-hooks implementation
56+
await page1.evaluate(() => {
57+
window.localStorage.setItem('fuselage-localStorage-preferedLanguage', JSON.stringify('pt-BR'));
58+
});
59+
});
60+
61+
await test.step('Verify requests count', async () => {
62+
// Wait a bit to allow all requests to be made
63+
await page1.waitForTimeout(5000);
64+
65+
// Log the results
66+
// With the fix, we expect 0 requests here because the user is already logged in
67+
// and we ignore storage changes after initial sync.
68+
expect(requests.length).toBe(0);
69+
});
70+
71+
await test.step('Verify Sync on Login', async () => {
72+
// Reset language to 'en' to ensure we have a diff
73+
const res = await setUserPreferences(api, { language: 'en' });
74+
expect(res.status()).toBe(200);
75+
76+
const poHomeChannel1 = new HomeChannel(page1);
77+
await poHomeChannel1.sidenav.logout();
78+
79+
// Set preference while logged out
80+
await page1.evaluate(() => {
81+
window.localStorage.setItem('fuselage-localStorage-preferedLanguage', JSON.stringify('pt-BR'));
82+
});
83+
84+
// Reload to ensure UserProvider picks up the new localStorage value
85+
await page1.reload();
86+
87+
// Login again
88+
const poRegistration1 = new Registration(page1);
89+
await poRegistration1.username.fill('user1');
90+
await poRegistration1.inputPassword.fill(DEFAULT_USER_CREDENTIALS.password);
91+
await poRegistration1.btnLogin.click();
92+
await expect(page1.locator('#main-content')).toBeVisible({ timeout: 30000 });
93+
94+
// Wait for request
95+
await page1.waitForTimeout(5000);
96+
97+
// Expect requests to have increased
98+
expect(requests.length).toBeGreaterThan(0);
99+
expect(requests.length).toBeLessThan(5);
100+
});
101+
102+
await context.unroute('**/users.setPreferences');
103+
await context.close();
104+
});
105+
});

0 commit comments

Comments
 (0)