DEV Community

Daniel Pertu
Daniel Pertu

Posted on

window.localStorage can be null, which is how our signup flow handed people an error screen

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')
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

The ?. and the try are not redundant, because they catch different failures:

  • window.localStorage is null (WebView, DOM storage off). Optional chaining handles this; a try alone would too, but only by treating a predictable platform state as an exception.
  • Reading throws a SecurityError (some privacy configurations). Only the try handles this. ?. is no help, because the property exists.
  • Writing throws a QuotaExceededError (full storage). Only the try again, 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';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

'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();
});
Enter fullscreen mode Exit fullscreen mode

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) } })()
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
launchgatecheck profile image
Launch Gate •

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?