Wrap local/session storage calls in try/catch (take 2) (#3066)

Steve tried this in #3043, but we reverted it in #3063. Steve's version
added `JSON.parse`/`JSON.stringify` to the helpers without checking for
where we were already `JSON.parse`ing (or not). In some places we just
store strings directly rather than wanting them jsonified, so in this
version we leave the jsonification to the callers - the helpers just do
the reading/writing and return the string values.

### Change Type

- [x] `patch` — Bug fix
This commit is contained in:
alex 2024-03-04 16:15:20 +00:00 committed by GitHub
parent 8adaaf8e22
commit ce782dc70b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 204 additions and 51 deletions

View file

@ -57,6 +57,14 @@ module.exports = {
'error', 'error',
{ selector: "MethodDefinition[kind='set']", message: 'Property setters are not allowed' }, { selector: "MethodDefinition[kind='set']", message: 'Property setters are not allowed' },
{ selector: "MethodDefinition[kind='get']", message: 'Property getters are not allowed' }, { selector: "MethodDefinition[kind='get']", message: 'Property getters are not allowed' },
{
selector: 'Identifier[name=localStorage]',
message: 'Use the getFromLocalStorage/setInLocalStorage helpers instead',
},
{
selector: 'Identifier[name=sessionStorage]',
message: 'Use the getFromSessionStorage/setInSessionStorage helpers instead',
},
], ],
}, },
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
@ -88,6 +96,7 @@ module.exports = {
files: ['apps/examples/**/*'], files: ['apps/examples/**/*'],
rules: { rules: {
'import/no-internal-modules': 'off', 'import/no-internal-modules': 'off',
'no-restricted-syntax': 'off',
}, },
}, },
{ {

View file

@ -8,10 +8,12 @@
* Many users still have that random string in their localStorage so we need to load it. But for new * Many users still have that random string in their localStorage so we need to load it. But for new
* users it does not need to be unique and we can just use a constant. * users it does not need to be unique and we can just use a constant.
*/ */
import { getFromLocalStorage, setInLocalStorage } from 'tldraw'
// DO NOT CHANGE THESE WITHOUT ADDING MIGRATION LOGIC. DOING SO WOULD WIPE ALL EXISTING LOCAL DATA. // DO NOT CHANGE THESE WITHOUT ADDING MIGRATION LOGIC. DOING SO WOULD WIPE ALL EXISTING LOCAL DATA.
const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2' const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2'
const w = typeof window === 'undefined' ? undefined : window
export const SCRATCH_PERSISTENCE_KEY = export const SCRATCH_PERSISTENCE_KEY =
(w?.localStorage.getItem(defaultDocumentKey) as any) ?? 'tldraw_document_v3' getFromLocalStorage(defaultDocumentKey) ?? 'tldraw_document_v3'
w?.localStorage.setItem(defaultDocumentKey, SCRATCH_PERSISTENCE_KEY) setInLocalStorage(defaultDocumentKey, SCRATCH_PERSISTENCE_KEY)

View file

@ -1,4 +1,4 @@
import { T, atom } from 'tldraw' import { T, atom, getFromLocalStorage, setInLocalStorage } from 'tldraw'
const channel = const channel =
typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('tldrawUserPreferences') : null typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('tldrawUserPreferences') : null
@ -37,9 +37,7 @@ function createPreference<Type>(key: string, validator: T.Validator<Type>, defau
} }
function loadItemFromStorage<Type>(key: string, validator: T.Validator<Type>): Type | null { function loadItemFromStorage<Type>(key: string, validator: T.Validator<Type>): Type | null {
if (typeof localStorage === 'undefined' || !localStorage) return null const item = getFromLocalStorage(`tldrawUserPreferences.${key}`)
const item = localStorage.getItem(`tldrawUserPreferences.${key}`)
if (item == null) return null if (item == null) return null
try { try {
return validator.validate(JSON.parse(item)) return validator.validate(JSON.parse(item))
@ -49,11 +47,5 @@ function loadItemFromStorage<Type>(key: string, validator: T.Validator<Type>): T
} }
function saveItemToStorage(key: string, value: unknown): void { function saveItemToStorage(key: string, value: unknown): void {
if (typeof localStorage === 'undefined' || !localStorage) return setInLocalStorage(`tldrawUserPreferences.${key}`, JSON.stringify(value))
try {
localStorage.setItem(`tldrawUserPreferences.${key}`, JSON.stringify(value))
} catch (e) {
// not a big deal
}
} }

View file

@ -17,7 +17,12 @@ import {
pageIdValidator, pageIdValidator,
shapeIdValidator, shapeIdValidator,
} from '@tldraw/tlschema' } from '@tldraw/tlschema'
import { objectMapFromEntries } from '@tldraw/utils' import {
deleteFromSessionStorage,
getFromSessionStorage,
objectMapFromEntries,
setInSessionStorage,
} from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { uniqueId } from '../utils/uniqueId' import { uniqueId } from '../utils/uniqueId'
@ -26,8 +31,6 @@ const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
const window = globalThis.window as const window = globalThis.window as
| { | {
navigator: Window['navigator'] navigator: Window['navigator']
localStorage: Window['localStorage']
sessionStorage: Window['sessionStorage']
addEventListener: Window['addEventListener'] addEventListener: Window['addEventListener']
TLDRAW_TAB_ID_v2?: string TLDRAW_TAB_ID_v2?: string
} }
@ -51,7 +54,7 @@ function iOS() {
* @public * @public
*/ */
export const TAB_ID: string = window export const TAB_ID: string = window
? window[tabIdKey] ?? window.sessionStorage[tabIdKey] ?? `TLDRAW_INSTANCE_STATE_V1_` + uniqueId() ? window[tabIdKey] ?? getFromSessionStorage(tabIdKey) ?? `TLDRAW_INSTANCE_STATE_V1_` + uniqueId()
: '<error>' : '<error>'
if (window) { if (window) {
window[tabIdKey] = TAB_ID window[tabIdKey] = TAB_ID
@ -62,14 +65,14 @@ if (window) {
// in which case they'll have two tabs with the same UI state. // in which case they'll have two tabs with the same UI state.
// It's not a big deal, but it's not ideal. // It's not a big deal, but it's not ideal.
// And anyway I can't see a way to duplicate a tab in iOS Safari. // And anyway I can't see a way to duplicate a tab in iOS Safari.
window.sessionStorage[tabIdKey] = TAB_ID setInSessionStorage(tabIdKey, TAB_ID)
} else { } else {
delete window.sessionStorage[tabIdKey] deleteFromSessionStorage(tabIdKey)
} }
} }
window?.addEventListener('beforeunload', () => { window?.addEventListener('beforeunload', () => {
window.sessionStorage[tabIdKey] = TAB_ID setInSessionStorage(tabIdKey, TAB_ID)
}) })
const Versions = { const Versions = {

View file

@ -1,6 +1,7 @@
import { atom } from '@tldraw/state' import { atom } from '@tldraw/state'
import { defineMigrations, migrate } from '@tldraw/store' import { defineMigrations, migrate } from '@tldraw/store'
import { getDefaultTranslationLocale } from '@tldraw/tlschema' import { getDefaultTranslationLocale } from '@tldraw/tlschema'
import { getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { uniqueId } from '../utils/uniqueId' import { uniqueId } from '../utils/uniqueId'
@ -200,11 +201,8 @@ function migrateUserPreferences(userData: unknown) {
} }
function loadUserPreferences(): TLUserPreferences { function loadUserPreferences(): TLUserPreferences {
const userData = const userData = (JSON.parse(getFromLocalStorage(USER_DATA_KEY) || 'null') ??
typeof window === 'undefined' null) as null | UserDataSnapshot
? null
: ((JSON.parse(window?.localStorage?.getItem(USER_DATA_KEY) || 'null') ??
null) as null | UserDataSnapshot)
return migrateUserPreferences(userData) return migrateUserPreferences(userData)
} }
@ -212,15 +210,10 @@ function loadUserPreferences(): TLUserPreferences {
const globalUserPreferences = atom<TLUserPreferences | null>('globalUserData', null) const globalUserPreferences = atom<TLUserPreferences | null>('globalUserData', null)
function storeUserPreferences() { function storeUserPreferences() {
if (typeof window !== 'undefined' && window.localStorage) { setInLocalStorage(
window.localStorage.setItem( USER_DATA_KEY,
USER_DATA_KEY, JSON.stringify({ version: userMigrations.currentVersion, user: globalUserPreferences.get() })
JSON.stringify({ )
version: userMigrations.currentVersion,
user: globalUserPreferences.get(),
})
)
}
} }
/** @public */ /** @public */

View file

@ -1,4 +1,5 @@
import { Atom, atom, react } from '@tldraw/state' import { Atom, atom, react } from '@tldraw/state'
import { deleteFromSessionStorage, getFromSessionStorage, setInSessionStorage } from '@tldraw/utils'
// --- 1. DEFINE --- // --- 1. DEFINE ---
// //
@ -126,14 +127,10 @@ function createDebugValueBase<T>(def: DebugFlagDef<T>): DebugFlag<T> {
if (def.shouldStoreForSession) { if (def.shouldStoreForSession) {
react(`debug:${def.name}`, () => { react(`debug:${def.name}`, () => {
const currentValue = valueAtom.get() const currentValue = valueAtom.get()
try { if (currentValue === defaultValue) {
if (currentValue === defaultValue) { deleteFromSessionStorage(`tldraw_debug:${def.name}`)
window.sessionStorage.removeItem(`tldraw_debug:${def.name}`) } else {
} else { setInSessionStorage(`tldraw_debug:${def.name}`, JSON.stringify(currentValue))
window.sessionStorage.setItem(`tldraw_debug:${def.name}`, JSON.stringify(currentValue))
}
} catch {
// not a big deal
} }
}) })
} }
@ -154,7 +151,7 @@ function createDebugValueBase<T>(def: DebugFlagDef<T>): DebugFlag<T> {
function getStoredInitialValue(name: string) { function getStoredInitialValue(name: string) {
try { try {
return JSON.parse(window?.sessionStorage.getItem(`tldraw_debug:${name}`) ?? 'null') return JSON.parse(getFromSessionStorage(`tldraw_debug:${name}`) ?? 'null')
} catch (err) { } catch (err) {
return null return null
} }

View file

@ -1,3 +1,4 @@
import { clearLocalStorage, clearSessionStorage } from '@tldraw/utils'
import { deleteDB } from 'idb' import { deleteDB } from 'idb'
import { getAllIndexDbNames } from './indexedDb' import { getAllIndexDbNames } from './indexedDb'
@ -6,11 +7,11 @@ import { getAllIndexDbNames } from './indexedDb'
* *
* @public */ * @public */
export async function hardReset({ shouldReload = true } = {}) { export async function hardReset({ shouldReload = true } = {}) {
sessionStorage.clear() clearSessionStorage()
await Promise.all(getAllIndexDbNames().map((db) => deleteDB(db))) await Promise.all(getAllIndexDbNames().map((db) => deleteDB(db)))
localStorage.clear() clearLocalStorage()
if (shouldReload) { if (shouldReload) {
window.location.reload() window.location.reload()
} }

View file

@ -1,4 +1,5 @@
import { createTLSchema } from '@tldraw/tlschema' import { createTLSchema } from '@tldraw/tlschema'
import { clearLocalStorage } from '@tldraw/utils'
import { import {
getAllIndexDbNames, getAllIndexDbNames,
loadDataFromStore, loadDataFromStore,
@ -9,7 +10,7 @@ import {
const clearAll = async () => { const clearAll = async () => {
const dbs = (indexedDB as any)._databases as Map<any, any> const dbs = (indexedDB as any)._databases as Map<any, any>
dbs.clear() dbs.clear()
localStorage.clear() clearLocalStorage()
} }
beforeEach(async () => { beforeEach(async () => {

View file

@ -1,5 +1,6 @@
import { RecordsDiff, SerializedSchema, SerializedStore } from '@tldraw/store' import { RecordsDiff, SerializedSchema, SerializedStore } from '@tldraw/store'
import { TLRecord, TLStoreSchema } from '@tldraw/tlschema' import { TLRecord, TLStoreSchema } from '@tldraw/tlschema'
import { getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
import { IDBPDatabase, openDB } from 'idb' import { IDBPDatabase, openDB } from 'idb'
import { TLSessionStateSnapshot } from '../../config/TLSessionStateSnapshot' import { TLSessionStateSnapshot } from '../../config/TLSessionStateSnapshot'
@ -221,7 +222,7 @@ async function pruneSessionState({
/** @internal */ /** @internal */
export function getAllIndexDbNames(): string[] { export function getAllIndexDbNames(): string[] {
const result = JSON.parse(window?.localStorage.getItem(dbNameIndexKey) || '[]') ?? [] const result = JSON.parse(getFromLocalStorage(dbNameIndexKey) || '[]') ?? []
if (!Array.isArray(result)) { if (!Array.isArray(result)) {
return [] return []
} }
@ -231,5 +232,5 @@ export function getAllIndexDbNames(): string[] {
function addDbName(name: string) { function addDbName(name: string) {
const all = new Set(getAllIndexDbNames()) const all = new Set(getAllIndexDbNames())
all.add(name) all.add(name)
window?.localStorage.setItem(dbNameIndexKey, JSON.stringify([...all])) setInLocalStorage(dbNameIndexKey, JSON.stringify([...all]))
} }

View file

@ -1,3 +1,4 @@
import { getFromLocalStorage, setInLocalStorage } from '@tldraw/editor'
import React from 'react' import React from 'react'
/** @public */ /** @public */
@ -5,7 +6,7 @@ export function useLocalStorageState<T = any>(key: string, defaultValue: T) {
const [state, setState] = React.useState(defaultValue) const [state, setState] = React.useState(defaultValue)
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const value = localStorage.getItem(key) const value = getFromLocalStorage(key)
if (value) { if (value) {
try { try {
setState(JSON.parse(value)) setState(JSON.parse(value))
@ -19,7 +20,7 @@ export function useLocalStorageState<T = any>(key: string, defaultValue: T) {
(setter: T | ((value: T) => T)) => { (setter: T | ((value: T) => T)) => {
setState((s) => { setState((s) => {
const value = typeof setter === 'function' ? (setter as any)(s) : setter const value = typeof setter === 'function' ? (setter as any)(s) : setter
localStorage.setItem(key, JSON.stringify(value)) setInLocalStorage(key, JSON.stringify(value))
return value return value
}) })
}, },

View file

@ -19,6 +19,12 @@ export const assert: (value: unknown, message?: string) => asserts value;
// @internal (undocumented) // @internal (undocumented)
export const assertExists: <T>(value: T, message?: string | undefined) => NonNullable<T>; export const assertExists: <T>(value: T, message?: string | undefined) => NonNullable<T>;
// @internal
export function clearLocalStorage(): void;
// @internal
export function clearSessionStorage(): void;
// @internal (undocumented) // @internal (undocumented)
export function compact<T>(arr: T[]): NonNullable<T>[]; export function compact<T>(arr: T[]): NonNullable<T>[];
@ -34,6 +40,12 @@ export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[]
// @public // @public
export function deepCopy<T = unknown>(obj: T): T; export function deepCopy<T = unknown>(obj: T): T;
// @internal
export function deleteFromLocalStorage(key: string): void;
// @internal
export function deleteFromSessionStorage(key: string): void;
// @public (undocumented) // @public (undocumented)
export type ErrorResult<E> = { export type ErrorResult<E> = {
readonly ok: false; readonly ok: false;
@ -68,6 +80,12 @@ export function getErrorAnnotations(error: Error): ErrorAnnotations;
// @public // @public
export function getFirstFromIterable<T = unknown>(set: Map<any, T> | Set<T>): T; export function getFirstFromIterable<T = unknown>(set: Map<any, T> | Set<T>): T;
// @internal
export function getFromLocalStorage(key: string): null | string;
// @internal
export function getFromSessionStorage(key: string): null | string;
// @public // @public
export function getHashForBuffer(buffer: ArrayBuffer): string; export function getHashForBuffer(buffer: ArrayBuffer): string;
@ -273,6 +291,12 @@ export function rng(seed?: string): () => number;
// @public // @public
export function rotateArray<T>(arr: T[], offset: number): T[]; export function rotateArray<T>(arr: T[], offset: number): T[];
// @internal
export function setInLocalStorage(key: string, value: string): void;
// @internal
export function setInSessionStorage(key: string, value: string): void;
// @public (undocumented) // @public (undocumented)
export function sortById<T extends { export function sortById<T extends {
id: any; id: any;

View file

@ -53,6 +53,16 @@ export {
validateIndexKey, validateIndexKey,
} from './lib/reordering/reordering' } from './lib/reordering/reordering'
export { sortById } from './lib/sort' export { sortById } from './lib/sort'
export {
clearLocalStorage,
clearSessionStorage,
deleteFromLocalStorage,
deleteFromSessionStorage,
getFromLocalStorage,
getFromSessionStorage,
setInLocalStorage,
setInSessionStorage,
} from './lib/storage'
export type { Expand, RecursivePartial, Required } from './lib/types' export type { Expand, RecursivePartial, Required } from './lib/types'
export { isDefined, isNonNull, isNonNullish, structuredClone } from './lib/value' export { isDefined, isNonNull, isNonNullish, structuredClone } from './lib/value'
export { warnDeprecatedGetter } from './lib/warnDeprecatedGetter' export { warnDeprecatedGetter } from './lib/warnDeprecatedGetter'

View file

@ -0,0 +1,119 @@
/* eslint-disable no-restricted-syntax */
/**
* Get a value from local storage.
*
* @param key - The key to get.
*
* @internal
*/
export function getFromLocalStorage(key: string) {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
/**
* Set a value in local storage. Will not throw an error if localStorage is not available.
*
* @param key - The key to set.
* @param value - The value to set.
*
* @internal
*/
export function setInLocalStorage(key: string, value: string) {
try {
localStorage.setItem(key, value)
} catch {
// noop
}
}
/**
* Remove a value from local storage. Will not throw an error if localStorage is not available.
*
* @param key - The key to set.
*
* @internal
*/
export function deleteFromLocalStorage(key: string) {
try {
localStorage.removeItem(key)
} catch {
// noop
}
}
/**
* Clear all values from local storage. Will not throw an error if localStorage is not available.
*
* @internal
*/
export function clearLocalStorage() {
try {
localStorage.clear()
} catch {
// noop
}
}
/**
* Get a value from session storage.
*
* @param key - The key to get.
*
* @internal
*/
export function getFromSessionStorage(key: string) {
try {
return sessionStorage.getItem(key)
} catch {
return null
}
}
/**
* Set a value in session storage. Will not throw an error if sessionStorage is not available.
*
* @param key - The key to set.
* @param value - The value to set.
*
* @internal
*/
export function setInSessionStorage(key: string, value: string) {
try {
sessionStorage.setItem(key, value)
} catch {
// noop
}
}
/**
* Remove a value from session storage. Will not throw an error if sessionStorage is not available.
*
* @param key - The key to set.
*
* @internal
*/
export function deleteFromSessionStorage(key: string) {
try {
sessionStorage.removeItem(key)
} catch {
// noop
}
}
/**
* Clear all values from session storage. Will not throw an error if sessionStorage is not available.
*
* @internal
*/
export function clearSessionStorage() {
try {
sessionStorage.clear()
} catch {
// noop
}
}