Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,961 changes: 1,502 additions & 1,459 deletions package-lock.json

Large diffs are not rendered by default.

51 changes: 22 additions & 29 deletions src/hooks/__tests__/useFeatureToggles.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { useFeatureToggles } from '../useFeatureToggles';
import { appStorage } from '../useStorage';
import { FeatureToggleProvider } from '../../providers';

describe('useFeatureToggles', () => {
Expand All @@ -15,65 +16,57 @@ describe('useFeatureToggles', () => {
);
};

it('should contain feature flag', () => {
const localStorageGetSpy = jest
.fn()
.mockImplementation(() => '["feature-flag-mock"]');
const setItemMock = jest.fn();
const getItemMock = jest.fn();

beforeEach(() => {
Storage.prototype.setItem = setItemMock;
Storage.prototype.getItem = getItemMock;

Object.defineProperty(window, 'localStorage', {
writable: true,
value: {
getItem: localStorageGetSpy,
},
});
appStorage.clear();
});

afterEach(() => {
setItemMock.mockRestore();
getItemMock.mockRestore();
});

it('should contain feature flag', () => {
getItemMock.mockReturnValue('["feature-flag-mock"]');

const { container } = render(
<FeatureToggleProvider>
<FeatureFlagRenderer />
</FeatureToggleProvider>
);

expect(localStorageGetSpy).toHaveBeenCalledWith('node_featureFlags');
expect(getItemMock).toHaveBeenCalledWith('node_featureFlags');
expect(container).toMatchSnapshot();
});

it('should not contain feature flag', () => {
const localStorageGetSpy = jest.fn().mockImplementation(() => '[]');

Object.defineProperty(window, 'localStorage', {
writable: true,
value: {
getItem: localStorageGetSpy,
},
});
getItemMock.mockReturnValue('[]');

const { container } = render(
<FeatureToggleProvider>
<FeatureFlagRenderer />
</FeatureToggleProvider>
);

expect(localStorageGetSpy).toHaveBeenCalledWith('node_featureFlags');
expect(getItemMock).toHaveBeenCalledWith('node_featureFlags');
expect(container).toMatchSnapshot();
});

it('should work if localstorage item does not exist ', () => {
const localStorageGetSpy = jest.fn().mockImplementation(() => null);

Object.defineProperty(window, 'localStorage', {
writable: true,
value: {
getItem: localStorageGetSpy,
},
});
getItemMock.mockReturnValue(null);

const { container } = render(
<FeatureToggleProvider>
<FeatureFlagRenderer />
</FeatureToggleProvider>
);

expect(localStorageGetSpy).toHaveBeenCalledWith('node_featureFlags');
expect(getItemMock).toHaveBeenCalledWith('node_featureFlags');
expect(container).toMatchSnapshot();
});
});
19 changes: 8 additions & 11 deletions src/hooks/useBrowserLanguageDetection.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,30 @@
import { useEffect } from 'react';
import { useLocalization } from 'gatsby-theme-i18n';
import { useStorage } from './useStorage';
import { useNavigateToDifferentLocale } from './useNavigateToDifferentLocale';
import { detectLanguage } from '../util/detectLanguage';

const languageStorageKey = 'NODE_DEV_LAST_LANGUAGE';

const lastLanguageStorage = {
getItem: () => window.localStorage.getItem(languageStorageKey),
setItem: (currentLocale: string) =>
window.localStorage.setItem(languageStorageKey, currentLocale),
};
const languageStorageKey = 'node_currentLocale';

export const useBrowserLanguageDetection = () => {
const { locale: currentLocale } = useLocalization();
const { navigate } = useNavigateToDifferentLocale();
const { getItem, setItem } = useStorage();

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

if (matchingBrowserLanguage) {
lastLanguageStorage.setItem(matchingBrowserLanguage);
setItem(languageStorageKey, matchingBrowserLanguage);

navigate(matchingBrowserLanguage);
return;
}
} else if (lastLanguageStorage.getItem() !== currentLocale) {
lastLanguageStorage.setItem(currentLocale);
}

setItem(languageStorageKey, currentLocale);
}, [currentLocale]);
};
59 changes: 59 additions & 0 deletions src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect } from 'react';

// Creates a permanent storage to be used between all the parts of the Application
// So it doesn't matter where this hook gets created it will be global.
// Note.: This could be moved within a Provider to move it to a React-isolated context-tree instead of a global runtime one.
export const appStorage = new Map<string, string>();

