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,