Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 656a6e6

Browse files
authored
hotfix(language-detection): fix language detection (#3021)
* hotfix(language-detection): hook was disabled * chore(dependencies): updated dependencies * feat(storage): introduced a better storage layer * fix(tests): updated and fixed tests
1 parent 40b940d commit 656a6e6

6 files changed

Lines changed: 1628 additions & 1532 deletions

File tree

package-lock.json

Lines changed: 1502 additions & 1459 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { render } from '@testing-library/react';
33
import { useFeatureToggles } from '../useFeatureToggles';
4+
import { appStorage } from '../useStorage';
45
import { FeatureToggleProvider } from '../../providers';
56

67
describe('useFeatureToggles', () => {
@@ -15,65 +16,57 @@ describe('useFeatureToggles', () => {
1516
);
1617
};
1718

18-
it('should contain feature flag', () => {
19-
const localStorageGetSpy = jest
20-
.fn()
21-
.mockImplementation(() => '["feature-flag-mock"]');
19+
const setItemMock = jest.fn();
20+
const getItemMock = jest.fn();
21+
22+
beforeEach(() => {
23+
Storage.prototype.setItem = setItemMock;
24+
Storage.prototype.getItem = getItemMock;
2225

23-
Object.defineProperty(window, 'localStorage', {
24-
writable: true,
25-
value: {
26-
getItem: localStorageGetSpy,
27-
},
28-
});
26+
appStorage.clear();
27+
});
28+
29+
afterEach(() => {
30+
setItemMock.mockRestore();
31+
getItemMock.mockRestore();
32+
});
33+
34+
it('should contain feature flag', () => {
35+
getItemMock.mockReturnValue('["feature-flag-mock"]');
2936

3037
const { container } = render(
3138
<FeatureToggleProvider>
3239
<FeatureFlagRenderer />
3340
</FeatureToggleProvider>
3441
);
3542

36-
expect(localStorageGetSpy).toHaveBeenCalledWith('node_featureFlags');
43+
expect(getItemMock).toHaveBeenCalledWith('node_featureFlags');
3744
expect(container).toMatchSnapshot();
3845
});
3946

4047
it('should not contain feature flag', () => {
41-
const localStorageGetSpy = jest.fn().mockImplementation(() => '[]');
42-
43-
Object.defineProperty(window, 'localStorage', {
44-
writable: true,
45-
value: {
46-
getItem: localStorageGetSpy,
47-
},
48-
});
48+
getItemMock.mockReturnValue('[]');
4949

5050
const { container } = render(
5151
<FeatureToggleProvider>
5252
<FeatureFlagRenderer />
5353
</FeatureToggleProvider>
5454
);
5555

56-
expect(localStorageGetSpy).toHaveBeenCalledWith('node_featureFlags');
56+
expect(getItemMock).toHaveBeenCalledWith('node_featureFlags');
5757
expect(container).toMatchSnapshot();
5858
});
5959

6060
it('should work if localstorage item does not exist ', () => {
61-
const localStorageGetSpy = jest.fn().mockImplementation(() => null);
62-
63-
Object.defineProperty(window, 'localStorage', {
64-
writable: true,
65-
value: {
66-
getItem: localStorageGetSpy,
67-
},
68-
});
61+
getItemMock.mockReturnValue(null);
6962

7063
const { container } = render(
7164
<FeatureToggleProvider>
7265
<FeatureFlagRenderer />
7366
</FeatureToggleProvider>
7467
);
7568

76-
expect(localStorageGetSpy).toHaveBeenCalledWith('node_featureFlags');
69+
expect(getItemMock).toHaveBeenCalledWith('node_featureFlags');
7770
expect(container).toMatchSnapshot();
7871
});
7972
});
Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,30 @@
11
import { useEffect } from 'react';
22
import { useLocalization } from 'gatsby-theme-i18n';
3+
import { useStorage } from './useStorage';
34
import { useNavigateToDifferentLocale } from './useNavigateToDifferentLocale';
45
import { detectLanguage } from '../util/detectLanguage';
56

6-
const languageStorageKey = 'NODE_DEV_LAST_LANGUAGE';
7-
8-
const lastLanguageStorage = {
9-
getItem: () => window.localStorage.getItem(languageStorageKey),
10-
setItem: (currentLocale: string) =>
11-
window.localStorage.setItem(languageStorageKey, currentLocale),
12-
};
7+
const languageStorageKey = 'node_currentLocale';
138

149
export const useBrowserLanguageDetection = () => {
1510
const { locale: currentLocale } = useLocalization();
1611
const { navigate } = useNavigateToDifferentLocale();
12+
const { getItem, setItem } = useStorage();
1713

1814
useEffect(() => {
19-
if (lastLanguageStorage.getItem() === null) {
15+
if (getItem(languageStorageKey) === undefined) {
2016
// Attempts to retrieve a Language that we suport that is accepted by the user
2117
// We use navigator.languages to identify the language preference of an user
2218
const matchingBrowserLanguage = detectLanguage();
2319

2420
if (matchingBrowserLanguage) {
25-
lastLanguageStorage.setItem(matchingBrowserLanguage);
21+
setItem(languageStorageKey, matchingBrowserLanguage);
2622

2723
navigate(matchingBrowserLanguage);
24+
return;
2825
}
29-
} else if (lastLanguageStorage.getItem() !== currentLocale) {
30-
lastLanguageStorage.setItem(currentLocale);
3126
}
27+
28+
setItem(languageStorageKey, currentLocale);
3229
}, [currentLocale]);
3330
};

src/hooks/useStorage.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useEffect } from 'react';
2+
3+
// Creates a permanent storage to be used between all the parts of the Application
4+
// So it doesn't matter where this hook gets created it will be global.
5+
// Note.: This could be moved within a Provider to move it to a React-isolated context-tree instead of a global runtime one.
6+
export const appStorage = new Map<string, string>();
7+
8+
const syncWithBrowser = () => {
9+
if (typeof localStorage === 'object') {
10+
return {
11+
sendToBrowser: (k: string, v: string) => localStorage.setItem(k, v),
12+
getFromBrowser: (k: string) => localStorage.getItem(k) || undefined,
13+
};
14+
}
15+
16+
return {
17+
sendToBrowser: () => undefined,
18+
getFromBrowser: () => undefined,
19+
};
20+
};
21+
22+
export const useStorage = () => {
23+
const { sendToBrowser, getFromBrowser } = syncWithBrowser();
24+
25+
const setItem = (key: string, value: unknown) =>
26+
appStorage.set(key, JSON.stringify(value));
27+
28+
const getItem = (key: string) => {
29+
// Attempts to retrieve items from the App Storage, but if they don't exist yet
30+
// They might exist in the Browser Local Storage. This is useful as we retrieve things
31+
// From the Web Storage API only if they don't exist in the App Storage
32+
const storageItem = appStorage.get(key) || getFromBrowser(key);
33+
const parsedValue = storageItem ? JSON.parse(storageItem) : undefined;
34+
35+
// If a said item doesn't exist in the App Storage we will create one
36+
// On the App Storage that will either be the value from the Web Storage if it exists
37+
// Or `undefined`. This is an interesting approach as it will ensure that all requested keys
38+
// at one point will exist in the Web Storage, and keeps things in sync.
39+
if (!appStorage.has(key)) {
40+
setItem(key, parsedValue);
41+
}
42+
43+
return parsedValue;
44+
};
45+
46+
const flushToStorage = () =>
47+
[...appStorage.entries()].forEach(([k, v]) => sendToBrowser(k, v));
48+
49+
useEffect(() => {
50+
// Instead of flushing to the localStorage on every change, we flush once the Application
51+
// is being unloaded, so it allows us to update all changes at once instead of constantly
52+
// invoking the Web Storage APIs for every change we need, as the "authoritative storage" is the app itself
53+
window.addEventListener('unload', flushToStorage);
54+
55+
return () => window.removeEventListener('unload', flushToStorage);
56+
}, []);
57+
58+
return { getItem, setItem };
59+
};

src/layouts/default.tsx

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CommonComponents } from '../components';
55
import Header from '../sections/Header';
66
import Footer from '../sections/Footer';
77
import { FeatureToggleProvider } from '../providers';
8+
import { useBrowserLanguageDetection } from '../hooks/useBrowserLanguageDetection';
89

910
interface Props {
1011
title?: string;
@@ -35,20 +36,28 @@ const Layout = ({
3536
img,
3637
showFooter = true,
3738
showRandomContributor = false,
38-
}: React.PropsWithChildren<Props>): JSX.Element => (
39-
<FeatureToggleProvider>
40-
<ThemeProvider theme={defaultTheme}>
41-
<CommonComponents.SEO title={title} description={description} img={img} />
42-
<MotionConfig reducedMotion="user">
43-
<div className="layout-container">
44-
<Header />
45-
{children}
46-
{showRandomContributor && <CommonComponents.RandomContributor />}
47-
{showFooter && <Footer />}
48-
</div>
49-
</MotionConfig>
50-
</ThemeProvider>
51-
</FeatureToggleProvider>
52-
);
39+
}: React.PropsWithChildren<Props>): JSX.Element => {
40+
useBrowserLanguageDetection();
41+
42+
return (
43+
<FeatureToggleProvider>
44+
<ThemeProvider theme={defaultTheme}>
45+
<CommonComponents.SEO
46+
title={title}
47+
description={description}
48+
img={img}
49+
/>
50+
<MotionConfig reducedMotion="user">
51+
<div className="layout-container">
52+
<Header />
53+
{children}
54+
{showRandomContributor && <CommonComponents.RandomContributor />}
55+
{showFooter && <Footer />}
56+
</div>
57+
</MotionConfig>
58+
</ThemeProvider>
59+
</FeatureToggleProvider>
60+
);
61+
};
5362

5463
export default Layout;

src/providers/FeatureToggles/index.tsx

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { createContext, useMemo } from 'react';
2+
import { useStorage } from '../../hooks/useStorage';
23

34
type Props = { children?: React.ReactNode };
45

@@ -7,26 +8,20 @@ const FEATURE_FLAGS_STORAGE = 'node_featureFlags';
78
export const FeatureToggleContext = createContext<string[]>([]);
89

910
export const FeatureToggleProvider: React.FC<Props> = ({ children }) => {
11+
const { getItem } = useStorage();
12+
1013
const featureFlags = useMemo(() => {
11-
try {
12-
const storageItem = localStorage.getItem(FEATURE_FLAGS_STORAGE) || '[]';
13-
14-
const parsedItem = JSON.parse(storageItem);
15-
16-
// We want to guarantee the parsedItem is truthy and and Array
17-
if (parsedItem && Array.isArray(parsedItem)) {
18-
// Lastly we filter non-string objects as we want to avoid code injection such as XSS
19-
// from starting from our feature flags
20-
return [...parsedItem].filter(f => typeof f === 'string' && f.length);
21-
}
22-
23-
return [];
24-
} catch (__noop) {
25-
// We don't care if the JSON.parse or localStorage.getItem fail
26-
// as we will just disable the feature flags then
27-
return [];
14+
const parsedItem = getItem(FEATURE_FLAGS_STORAGE) || [];
15+
16+
// We want to guarantee the parsedItem is truthy and and Array
17+
if (parsedItem && Array.isArray(parsedItem)) {
18+
// Lastly we filter non-string objects as we want to avoid code injection such as XSS
19+
// from starting from our feature flags
20+
return [...parsedItem].filter(f => typeof f === 'string' && f.length);
2821
}
29-
}, []);
22+
23+
return [];
24+
}, [getItem]);
3025

3126
return (
3227
<FeatureToggleContext.Provider value={featureFlags}>

0 commit comments

Comments
 (0)