Files
supabase/packages/common/safe-storage.test.ts
Ali Waseem 1c2d28d5b3 chore: wrap local storage into helper methods that are safer (#46628)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

- Noticing our code we have many patterns of calling localstorage and
handling those errors
- We should add those in a single well tested file
- Handle those errors in the singleton which makes it easier for us to
debug customer issues. Logger is outputing local storage warnings for
feature we expose
- Side effect of this is random crashes on studio when local storage
isn't available or handled correctly

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Improved browser storage handling across the app for more reliable
persistence and graceful behavior in restricted or non-browser
environments (settings, previews, charts, tabs, sign-in/session flows,
integrations, and UI state).

* **New Features**
* Introduced a safe storage layer to standardize and harden
local/session persistence.

* **Tests**
  * Added comprehensive tests covering the new safe storage behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-04 07:41:28 -06:00

184 lines
6.3 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { safeLocalStorage, safeSessionStorage } from './safe-storage'
type StorageName = 'localStorage' | 'sessionStorage'
function createMemoryStorage(): Storage {
const data = new Map<string, string>()
const methods = {
getItem: (key: string) => (data.has(key) ? data.get(key)! : null),
setItem: (key: string, value: string) => void data.set(key, String(value)),
removeItem: (key: string) => void data.delete(key),
clear: () => data.clear(),
key: (index: number) => Array.from(data.keys())[index] ?? null,
get length() {
return data.size
},
}
return new Proxy(methods as unknown as Storage, {
ownKeys: () => Array.from(data.keys()),
getOwnPropertyDescriptor: (_target, prop) =>
data.has(prop as string)
? { enumerable: true, configurable: true, value: data.get(prop as string) }
: undefined,
get: (_target, prop) => (prop in methods ? (methods as any)[prop] : data.get(prop as string)),
})
}
function throwingStorage(): Storage {
return new Proxy({} as Storage, {
get() {
throw new DOMException('storage blocked', 'SecurityError')
},
ownKeys() {
throw new DOMException('storage blocked', 'SecurityError')
},
})
}
function installStorage(name: StorageName, value: Storage) {
Object.defineProperty(window, name, { value, configurable: true, writable: true })
}
// Make even reading `window.localStorage` throw (sandboxed iframe, disabled storage, etc)
function installUnavailableStorage(name: StorageName) {
Object.defineProperty(window, name, {
configurable: true,
get() {
throw new Error('storage access denied')
},
})
}
beforeEach(() => {
installStorage('localStorage', createMemoryStorage())
installStorage('sessionStorage', createMemoryStorage())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('safeLocalStorage', () => {
describe('happy path', () => {
it('stores and retrieves a value', () => {
safeLocalStorage.setItem('greeting', 'hello')
expect(safeLocalStorage.getItem('greeting')).toBe('hello')
})
it('returns null for a missing key', () => {
expect(safeLocalStorage.getItem('does-not-exist')).toBeNull()
})
it('removes a value', () => {
safeLocalStorage.setItem('temp', 'value')
safeLocalStorage.removeItem('temp')
expect(safeLocalStorage.getItem('temp')).toBeNull()
})
it('lists all keys', () => {
safeLocalStorage.setItem('a', '1')
safeLocalStorage.setItem('b', '2')
const keys = safeLocalStorage.keys()
expect(keys).toHaveLength(2)
expect(keys).toEqual(expect.arrayContaining(['a', 'b']))
})
it('clears all keys', () => {
safeLocalStorage.setItem('a', '1')
safeLocalStorage.setItem('b', '2')
safeLocalStorage.clear()
expect(safeLocalStorage.keys()).toHaveLength(0)
})
})
describe('return types match the native Storage API', () => {
it('write methods return undefined (void)', () => {
expect(safeLocalStorage.setItem('k', 'v')).toBeUndefined()
expect(safeLocalStorage.removeItem('k')).toBeUndefined()
expect(safeLocalStorage.clear()).toBeUndefined()
})
})
describe('when storage methods throw', () => {
beforeEach(() => {
installStorage('localStorage', throwingStorage())
})
it('getItem returns null and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(safeLocalStorage.getItem('fail-get')).toBeNull()
expect(warn).toHaveBeenCalledOnce()
})
it('setItem swallows the error and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(() => safeLocalStorage.setItem('fail-set', 'v')).not.toThrow()
expect(warn).toHaveBeenCalledOnce()
})
it('removeItem swallows the error and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(() => safeLocalStorage.removeItem('fail-remove')).not.toThrow()
expect(warn).toHaveBeenCalledOnce()
})
it('keys returns an empty array and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(safeLocalStorage.keys()).toEqual([])
expect(warn).toHaveBeenCalledOnce()
})
it('clear swallows the error and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(() => safeLocalStorage.clear()).not.toThrow()
expect(warn).toHaveBeenCalledOnce()
})
it('warns only once per key+action (dedupes repeated failures)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
safeLocalStorage.setItem('dedupe-key', 'a')
safeLocalStorage.setItem('dedupe-key', 'b')
safeLocalStorage.setItem('dedupe-key', 'c')
expect(warn).toHaveBeenCalledOnce()
})
})
describe('when storage is entirely unavailable', () => {
beforeEach(() => {
installUnavailableStorage('localStorage')
})
it('returns safe defaults without warning', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(safeLocalStorage.getItem('x')).toBeNull()
expect(safeLocalStorage.keys()).toEqual([])
expect(() => safeLocalStorage.setItem('x', 'y')).not.toThrow()
expect(() => safeLocalStorage.removeItem('x')).not.toThrow()
expect(() => safeLocalStorage.clear()).not.toThrow()
// Unavailable storage is an expected condition, not a failure to report.
expect(warn).not.toHaveBeenCalled()
})
})
})
describe('safeSessionStorage', () => {
it('reads and writes independently from localStorage', () => {
safeSessionStorage.setItem('session-key', 'session-value')
expect(safeSessionStorage.getItem('session-key')).toBe('session-value')
// Not visible to localStorage.
expect(safeLocalStorage.getItem('session-key')).toBeNull()
})
it('swallows errors when session storage methods throw', () => {
installStorage('sessionStorage', throwingStorage())
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(safeSessionStorage.getItem('session-fail')).toBeNull()
expect(() => safeSessionStorage.setItem('session-fail', 'v')).not.toThrow()
expect(warn).toHaveBeenCalled()
})
})