diff --git a/app/javascript/components/common/NewListItemForm.tsx b/app/javascript/components/common/NewListItemForm.tsx index df11d9c5ec..264a6c2b32 100644 --- a/app/javascript/components/common/NewListItemForm.tsx +++ b/app/javascript/components/common/NewListItemForm.tsx @@ -21,9 +21,13 @@ export const NewListItemForm = ({ onCompressed?: () => void defaultError: Error }): JSX.Element => { - const [value, setValue] = useState( - localStorage.getItem(`smde_${contextId}`) || '' - ) + const [value, setValue] = useState(() => { + try { + return localStorage.getItem(`smde_${contextId}`) || '' + } catch { + return '' + } + }) const handleSuccess = useCallback( (item: T) => { setValue('') diff --git a/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx b/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx index 778ab91095..c0de0a354c 100644 --- a/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx +++ b/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx @@ -29,9 +29,14 @@ export const StartDiscussionPanel = ({ links: Links }): JSX.Element => { const contextId = `start-discussion-request-${request.uuid}` - const [state, setState] = useState({ - expanded: defaultExpanded, - value: localStorage.getItem(`smde_${contextId}`) || '', + const [state, setState] = useState(() => { + let value = '' + try { + value = localStorage.getItem(`smde_${contextId}`) || '' + } catch { + // localStorage may be inaccessible (e.g. Safari private browsing) + } + return { expanded: defaultExpanded, value } }) const lastIteration = iterations[iterations.length - 1] diff --git a/app/javascript/packs/application.tsx b/app/javascript/packs/application.tsx index f99a6668dc..20e43a1cf4 100644 --- a/app/javascript/packs/application.tsx +++ b/app/javascript/packs/application.tsx @@ -4,6 +4,7 @@ import { lazy } from '@/utils/lazy-with-retry' import { camelizeKeys } from 'humps' import { camelizeKeysAs } from '@/utils/camelize-keys-as' import { initReact } from '@/utils/react-bootloader' +import { safeLocalStorage } from '@/utils/safe-local-storage' import { RenderLoader } from '@/components/common/RenderLoader' import 'focus-visible' import 'tippy.js/animations/shift-away-subtle.css' @@ -220,7 +221,7 @@ declare global { if (typeof window !== 'undefined') { const persister = createSyncStoragePersister({ - storage: window.localStorage, + storage: safeLocalStorage(), key: 'REACT_QUERY_OFFLINE_CACHE', // Strip non-serializable `promise` fields from dehydrated query state. // When a query is pending during dehydration, its state includes a real @@ -878,13 +879,16 @@ document.addEventListener('submit', function (event: SubmitEvent) { 'frontend-training-page-size', ] - const allKeys = Object.keys(localStorage) + const storage = safeLocalStorage() + if (storage) { + const allKeys = Object.keys(storage) - allKeys.forEach((key) => { - if (!keysToKeep.includes(key)) { - localStorage.removeItem(key) - } - }) + allKeys.forEach((key) => { + if (!keysToKeep.includes(key)) { + storage.removeItem(key) + } + }) + } } }) diff --git a/app/javascript/utils/react-bootloader.tsx b/app/javascript/utils/react-bootloader.tsx index 47496feecb..8527d3aeed 100644 --- a/app/javascript/utils/react-bootloader.tsx +++ b/app/javascript/utils/react-bootloader.tsx @@ -128,6 +128,17 @@ if (process.env.SENTRY_DSN) { ) if (isStudentCodeError) return null + // Drop non-actionable localStorage/sessionStorage SecurityErrors (Safari private + // browsing, restricted iframes, or strict cookie settings block storage access entirely) + const isStorageAccessError = event.exception?.values?.some( + (ex) => + ex.value?.includes( + "read the 'localStorage' property from 'Window'" + ) || + ex.value?.includes("read the 'sessionStorage' property from 'Window'") + ) + if (isStorageAccessError) return null + // Drop non-actionable "Maximum call stack size exceeded" errors. // These come from browser extensions, third-party scripts, or users // with cached bundles (the root cause in app code was fixed in #8408). diff --git a/app/javascript/utils/safe-local-storage.ts b/app/javascript/utils/safe-local-storage.ts new file mode 100644 index 0000000000..953b3347f9 --- /dev/null +++ b/app/javascript/utils/safe-local-storage.ts @@ -0,0 +1,14 @@ +/** + * Safely access window.localStorage. + * + * In some browsers (Safari private browsing, restricted iframes, strict cookie + * settings) even reading window.localStorage throws a SecurityError. This + * helper returns null when access is denied so callers can degrade gracefully. + */ +export function safeLocalStorage(): Storage | null { + try { + return window.localStorage + } catch { + return null + } +}