diff --git a/explorer/src/lib/dusk/storage/__tests__/createStorage.spec.js b/explorer/src/lib/dusk/storage/__tests__/createStorage.spec.js deleted file mode 100644 index 7977390ab9..0000000000 --- a/explorer/src/lib/dusk/storage/__tests__/createStorage.spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { createStorage } from ".."; - -describe("createStorage", () => { - const serializer = vi.fn(JSON.stringify); - const deserializer = vi.fn(JSON.parse); - - afterEach(() => { - serializer.mockClear(); - deserializer.mockClear(); - }); - - for (const type of /** @type {StorageType[]} */ (["local", "session"])) { - const storage = createStorage(type, serializer, deserializer); - const systemStorage = globalThis[`${type}Storage`]; - const valueA = { a: 1, b: 2 }; - const serializedA = JSON.stringify(valueA); - const valueB = ["a", "b", "c", "d"]; - const serializedB = JSON.stringify(valueB); - - beforeEach(() => { - systemStorage.setItem("some-key", serializedA); - }); - - it("should expose a method to clear the created storage", async () => { - await expect(storage.clear()).resolves.toBe(undefined); - - expect(systemStorage.length).toBe(0); - }); - - it("should expose a method to retrieve a value from the created storage", async () => { - await expect(storage.getItem("some-key")).resolves.toStrictEqual(valueA); - - expect(deserializer).toHaveBeenCalledTimes(1); - expect(deserializer).toHaveBeenCalledWith(serializedA); - - await expect(storage.getItem("some-other-key")).resolves.toBe(null); - - expect(deserializer).toHaveBeenCalledTimes(1); - }); - - it("should expose a method to remove a value from the created storage", async () => { - await expect(storage.removeItem("some-key")).resolves.toBe(undefined); - - expect(systemStorage.getItem("some-key")).toBe(null); - }); - - it("should expose a method to set a value in the selected storage", async () => { - await expect(storage.setItem("some-key", valueB)).resolves.toBe( - undefined - ); - - expect(serializer).toHaveBeenCalledTimes(1); - expect(serializer).toHaveBeenCalledWith(valueB); - expect(systemStorage.getItem("some-key")).toBe(serializedB); - }); - - it("should return a rejected promise if any of the underlying storage method fails", async () => { - /** @type {Array} */ - const methods = ["clear", "getItem", "removeItem", "setItem"]; - const error = new Error("some error message"); - - for (const method of methods) { - const methodSpy = vi - .spyOn(Storage.prototype, method) - .mockImplementation(() => { - throw error; - }); - - // we don't care for correct parameters here - await expect( - storage[method]("some-key", "some value") - ).rejects.toStrictEqual(error); - - methodSpy.mockRestore(); - } - }); - } -}); diff --git a/explorer/src/lib/dusk/storage/createStorage.js b/explorer/src/lib/dusk/storage/createStorage.js deleted file mode 100644 index 05b0ffea25..0000000000 --- a/explorer/src/lib/dusk/storage/createStorage.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @param {StorageType} type - * @param {StorageSerializer} serializer - * @param {StorageDeserializer} deserializer - * @returns {DuskStorage} - */ -function createStorage(type, serializer, deserializer) { - const storage = type === "local" ? localStorage : sessionStorage; - - return { - async clear() { - return storage.clear(); - }, - - async getItem(key) { - const value = storage.getItem(key); - - return value === null ? null : deserializer(value); - }, - - async removeItem(key) { - return storage.removeItem(key); - }, - - async setItem(key, value) { - return storage.setItem(key, serializer(value)); - }, - }; -} - -export default createStorage; diff --git a/explorer/src/lib/dusk/storage/index.js b/explorer/src/lib/dusk/storage/index.js deleted file mode 100644 index 8f017f043a..0000000000 --- a/explorer/src/lib/dusk/storage/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as createStorage } from "./createStorage"; diff --git a/explorer/src/lib/dusk/storage/storage.d.ts b/explorer/src/lib/dusk/storage/storage.d.ts deleted file mode 100644 index ef83ee194c..0000000000 --- a/explorer/src/lib/dusk/storage/storage.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -type StorageType = "local" | "session"; - -type StorageSerializer = (value: any) => string; - -type StorageDeserializer = (value: string) => any; - -type DuskStorage = { - clear: () => Promise; - getItem: (key: string) => Promise; - removeItem: (key: string) => Promise; - setItem: (key: string, value: any) => Promise; -}; diff --git a/explorer/src/lib/market-data/MarketDataInfo.js b/explorer/src/lib/market-data/MarketDataInfo.js new file mode 100644 index 0000000000..9c9a2f9bae --- /dev/null +++ b/explorer/src/lib/market-data/MarketDataInfo.js @@ -0,0 +1,53 @@ +import { compose, updateKey } from "lamb"; + +/** @returns {MarketData} */ +const jsonToMarketData = compose( + updateKey("lastUpdate", (v) => new Date(v)), + JSON.parse +); + +class MarketDataInfo { + /** @type {MarketData} */ + #data; + + /** @type {Date} */ + #lastUpdate; + + /** @param {string} json */ + static parse(json) { + const { data, lastUpdate } = jsonToMarketData(json); + + return new MarketDataInfo(data, lastUpdate); + } + + /** + * + * @param {MarketData} data + * @param {Date} lastUpdate + */ + constructor(data, lastUpdate) { + this.#data = data; + this.#lastUpdate = lastUpdate; + } + + get data() { + return this.#data; + } + + get lastUpdate() { + return this.#lastUpdate; + } + + toJSON() { + return JSON.stringify(this.toStorageData()); + } + + toStorageData() { + return { + data: this.#data, + lastUpdate: this.#lastUpdate, + }; + } +} + +export default MarketDataInfo; diff --git a/explorer/src/lib/market-data/__tests__/MarketDataInfo.spec.js b/explorer/src/lib/market-data/__tests__/MarketDataInfo.spec.js new file mode 100644 index 0000000000..91f5905948 --- /dev/null +++ b/explorer/src/lib/market-data/__tests__/MarketDataInfo.spec.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { MarketDataInfo } from ".."; + +describe("MarketDataInfo", () => { + const marketData = { + currentPrice: {}, + marketCap: {}, + }; + const now = new Date(); + const marketDataInfo = new MarketDataInfo(marketData, now); + + it("should expose the market data and the last update as read-only props", () => { + expect(() => { + // @ts-expect-error + marketDataInfo.data = {}; + }).toThrow(); + + expect(() => { + // @ts-expect-error + marketDataInfo.lastUpdate = new Date(2010, 3, 4); + }).toThrow(); + + expect(marketDataInfo.data).toStrictEqual(marketData); + expect(marketDataInfo.lastUpdate).toBe(now); + }); + + it("should expose a method to convert the instance to JSON and a static method to parse it", () => { + const newMarketDataInfo = MarketDataInfo.parse(marketDataInfo.toJSON()); + + expect(newMarketDataInfo).toStrictEqual(marketDataInfo); + }); + + it("should expose a method to convert the data to the format used for storage", () => { + expect(marketDataInfo.toStorageData()).toStrictEqual({ + data: marketData, + lastUpdate: now, + }); + }); +}); diff --git a/explorer/src/lib/market-data/index.js b/explorer/src/lib/market-data/index.js new file mode 100644 index 0000000000..e25a678842 --- /dev/null +++ b/explorer/src/lib/market-data/index.js @@ -0,0 +1 @@ +export { default as MarketDataInfo } from "./MarketDataInfo"; diff --git a/explorer/src/lib/market-data/market-data.d.ts b/explorer/src/lib/market-data/market-data.d.ts index 8804a1875b..a210d47d08 100644 --- a/explorer/src/lib/market-data/market-data.d.ts +++ b/explorer/src/lib/market-data/market-data.d.ts @@ -2,3 +2,8 @@ type MarketData = { currentPrice: Record; marketCap: Record; }; + +type MarketDataStorage = { + data: MarketData; + lastUpdate: Date; +}; diff --git a/explorer/src/lib/services/__tests__/marketDataStorage.spec.js b/explorer/src/lib/services/__tests__/marketDataStorage.spec.js deleted file mode 100644 index 0cf91690e9..0000000000 --- a/explorer/src/lib/services/__tests__/marketDataStorage.spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent } from "@testing-library/svelte"; - -import { marketDataStorage } from ".."; - -describe("marketDataStorage", () => { - const marketData = { data: { a: 1 }, lastUpdate: new Date() }; - - beforeEach(() => { - localStorage.setItem("market-data", JSON.stringify(marketData)); - }); - - it("should expose a method to clear the market data storage", async () => { - localStorage.setItem("some-key", "some value"); - - await expect(marketDataStorage.clear()).resolves.toBe(undefined); - - expect(localStorage.getItem("market-data")).toBeNull(); - expect(localStorage.getItem("some-key")).toBe("some value"); - }); - - it("should expose a method to retrieve market data from the storage", async () => { - await expect(marketDataStorage.get()).resolves.toStrictEqual(marketData); - }); - - it("should expose a method to set the data in the storage", async () => { - const newData = { data: { b: 2 }, lastUpdate: new Date() }; - - // @ts-expect-error - await expect(marketDataStorage.set(newData)).resolves.toBe(undefined); - await expect(marketDataStorage.get()).resolves.toStrictEqual(newData); - - expect(localStorage.getItem("market-data")).toBe(JSON.stringify(newData)); - }); - - it("should expose a method that allows to set a listener for storage events and returns a function to remove the listener", async () => { - const eventA = new StorageEvent("storage", { key: "market-data" }); - const eventB = new StorageEvent("storage", { key: "some-other-key" }); - const listener = vi.fn(); - const removeListener = marketDataStorage.onChange(listener); - - await fireEvent(window, eventA); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(eventA); - - await fireEvent(window, eventB); - - expect(listener).toHaveBeenCalledTimes(1); - - removeListener(); - - await fireEvent(window, eventA); - - expect(listener).toHaveBeenCalledTimes(1); - }); -}); diff --git a/explorer/src/lib/services/index.js b/explorer/src/lib/services/index.js index 8206e86b6d..cdb00d2b84 100644 --- a/explorer/src/lib/services/index.js +++ b/explorer/src/lib/services/index.js @@ -1,2 +1 @@ export { default as duskAPI } from "./duskAPI"; -export { default as marketDataStorage } from "./marketDataStorage"; diff --git a/explorer/src/lib/services/marketDataStorage.js b/explorer/src/lib/services/marketDataStorage.js deleted file mode 100644 index 760b306b32..0000000000 --- a/explorer/src/lib/services/marketDataStorage.js +++ /dev/null @@ -1,46 +0,0 @@ -import { createStorage } from "$lib/dusk/storage"; - -/** - * @param {string} key - * @param {any} value - */ -const reviver = (key, value) => - key === "lastUpdate" ? new Date(value) : value; -const storage = createStorage("local", JSON.stringify, (value) => - JSON.parse(value, reviver) -); -const key = "market-data"; - -export default { - clear() { - return storage.removeItem(key); - }, - - /** @returns {Promise} */ - get() { - return storage.getItem(key); - }, - - /** - * - * @param {(evt: StorageEvent) => void} listener - * @returns {(() => void)} The function to remove the listener. - */ - onChange(listener) { - /** @param {StorageEvent} evt */ - const handleStorageChange = (evt) => { - if (evt.key === key) { - listener(evt); - } - }; - - window.addEventListener("storage", handleStorageChange); - - return () => window.removeEventListener("storage", handleStorageChange); - }, - - /** @param {MarketDataStorage} value */ - set(value) { - return storage.setItem(key, value); - }, -}; diff --git a/explorer/src/lib/services/services.d.ts b/explorer/src/lib/services/services.d.ts deleted file mode 100644 index 8e0dc2cb67..0000000000 --- a/explorer/src/lib/services/services.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -type MarketDataStorage = { - data: MarketData; - lastUpdate: Date; -}; diff --git a/explorer/src/lib/stores/__tests__/marketDataStore.spec.js b/explorer/src/lib/stores/__tests__/marketDataStore.spec.js index 0473c05fae..8ae391a580 100644 --- a/explorer/src/lib/stores/__tests__/marketDataStore.spec.js +++ b/explorer/src/lib/stores/__tests__/marketDataStore.spec.js @@ -10,7 +10,7 @@ import { import { get } from "svelte/store"; import { rejectAfter, resolveAfter } from "$lib/dusk/promise"; -import { duskAPI, marketDataStorage } from "$lib/services"; +import { duskAPI } from "$lib/services"; /** * We don't import from "..", because we don't want @@ -41,6 +41,7 @@ vi.mock("$lib/services", async (importOriginal) => ({ })); describe("marketDataStore", async () => { + const storeKey = "market-data"; const { marketDataFetchInterval } = get(appStore); const fakeMarketDataB = { data: "B" }; @@ -54,7 +55,7 @@ describe("marketDataStore", async () => { vi.clearAllTimers(); vi.mocked(duskAPI.getMarketData).mockClear(); - await marketDataStorage.clear(); + localStorage.clear(); marketDataStore = (await import("../marketDataStore")).default; }); @@ -233,6 +234,8 @@ describe("marketDataStore", async () => { }); describe("Handling local storage", () => { + const consoleErrorSpy = vi.spyOn(console, "error"); + beforeEach(() => { vi.resetModules(); vi.clearAllTimers(); @@ -240,35 +243,51 @@ describe("marketDataStore", async () => { }); afterEach(() => { - marketDataStorage.clear(); + consoleErrorSpy.mockReset(); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); }); it("should use data in local storage to initialize the store if present", async () => { const storedData = { data: "C", - lastUpdate: new Date(2024, 1, 15), + lastUpdate: new Date(Date.now()), }; - // @ts-expect-error we don't care to pass the correct type - marketDataStorage.set(storedData); + localStorage.setItem(storeKey, JSON.stringify(storedData)); marketDataStore = (await import("../marketDataStore")).default; expect(get(marketDataStore)).toStrictEqual({ error: null, - isLoading: true, + isLoading: false, ...storedData, }); }); - it("should ignore errors while retrieving local storage data and initialize the store as usual", async () => { - const getDataSpy = vi - .spyOn(marketDataStorage, "get") - .mockRejectedValue(new Error("some erro")); + it("should ignore errors while retrieving local storage data and initialize the store as usual, after logging them in the console", async () => { + const FakeMarketDataInfo = () => {}; + + FakeMarketDataInfo.parse = () => { + throw new Error("some error"); + }; + + vi.doMock("$lib/market-data", async (importOriginal) => ({ + .../** @type {typeof import("$lib/market-data")} */ ( + await importOriginal() + ), + MarketDataInfo: FakeMarketDataInfo, + })); + localStorage.setItem(storeKey, "{}"); + + // we don't want to see our fake error in the console + consoleErrorSpy.mockImplementationOnce(() => {}); marketDataStore = (await import("../marketDataStore")).default; - expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); expect(get(marketDataStore)).toStrictEqual({ data: null, error: null, @@ -276,17 +295,17 @@ describe("marketDataStore", async () => { lastUpdate: null, }); - getDataSpy.mockRestore(); + vi.doUnmock("$lib/market-data"); }); it("should start the polling as usual if there's data stored, but it's stale", async () => { - const storedData = { - data: "D", - lastUpdate: new Date(Date.now() - marketDataFetchInterval - 1), - }; - - // @ts-expect-error we don't care to pass the correct type - marketDataStorage.set(storedData); + localStorage.setItem( + storeKey, + JSON.stringify({ + data: "D", + lastUpdate: new Date(Date.now() - marketDataFetchInterval - 1), + }) + ); marketDataStore = (await import("../marketDataStore")).default; @@ -300,13 +319,14 @@ describe("marketDataStore", async () => { it("should delay the polling if there's data stored and it's not stale", async () => { const offset = Math.floor(marketDataFetchInterval / 2); const expectedDelay = marketDataFetchInterval - offset; - const storedData = { - data: "D", - lastUpdate: new Date(Date.now() - marketDataFetchInterval + offset), - }; - // @ts-expect-error we don't care to pass the correct type - marketDataStorage.set(storedData); + localStorage.setItem( + storeKey, + JSON.stringify({ + data: "D", + lastUpdate: new Date(Date.now() - marketDataFetchInterval + offset), + }) + ); marketDataStore = (await import("../marketDataStore")).default; @@ -326,8 +346,6 @@ describe("marketDataStore", async () => { }); it("should save the received data in local storage if the request has new data", async () => { - const setDataSpy = vi.spyOn(marketDataStorage, "set"); - marketDataStore = (await import("../marketDataStore")).default; await vi.advanceTimersByTimeAsync(settleTime); @@ -342,22 +360,11 @@ describe("marketDataStore", async () => { isLoading: false, }; + expect(duskAPI.getMarketData).toHaveBeenCalledTimes(1); expect(get(marketDataStore)).toStrictEqual(expectedStore); - - await expect(marketDataStorage.get()).resolves.toStrictEqual( - expectedStorage - ); - - await vi.advanceTimersByTimeAsync(marketDataFetchInterval + settleTime); - - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(duskAPI.getMarketData).toHaveBeenCalledTimes(2); - expect(get(marketDataStore)).toStrictEqual(expectedStore); - await expect(marketDataStorage.get()).resolves.toStrictEqual( - expectedStorage + expect(localStorage.getItem(storeKey)).toStrictEqual( + JSON.stringify(expectedStorage) ); - - setDataSpy.mockRestore(); }); it("should leave the local storage as it is if the market data request ends with an error", async () => { @@ -367,13 +374,10 @@ describe("marketDataStore", async () => { rejectAfter(settleTime, error) ); - const setDataSpy = vi.spyOn(marketDataStorage, "set"); - marketDataStore = (await import("../marketDataStore")).default; await vi.advanceTimersByTimeAsync(settleTime); - expect(setDataSpy).not.toHaveBeenCalled(); expect(get(marketDataStore)).toStrictEqual({ data: null, error, @@ -381,15 +385,18 @@ describe("marketDataStore", async () => { lastUpdate: null, }); - await expect(marketDataStorage.get()).resolves.toBeNull(); - - setDataSpy.mockRestore(); + expect(localStorage.getItem(storeKey)).toBeNull(); }); it("should ignore errors while writing to the storage and continue polling as usual", async () => { const setDataSpy = vi - .spyOn(marketDataStorage, "set") - .mockRejectedValue(new Error("some error")); + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("some error"); + }); + + // we don't want to see our fake error in the console + consoleErrorSpy.mockImplementationOnce(() => {}); marketDataStore = (await import("../marketDataStore")).default; @@ -403,7 +410,7 @@ describe("marketDataStore", async () => { lastUpdate: new Date(), }); - await expect(marketDataStorage.get()).resolves.toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); setDataSpy.mockRestore(); }); diff --git a/explorer/src/lib/stores/marketDataStore.js b/explorer/src/lib/stores/marketDataStore.js index 23134947f8..5481b413b8 100644 --- a/explorer/src/lib/stores/marketDataStore.js +++ b/explorer/src/lib/stores/marketDataStore.js @@ -1,27 +1,54 @@ import { derived, get } from "svelte/store"; -import { always, pickIn } from "lamb"; import { createPollingDataStore } from "$lib/dusk/svelte-stores"; -import { duskAPI, marketDataStorage } from "$lib/services"; +import { MarketDataInfo } from "$lib/market-data"; +import { duskAPI } from "$lib/services"; import appStore from "./appStore"; +const storeKey = "market-data"; + +/** + * @param {"reading" | "storing"} action + * @param {unknown} err + */ +const logStoreError = (action, err) => + /* eslint-disable-next-line no-console */ + console.error(`Error while ${action} market data: %s`, err); + +/** @type {() => MarketDataStorage | null} */ +function getStorage() { + try { + const storedData = localStorage.getItem(storeKey); + + return storedData ? MarketDataInfo.parse(storedData).toStorageData() : null; + } catch (err) { + logStoreError("reading", err); + + return null; + } +} + +/** @param {MarketDataInfo} info */ +function setStorage(info) { + try { + localStorage.setItem(storeKey, info.toJSON()); + } catch (err) { + logStoreError("storing", err); + } +} + const fetchInterval = get(appStore).marketDataFetchInterval; const pollingDataStore = createPollingDataStore( duskAPI.getMarketData, fetchInterval ); -const getStorage = () => marketDataStorage.get().catch(always(null)); - -/** @param {MarketDataStorage} value */ -const setStorage = (value) => - marketDataStorage.set(value).catch(always(undefined)); /** @type {MarketDataStoreContent} */ const initialState = { ...get(pollingDataStore), lastUpdate: null, - ...(await getStorage()), + ...getStorage(), }; const marketDataStore = derived( @@ -43,8 +70,9 @@ const marketDataStore = derived( if (hasNewData) { setStorage( - /** @type {MarketDataStorage} */ ( - pickIn(newStore, ["data", "lastUpdate"]) + new MarketDataInfo( + newStore.data, + /** @type {Date}*/ (newStore.lastUpdate) ) ); } diff --git a/explorer/vite.config.js b/explorer/vite.config.js index 2598fe0a36..ff35e4734d 100644 --- a/explorer/vite.config.js +++ b/explorer/vite.config.js @@ -17,9 +17,6 @@ export default defineConfig(({ mode }) => { process.env.PUBLIC_APP_VERSION = APP_VERSION; return { - build: { - target: ["es2022", "edge89", "firefox89", "chrome89", "safari15"], - }, define: { CONFIG: { LOCAL_STORAGE_APP_KEY: process.env.npm_package_name,