Guard reader view localStorage access - #19988
Conversation
localStorage.setItem in ReaderViewProvider ran without a guard. When storage is full or blocked, the write threw during the React commit phase and the page did not render. Add safeLocalStorageGet/Set/Remove helpers in lib/utils and use them for all reader view preference storage: the pinned sidebar, the background image, and the content width. Generated-By: PostHog Desktop Task-Id: 211e48f8-89d9-4ef6-b5cd-68f71428940b
🦔 PostHog Review reviewed this pull requestFound 0 must fix, 1 should fix, 0 consider. Published 1 finding (view the review). Resolved comments: 1 left for you |
Deploy preview
|
|
PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
| if (typeof window === 'undefined') return null | ||
| if (persistedPinnedMemory !== null) return persistedPinnedMemory | ||
| const raw = localStorage.getItem(SIDEBAR_PINNED_KEY) | ||
| const raw = safeLocalStorageGet(SIDEBAR_PINNED_KEY) |
There was a problem hiding this comment.
Guard the storage read that runs before ReaderView
Why we think it's a valid issue
- Checked: the mount order of the providers around
ReaderViewProvider, the call site ofgetInitialSiteSettings, the presence of an error boundary, and the other unguardedlocalStoragereads that run on the same page load. - Found:
getInitialSiteSettingsreads storage without a guard atsrc/context/App.tsx:1706, and theProvidercalls it fromuseIsomorphicLayoutEffectatsrc/context/App.tsx:1785-1790. - Found:
gatsby-ssr.js:25-33wraps every page in thatProvider, so it is a parent ofReaderViewProvider. React runs all layout effects before any passive effect, so this read runs before thesafeLocalStorageGetcalls that the PR added atsrc/components/ReaderView/context/ReaderViewContext.tsx:71and:110. - Found:
grepforcomponentDidCatch,getDerivedStateFromError, andErrorBoundaryreturns no match insrc/. An uncaught throw in the commit phase therefore unmounts the whole tree and gives the blank page. - Impact: confirmed for the blocked-storage mode only. When the browser denies storage access,
getItemthrows aSecurityError,src/context/App.tsx:1706throws first, and the page still goes blank. This happens today, for example when posthog.com runs in a third-party iframe and the browser blocks third-party storage. - Impact: the PR is not defeated in the mode its description names. A quota failure throws on write, not on read. In that mode
src/context/App.tsx:1706succeeds,updateSiteSettingsalready catches its own write atsrc/context/App.tsx:2476-2481, and the reader view guards now catch the write that crashed. The PR fixes that path in full. - Priority: lowered to
should_fix. The defect is real and the fix is one line with the helper this PR adds, but it does not block the PR, which already removes the reported crash. The suggested remedy is also incomplete on its own:src/components/CookieBanner/ToastVersion.tsx:14reads storage without a guard inside a passive effect, andCookieBannerToastmounts on every page throughsrc/components/Wrapper/index.tsx:41. A regression test that only guardsgetInitialSiteSettingswould still see the tree unmount.
Issue description
The new guard runs inside ReaderViewProvider, but Gatsby first mounts AppProvider. Its layout effect calls getInitialSiteSettings(), which still uses unguarded localStorage.getItem('siteSettings') at src/context/App.tsx:1706. A browser that blocks reads throws there before these ReaderView effects can recover. React then removes the full page, so the blocked-storage failure remains.
Suggested fix
Use safeLocalStorageGet in getInitialSiteSettings() and catch invalid JSON. Use {} when either operation fails. Add a regression test that makes localStorage.getItem throw while AppProvider mounts. Confirm that ReaderView stays mounted with default settings.
Prompt to fix with AI (copy-paste)
## Context
@src/components/ReaderView/context/ReaderViewContext.tsx#L31
<issue_description>
The new guard runs inside `ReaderViewProvider`, but Gatsby first mounts `AppProvider`. Its layout effect calls `getInitialSiteSettings()`, which still uses unguarded `localStorage.getItem('siteSettings')` at `src/context/App.tsx:1706`. A browser that blocks reads throws there before these ReaderView effects can recover. React then removes the full page, so the blocked-storage failure remains.
</issue_description>
<issue_validation>
- **Checked:** the mount order of the providers around `ReaderViewProvider`, the call site of `getInitialSiteSettings`, the presence of an error boundary, and the other unguarded `localStorage` reads that run on the same page load.
- **Found:** `getInitialSiteSettings` reads storage without a guard at `src/context/App.tsx:1706`, and the `Provider` calls it from `useIsomorphicLayoutEffect` at `src/context/App.tsx:1785-1790`.
- **Found:** `gatsby-ssr.js:25-33` wraps every page in that `Provider`, so it is a parent of `ReaderViewProvider`. React runs all layout effects before any passive effect, so this read runs before the `safeLocalStorageGet` calls that the PR added at `src/components/ReaderView/context/ReaderViewContext.tsx:71` and `:110`.
- **Found:** `grep` for `componentDidCatch`, `getDerivedStateFromError`, and `ErrorBoundary` returns no match in `src/`. An uncaught throw in the commit phase therefore unmounts the whole tree and gives the blank page.
- **Impact:** confirmed for the blocked-storage mode only. When the browser denies storage access, `getItem` throws a `SecurityError`, `src/context/App.tsx:1706` throws first, and the page still goes blank. This happens today, for example when posthog.com runs in a third-party iframe and the browser blocks third-party storage.
- **Impact:** the PR is not defeated in the mode its description names. A quota failure throws on write, not on read. In that mode `src/context/App.tsx:1706` succeeds, `updateSiteSettings` already catches its own write at `src/context/App.tsx:2476-2481`, and the reader view guards now catch the write that crashed. The PR fixes that path in full.
- **Priority:** lowered to `should_fix`. The defect is real and the fix is one line with the helper this PR adds, but it does not block the PR, which already removes the reported crash. The suggested remedy is also incomplete on its own: `src/components/CookieBanner/ToastVersion.tsx:14` reads storage without a guard inside a passive effect, and `CookieBannerToast` mounts on every page through `src/components/Wrapper/index.tsx:41`. A regression test that only guards `getInitialSiteSettings` would still see the tree unmount.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Use `safeLocalStorageGet` in `getInitialSiteSettings()` and catch invalid JSON. Use `{}` when either operation fails. Add a regression test that makes `localStorage.getItem` throw while `AppProvider` mounts. Confirm that ReaderView stays mounted with default settings.
</potential_solution>
There was a problem hiding this comment.
Confirmed — this is a real gap, and I have left the code unchanged so a maintainer can decide how to close it.
What I checked at the current head: the site settings read in the app provider genuinely runs unguarded, and it runs in a layout effect, so it fires before the reader view guards this PR added, which sit in passive effects. The provider wraps every page through the Gatsby SSR wrapper, and there is no error boundary anywhere in src/, so a throw there does take the whole page down. Your scoping caveat also holds: the cookie banner toast reads storage unguarded too and mounts on every page, so guarding the app provider on its own would not actually clear the blank page in blocked-storage mode.
I am escalating rather than fixing for four reasons:
- The repo's own contributor guide lists
src/context/App.tsxunder "Ask first" as core window management. An automated change to that file is exactly what the rule prevents. - This PR deliberately scoped itself to the reader view, the one surface with reported crashes, and left the other storage call sites to adopt the shared helper later. Pulling the site-wide provider in would widen the PR past that decision.
- A one-line guard here would be partial protection, not a fix, since the cookie banner still throws on the same page load. The complete answer is a sweep of the remaining unguarded reads, which belongs in its own PR.
- There is no component test setup in this repo —
pnpm testis a stub, and the existing tests only cover scripts, navigation, and pure utilities. The regression test you suggested would need new test dependencies, which I am not permitted to add unattended.
What a human needs to decide: whether to approve touching the app provider at all; whether to do the guard as a follow-up sweep covering both the provider and the cookie banner toast rather than expanding this PR; and whether silently falling back to empty settings when the stored value is corrupt is acceptable, since that quietly resets a visitor's saved theme, wallpaper, and cursor instead of surfacing the problem.
Bundle reportTotal JS (gzip)8.16 MiB (+0.1 KiB / +0.0%) Eager graph (modules shipped in each entrypoint's initial chunks)
Largest modules in the
|
| Module | Size |
|---|---|
./src/data/mcp-tools.json |
1081.9 KiB |
css ./node_modules/.pnpm/css-loader@5.2.7_webpack@5.101.3/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[8].oneOf[1].use[1]!./node_modules/.pnpm/postcss-loader@4.3.0_postcss@8.5.6_webpack@5.101.3/node_modules/postcss-loader/dist/cjs.js??ruleSet[1].rules[8].oneOf[1].use[2]!./src/styles/global.css |
758.7 KiB |
./src/components/Stickers/Stickers.tsx |
696.4 KiB |
./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.3.1/node_modules/@radix-ui/react-icons/dist/react-icons.esm.js |
481.4 KiB |
./node_modules/.pnpm/@posthog+brand@0.8.0_react@18.3.1/node_modules/@posthog/brand/dist/generated/hoggies/svg/x-ray.mjs |
480.8 KiB |
./node_modules/.pnpm/rehype-raw@7.0.0/node_modules/rehype-raw/lib/index.js + 29 modules |
395.1 KiB |
./node_modules/.pnpm/@posthog+brand@0.8.0_react@18.3.1/node_modules/@posthog/brand/dist/generated/hoggies/svg/im-the-driver.mjs |
385.7 KiB |
./src/hooks/useCustomers.tsx + 55 modules |
370.0 KiB |
./node_modules/.pnpm/@posthog+icons@0.36.6_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js |
354.8 KiB |
./node_modules/.pnpm/react-markdown@8.0.7_@types+react@16.14.66_react@18.3.1/node_modules/react-markdown/lib/react-markdown.js + 88 modules |
351.4 KiB |
./src/components/ProductComparisonTable/index.tsx + 126 modules |
301.8 KiB |
./node_modules/.pnpm/cloudinary-core@2.14.0_lodash@4.17.21/node_modules/cloudinary-core/cloudinary-core.js |
281.9 KiB |
./node_modules/.pnpm/@posthog+brand@0.8.0_react@18.3.1/node_modules/@posthog/brand/dist/generated/hoggies/svg/doll-house.mjs |
281.7 KiB |
./node_modules/.pnpm/@posthog+brand@0.8.0_react@18.3.1/node_modules/@posthog/brand/dist/generated/hoggies/svg/director.mjs |
275.6 KiB |
./src/components/SearchUI/index.tsx + 87 modules |
273.0 KiB |
Eager-graph budgets are report-only until a baseline is established. Sizes are gzip of public/**/*.js; eager size is webpack module source bytes for the modules actually shipped in the entrypoint's initial chunks (post-tree-shake).
Changes
Problem
ReaderViewProviderwrites its layout preferences tolocalStoragewith notry/catch.localStorage.setItem('fullWidthContent', ...)runs on every mount, so when storage is full or blocked (Safari private mode, partitioned storage) it throwsQuotaExceededError.Fix
safeLocalStorageGet,safeLocalStorageSet, andsafeLocalStorageRemovetosrc/lib/utils.ts. Each one catches the failure and returns without a value. This is the behaviorsrc/context/App.tsxalready has for its own write.fullWidthContentReaderViewContext.tsxreader-sidebar-pinnedReaderViewContext.tsxbackground-imageReaderViewContext.tsxfull-width-contentReaderView/index.tsxRisk
Note
Behavior does not change when storage works. The helpers only add a
catchpath, and no call site reads a return value that the guard can change: a caught read returnsnull, which is what an absent key already returns.Scope
localStorage.setItemcalls insrc/share this missing guard. This PR fixes the reader view, the one site with reported crashes, and adds the shared helper the other sites can adopt later.Agent context
full-width-contentlooks unused inReaderView/index.tsx, butsrc/components/Layout/context.tsxreads it, so the write stays and is guarded instead.node_modulesis not installed in this environment, so the dev server andtscdid not run. The change is not visual. I checked the guard logic with a standalone script that makesgetItem,setItem, andremoveItemthrow, and confirmed no exception escapes.Checklist
vercel.json(no pages moved)Created with PostHog Desktop from this inbox report.