Skip to content
This repository has been archived by the owner on Nov 10, 2023. It is now read-only.

Enhancement: cache owned Safes in localStorage #3066

Merged
merged 6 commits into from
Dec 2, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,6 @@
"framer-motion": "^4.1.17",
"fuse.js": "^6.4.6",
"history": "4.10.1",
"immortal-db": "^1.1.0",
"immutable": "4.0.0-rc.12",
"js-cookie": "^3.0.0",
"lodash": "^4.17.21",
Expand Down
2 changes: 1 addition & 1 deletion src/components/GlobalErrorBoundary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const LinkContent = styled.div`

// When loading app during release, chunk load failure may occur
export const handleChunkError = (error: Error): boolean => {
const LAST_CHUNK_FAILURE_RELOAD_KEY = 'SAFE__lastChunkFailureReload'
const LAST_CHUNK_FAILURE_RELOAD_KEY = 'lastChunkFailureReload'
const MIN_RELOAD_TIME = 10_000

const chunkFailedMessage = /Loading chunk [\d]+ failed/
Expand Down
2 changes: 1 addition & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const isNetworkId = (id: unknown): id is ETHEREUM_NETWORK => {
return Object.values(ETHEREUM_NETWORK).some((network) => network === id)
}

export const NETWORK_ID_KEY = 'SAFE__networkId'
export const NETWORK_ID_KEY = 'networkId'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the chains migration, I am trying to move all networkId references to chainId. Do you think it's worthwhile including this here too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope, I'm just removing the prefix here.

export const getInitialNetworkId = (): ETHEREUM_NETWORK => {
const { pathname } = window.location

Expand Down
3 changes: 0 additions & 3 deletions src/logic/exceptions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ enum ErrorCodes {
_701 = '701: Failed to save a localStorage item',
_702 = '702: Failed to remove a localStorage item',
_703 = '703: Error migrating localStorage',
_704 = '704: Failed to load a sessionStorage item',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have you removed these? The /load and /open routes use the sessionStorage to retrieve the current chainId.

Copy link
Member Author

@katspaugh katspaugh Nov 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know but there's little value in having two sets of error messages depending on which storage this is. Besides, we're not even tracking them. I can change localStorage -> storage.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I would suggest generalising 701-703 then.

_705 = '705: Failed to save a sessionStorage item',
_706 = '706: Failed to remove a sessionStorage item',
_800 = '800: Safe creation tx failed',
_801 = '801: Failed to send a tx with a spending limit',
_802 = '802: Error submitting a transaction, safeAddress not found',
Expand Down
25 changes: 15 additions & 10 deletions src/logic/safe/hooks/useOwnerSafes.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { useState, useEffect } from 'react'
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { fetchSafesByOwner } from 'src/logic/safe/api/fetchSafesByOwner'
import { Errors, logError } from 'src/logic/exceptions/CodedException'
import { currentChainId } from 'src/logic/config/store/selectors'
import useStoredState from 'src/utils/storage/useStoredState'

const cache: Record<string, Record<string, string[]>> = {}
type OwnedSafesCache = Record<string, Record<string, string[]>>

const storageKey = 'ownedSafes'

const useOwnerSafes = (): Record<string, string[]> => {
const connectedWalletAddress = useSelector(userAccountSelector)
const chainId = useSelector(currentChainId)
const [ownerSafes, setOwnerSafes] = useState<Record<string, string[]>>(cache[connectedWalletAddress] || {})
const [cache = {}, setCache] = useStoredState<OwnedSafesCache>(storageKey)
const ownerSafes = cache[connectedWalletAddress] || {}

useEffect(() => {
if (!connectedWalletAddress) {
Expand All @@ -21,19 +25,20 @@ const useOwnerSafes = (): Record<string, string[]> => {
try {
const safes = await fetchSafesByOwner(connectedWalletAddress)

cache[connectedWalletAddress] = {
...(cache[connectedWalletAddress] || {}),
[chainId]: safes,
}

setOwnerSafes(cache[connectedWalletAddress])
setCache((prev = {}) => ({
...prev,
[connectedWalletAddress]: {
...(prev[connectedWalletAddress] || {}),
[chainId]: safes,
},
}))
} catch (err) {
logError(Errors._610, err.message)
}
}

load()
}, [chainId, connectedWalletAddress, setOwnerSafes])
}, [chainId, connectedWalletAddress, setCache])

return ownerSafes
}
Expand Down
61 changes: 61 additions & 0 deletions src/utils/storage/Storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { logError, Errors } from 'src/logic/exceptions/CodedException'

type BrowserStorage = typeof localStorage | typeof sessionStorage

const DEFAULT_PREFIX = 'SAFE__'

class Storage {
private prefix: string
private storage: BrowserStorage

constructor(storage: BrowserStorage, prefix = DEFAULT_PREFIX) {
this.prefix = prefix
this.storage = storage
}

private prefixKey = (key: string): string => {
return `${this.prefix}${key}`
}

public getItem = <T>(key: string): T | undefined => {
const fullKey = this.prefixKey(key)
let saved: string | null = null
try {
saved = this.storage.getItem(fullKey)
} catch (err) {
logError(Errors._700, `key ${key} – ${err.message}`)
}

let data = undefined
if (saved) {
try {
data = JSON.parse(saved)
} catch (err) {
logError(Errors._700, `key ${key} – ${err.message}`)
data = undefined
}
}

return data as unknown as T | undefined
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just return on line 32/35? You won't need to cast to both types, only line 32 to T.

}

public setItem = <T>(key: string, item: T): void => {
const fullKey = this.prefixKey(key)
try {
this.storage.setItem(fullKey, JSON.stringify(item))
} catch (err) {
logError(Errors._701, `key ${key} – ${err.message}`)
}
}

public removeItem = (key: string): void => {
const fullKey = this.prefixKey(key)
try {
this.storage.removeItem(fullKey)
} catch (err) {
logError(Errors._702, `key ${key} – ${err.message}`)
}
}
}

export default Storage
33 changes: 33 additions & 0 deletions src/utils/storage/__tests__/local.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import local from '../local'

describe('local storage', () => {
const { getItem, setItem } = local

beforeAll(() => {
window.localStorage.clear()
})

afterEach(() => {
window.localStorage.clear()
})

describe('getItem', () => {
it('returns a parsed value', () => {
const stringifiedValue = JSON.stringify({ test: 'value' })
window.localStorage.setItem('SAFE__test', stringifiedValue)

expect(getItem('test')).toStrictEqual({ test: 'value' })
})
it("returns undefined the key doesn't exist", () => {
expect(getItem('notAKey')).toBe(undefined)
})
})

describe('setItem', () => {
it('saves a stringified value', () => {
setItem('test', true)
expect(getItem('test')).toBe(true)
expect(window.localStorage.getItem('SAFE__test')).toBe('true')
})
})
})
52 changes: 20 additions & 32 deletions src/utils/storage/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,35 @@
import { loadFromSessionStorage, removeFromSessionStorage, saveToSessionStorage } from '../session'

describe('loadFromSessionStorage', () => {
describe('session storage', () => {
beforeEach(() => {
window.sessionStorage.clear()
})
it('returns a parsed value', () => {
const stringifiedValue = JSON.stringify({ test: 'value' })
window.sessionStorage.setItem('test', stringifiedValue)

expect(loadFromSessionStorage('test')).toStrictEqual({ test: 'value' })
})
it("returns undefined the key doesn't exist", () => {
expect(loadFromSessionStorage('notAKey')).toBe(undefined)
})
})
describe('loadFromSessionStorage', () => {
it('returns a parsed value', () => {
const stringifiedValue = JSON.stringify({ test: 'value' })
window.sessionStorage.setItem('SAFE__test', stringifiedValue)

describe('saveToSessionStorage', () => {
beforeEach(() => {
window.sessionStorage.clear()
expect(loadFromSessionStorage('test')).toStrictEqual({ test: 'value' })
})
it("returns undefined the key doesn't exist", () => {
expect(loadFromSessionStorage('notAKey')).toBe(undefined)
})
})

it('saves a stringified value', () => {
saveToSessionStorage('test', true)
describe('saveToSessionStorage', () => {
it('saves a stringified value', () => {
saveToSessionStorage('test', true)

expect(window.sessionStorage?.test).toBe('true')
})
})

describe('removeFromSessionStorage', () => {
beforeEach(() => {
window.sessionStorage.clear()
expect(window.sessionStorage?.SAFE__test).toBe('true')
})
})

it('removes the key', () => {
Object.defineProperty(window, 'sessionStorage', {
writable: true,
value: {
removeItem: jest.fn(),
},
describe('removeFromSessionStorage', () => {
it('removes the key', () => {
window.sessionStorage.setItem('SAFE__test', '1')
removeFromSessionStorage('test')
expect(window.sessionStorage.getItem('SAFE__test')).toBe(null)
})

removeFromSessionStorage('test')

expect(window.sessionStorage.removeItem).toHaveBeenCalledWith('test')
})
})
49 changes: 11 additions & 38 deletions src/utils/storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,27 @@
import { ImmortalStorage, LocalStorageStore } from 'immortal-db'

import { getNetworkName } from 'src/config'
import { Errors, logError } from 'src/logic/exceptions/CodedException'
import Storage from './Storage'

const stores = [LocalStorageStore]
export const storage = new ImmortalStorage(stores)
export const storage = new Storage(window.localStorage, '')

// We need this to update on run time depending on selected network name
export const getStoragePrefix = (networkName = getNetworkName()): string => `v2_${networkName}`
// We need this to update the key in runtime depending on selected network name
export const getStoragePrefix = (networkName = getNetworkName()): string => `_immortal|v2_${networkName}__`

export const loadFromStorage = async <T = unknown>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need this still be async?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code that uses it still expects a promise.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you not think it's worthwhile changing it? Or create a new issue to do that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should eventually un-async all that code. 👍
Created #3073

key: string,
prefix = getStoragePrefix(),
): Promise<T | undefined> => {
try {
const stringifiedValue = await storage.get(`${prefix}__${key}`)
if (stringifiedValue === null || stringifiedValue === undefined) {
return undefined
}

return JSON.parse(stringifiedValue)
} catch (err) {
logError(Errors._700, `key ${key} – ${err.message}`)
return undefined
}
return storage.getItem(`${prefix}${key}`)
}

export const saveToStorage = async <T = unknown>(key: string, value: T): Promise<void> => {
try {
const stringifiedValue = JSON.stringify(value)
await storage.set(`${getStoragePrefix()}__${key}`, stringifiedValue)
} catch (err) {
logError(Errors._701, `key ${key} – ${err.message}`)
}
storage.setItem<T>(`${getStoragePrefix()}${key}`, value)
}

// This function is only meant to be used in L2-UX migration to gather information from other networks
export const saveMigratedKeyToStorage = async <T = unknown>(key: string, value: T): Promise<void> => {
try {
const stringifiedValue = JSON.stringify(value)
await storage.set(key, stringifiedValue)
} catch (err) {
logError(Errors._703, `key ${key} – ${err.message}`)
}
export const removeFromStorage = async (key: string): Promise<void> => {
storage.removeItem(`${getStoragePrefix()}${key}`)
}

export const removeFromStorage = async (key: string): Promise<void> => {
try {
await storage.remove(`${getStoragePrefix()}__${key}`)
} catch (err) {
logError(Errors._702, `key ${key} – ${err.message}`)
}
// This function is only meant to be used in L2-UX migration to gather information from other networks
export const saveMigratedKeyToStorage = async <T = unknown>(key: string, value: T): Promise<void> => {
storage.setItem(key, value)
}
5 changes: 5 additions & 0 deletions src/utils/storage/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Storage from './Storage'

const local = new Storage(window.localStorage)

export default local
38 changes: 8 additions & 30 deletions src/utils/storage/session.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,11 @@
import { logError, Errors } from 'src/logic/exceptions/CodedException'
import Storage from './Storage'

export const loadFromSessionStorage = <T = unknown>(key: string): T | undefined => {
try {
const stringifiedValue = sessionStorage.getItem(key)
const session = new Storage(window.sessionStorage)

if (stringifiedValue === null || stringifiedValue === undefined) {
return undefined
}
export default session

return JSON.parse(stringifiedValue)
} catch (err) {
logError(Errors._704, `key ${key} – ${err.message}`)
}
}

export const saveToSessionStorage = <T = unknown>(key: string, value: T): void => {
try {
const stringifiedValue = JSON.stringify(value)

sessionStorage.setItem(key, stringifiedValue)
} catch (err) {
logError(Errors._705, `key ${key} – ${err.message}`)
}
}

export const removeFromSessionStorage = (key: string): void => {
try {
sessionStorage.removeItem(key)
} catch (err) {
logError(Errors._706, `key ${key} – ${err.message}`)
}
}
export const {
getItem: loadFromSessionStorage,
setItem: saveToSessionStorage,
removeItem: removeFromSessionStorage,
} = session
19 changes: 19 additions & 0 deletions src/utils/storage/useStoredState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useState, useEffect } from 'react'
import local from './local'

const useStoredState = <T>(key: string): [T | undefined, React.Dispatch<React.SetStateAction<T>>] => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be name something like useLocalState just to be clear that it isn't involving the sessionStorage.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Local" state, to me, doesn't imply that it's stored in a permanent place. I would keep this name.

const [cache, setCache] = useState<T>()

useEffect(() => {
const saved = local.getItem<T>(key)
setCache(saved)
}, [key, setCache])

useEffect(() => {
local.setItem<T | undefined>(key, cache)
}, [key, cache])

return [cache, setCache]
}

export default useStoredState
Loading