diff --git a/.eslintrc.js b/.eslintrc.js index b30c4aff9..62c0dddb5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,6 +14,7 @@ module.exports = { '@next/next', 'react-hooks', 'deprecation', + 'no-storage', ], settings: { next: { @@ -21,6 +22,7 @@ module.exports = { }, }, rules: { + 'no-storage/no-browser-storage': 2, 'deprecation/deprecation': 'error', '@next/next/no-html-link-for-pages': 'off', 'react/jsx-key': 'off', diff --git a/apps/dotcom/src/utils/scratch-persistence-key.ts b/apps/dotcom/src/utils/scratch-persistence-key.ts index 9f2ec1b83..283a01774 100644 --- a/apps/dotcom/src/utils/scratch-persistence-key.ts +++ b/apps/dotcom/src/utils/scratch-persistence-key.ts @@ -1,17 +1,15 @@ -/** - * What is going on in this file? - * - * We had some bad early assumptions about how we would store documents. - * Which ended up with us generating random persistenceKey strings for the - * 'scratch' document for each user (i.e. each browser context), and storing it in localStorage. - * - * 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. - */ +// What is going on in this file? +// We had some bad early assumptions about how we would store documents. +// Which ended up with us generating random persistenceKey strings for the +// 'scratch' document for each user (i.e. each browser context), and storing it in localStorage. +// 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. + +import { getFromLocalStorage, setInLocalStorage } from 'tldraw' + // DO NOT CHANGE THESE WITHOUT ADDING MIGRATION LOGIC. DOING SO WOULD WIPE ALL EXISTING LOCAL DATA. const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2' -const w = typeof window === 'undefined' ? undefined : window export const SCRATCH_PERSISTENCE_KEY = - (w?.localStorage.getItem(defaultDocumentKey) as any) ?? 'tldraw_document_v3' -w?.localStorage.setItem(defaultDocumentKey, SCRATCH_PERSISTENCE_KEY) + (getFromLocalStorage(defaultDocumentKey) as any) ?? 'tldraw_document_v3' +setInLocalStorage(defaultDocumentKey, SCRATCH_PERSISTENCE_KEY) diff --git a/apps/dotcom/src/utils/userPreferences.ts b/apps/dotcom/src/utils/userPreferences.ts index a2a938cbc..1852bda16 100644 --- a/apps/dotcom/src/utils/userPreferences.ts +++ b/apps/dotcom/src/utils/userPreferences.ts @@ -1,4 +1,4 @@ -import { T, atom } from 'tldraw' +import { T, atom, getFromLocalStorage, setInLocalStorage } from 'tldraw' const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('tldrawUserPreferences') : null @@ -37,9 +37,7 @@ function createPreference(key: string, validator: T.Validator, defau } function loadItemFromStorage(key: string, validator: T.Validator): Type | null { - if (typeof localStorage === 'undefined' || !localStorage) return null - - const item = localStorage.getItem(`tldrawUserPreferences.${key}`) + const item = getFromLocalStorage(`tldrawUserPreferences.${key}`, null) if (item == null) return null try { return validator.validate(JSON.parse(item)) @@ -49,11 +47,5 @@ function loadItemFromStorage(key: string, validator: T.Validator): T } function saveItemToStorage(key: string, value: unknown): void { - if (typeof localStorage === 'undefined' || !localStorage) return - - try { - localStorage.setItem(`tldrawUserPreferences.${key}`, JSON.stringify(value)) - } catch (e) { - // not a big deal - } + setInLocalStorage(`tldrawUserPreferences.${key}`, JSON.stringify(value)) } diff --git a/apps/examples/src/examples/local-storage/LocalStorageExample.tsx b/apps/examples/src/examples/local-storage/LocalStorageExample.tsx index 2ff6af01e..3501476f1 100644 --- a/apps/examples/src/examples/local-storage/LocalStorageExample.tsx +++ b/apps/examples/src/examples/local-storage/LocalStorageExample.tsx @@ -1,5 +1,12 @@ import { useLayoutEffect, useState } from 'react' -import { Tldraw, createTLStore, defaultShapeUtils, throttle } from 'tldraw' +import { + Tldraw, + createTLStore, + defaultShapeUtils, + getFromLocalStorage, + setInLocalStorage, + throttle, +} from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! @@ -20,7 +27,7 @@ export default function PersistenceExample() { setLoadingState({ status: 'loading' }) // Get persisted data from local storage - const persistedSnapshot = localStorage.getItem(PERSISTENCE_KEY) + const persistedSnapshot = getFromLocalStorage(PERSISTENCE_KEY) if (persistedSnapshot) { try { @@ -38,7 +45,7 @@ export default function PersistenceExample() { const cleanupFn = store.listen( throttle(() => { const snapshot = store.getSnapshot() - localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(snapshot)) + setInLocalStorage(PERSISTENCE_KEY, JSON.stringify(snapshot)) }, 500) ) diff --git a/package.json b/package.json index 4818aa2d5..4a60a15ec 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@sentry/cli": "^2.25.0", "@yarnpkg/types": "^4.0.0", "cross-env": "^7.0.3", + "eslint-plugin-no-storage": "^1.0.2", "purgecss": "^5.0.0", "svgo": "^3.0.2" } diff --git a/packages/editor/src/lib/config/TLSessionStateSnapshot.ts b/packages/editor/src/lib/config/TLSessionStateSnapshot.ts index a3315feda..4dee1821b 100644 --- a/packages/editor/src/lib/config/TLSessionStateSnapshot.ts +++ b/packages/editor/src/lib/config/TLSessionStateSnapshot.ts @@ -17,7 +17,12 @@ import { pageIdValidator, shapeIdValidator, } from '@tldraw/tlschema' -import { objectMapFromEntries } from '@tldraw/utils' +import { + deleteFromSessionStorage, + getFromSessionStorage, + objectMapFromEntries, + setInSessionStorage, +} from '@tldraw/utils' import { T } from '@tldraw/validate' import { uniqueId } from '../utils/uniqueId' @@ -26,7 +31,9 @@ const tabIdKey = 'TLDRAW_TAB_ID_v2' as const const window = globalThis.window as | { navigator: Window['navigator'] + // eslint-disable-next-line no-storage/no-browser-storage localStorage: Window['localStorage'] + // eslint-disable-next-line no-storage/no-browser-storage sessionStorage: Window['sessionStorage'] addEventListener: Window['addEventListener'] TLDRAW_TAB_ID_v2?: string @@ -51,7 +58,7 @@ function iOS() { * @public */ 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() : '' if (window) { window[tabIdKey] = TAB_ID @@ -62,14 +69,14 @@ if (window) { // in which case they'll have two tabs with the same UI state. // 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. - window.sessionStorage[tabIdKey] = TAB_ID + setInSessionStorage(tabIdKey, TAB_ID) } else { - delete window.sessionStorage[tabIdKey] + deleteFromSessionStorage(tabIdKey) } } window?.addEventListener('beforeunload', () => { - window.sessionStorage[tabIdKey] = TAB_ID + setInSessionStorage(tabIdKey, TAB_ID) }) const Versions = { diff --git a/packages/editor/src/lib/config/TLUserPreferences.ts b/packages/editor/src/lib/config/TLUserPreferences.ts index 8c6cf55ad..7ec23d13e 100644 --- a/packages/editor/src/lib/config/TLUserPreferences.ts +++ b/packages/editor/src/lib/config/TLUserPreferences.ts @@ -1,6 +1,7 @@ import { atom } from '@tldraw/state' import { defineMigrations, migrate } from '@tldraw/store' import { getDefaultTranslationLocale } from '@tldraw/tlschema' +import { getFromLocalStorage, setInLocalStorage } from '@tldraw/utils' import { T } from '@tldraw/validate' import { uniqueId } from '../utils/uniqueId' @@ -203,7 +204,7 @@ function loadUserPreferences(): TLUserPreferences { const userData = typeof window === 'undefined' ? null - : ((JSON.parse(window?.localStorage?.getItem(USER_DATA_KEY) || 'null') ?? + : ((JSON.parse(getFromLocalStorage(USER_DATA_KEY) || 'null') ?? null) as null | UserDataSnapshot) return migrateUserPreferences(userData) @@ -212,15 +213,13 @@ function loadUserPreferences(): TLUserPreferences { const globalUserPreferences = atom('globalUserData', null) function storeUserPreferences() { - if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.setItem( - USER_DATA_KEY, - JSON.stringify({ - version: userMigrations.currentVersion, - user: globalUserPreferences.get(), - }) - ) - } + setInLocalStorage( + USER_DATA_KEY, + JSON.stringify({ + version: userMigrations.currentVersion, + user: globalUserPreferences.get(), + }) + ) } /** @public */ diff --git a/packages/editor/src/lib/utils/debug-flags.ts b/packages/editor/src/lib/utils/debug-flags.ts index 2e11d3143..eb5e78b98 100644 --- a/packages/editor/src/lib/utils/debug-flags.ts +++ b/packages/editor/src/lib/utils/debug-flags.ts @@ -1,4 +1,5 @@ import { Atom, atom, react } from '@tldraw/state' +import { deleteFromSessionStorage, getFromSessionStorage, setInSessionStorage } from '@tldraw/utils' // --- 1. DEFINE --- // @@ -128,9 +129,9 @@ function createDebugValueBase(def: DebugFlagDef): DebugFlag { const currentValue = valueAtom.get() try { if (currentValue === defaultValue) { - window.sessionStorage.removeItem(`tldraw_debug:${def.name}`) + deleteFromSessionStorage(`tldraw_debug:${def.name}`) } else { - window.sessionStorage.setItem(`tldraw_debug:${def.name}`, JSON.stringify(currentValue)) + setInSessionStorage(`tldraw_debug:${def.name}`, JSON.stringify(currentValue)) } } catch { // not a big deal @@ -154,7 +155,7 @@ function createDebugValueBase(def: DebugFlagDef): DebugFlag { function getStoredInitialValue(name: string) { try { - return JSON.parse(window?.sessionStorage.getItem(`tldraw_debug:${name}`) ?? 'null') + return JSON.parse(getFromSessionStorage(`tldraw_debug:${name}`) ?? 'null') } catch (err) { return null } diff --git a/packages/editor/src/lib/utils/sync/hardReset.ts b/packages/editor/src/lib/utils/sync/hardReset.ts index 956cec159..9d9b4eac8 100644 --- a/packages/editor/src/lib/utils/sync/hardReset.ts +++ b/packages/editor/src/lib/utils/sync/hardReset.ts @@ -1,3 +1,4 @@ +import { clearLocalStorage } from '@tldraw/utils' import { deleteDB } from 'idb' import { getAllIndexDbNames } from './indexedDb' @@ -6,13 +7,15 @@ import { getAllIndexDbNames } from './indexedDb' * * @public */ export async function hardReset({ shouldReload = true } = {}) { - sessionStorage.clear() + clearLocalStorage() await Promise.all(getAllIndexDbNames().map((db) => deleteDB(db))) - localStorage.clear() + clearLocalStorage() if (shouldReload) { - window.location.reload() + if (typeof window !== 'undefined') { + window.location.reload() + } } } diff --git a/packages/editor/src/lib/utils/sync/indexedDb.test.ts b/packages/editor/src/lib/utils/sync/indexedDb.test.ts index 78c66a458..fe5533b94 100644 --- a/packages/editor/src/lib/utils/sync/indexedDb.test.ts +++ b/packages/editor/src/lib/utils/sync/indexedDb.test.ts @@ -9,6 +9,7 @@ import { const clearAll = async () => { const dbs = (indexedDB as any)._databases as Map dbs.clear() + // eslint-disable-next-line no-storage/no-browser-storage localStorage.clear() } diff --git a/packages/editor/src/lib/utils/sync/indexedDb.ts b/packages/editor/src/lib/utils/sync/indexedDb.ts index 39c215d54..b042a5f19 100644 --- a/packages/editor/src/lib/utils/sync/indexedDb.ts +++ b/packages/editor/src/lib/utils/sync/indexedDb.ts @@ -1,5 +1,6 @@ import { RecordsDiff, SerializedSchema, SerializedStore } from '@tldraw/store' import { TLRecord, TLStoreSchema } from '@tldraw/tlschema' +import { getFromLocalStorage, setInLocalStorage } from '@tldraw/utils' import { IDBPDatabase, openDB } from 'idb' import { TLSessionStateSnapshot } from '../../config/TLSessionStateSnapshot' @@ -221,7 +222,7 @@ async function pruneSessionState({ /** @internal */ export function getAllIndexDbNames(): string[] { - const result = JSON.parse(window?.localStorage.getItem(dbNameIndexKey) || '[]') ?? [] + const result = JSON.parse(getFromLocalStorage(dbNameIndexKey) || '[]') ?? [] if (!Array.isArray(result)) { return [] } @@ -231,5 +232,5 @@ export function getAllIndexDbNames(): string[] { function addDbName(name: string) { const all = new Set(getAllIndexDbNames()) all.add(name) - window?.localStorage.setItem(dbNameIndexKey, JSON.stringify([...all])) + setInLocalStorage(dbNameIndexKey, JSON.stringify([...all])) } diff --git a/packages/tldraw/src/lib/ui/hooks/useLocalStorageState.ts b/packages/tldraw/src/lib/ui/hooks/useLocalStorageState.ts index d7e6d8796..fd84aa122 100644 --- a/packages/tldraw/src/lib/ui/hooks/useLocalStorageState.ts +++ b/packages/tldraw/src/lib/ui/hooks/useLocalStorageState.ts @@ -1,3 +1,4 @@ +import { getFromLocalStorage, setInLocalStorage } from '@tldraw/editor' import React from 'react' /** @public */ @@ -5,7 +6,7 @@ export function useLocalStorageState(key: string, defaultValue: T) { const [state, setState] = React.useState(defaultValue) React.useLayoutEffect(() => { - const value = localStorage.getItem(key) + const value = getFromLocalStorage(key) if (value) { try { setState(JSON.parse(value)) @@ -19,7 +20,7 @@ export function useLocalStorageState(key: string, defaultValue: T) { (setter: T | ((value: T) => T)) => { setState((s) => { const value = typeof setter === 'function' ? (setter as any)(s) : setter - localStorage.setItem(key, JSON.stringify(value)) + setInLocalStorage(key, JSON.stringify(value)) return value }) }, diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index 63b0f9f19..4a9bb410c 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -19,6 +19,12 @@ export const assert: (value: unknown, message?: string) => asserts value; // @internal (undocumented) export const assertExists: (value: T, message?: string | undefined) => NonNullable; +// @public +export function clearLocalStorage(): void; + +// @public +export function clearSessionStorage(): void; + // @internal (undocumented) export function compact(arr: T[]): NonNullable[]; @@ -34,6 +40,12 @@ export function dedupe(input: T[], equals?: (a: any, b: any) => boolean): T[] // @public export function deepCopy(obj: T): T; +// @public +export function deleteFromLocalStorage(key: string): void; + +// @public +export function deleteFromSessionStorage(key: string): void; + // @public (undocumented) export type ErrorResult = { readonly ok: false; @@ -68,6 +80,12 @@ export function getErrorAnnotations(error: Error): ErrorAnnotations; // @public export function getFirstFromIterable(set: Map | Set): T; +// @public +export function getFromLocalStorage(key: string, defaultValue?: null): any; + +// @public +export function getFromSessionStorage(key: string, defaultValue?: null): any; + // @public export function getHashForBuffer(buffer: ArrayBuffer): string; @@ -273,6 +291,12 @@ export function rng(seed?: string): () => number; // @public export function rotateArray(arr: T[], offset: number): T[]; +// @public +export function setInLocalStorage(key: string, value: any): void; + +// @public +export function setInSessionStorage(key: string, value: any): void; + // @public (undocumented) export function sortById