Skip to content

Commit

Permalink
fix: test for localStorage before using it and enable it to be shimme…
Browse files Browse the repository at this point in the history
…d with a Storage-compatible interface

Fixes #541
  • Loading branch information
JasonBerry committed Aug 13, 2024
1 parent 6a89eab commit 86a1e33
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 49 deletions.
1 change: 1 addition & 0 deletions index-node.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ module.exports = aa.default;
module.exports.createInsightsClient = aa.createInsightsClient;
module.exports.getRequesterForNode = aa.getRequesterForNode;
module.exports.AlgoliaAnalytics = aa.AlgoliaAnalytics;
module.exports.LocalStorage = aa.LocalStorage;
module.exports.getFunctionalInterface = aa.getFunctionalInterface;
module.exports.processQueue = aa.processQueue;
2 changes: 2 additions & 0 deletions lib/entry-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { getFunctionalInterface } from "./_getFunctionalInterface";
import { processQueue } from "./_processQueue";
import AlgoliaAnalytics from "./insights";
import { getRequesterForBrowser } from "./utils/getRequesterForBrowser";
import { LocalStorage } from "./utils/localStorage";

export {
createInsightsClient,
getRequesterForBrowser,
AlgoliaAnalytics,
LocalStorage,
getFunctionalInterface,
processQueue
};
Expand Down
2 changes: 2 additions & 0 deletions lib/entry-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { getFunctionalInterface } from "./_getFunctionalInterface";
import { processQueue } from "./_processQueue";
import AlgoliaAnalytics from "./insights";
import { getRequesterForNode } from "./utils/getRequesterForNode";
import { LocalStorage } from "./utils/localStorage";

export {
createInsightsClient,
getRequesterForNode,
AlgoliaAnalytics,
LocalStorage,
getFunctionalInterface,
processQueue
};
Expand Down
179 changes: 135 additions & 44 deletions lib/utils/__tests__/localStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { LocalStorage } from "../localStorage";

const setItemMock = jest.spyOn(Object.getPrototypeOf(localStorage), "setItem");
Expand All @@ -7,71 +8,161 @@ const consoleErrorSpy = jest

describe("LocalStorage", () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});

