Skip to content

Commit 4789bad

Browse files
committed
fix: rare useEffect cycle with multiple tabs
1 parent a2b2e26 commit 4789bad

File tree

3 files changed

+152
-18
lines changed

3 files changed

+152
-18
lines changed

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

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { IRoom } from '@rocket.chat/core-typings';
22
import { Emitter } from '@rocket.chat/emitter';
3-
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
43
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
54
import type { FindOptions, SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
6-
import { UserContext, useEndpoint, useRouteParameter, useSearchParameter } from '@rocket.chat/ui-contexts';
5+
import { UserContext, useRouteParameter, useSearchParameter } from '@rocket.chat/ui-contexts';
76
import { useQueryClient } from '@tanstack/react-query';
87
import { Accounts } from 'meteor/accounts-base';
98
import { Meteor } from 'meteor/meteor';
@@ -17,6 +16,7 @@ import { useDeleteUser } from './hooks/useDeleteUser';
1716
import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning';
1817
import { useReloadAfterLogin } from './hooks/useReloadAfterLogin';
1918
import { useUpdateAvatar } from './hooks/useUpdateAvatar';
19+
import { useUserLanguageSync } from './hooks/useUserLanguageSync';
2020
import { getUserPreference } from '../../../app/utils/client';
2121
import { sdk } from '../../../app/utils/client/lib/SDKClient';
2222
import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCleanUpCallback';
@@ -73,21 +73,18 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => {
7373
});
7474

7575
const previousUserId = useRef(userId);
76-
const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', '');
77-
const [preferedLanguage, setPreferedLanguage] = useLocalStorage('preferedLanguage', '');
7876
const [, setSamlInviteToken] = useSamlInviteToken();
7977
const samlCredentialToken = useSearchParameter('saml_idp_credentialToken');
8078
const inviteTokenHash = useRouteParameter('hash');
8179

82-
const setUserPreferences = useEndpoint('POST', '/v1/users.setPreferences');
83-
8480
useEmailVerificationWarning(user ?? undefined);
8581
useClearRemovedRoomsHistory(userId);
8682

8783
useDeleteUser();
8884
useUpdateAvatar();
8985
useIdleConnection(userId);
9086
useReloadAfterLogin(user);
87+
useUserLanguageSync(user);
9188

