diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 21b16e026..308066462 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -1,7 +1,7 @@ import { Container, Flex, useMediaQuery } from '@chakra-ui/react'; import { SkipNavLink } from '@chakra-ui/skip-nav'; import { useRouter } from 'next/router'; -import { FC } from 'react'; +import { FC, ReactNode } from 'react'; import { Footer } from '../Footer'; import { NavBar } from '../NavBar'; import dynamic from 'next/dynamic'; @@ -24,7 +24,7 @@ const LandingTabs = dynamic( ); const LANDING_PAGES = ['/', '/classic-form', '/paper-form']; -export const Layout: FC = ({ children }) => { +export const Layout: FC<{ banner?: ReactNode }> = ({ children, banner }) => { const router = useRouter(); const isLandingPage = LANDING_PAGES.includes(router.pathname); @@ -42,6 +42,7 @@ export const Layout: FC = ({ children }) => { {isPrint ? null : } {isPrint ? null : } {isPrint ? null : } + {banner}
{isLandingPage && } diff --git a/src/components/NavBar/useTour.ts b/src/components/NavBar/useTour.ts index 372172c4f..15bb1fd64 100644 --- a/src/components/NavBar/useTour.ts +++ b/src/components/NavBar/useTour.ts @@ -1,4 +1,5 @@ import { useShepherd } from 'react-shepherd'; +import { safeLocalStorageSet } from '@/lib/browserStorage'; import { Step, StepOptions } from 'shepherd.js'; import { offset } from '@floating-ui/react-dom'; import { useRouter } from 'next/router'; @@ -46,11 +47,11 @@ export const useTour = (type?: 'home' | 'results' | 'abstract') => { tour.on('start', () => { if (tourType === 'home') { - localStorage.setItem(LocalSettings.SEEN_LANDING_TOUR, 'true'); + safeLocalStorageSet(LocalSettings.SEEN_LANDING_TOUR, 'true'); } else if (tourType === 'results') { - localStorage.setItem(LocalSettings.SEEN_RESULTS_TOUR, 'true'); + safeLocalStorageSet(LocalSettings.SEEN_RESULTS_TOUR, 'true'); } else if (tourType === 'abstract') { - localStorage.setItem(LocalSettings.SEEN_ABSTRACT_TOUR, 'true'); + safeLocalStorageSet(LocalSettings.SEEN_ABSTRACT_TOUR, 'true'); } sendGTMEvent({ event: 'tour_start', tour_type: tourType, is_mobile: !!isMobile }); diff --git a/src/components/SiteAlert/SiteAlert.tsx b/src/components/SiteAlert/SiteAlert.tsx index 4ff16ffc0..8bfe8eabb 100644 --- a/src/components/SiteAlert/SiteAlert.tsx +++ b/src/components/SiteAlert/SiteAlert.tsx @@ -1,6 +1,7 @@ import { useGetSiteWideMsg } from '@/api/vault/vault'; import { useSession } from '@/lib/useSession'; import { useSettings } from '@/lib/useSettings'; +import { safeLocalStorageGet, safeLocalStorageSet } from '@/lib/browserStorage'; import { Alert, AlertDescription, CloseButton, Flex } from '@chakra-ui/react'; import { useEffect, useState } from 'react'; @@ -27,7 +28,7 @@ export const SiteAlert = () => { if (isAuthenticated) { updateSettings({ last_seen_message: systemMsg }); } else { - localStorage.setItem(LAST_DISMISSED_SYS_MSG, systemMsg); + safeLocalStorageSet(LAST_DISMISSED_SYS_MSG, systemMsg); } }; @@ -39,8 +40,8 @@ export const SiteAlert = () => { setLastDismissedMsg(settings.last_seen_message); setInitialized(true); } - } else if (typeof window !== 'undefined' && window.localStorage) { - setLastDismissedMsg(localStorage.getItem(LAST_DISMISSED_SYS_MSG) ?? ''); + } else { + setLastDismissedMsg(safeLocalStorageGet(LAST_DISMISSED_SYS_MSG) ?? ''); setInitialized(true); } } diff --git a/src/components/StorageDegradedBanner/StorageDegradedBanner.test.tsx b/src/components/StorageDegradedBanner/StorageDegradedBanner.test.tsx new file mode 100644 index 000000000..0a16b1c27 --- /dev/null +++ b/src/components/StorageDegradedBanner/StorageDegradedBanner.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import { ChakraProvider } from '@chakra-ui/react'; +import { StorageDegradedBanner } from './StorageDegradedBanner'; + +const renderWithChakra = (ui: ReactElement) => render({ui}); + +describe('StorageDegradedBanner', () => { + it('renders the warning message', () => { + renderWithChakra(); + expect(screen.getByText(/browser is blocking site storage/i)).toBeInTheDocument(); + }); + + it('hides the banner when dismiss button is clicked', async () => { + const user = userEvent.setup(); + renderWithChakra(); + const closeButton = screen.getByRole('button', { name: /close/i }); + await user.click(closeButton); + expect(screen.queryByText(/browser is blocking site storage/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/StorageDegradedBanner/StorageDegradedBanner.tsx b/src/components/StorageDegradedBanner/StorageDegradedBanner.tsx new file mode 100644 index 000000000..bfe5b716b --- /dev/null +++ b/src/components/StorageDegradedBanner/StorageDegradedBanner.tsx @@ -0,0 +1,19 @@ +import { Alert, AlertDescription, CloseButton } from '@chakra-ui/react'; +import { useState } from 'react'; + +export const StorageDegradedBanner = () => { + const [dismissed, setDismissed] = useState(false); + + if (dismissed) { + return null; + } + + return ( + + + Your browser is blocking site storage. Preferences and settings won't be saved between sessions. + + setDismissed(true)} /> + + ); +}; diff --git a/src/components/StorageUnavailableNotice/StorageUnavailableNotice.test.tsx b/src/components/StorageUnavailableNotice/StorageUnavailableNotice.test.tsx new file mode 100644 index 000000000..999e15c7e --- /dev/null +++ b/src/components/StorageUnavailableNotice/StorageUnavailableNotice.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import { ChakraProvider } from '@chakra-ui/react'; +import { StorageUnavailableNotice } from './StorageUnavailableNotice'; + +const renderWithChakra = (ui: ReactElement) => render({ui}); + +describe('StorageUnavailableNotice', () => { + it('renders the heading', () => { + renderWithChakra(); + expect(screen.getByRole('heading', { name: /cookies are required/i })).toBeInTheDocument(); + }); + + it('renders a link for Chrome', () => { + renderWithChakra(); + expect(screen.getByRole('link', { name: /chrome/i })).toBeInTheDocument(); + }); + + it('renders a link for Firefox', () => { + renderWithChakra(); + expect(screen.getByRole('link', { name: /firefox/i })).toBeInTheDocument(); + }); + + it('renders a link for Safari', () => { + renderWithChakra(); + expect(screen.getByRole('link', { name: /safari/i })).toBeInTheDocument(); + }); +}); diff --git a/src/components/StorageUnavailableNotice/StorageUnavailableNotice.tsx b/src/components/StorageUnavailableNotice/StorageUnavailableNotice.tsx new file mode 100644 index 000000000..3a8667f16 --- /dev/null +++ b/src/components/StorageUnavailableNotice/StorageUnavailableNotice.tsx @@ -0,0 +1,40 @@ +import { Box, Heading, Link, Text, VStack } from '@chakra-ui/react'; + +export const StorageUnavailableNotice = () => { + return ( + + + + Cookies are required + + + SciX uses cookies and site data to maintain your session and preferences. Cookies appear to be blocked by your + browser settings. + + To use SciX, enable cookies and site data in your browser: + + + How to enable cookies in Chrome + + + How to enable cookies in Firefox + + + How to enable cookies in Safari + + + + After enabling cookies, reload this page. + + + + ); +}; diff --git a/src/lib/__tests__/browserStorage.test.ts b/src/lib/__tests__/browserStorage.test.ts new file mode 100644 index 000000000..97370a6f6 --- /dev/null +++ b/src/lib/__tests__/browserStorage.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createSafeStorage, + isCookiesAvailable, + isLocalStorageAvailable, + safeLocalStorageGet, + safeLocalStorageSet, + safeSessionStorageGet, + safeSessionStorageRemove, + safeSessionStorageSet, +} from '@/lib/browserStorage'; + +describe('isCookiesAvailable', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false when navigator.cookieEnabled is false', () => { + vi.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(false); + expect(isCookiesAvailable()).toBe(false); + }); + + it('returns false when document.cookie access throws SecurityError', () => { + vi.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(true); + Object.defineProperty(document, 'cookie', { + get() { + throw new DOMException('Access denied', 'SecurityError'); + }, + configurable: true, + }); + expect(isCookiesAvailable()).toBe(false); + Object.defineProperty(document, 'cookie', { + get() { + return ''; + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + set() {}, + configurable: true, + }); + }); + + it('returns true when cookies are accessible', () => { + expect(isCookiesAvailable()).toBe(true); + }); +}); + +describe('isLocalStorageAvailable', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false when localStorage.setItem throws SecurityError', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + expect(isLocalStorageAvailable()).toBe(false); + }); + + it('returns true when localStorage probe succeeds', () => { + expect(isLocalStorageAvailable()).toBe(true); + }); +}); + +describe('createSafeStorage', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null from getItem when localStorage.getItem throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + const storage = createSafeStorage(); + expect(storage.getItem('key')).toBeNull(); + }); + + it('returns stored value from getItem when localStorage works', () => { + localStorage.setItem('key', 'value'); + const storage = createSafeStorage(); + expect(storage.getItem('key')).toBe('value'); + localStorage.removeItem('key'); + }); + + it('does not throw from setItem when localStorage.setItem throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + const storage = createSafeStorage(); + expect(() => storage.setItem('key', 'value')).not.toThrow(); + }); + + it('does not throw from removeItem when localStorage.removeItem throws', () => { + vi.spyOn(Storage.prototype, 'removeItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + const storage = createSafeStorage(); + expect(() => storage.removeItem?.('key')).not.toThrow(); + }); +}); + +describe('safeLocalStorageGet', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when localStorage.getItem throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + expect(safeLocalStorageGet('key')).toBeNull(); + }); + + it('returns the value when localStorage works', () => { + localStorage.setItem('key', 'stored'); + expect(safeLocalStorageGet('key')).toBe('stored'); + localStorage.removeItem('key'); + }); +}); + +describe('safeLocalStorageSet', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not throw when localStorage.setItem throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + expect(() => safeLocalStorageSet('key', 'value')).not.toThrow(); + }); +}); + +describe('safeSessionStorageGet', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when sessionStorage.getItem throws', () => { + vi.spyOn(window.sessionStorage, 'getItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + expect(safeSessionStorageGet('key')).toBeNull(); + }); +}); + +describe('safeSessionStorageSet', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not throw when sessionStorage.setItem throws', () => { + vi.spyOn(window.sessionStorage, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + expect(() => safeSessionStorageSet('key', 'value')).not.toThrow(); + }); +}); + +describe('safeSessionStorageRemove', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not throw when sessionStorage.removeItem throws', () => { + vi.spyOn(window.sessionStorage, 'removeItem').mockImplementationOnce(() => { + throw new DOMException('Access denied', 'SecurityError'); + }); + expect(() => safeSessionStorageRemove('key')).not.toThrow(); + }); +}); diff --git a/src/lib/browserStorage.ts b/src/lib/browserStorage.ts new file mode 100644 index 000000000..0b76cdc71 --- /dev/null +++ b/src/lib/browserStorage.ts @@ -0,0 +1,136 @@ +import { StateStorage } from 'zustand/middleware'; +import { logger } from '@/logger'; + +const STORAGE_PROBE_KEY = '__scix_storage_probe__'; + +// In-memory fallback used when localStorage is blocked. Scoped to the page +// session so dismissals (e.g. tour "seen" flags) survive re-renders even when +// the browser denies storage access. +const memoryFallback = new Map(); + +/** + * Returns false if cookies are blocked at the browser level. + * Checks navigator.cookieEnabled first, then probes document.cookie + * to catch SecurityError thrown by strict privacy policies (e.g. Firefox ETP). + * Always returns true during SSR. + */ +export function isCookiesAvailable(): boolean { + if (typeof window === 'undefined') { + return true; + } + if (!navigator.cookieEnabled) { + return false; + } + try { + void document.cookie; + return true; + } catch { + return false; + } +} + +/** + * Returns false if localStorage is blocked at the browser level. + * Probes via setItem/removeItem to catch SecurityError. + * Always returns true during SSR. + */ +export function isLocalStorageAvailable(): boolean { + if (typeof window === 'undefined') { + return true; + } + try { + localStorage.setItem(STORAGE_PROBE_KEY, '1'); + localStorage.removeItem(STORAGE_PROBE_KEY); + return true; + } catch { + return false; + } +} + +/** + * Returns a Zustand StateStorage adapter that wraps localStorage in try/catch. + * On error: logs at debug level and returns null / no-ops. + * Zustand handles JSON serialization; this is a string-based storage adapter. + * + * Usage in persist config: `getStorage: createSafeStorage` + */ +export function createSafeStorage(): StateStorage { + if (typeof window === 'undefined') { + // SSR: return a no-op adapter so the store can be created without logging + // or incurring try/catch overhead on every server-side request. + return { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }; + } + return { + getItem(name: string): string | null { + try { + return localStorage.getItem(name); + } catch { + logger.debug({ name }, 'browserStorage: getItem failed'); + return null; + } + }, + setItem(name: string, value: string): void { + try { + localStorage.setItem(name, value); + } catch { + logger.debug({ name }, 'browserStorage: setItem failed'); + } + }, + removeItem(name: string): void { + try { + localStorage.removeItem(name); + } catch { + logger.debug({ name }, 'browserStorage: removeItem failed'); + } + }, + }; +} + +export function safeLocalStorageGet(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return memoryFallback.get(key) ?? null; + } +} + +export function safeLocalStorageSet(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + memoryFallback.set(key, value); + } +} + +export function safeSessionStorageGet(key: string): string | null { + if (typeof window === 'undefined') { + return null; + } + try { + return sessionStorage.getItem(key); + } catch { + return null; + } +} + +export function safeSessionStorageSet(key: string, value: string): void { + if (typeof window === 'undefined') { + return; + } + try { + sessionStorage.setItem(key, value); + } catch { + // ignore + } +} + +export function safeSessionStorageRemove(key: string): void { + if (typeof window === 'undefined') { + return; + } + try { + sessionStorage.removeItem(key); + } catch { + // ignore + } +} diff --git a/src/lib/useLandingFormPreference.ts b/src/lib/useLandingFormPreference.ts index e76258969..cfaf6630b 100644 --- a/src/lib/useLandingFormPreference.ts +++ b/src/lib/useLandingFormPreference.ts @@ -1,9 +1,10 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useSession } from '@/lib/useSession'; import { useSettings } from '@/lib/useSettings'; import { useStore } from '@/store'; import { AppMode, LocalSettings } from '@/types'; import { LandingFormPreference, UserDataKeys } from '@/api/user/types'; +import { safeLocalStorageGet, safeLocalStorageSet } from '@/lib/browserStorage'; export type LandingFormKey = 'modern' | 'classic' | 'paper'; @@ -30,28 +31,24 @@ interface UseLandingFormPreferenceResult { persistCurrentForm: (form: LandingFormKey) => void; } -// Initialize localStorage value synchronously to avoid double-render -function getInitialLastUsedForm(): LandingFormKey | null { - if (typeof window === 'undefined') { - return null; - } - const stored = localStorage.getItem(LocalSettings.LAST_LANDING_FORM); - return stored && isValidFormKey(stored) ? stored : null; -} - export const useLandingFormPreference = (): UseLandingFormPreferenceResult => { const { isAuthenticated } = useSession(); const { settings } = useSettings({ suspense: false }, true); const mode = useStore((state) => state.mode); - const [lastUsedForm, setLastUsedForm] = useState( - getInitialLastUsedForm - ); + const [lastUsedForm, setLastUsedForm] = useState(null); + + // Read from localStorage in an effect so the property access never runs + // during the render phase (where a SecurityError could surface as a render error). + useEffect(() => { + const stored = safeLocalStorageGet(LocalSettings.LAST_LANDING_FORM); + if (stored && isValidFormKey(stored)) { + setLastUsedForm(stored); + } + }, []); const persistCurrentForm = useCallback((form: LandingFormKey) => { - if (typeof window !== 'undefined') { - localStorage.setItem(LocalSettings.LAST_LANDING_FORM, form); - setLastUsedForm(form); - } + safeLocalStorageSet(LocalSettings.LAST_LANDING_FORM, form); + setLastUsedForm(form); }, []); const landingFormUrl = getLandingFormUrl({ @@ -78,12 +75,7 @@ interface GetLandingFormUrlParams { mode: AppMode; } -function getLandingFormUrl({ - isAuthenticated, - userPreference, - lastUsedForm, - mode, -}: GetLandingFormUrlParams): string { +function getLandingFormUrl({ isAuthenticated, userPreference, lastUsedForm, mode }: GetLandingFormUrlParams): string { // Form tabs only exist in Astrophysics mode if (mode !== AppMode.ASTROPHYSICS) { return '/'; diff --git a/src/lib/useScrollRestoration.ts b/src/lib/useScrollRestoration.ts index cddcfa61b..ffb487c79 100644 --- a/src/lib/useScrollRestoration.ts +++ b/src/lib/useScrollRestoration.ts @@ -1,6 +1,7 @@ import { useRouter } from 'next/router'; import { useEffect, useRef } from 'react'; import { logger } from '@/logger'; +import { safeSessionStorageGet, safeSessionStorageRemove, safeSessionStorageSet } from '@/lib/browserStorage'; const SCROLL_POSITION_KEY = 'search-scroll-position'; @@ -12,13 +13,7 @@ export const useScrollRestoration = () => { const shouldRestoreRef = useRef(false); useEffect(() => { - // Only run on client side - if (typeof window === 'undefined') { - return; - } - - // Check if we should restore scroll position on mount - const savedPosition = sessionStorage.getItem(SCROLL_POSITION_KEY); + const savedPosition = safeSessionStorageGet(SCROLL_POSITION_KEY); logger.debug({ savedPosition }, 'useScrollRestoration'); if (savedPosition && router.pathname === '/search') { shouldRestoreRef.current = true; @@ -34,7 +29,7 @@ export const useScrollRestoration = () => { }); } // Clear the stored position after restoration - sessionStorage.removeItem(SCROLL_POSITION_KEY); + safeSessionStorageRemove(SCROLL_POSITION_KEY); shouldRestoreRef.current = false; }); } @@ -47,18 +42,15 @@ export const useScrollRestoration = () => { const saveScrollPosition = () => { if (typeof window !== 'undefined' && router.pathname === '/search') { const scrollPosition = window.scrollY || window.pageYOffset || document.documentElement.scrollTop; - sessionStorage.setItem(SCROLL_POSITION_KEY, scrollPosition.toString()); + safeSessionStorageSet(SCROLL_POSITION_KEY, scrollPosition.toString()); } }; /** * Clear saved scroll position - * Useful if you want to prevent restoration in certain cases */ const clearScrollPosition = () => { - if (typeof window !== 'undefined') { - sessionStorage.removeItem(SCROLL_POSITION_KEY); - } + safeSessionStorageRemove(SCROLL_POSITION_KEY); }; return { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 75e41ac95..f61cd1da7 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -4,7 +4,10 @@ import { AppProps, NextWebVitalsMetric } from 'next/app'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import 'nprogress/nprogress.css'; -import { memo, ReactElement, useEffect, useMemo } from 'react'; +import { memo, ReactElement, useEffect, useMemo, useState } from 'react'; +import { isCookiesAvailable, isLocalStorageAvailable } from '@/lib/browserStorage'; +import { StorageUnavailableNotice } from '@/components/StorageUnavailableNotice/StorageUnavailableNotice'; +import { StorageDegradedBanner } from '@/components/StorageDegradedBanner/StorageDegradedBanner'; import { DehydratedState, useQuery, useQueryClient } from '@tanstack/react-query'; import { IronSession } from 'iron-session'; import axios from 'axios'; @@ -49,10 +52,20 @@ const NectarApp = memo(({ Component, pageProps }: AppProps): ReactElement => { logger.debug('App', { props: pageProps as unknown }); const router = useRouter(); + // Storage availability is stable after mount — compute once, not on every render. + // Defaults to true so SSR renders the full app without flashing the notice. + const [cookiesOk, setCookiesOk] = useState(true); + const [localStorageOk, setLocalStorageOk] = useState(true); + useMemo(() => { router.prefetch = () => Promise.resolve(); }, [router]); + useEffect(() => { + setCookiesOk(isCookiesAvailable()); + setLocalStorageOk(isLocalStorageAvailable()); + }, []); + return ( <> @@ -60,13 +73,19 @@ const NectarApp = memo(({ Component, pageProps }: AppProps): ReactElement => { - - - - - - - + {!cookiesOk ? ( + + ) : ( + <> + + + + : null}> + + + + + )} ); diff --git a/src/pages/abs/[id]/abstract.tsx b/src/pages/abs/[id]/abstract.tsx index 4e437dc04..3d5ceee4e 100644 --- a/src/pages/abs/[id]/abstract.tsx +++ b/src/pages/abs/[id]/abstract.tsx @@ -2,6 +2,7 @@ import { Box, Button, Flex, IconButton, Stack, Text, Tooltip, useDisclosure, Vis import { EditIcon } from '@chakra-ui/icons'; import { FolderPlusIcon } from '@heroicons/react/24/solid'; import dynamic from 'next/dynamic'; +import { safeLocalStorageGet, safeLocalStorageSet } from '@/lib/browserStorage'; import { NextPage } from 'next'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; @@ -214,7 +215,7 @@ const useTour = () => { }, []); useEffect(() => { - if (isRendered && !localStorage.getItem(LocalSettings.SEEN_ABSTRACT_TOUR)) { + if (isRendered && !safeLocalStorageGet(LocalSettings.SEEN_ABSTRACT_TOUR)) { const tour = new Shepherd.Tour({ useModalOverlay: true, defaultStepOptions: { @@ -226,7 +227,7 @@ const useTour = () => { exitOnEsc: true, }); tour.addSteps(getAbstractSteps(!isScreenLarge)); - localStorage.setItem(LocalSettings.SEEN_ABSTRACT_TOUR, 'true'); + safeLocalStorageSet(LocalSettings.SEEN_ABSTRACT_TOUR, 'true'); setTimeout(() => { tour.start(); }, 1000); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index f6dd6d4eb..67242fb33 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -24,6 +24,7 @@ import Image from 'next/image'; import { useRouter } from 'next/router'; import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import { useSettings } from '@/lib/useSettings'; +import { safeLocalStorageGet, safeLocalStorageSet } from '@/lib/browserStorage'; import { NotificationId } from '@/store/slices'; import { SearchBar } from '@/components/SearchBar'; @@ -206,11 +207,11 @@ const Carousel = (props: { onSelectExample: (text: string) => void }) => { const { onSelectExample } = props; useEffect(() => { - setInitialPage(parseInt(localStorage.getItem(LocalSettings.CAROUSEL) ?? '0', 10)); + setInitialPage(parseInt(safeLocalStorageGet(LocalSettings.CAROUSEL) ?? '0', 10)); }, []); const handlePageChange = (page: number) => { - localStorage.setItem(LocalSettings.CAROUSEL, page.toString()); + safeLocalStorageSet(LocalSettings.CAROUSEL, page.toString()); setInitialPage(page); }; @@ -472,7 +473,7 @@ const useTour = () => { }, []); useEffect(() => { - if (isRendered && !localStorage.getItem(LocalSettings.SEEN_LANDING_TOUR)) { + if (isRendered && !safeLocalStorageGet(LocalSettings.SEEN_LANDING_TOUR)) { const tour = new Shepherd.Tour({ useModalOverlay: true, defaultStepOptions: { @@ -484,7 +485,7 @@ const useTour = () => { exitOnEsc: true, }); tour.addSteps(getHomeSteps(!isScreenLarge)); - localStorage.setItem(LocalSettings.SEEN_LANDING_TOUR, 'true'); + safeLocalStorageSet(LocalSettings.SEEN_LANDING_TOUR, 'true'); setTimeout(() => { tour.start(); }, 1000); diff --git a/src/pages/search/index.tsx b/src/pages/search/index.tsx index 667a4a3f1..0527d80b9 100644 --- a/src/pages/search/index.tsx +++ b/src/pages/search/index.tsx @@ -1,4 +1,5 @@ import dynamic from 'next/dynamic'; +import { safeLocalStorageGet, safeLocalStorageSet } from '@/lib/browserStorage'; import { IYearHistogramSliderProps } from '@/components/SearchFacet/YearHistogramSlider'; import { ISearchFacetsProps } from '@/components/SearchFacet'; import { AppState, useStore, useStoreApi } from '@/store'; @@ -631,7 +632,7 @@ const useTour = () => { }, []); useEffect(() => { - if (isRendered && !localStorage.getItem(LocalSettings.SEEN_RESULTS_TOUR)) { + if (isRendered && !safeLocalStorageGet(LocalSettings.SEEN_RESULTS_TOUR)) { const tour = new Shepherd.Tour({ useModalOverlay: true, defaultStepOptions: { @@ -643,7 +644,7 @@ const useTour = () => { exitOnEsc: true, }); tour.addSteps(getResultsSteps()); - localStorage.setItem(LocalSettings.SEEN_RESULTS_TOUR, 'true'); + safeLocalStorageSet(LocalSettings.SEEN_RESULTS_TOUR, 'true'); setTimeout(() => { tour.start(); diff --git a/src/store/store.ts b/src/store/store.ts index 80f653df2..4840bb45c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import create, { GetState, Mutate, SetState, StoreApi } from 'zustand'; import createContext from 'zustand/context'; import { devtools, NamedSet, persist, subscribeWithSelector } from 'zustand/middleware'; +import { createSafeStorage } from '@/lib/browserStorage'; import { appModeSlice, docsSlice, @@ -49,6 +50,7 @@ export const createStore = (preloadedState: Partial = {}) => { devtools( persist(state, { name: APP_STORAGE_KEY, + getStorage: createSafeStorage, partialize: (state) => ({ user: state.user, mode: state.mode,