Simplify local storage access

The caching for local storage seems way over-engineered.
With this the context for the local storage is removed
and the local storage is accessed directly.

Pushed-by: k8s-git-ops<admin@cloudogu.com>
Pushed-by: Rene Pfeuffer<rene.pfeuffer@cloudogu.com>
Co-authored-by: René Pfeuffer<rene.pfeuffer@cloudogu.com>
This commit is contained in:
Rene Pfeuffer
2024-10-28 15:05:13 +01:00
parent 824f4224d1
commit 141e45aa06
11 changed files with 90 additions and 147 deletions

View File

@@ -0,0 +1,2 @@
- type: fixed
description: First access to local storage returning the default value instead of the actual value

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
import { useEffect, useMemo, useState } from "react";
type LocalStorageSetter<T> = (value: T | ((previousValue: T) => T)) => void;
const determineInitialValue = <T>(key: string, initialValue: T) => {
try {
const itemFromStorage = localStorage.getItem(key);
return itemFromStorage ? JSON.parse(itemFromStorage) : initialValue;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return initialValue;
}
};
/**
* Provides an api to access the browser's local storage for a given key.
*
* @param key The local storage key
* @param initialValue Value to be used if the local storage does not yet have the given key defined
*/
export function useLocalStorage<T>(key: string, initialValue: T): [value: T, setValue: LocalStorageSetter<T>] {
const initialValueOrValueFromStorage = useMemo(() => determineInitialValue(key, initialValue), [key, initialValue]);
const [item, setItem] = useState(initialValueOrValueFromStorage);
useEffect(() => {
const listener = (event: StorageEvent) => {
if (event.key === key) {
setItem(determineInitialValue(key, initialValue));
}
};
window.addEventListener("storage", listener);
return () => window.removeEventListener("storage", listener);
}, [key, initialValue]);
const setValue: LocalStorageSetter<T> = (newValue) => {
const computedNewValue = newValue instanceof Function ? newValue(item) : newValue;
setItem(computedNewValue);
const json = JSON.stringify(computedNewValue);
localStorage.setItem(key, json);
// storage event is no triggered in same tab
window.dispatchEvent(
new StorageEvent("storage", { key, oldValue: item, newValue: json, storageArea: localStorage })
);
};
return [item, setValue];
}

View File

@@ -1,97 +0,0 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useState } from "react";
type LocalStorage = {
getItem: <T>(key: string, fallback: T) => T;
setItem: <T>(key: string, value: T) => void;
preload: <T>(key: string, initialValue: T) => void;
};
const LocalStorageContext = createContext<LocalStorage>(null as unknown as LocalStorage);
/**
* Cache provider for local storage which enables listening to changes and triggering re-renders when writing.
*
* Only required once as a wrapper for the whole application.
*
* @see useLocalStorage
*/
export const LocalStorageProvider: FC = ({ children }) => {
const [localStorageCache, setLocalStorageCache] = useState<Record<string, unknown>>({});
const setItem = useCallback(<T,>(key: string, value: T) => {
localStorage.setItem(key, JSON.stringify(value));
setLocalStorageCache((prevState) => ({
...prevState,
[key]: value,
}));
}, []);
const getItem = useCallback(
<T,>(key: string, fallback: T): T => (key in localStorageCache ? (localStorageCache[key] as T) : fallback),
[localStorageCache]
);
const preload = useCallback(
<T,>(key: string, initialValue: T) => {
if (!(key in localStorageCache)) {
let initialLoadResult: T | undefined;
try {
const item = localStorage.getItem(key);
initialLoadResult = item ? JSON.parse(item) : initialValue;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
initialLoadResult = initialValue;
}
setItem(key, initialLoadResult);
}
},
[localStorageCache, setItem]
);
return (
<LocalStorageContext.Provider value={useMemo(() => ({ getItem, setItem, preload }), [getItem, preload, setItem])}>
{children}
</LocalStorageContext.Provider>
);
};
/**
* Provides an api to access the browser's local storage for a given key.
*
* @param key The local storage key
* @param initialValue Value to be used if the local storage does not yet have the given key defined
*/
export function useLocalStorage<T>(
key: string,
initialValue: T
): [value: T, setValue: (value: T | ((previousConfig: T) => T)) => void] {
const { getItem, setItem, preload } = useContext(LocalStorageContext);
const value = useMemo(() => getItem(key, initialValue), [getItem, initialValue, key]);
const setValue = useCallback(
(newValue: T | ((previousConfig: T) => T)) =>
// @ts-ignore T could be a function type, although this does not make sense because function types are not serializable to json
setItem(key, typeof newValue === "function" ? newValue(value) : newValue),
// eslint does not understand generics in certain circumstances
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, setItem, value]
);
useEffect(() => preload(key, initialValue), [initialValue, key, preload]);
return useMemo(() => [value, setValue], [setValue, value]);
}

View File

