Everyone knows two things about localStorage. It might be empty, and reading it might throw in a privacy mode. There is a third state that I had never written code for, and it cost new CogniPrep users their first logged-in page:
TypeError: Cannot read properties of null (reading 'getItem')
Not "storage is empty". Not "access denied". window.localStorage was null. An Android WebView with DOM storage disabled hands you a null property where the object should be, so the obvious line
const stored = localStorage.getItem('theme');
throws a TypeError before any storage policy gets a say. Android WebViews are how in-app browsers render pages, which makes this ordinary traffic rather than an edge case. The error arrived tagged Chrome Mobile WebView.
Why it landed on the worst possible page
CogniPrep forces dark mode on its marketing and auth pages, and respects your preference everywhere else. The list of forced paths is serialised into the blocking inline script in the root layout, so you can count it yourself: view source on cogniprep.app and look for var forceDarkPaths. There are 77 entries today, and /signup is one of them.
/dashboard is not. So a client-side navigation from /signup to /dashboard runs the "restore the user's preference" branch of a useEffect:
// before
const stored = localStorage.getItem('theme');
const theme = stored || 'system';
In a WebView with no storage object, that effect throws. A throw inside an effect is not a silently broken theme, it is an unhandled render error, so the error boundary took over and the first page a brand new account ever saw was an error screen. The signup worked. The theme read is what broke.
One module, three hostile storages
Every access now goes through lib/theme/theme-storage.ts, and each one is wrapped twice over:
export function readStoredTheme(): StoredTheme | null {
try {
const stored = window.localStorage?.getItem(THEME_STORAGE_KEY) ?? null;
return isStoredTheme(stored) ? stored : null;
} catch {
return null;
}
}
The ?. and the try are not redundant, because they catch different failures:
-
window.localStorageis null (WebView, DOM storage off). Optional chaining handles this; atryalone would too, but only by treating a predictable platform state as an exception. - Reading throws a
SecurityError(some privacy configurations). Only thetryhandles this.?.is no help, because the property exists. - Writing throws a
QuotaExceededError(full storage). Only thetryagain, and the write wrapper is a deliberate no-op: the theme is already applied to the document, so the only thing lost is the memory of it.
That is the rule the module is built on. A missing, disabled, full or null storage object may cost the user their theme preference and nothing else.
The blocking inline script in the layout got the same treatment, and it now imports the key rather than typing it:
var theme = (window.localStorage && localStorage.getItem("theme")) || 'system';
It always had a try/catch around the whole body, which is why the inline script never crashed a page. But a catch that swallows the whole function means the theme class is never applied, so a WebView user got the wrong colour scheme on first paint and nobody filed a bug about it.
The cast that was a lie
Moving the reads into one module exposed something the crash had hidden. The theme switcher had this:
const stored = localStorage.getItem('theme') as Theme;
A string that came out of the browser, asserted into a three-member union. as is a promise the compiler cannot check, and storage written by an older build of your app, or by a different app on the same origin, or by somebody typing in a console, is untrusted input. The module validates instead:
function isStoredTheme(value: string | null): value is StoredTheme {
return value !== null && (THEMES as readonly string[]).includes(value);
}
'neon' now reads as "no preference" rather than as a theme class nobody defined.
The tests are about storage, not about themes
Seven cases, and every one of them is a different way for the platform to be unhelpful. The suite runs in Node, so there is no DOM and window is whatever the test says it is:
function setStorage(storage: unknown): void {
(globalThis as { window?: unknown }).window = { localStorage: storage };
}
it('returns null when localStorage is null, rather than throwing', () => {
setStorage(null);
expect(() => readStoredTheme()).not.toThrow();
expect(readStoredTheme()).toBeNull();
});
it('returns null when reading throws, as in some privacy modes', () => {
setStorage({ getItem() { throw new DOMException('The operation is insecure.', 'SecurityError'); } });
expect(readStoredTheme()).toBeNull();
});
Faking the platform is the whole point. You cannot get a null localStorage out of jsdom or a headless Chrome, and you certainly cannot get one by hand on your laptop, so the only way this stays fixed is a test that asserts the shape the WebView actually sends.
See it
Open cogniprep.app (or the signup page, which is one of the 77 forced-dark paths) and view source. Search for window.localStorage: it appears exactly twice. Once in the inline <script> with real ampersands, and once inside the flight payload Next.js emits for hydration, where the same line reads window.localStorage \u0026\u0026 localStorage.getItem(\"theme\"). Then run this in the console on that page to see the same read your browser is doing:
(() => { try { return window.localStorage?.getItem('theme') } catch (e) { return String(e) } })()
You will get null, 'dark', 'light' or 'system'. In an in-app browser with storage switched off you would have got a TypeError, and before last week you would have got it on the first page after signing up.
The practice platform itself is at cogniprep.app, which is 56 assessment providers of timed tests where an error boundary on the first page is an especially bad look.
Top comments (1)
The "three hostile storages" taxonomy is the useful part: null object, throwing read, throwing write are three different failures, and optional chaining only covers the first. Most "safe storage" helpers I have reviewed handle the SecurityError case and quietly assume the object itself always exists.
The nasty indirection is where the crash surfaced: the boundary fired on /dashboard, not /signup, so the error report pointed at the wrong page. A client-side navigation carrying a signup-page assumption into a dashboard crash makes the original cause hard to see from the inbox alone.
Faking the platform in tests is the right call, since jsdom and headless Chrome will never hand you a null localStorage. Have you seen any other environment return null rather than a throwing object, or is it specifically Android WebView with DOM storage disabled?