const syncWithBrowser = () => {
if (typeof localStorage === 'object') {
return {
sendToBrowser: (k: string, v: string) => localStorage.setItem(k, v),
getFromBrowser: (k: string) => localStorage.getItem(k) || undefined,
};
}

return {
sendToBrowser: () => undefined,
getFromBrowser: () => undefined,
};
};

export const useStorage = () => {
const { sendToBrowser, getFromBrowser } = syncWithBrowser();

const setItem = (key: string, value: unknown) =>
appStorage.set(key, JSON.stringify(value));

const getItem = (key: string) => {
// Attempts to retrieve items from the App Storage, but if they don't exist yet
// They might exist in the Browser Local Storage. This is useful as we retrieve things
// From the Web Storage API only if they don't exist in the App Storage
const storageItem = appStorage.get(key) || getFromBrowser(key);
const parsedValue = storageItem ? JSON.parse(storageItem) : undefined;

// If a said item doesn't exist in the App Storage we will create one
// On the App Storage that will either be the value from the Web Storage if it exists
// Or `undefined`. This is an interesting approach as it will ensure that all requested keys
// at one point will exist in the Web Storage, and keeps things in sync.
if (!appStorage.has(key)) {
setItem(key, parsedValue);
}

return parsedValue;
};

const flushToStorage = () =>
[...appStorage.entries()].forEach(([k, v]) => sendToBrowser(k, v));

useEffect(() => {
// Instead of flushing to the localStorage on every change, we flush once the Application
// is being unloaded, so it allows us to update all changes at once instead of constantly
// invoking the Web Storage APIs for every change we need, as the "authoritative storage" is the app itself
window.addEventListener('unload', flushToStorage);

return () => window.removeEventListener('unload', flushToStorage);
}, []);

return { getItem, setItem };
};
39 changes: 24 additions & 15 deletions src/layouts/default.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CommonComponents } from '../components';
import Header from '../sections/Header';
import Footer from '../sections/Footer';
import { FeatureToggleProvider } from '../providers';
import { useBrowserLanguageDetection } from '../hooks/useBrowserLanguageDetection';

interface Props {
title?: string;
Expand Down Expand Up @@ -35,20 +36,28 @@ const Layout = ({
img,
showFooter = true,
showRandomContributor = false,
}: React.PropsWithChildren<Props>): JSX.Element => (
<FeatureToggleProvider>
<ThemeProvider theme={defaultTheme}>
<CommonComponents.SEO title={title} description={description} img={img} />
<MotionConfig reducedMotion="user">
<div className="layout-container">
<Header />
{children}
{showRandomContributor && <CommonComponents.RandomContributor />}
{showFooter && <Footer />}
</div>
</MotionConfig>
</ThemeProvider>
</FeatureToggleProvider>
);
}: React.PropsWithChildren<Props>): JSX.Element => {
useBrowserLanguageDetection();

return (
<FeatureToggleProvider>
<ThemeProvider theme={defaultTheme}>
<CommonComponents.SEO
title={title}
description={description}
img={img}
/>
<MotionConfig reducedMotion="user">
<div className="layout-container">
<Header />
{children}
{showRandomContributor && <CommonComponents.RandomContributor />}
{showFooter && <Footer />}
</div>
</MotionConfig>
</ThemeProvider>
</FeatureToggleProvider>
);
};

export default Layout;
31 changes: 13 additions & 18 deletions src/providers/FeatureToggles/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useMemo } from 'react';
import { useStorage } from '../../hooks/useStorage';

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

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

export const FeatureToggleProvider: React.FC<Props> = ({ children }) => {
const { getItem } = useStorage();

const featureFlags = useMemo(() => {
try {
const storageItem = localStorage.getItem(FEATURE_FLAGS_STORAGE) || '[]';

const parsedItem = JSON.parse(storageItem);

// We want to guarantee the parsedItem is truthy and and Array
if (parsedItem && Array.isArray(parsedItem)) {
// Lastly we filter non-string objects as we want to avoid code injection such as XSS
// from starting from our feature flags
return [...parsedItem].filter(f => typeof f === 'string' && f.length);
}

return [];
} catch (__noop) {
// We don't care if the JSON.parse or localStorage.getItem fail
// as we will just disable the feature flags then
return [];
const parsedItem = getItem(FEATURE_FLAGS_STORAGE) || [];

// We want to guarantee the parsedItem is truthy and and Array
if (parsedItem && Array.isArray(parsedItem)) {
// Lastly we filter non-string objects as we want to avoid code injection such as XSS
// from starting from our feature flags
return [...parsedItem].filter(f => typeof f === 'string' && f.length);
}
}, []);

return [];
}, [getItem]);

return (
<FeatureToggleContext.Provider value={featureFlags}>
Expand Down