9289
const querySubscriptions = useMemo(() => {
9390
const createSubscriptionFactory =
@@ -156,18 +153,6 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => {
156153
[userId, user, querySubscription, querySubscriptions],
157154
);
158155

159-
useEffect(() => {
160-
if (!!userId && preferedLanguage !== userLanguage) {
161-
setUserPreferences({ data: { language: preferedLanguage } });
162-
setUserLanguage(preferedLanguage);
163-
}
164-
165-
if (user?.language !== undefined && user.language !== userLanguage) {
166-
setUserLanguage(user.language);
167-
setPreferedLanguage(user.language);
168-
}
169-
}, [preferedLanguage, setPreferedLanguage, setUserLanguage, user?.language, userLanguage, userId, setUserPreferences]);
170-
171156
useEffect(() => {
172157
if (!samlCredentialToken && !inviteTokenHash) {
173158
setSamlInviteToken(null);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
2+
import { useEndpoint } from '@rocket.chat/ui-contexts';
3+
import { useEffect, useRef } from 'react';
4+
5+
type UseUserLanguageSyncParams = { language?: string } | null;
6+
7+
export const useUserLanguageSync = (user: UseUserLanguageSyncParams): void => {
8+
const setUserPreferences = useEndpoint('POST', '/v1/users.setPreferences');
9+
const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', '');
10+
const [preferedLanguage, setPreferedLanguage] = useLocalStorage('preferedLanguage', '');
11+
const [isLanguageSyncPending, setLanguageSyncPending] = useLocalStorage('userLanguagePendingSync', false);
12+
const pendingLanguageRef = useRef<string | null>(null);
13+
const serverLanguage = user?.language;
14+
15+
useEffect(() => {
16+
if (!serverLanguage) {
17+
return;
18+
}
19+
20+
const hasPreferedLanguage = typeof preferedLanguage === 'string' && preferedLanguage.length > 0;
21+
22+
if (isLanguageSyncPending) {
23+
if (serverLanguage !== undefined && serverLanguage === preferedLanguage) {
24+
setLanguageSyncPending(false);
25+
pendingLanguageRef.current = null;
26+
return;
27+
}
28+
29+
if (hasPreferedLanguage && serverLanguage !== preferedLanguage) {
30+
if (pendingLanguageRef.current !== preferedLanguage) {
31+
pendingLanguageRef.current = preferedLanguage;
32+
void setUserPreferences({ data: { language: preferedLanguage } }).catch(() => {
33+
setLanguageSyncPending(false);
34+
pendingLanguageRef.current = null;
35+
});
36+
}
37+
if (userLanguage !== preferedLanguage) {
38+
setUserLanguage(preferedLanguage);
39+
}
40+
return;
41+
}
42+
} else if (pendingLanguageRef.current) {
43+
pendingLanguageRef.current = null;
44+
}
45+
46+
if (serverLanguage !== undefined && userLanguage !== serverLanguage) {
47+
setUserLanguage(serverLanguage);
48+
}
49+
50+
if (serverLanguage !== undefined && preferedLanguage !== serverLanguage) {
51+
setPreferedLanguage(serverLanguage);
52+
}
53+
}, [
54+
isLanguageSyncPending,
55+
preferedLanguage,
56+
serverLanguage,
57+
setLanguageSyncPending,
58+
setPreferedLanguage,
59+
setUserLanguage,
60+
setUserPreferences,
61+
userLanguage,
62+
]);
63+
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { BrowserContext, Page, Request } from '@playwright/test';
2+
import type { useEndpoint } from '@rocket.chat/ui-contexts';
3+
4+
import { Users } from './fixtures/userStates';
5+
import { setUserPreferences } from './utils';
6+
import { test, expect } from './utils/test';
7+
8+
class RequestTracker {
9+
private _method: Parameters<typeof useEndpoint>[0];
10+
11+
private _pathPattern: Parameters<typeof useEndpoint>[1];
12+
13+
private _count: number;
14+
15+
constructor(method: Parameters<typeof useEndpoint>[0], pathPattern: Parameters<typeof useEndpoint>[1]) {
16+
this._method = method;
17+
this._pathPattern = pathPattern;
18+
this._count = 0;
19+
}
20+
21+
public track(page: Page): void {
22+
page.on('request', (request) => this.onRequest(request));
23+
}
24+
25+
public get count(): number {
26+
return this._count;
27+
}
28+
29+
private onRequest(request: Request): void {
30+
const url = new URL(request.url());
31+
if (request.method() === this._method && url.pathname.endsWith(this._pathPattern)) {
32+
this._count += 1;
33+
}
34+
}
35+
}
36+
37+
test.describe('User preferences language propagation', () => {
38+
test.use({ storageState: Users.user1.state, locale: 'en' });
39+
40+
test.beforeAll(async ({ api }) => {
41+
await setUserPreferences(api, { language: 'en' });
42+
});
43+
44+
test.afterAll(async ({ api }) => {
45+
await setUserPreferences(api, { language: '' });
46+
});
47+
48+
test('should keep calling users.setPreferences once a third tab opens', async ({ context }) => {
49+
const requestTracker = new RequestTracker('POST', '/v1/users.setPreferences');
50+
51+
await test.step('load the first tab', async () => {
52+
const page = await navigateToHomePage(context, 'de');
53+
requestTracker.track(page);
54+
return page;
55+
});
56+
57+
await test.step('load a second tab to ensure the baseline stays stable', async () => {
58+
const page = await navigateToHomePage(context, 'pt');
59+
requestTracker.track(page);
60+
return page;
61+
});
62+
63+
await test.step('opening a third tab should trigger the cascading loop', async () => {
64+
const page = await navigateToHomePage(context, 'fr');
65+
requestTracker.track(page);
66+
return page;
67+
});
68+
69+
await Promise.all(context.pages().map((page) => page.reload()));
70+
71+
await test.step('assert no further calls were made', async () => {
72+
expect(requestTracker.count).toBe(0);
73+
});
74+
});
75+
});
76+
77+
async function navigateToHomePage(context: BrowserContext, language: 'en' | 'de' | 'pt' | 'fr'): Promise<Page> {
78+
const page = await context.newPage();
79+
await page.goto('/home');
80+
await page.evaluate((language) => {
81+
localStorage.setItem('fuselage-localStorage-userLanguage', `"${language}"`);
82+
localStorage.setItem('fuselage-localStorage-preferedLanguage', `"${language}"`);
83+
}, language);
84+
await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
85+
return page;
86+
}

0 commit comments

Comments
 (0)