Fix mobile white screen when localStorage throws on access (#54) (#55)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m24s

## Summary

- Closes #54
- Add `getLocalStorageSafe()` helper in `tokenStorage.js` to handle browsers that expose `localStorage` but throw `SecurityError` on access
- Wrap all JWT token get/set/remove calls in try/catch so auth bootstrap no longer crashes React on mobile

## Problem

On mobile browsers, hesychia.ai flashes the UI briefly after login then goes white. Auth startup reads tokens from `localStorage` without guarding against storage access errors (common in private mode or blocked storage).

## Test plan

- [ ] Open hesychia.ai on mobile Safari/Chrome (normal mode) and sign in — dashboard should render
- [ ] Repeat in private/incognito mode — app should not white-screen (may require re-login each session if storage blocked)
- [ ] Verify existing web login/logout still works on desktop
- [ ] Run `npm test -- tokenStorage` in `llm-fe`Reviewed-on: #55
This commit was merged in pull request #55.
This commit is contained in:
2026-07-29 03:00:04 -07:00
parent 799d3d591f
commit 103ca2c5e4
+42 -10
View File
@@ -9,6 +9,21 @@
export const ACCESS_TOKEN_KEY = "access_token";
export const REFRESH_TOKEN_KEY = "refresh_token";
/**
* Browsers can expose localStorage but throw on access (privacy mode / blocked storage).
* @returns {Storage|null}
*/
function getLocalStorageSafe() {
try {
if (typeof window === "undefined" || !window.localStorage) {
return null;
}
return window.localStorage;
} catch {
return null;
}
}
/**
* @returns {{ get?: Function, set?: Function, remove?: Function } | undefined}
*/
@@ -24,10 +39,15 @@ function getPreferencesPlugin() {
* @returns {string|null}
*/
export function getToken(key) {
if (typeof localStorage === "undefined") {
const storage = getLocalStorageSafe();
if (!storage) {
return null;
}
try {
return storage.getItem(key);
} catch {
return null;
}
return localStorage.getItem(key);
}
/**
@@ -51,10 +71,15 @@ export function getRefreshToken() {
* @returns {Promise<void>}
*/
export async function setTokens(access, refresh) {
if (typeof localStorage !== "undefined") {
localStorage.setItem(ACCESS_TOKEN_KEY, access);
const storage = getLocalStorageSafe();
if (storage) {
try {
storage.setItem(ACCESS_TOKEN_KEY, access);
if (refresh != null) {
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
storage.setItem(REFRESH_TOKEN_KEY, refresh);
}
} catch {
/* localStorage unavailable */
}
}
@@ -75,9 +100,14 @@ export async function setTokens(access, refresh) {
* @returns {Promise<void>}
*/
export async function clearTokens() {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
const storage = getLocalStorageSafe();
if (storage) {
try {
storage.removeItem(ACCESS_TOKEN_KEY);
storage.removeItem(REFRESH_TOKEN_KEY);
} catch {
/* localStorage unavailable */
}
}
const Preferences = getPreferencesPlugin();
@@ -110,7 +140,8 @@ export async function hydrateTokensFromNativeStorage() {
try {
const result = await Preferences.get({ key: ACCESS_TOKEN_KEY });
if (result?.value) {
localStorage.setItem(ACCESS_TOKEN_KEY, result.value);
const storage = getLocalStorageSafe();
storage?.setItem(ACCESS_TOKEN_KEY, result.value);
access = result.value;
}
} catch {
@@ -122,7 +153,8 @@ export async function hydrateTokensFromNativeStorage() {
try {
const result = await Preferences.get({ key: REFRESH_TOKEN_KEY });
if (result?.value) {
localStorage.setItem(REFRESH_TOKEN_KEY, result.value);
const storage = getLocalStorageSafe();
storage?.setItem(REFRESH_TOKEN_KEY, result.value);
refresh = result.value;
}
} catch {