it("gets a value from localStorage", () => {
const key = "testKey";
const value = "testValue";
localStorage.setItem(key, JSON.stringify(value));
describe("when localStorage is defined", () => {
beforeEach(() => {
localStorage.clear();
});

const result = LocalStorage.get(key);
it("gets a value from localStorage", () => {
const key = "testKey";
const value = "testValue";
localStorage.setItem(key, JSON.stringify(value));

expect(result).toEqual(value);
});
const result = LocalStorage.get(key);

it("returns null if the key is not found", () => {
const key = "nonExistentKey";
expect(result).toEqual(value);
});

const result = LocalStorage.get(key);
it("returns null if the key is not found", () => {
const key = "nonExistentKey";

expect(result).toBeNull();
});
const result = LocalStorage.get(key);

it("returns null if the value cannot be parsed as JSON", () => {
const key = "testKey";
const value = "invalidJSON";
localStorage.setItem(key, value);
expect(result).toBeNull();
});

const result = LocalStorage.get(key);
it("returns null if the value cannot be parsed as JSON", () => {
const key = "testKey";
const value = "invalidJSON";
localStorage.setItem(key, value);

expect(result).toBeNull();
});
const result = LocalStorage.get(key);

it("sets a value in localStorage", () => {
const key = "testKey";
const value = "testValue";
expect(result).toBeNull();
});

LocalStorage.set(key, value);
it("sets a value in localStorage", () => {
const key = "testKey";
const value = "testValue";

const result = localStorage.getItem(key);
expect(result).toEqual(JSON.stringify(value));
});
LocalStorage.set(key, value);

it("catches the error and logs an error if the storage is full", () => {
const key = "testKey";
const value = "testValue";
const result = localStorage.getItem(key);
expect(result).toEqual(JSON.stringify(value));
});

it("catches the error and logs an error if the storage is full", () => {
const key = "testKey";
const value = "testValue";

// Emulate filling up the storage
setItemMock.mockImplementationOnce(() => {
throw new Error("pretend QuotaExceededError");
});

LocalStorage.set(key, value);

// Emulate filling up the storage
setItemMock.mockImplementationOnce(() => {
throw new Error("pretend QuotaExceededError");
const result = localStorage.getItem(key);
expect(result).toBeNull();
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});

LocalStorage.set(key, value);
it("removes a value from localStorage", () => {
const key = "testKey";
const value = "testValue";
localStorage.setItem(key, JSON.stringify(value));

LocalStorage.remove(key);

const result = localStorage.getItem(key);
expect(result).toBeNull();
});
});

const result = localStorage.getItem(key);
expect(result).toBeNull();
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
describe("when localStorage is not defined", () => {
class LocalStorageWithoutStore extends LocalStorage {}
LocalStorageWithoutStore.store = undefined;

it("doesn't throw errors", () => {
expect(LocalStorageWithoutStore.get("testKey")).toBeNull();
expect(() =>
LocalStorageWithoutStore.set("testKey", "testValue")
).not.toThrow();
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(() => LocalStorageWithoutStore.remove("testKey")).not.toThrow();
});
});

it("removes a value from localStorage", () => {
const key = "testKey";
const value = "testValue";
localStorage.setItem(key, JSON.stringify(value));
describe("when localStorage is replaced", () => {
const store = {
getItem: jest.fn() as jest.MockedFn<Storage["getItem"]>,
setItem: jest.fn() as jest.MockedFn<Storage["setItem"]>,
removeItem: jest.fn() as jest.MockedFn<Storage["removeItem"]>,
clear: jest.fn() as jest.MockedFn<Storage["clear"]>,
key: jest.fn() as jest.MockedFn<Storage["key"]>,
length: 50
};
class LocalStorageWithReplacedStore extends LocalStorage {}
LocalStorageWithReplacedStore.store = store;

it("gets a value from the localStorage replacement", () => {
const key = "testKey";
const value = "testValue";
store.getItem.mockReturnValue(JSON.stringify(value));

expect(LocalStorageWithReplacedStore.get(key)).toEqual(value);
expect(
LocalStorageWithReplacedStore.store?.getItem
).toHaveBeenCalledTimes(1);
expect(LocalStorageWithReplacedStore.store?.getItem).toHaveBeenCalledWith(
key
);
});

it("returns null if the key is not found in the localStorage replacement", () => {
const key = "nonExistentKey";
store.getItem.mockReturnValue(null);

LocalStorage.remove(key);
expect(LocalStorageWithReplacedStore.get(key)).toBeNull();
});

it("returns null if the value from the localStorage replacement cannot be parsed as JSON", () => {
const key = "testKey";
const value = "invalidJSON";
store.getItem.mockReturnValue(value);

expect(LocalStorageWithReplacedStore.get(key)).toBeNull();
});

const result = localStorage.getItem(key);
expect(result).toBeNull();
it("sets a value in the localStorage replacement", () => {
const key = "testKey";
const value = "testValue";

LocalStorageWithReplacedStore.set(key, value);

expect(
LocalStorageWithReplacedStore.store?.setItem
).toHaveBeenCalledTimes(1);
expect(LocalStorageWithReplacedStore.store?.setItem).toHaveBeenCalledWith(
key,
JSON.stringify(value)
);
});

it("removes a value from the localStorage replacement", () => {
const key = "testKey";

LocalStorageWithReplacedStore.remove(key);

expect(
LocalStorageWithReplacedStore.store?.removeItem
).toHaveBeenCalledTimes(1);
expect(
LocalStorageWithReplacedStore.store?.removeItem
).toHaveBeenCalledWith(key);
});
});
});
10 changes: 5 additions & 5 deletions lib/utils/localStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* A utility class for safely interacting with localStorage.
*/
export class LocalStorage {
static readonly THRESHOLD = 0.9;
static store: Storage | undefined = globalThis.localStorage;

/**
* Safely get a value from localStorage.
Expand All @@ -12,14 +12,14 @@ export class LocalStorage {
* @returns Null if the key is not found or unable to be parsed, the value otherwise.
*/
static get<T>(key: string): T | null {
const val = localStorage.getItem(key);
const val = this.store?.getItem(key);
if (!val) {
return null;
}

try {
return JSON.parse(val) as T;
} catch (e) {
} catch {
return null;
}
}
Expand All @@ -33,7 +33,7 @@ export class LocalStorage {
*/
static set(key: string, value: any): void {
try {
localStorage.setItem(key, JSON.stringify(value));
this.store?.setItem(key, JSON.stringify(value));
} catch {
// eslint-disable-next-line no-console
console.error(
Expand All @@ -48,6 +48,6 @@ export class LocalStorage {
* @param key - String value of the key.
*/
static remove(key: string): void {
localStorage.removeItem(key);
this.store?.removeItem(key);
}
}

0 comments on commit 86a1e33

Please sign in to comment.