@@ -17,9 +17,9 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { withI18next } from "storybook-addon-i18next";
import React, {useEffect} from "react";
import React, { useEffect } from "react";
import withApiProvider from "./withApiProvider";
import { withThemes } from 'storybook-addon-themes/react';
import { withThemes } from "storybook-addon-themes/react";
let i18n = i18next;
@@ -27,7 +27,7 @@ let i18n = i18next;
// and not for storyshots
if (!process.env.JEST_WORKER_ID) {
const Backend = require("i18next-fetch-backend");
i18n = i18n.use(Backend.default);
i18n = i18n.use(Backend);
}
i18n.use(initReactI18next).init({
@@ -58,10 +58,10 @@ export const decorators = [
},
}),
withApiProvider,
withThemes
withThemes,
];
const Decorator = ({children, themeName}) => {
const Decorator = ({ children, themeName }) => {
useEffect(() => {
const link = document.querySelector("#ui-theme");
if (link && link["data-theme"] !== themeName) {
@@ -69,7 +69,7 @@ const Decorator = ({children, themeName}) => {
link["data-theme"] = themeName;
}
}, [themeName]);
return <>{children}</>
return <>{children}</>;
};
export const parameters = {

View File

@@ -77755,16 +77755,9 @@ exports[`Storyshots Secondary Navigation Active when match 1`] = `
<div>
<p
aria-label="secondaryNavigation.hideContent"
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 joMQbq menu-label is-clickable"
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 joMQbq menu-label"
onClick={[Function]}
>
<i
className="SecondaryNavigation__Icon-sc-8p1rgi-1 hDCYCs is-medium"
>
<i
className="fas fa-caret-down"
/>
</i>
Hitchhiker
</p>
<ul

View File

@@ -19,7 +19,6 @@ import { storiesOf } from "@storybook/react";
import Footer from "./Footer";
import { Binder, BinderContext, extensionPoints } from "@scm-manager/ui-extensions";
import { Me } from "@scm-manager/ui-types";
import { LocalStorageProvider } from "@scm-manager/ui-api";
import { EXTENSION_POINT } from "../avatar/Avatar";
// @ts-ignore ignore unknown png
import hitchhiker from "../__resources__/hitchhiker.png";
@@ -56,7 +55,6 @@ const withBinder = (binder: Binder) => {
};
storiesOf("Footer", module)
.addDecorator((story) => <LocalStorageProvider>{story()}</LocalStorageProvider>)
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.add("Default", () => {
return <Footer me={trillian} version="2.0.0" links={{}} />;

View File

@@ -16,7 +16,6 @@
import { storiesOf } from "@storybook/react";
import React, { ReactElement } from "react";
import { LocalStorageProvider } from "@scm-manager/ui-api";
import SecondaryNavigation from "./SecondaryNavigation";
import SecondaryNavigationItem from "./SecondaryNavigationItem";
import styled from "styled-components";
@@ -45,7 +44,6 @@ const withRoute = (route: string) => {
};
storiesOf("Secondary Navigation", module)
.addDecorator((story) => <LocalStorageProvider>{story()}</LocalStorageProvider>)
.addDecorator((story) => (
<Columns className="columns">
<div className="column is-3">{story()}</div>

View File

@@ -28,7 +28,7 @@ import { binder, extensionPoints } from "@scm-manager/ui-extensions";
import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink";
import "./tokenExpired";
import { ApiProvider, LocalStorageProvider } from "@scm-manager/ui-api";
import { ApiProvider } from "@scm-manager/ui-api";
import { ShortcutDocsContextProvider } from "@scm-manager/ui-core"; // Makes sure that the global `define` function is registered and all provided modules are included in the final bundle at all times
import "./_modules/provided-modules";
@@ -42,7 +42,6 @@ if (!root) {
ReactDOM.render(
<ApiProvider>
<I18nextProvider i18n={i18n}>
<LocalStorageProvider>
<ShortcutDocsContextProvider>
<ActiveModalCountContextProvider>
<Router basename={urls.contextPath}>
@@ -50,7 +49,6 @@ ReactDOM.render(
</Router>
</ActiveModalCountContextProvider>
</ShortcutDocsContextProvider>
</LocalStorageProvider>
</I18nextProvider>
</ApiProvider>,
root

View File

@@ -17,7 +17,6 @@
import React from "react";
import EditRepoNavLink from "./EditRepoNavLink";
import { mount, shallow } from "@scm-manager/ui-tests";
import { LocalStorageProvider } from "@scm-manager/ui-api";
describe("GeneralNavLink", () => {
it("should render nothing, if the modify link is missing", () => {
@@ -44,11 +43,7 @@ describe("GeneralNavLink", () => {
},
};
const navLink = mount(
<LocalStorageProvider>
<EditRepoNavLink repository={repository} editUrl="" />
</LocalStorageProvider>
);
const navLink = mount(<EditRepoNavLink repository={repository} editUrl="" />);
expect(navLink.text()).toBe("repositoryRoot.menu.generalNavLink");
});
});

View File

@@ -17,7 +17,6 @@
import React from "react";
import { mount, shallow } from "@scm-manager/ui-tests";
import "@scm-manager/ui-tests";
import { LocalStorageProvider } from "@scm-manager/ui-api";
import PermissionsNavLink from "./PermissionsNavLink";
describe("PermissionsNavLink", () => {
@@ -39,11 +38,7 @@ describe("PermissionsNavLink", () => {
},
};
const navLink = mount(
<LocalStorageProvider>
<PermissionsNavLink repository={repository} permissionUrl="" />
</LocalStorageProvider>
);
const navLink = mount(<PermissionsNavLink repository={repository} permissionUrl="" />);
expect(navLink.text()).toBe("repositoryRoot.menu.permissionsNavLink");
});
});

View File

@@ -17,7 +17,6 @@
import React from "react";
import { mount, shallow } from "@scm-manager/ui-tests";
import "@scm-manager/ui-tests";
import { LocalStorageProvider } from "@scm-manager/ui-api";
import RepositoryNavLink from "./RepositoryNavLink";
describe("RepositoryNavLink", () => {
@@ -54,7 +53,6 @@ describe("RepositoryNavLink", () => {
};
const navLink = mount(
<LocalStorageProvider>
<RepositoryNavLink
repository={repository}
linkName="sources"
@@ -62,7 +60,6 @@ describe("RepositoryNavLink", () => {
label="Sources"
activeOnlyWhenExact={true}
/>
</LocalStorageProvider>
);
expect(navLink.text()).toBe("Sources");
});