Skip to content

Commit

Permalink
Add serializer option (#9)
Browse files Browse the repository at this point in the history
* impl store's serializer option

* export json serializer

* serializer error handler impl

* Refactor the store description, modify to notify even in case of error, change Serializer property name

* fix StorageStore's storage injection to optional

* refactor UrlStore's set processing order

* rename type Serializer

* refactor code block break
  • Loading branch information
AkifumiSato authored Sep 11, 2023
1 parent 1601b67 commit 766479c
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 18 deletions.
1 change: 1 addition & 0 deletions packages/location-state-core/src/stores/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./storage-store";
export * from "./url-store";
export * from "./types";
export * from "./serializer";
6 changes: 6 additions & 0 deletions packages/location-state-core/src/stores/serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { StateSerializer } from "./types";

export const jsonSerializer: StateSerializer = {
deserialize: JSON.parse,
serialize: JSON.stringify,
};
83 changes: 82 additions & 1 deletion packages/location-state-core/src/stores/storage-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,45 @@ test("On `load` called, if the value of the corresponding key is in Storage, the
);
});

test("On `load` called with serializer, if the value of the corresponding key is in Storage, then slice is evaluated by deserialize.", () => {
// Arrange
const navigationKey = "current_location";
storageMock.getItem.mockReturnValueOnce(
JSON.stringify({ foo: "storage value" }),
);
const store = new StorageStore(storage, {
serialize: () => "not-used-value",
deserialize: () => ({
foo: "dummy-result",
}),
});
// Act
store.load(navigationKey);
// Assert
expect(store.get("foo")).toBe("dummy-result");
});

test("On `load` called with invalid serializer, the value of slice remains at its initial value.", () => {
// Arrange
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
const navigationKey = "current_location";
storageMock.getItem.mockReturnValueOnce(
JSON.stringify({ foo: "storage value" }),
);
const store = new StorageStore(storage, {
serialize: JSON.stringify,
deserialize: () => {
throw new Error("deserialize error");
},
});
// Act
store.load(navigationKey);
// Assert
expect(store.get("foo")).toBeUndefined();
// Restore console
consoleSpy.mockRestore();
});

test("On `load` called, all listener notified.", async () => {
// Arrange
const navigationKey = "current_location";
Expand All @@ -126,7 +165,28 @@ test("On `load` called, all listener notified.", async () => {
expect(listener2).toBeCalledTimes(1);
});

test("On `save` called, the state is saved in Storage with the previous Location key.", () => {
test("On `save` called, the state is saved in Storage with evaluated by deserialize.", () => {
// Arrange
const currentLocationKey = "current_location";
const store = new StorageStore(storage, {
serialize: () => "dummy-result",
deserialize: () => ({
foo: "not-used-value",
}),
});
store.load(currentLocationKey);
store.set("foo", "updated");
// Act
store.save();
// Assert
expect(storageMock.setItem).toHaveBeenCalledTimes(1);
expect(storageMock.setItem).toHaveBeenCalledWith(
`${locationKeyPrefix}${currentLocationKey}`,
"dummy-result",
);
});

test("On `save` called with serializer, the state is saved in Storage with the previous Location key.", () => {
// Arrange
const currentLocationKey = "current_location";
const store = new StorageStore(storage);
Expand All @@ -142,6 +202,27 @@ test("On `save` called, the state is saved in Storage with the previous Location
);
});

test("On `save` called with invalid serializer, the state is not saved in Storage.", () => {
// Arrange
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
const currentLocationKey = "current_location";
const store = new StorageStore(storage, {
serialize: () => {
throw new Error("serialize error");
},
deserialize: JSON.parse,
});
store.load(currentLocationKey);
store.set("foo", "updated");
// Act
store.save();
// Assert
expect(store.get("foo")).toBe("updated");
expect(storageMock.setItem).not.toBeCalled();
// Restore console
consoleSpy.mockRestore();
});

test("Calling `save` with empty will remove the Storage with Location key.", () => {
// Arrange
const currentLocationKey = "current_location";
Expand Down
26 changes: 19 additions & 7 deletions packages/location-state-core/src/stores/storage-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Listener, Store } from "./types";
import { jsonSerializer } from "./serializer";
import { Listener, StateSerializer, Store } from "./types";

export const locationKeyPrefix = "__location_state_";

Expand All @@ -7,7 +8,10 @@ export class StorageStore implements Store {
private readonly listeners: Map<string, Set<Listener>> = new Map();
private currentKey: string | null = null;

constructor(private readonly storage?: Storage) {}
constructor(
private readonly storage?: Storage, // Storage is undefined in SSR.
private readonly stateSerializer: StateSerializer = jsonSerializer,
) {}

subscribe(name: string, listener: Listener) {
const listeners = this.listeners.get(name);
Expand Down Expand Up @@ -54,10 +58,11 @@ export class StorageStore implements Store {
if (this.currentKey === locationKey) return;
this.currentKey = locationKey;
const value = this.storage?.getItem(this.createStorageKey()) ?? null;
if (value !== null) {
// todo: impl JSON or Transit
this.state = JSON.parse(value);
} else {
try {
this.state =
value !== null ? this.stateSerializer.deserialize(value) : {};
} catch (e) {
console.error(e);
this.state = {};
}
queueMicrotask(() => this.notifyAll());
Expand All @@ -71,7 +76,14 @@ export class StorageStore implements Store {
this.storage?.removeItem(this.createStorageKey());
return;
}
this.storage?.setItem(this.createStorageKey(), JSON.stringify(this.state));
let value: string;
try {
value = this.stateSerializer.serialize(this.state);
} catch (e) {
console.error(e);
return;
}
this.storage?.setItem(this.createStorageKey(), value);
}

private createStorageKey() {
Expand Down
8 changes: 8 additions & 0 deletions packages/location-state-core/src/stores/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
export type Listener = () => void;

type Serialize = (value: Record<string, unknown>) => string;
type Deserialize = (value: string) => Record<string, unknown>;

export type StateSerializer = {
serialize: Serialize;
deserialize: Deserialize;
};

export type Store = {
subscribe(name: string, listener: Listener): () => void;

Expand Down
92 changes: 91 additions & 1 deletion packages/location-state-core/src/stores/url-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ test("If params is empty, the initial value is undefined.", () => {
expect(slice).toBeUndefined();
});

test("On `set` called, store's values are updated and reflected in the URL", () => {
test("On `set` called, store's values are updated and reflected in the URL.", () => {
// Arrange
prepareLocation({
pathname: "/",
Expand All @@ -56,6 +56,52 @@ test("On `set` called, store's values are updated and reflected in the URL", ()
);
});

test("On `set` called with serializer, store's values are updated and reflected in the URL.", () => {
// Arrange
prepareLocation({
pathname: "/",
search: "?hoge=fuga",
});
const store = new URLStore("store-key", syncerMock, {
serialize: () => "dummy-result",
deserialize: () => ({
foo: "not-used-value",
}),
});
// Act
store.set("foo", "updated");
// Assert
expect(store.get("foo")).toBe("updated");
expect(syncerMock.updateURL).toHaveBeenCalledTimes(1);
expect(syncerMock.updateURL).toHaveBeenCalledWith(
"http://localhost/?hoge=fuga&store-key=dummy-result",
);
});

test("On `set` called with invalid serializer, store's values are initial value and not reflected in the URL.", () => {
// Arrange
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
prepareLocation({
pathname: "/",
search: "?hoge=fuga",
});
const store = new URLStore("store-key", syncerMock, {
serialize: () => {
throw new Error("serialize error");
},
deserialize: () => ({
foo: "not-used-value",
}),
});
// Act
store.set("foo", "updated");
// Assert
expect(store.get("foo")).toBe("updated");
expect(syncerMock.updateURL).not.toHaveBeenCalled();
// Restore console
consoleSpy.mockRestore();
});

test("listener is called when updating slice.", () => {
// Arrange
const store = new URLStore("store-key", syncerMock);
Expand Down Expand Up @@ -128,6 +174,47 @@ test("On `load` called, the state is loaded from url.", () => {
expect(store.get("foo")).toBe("updated");
});

test("On `load` called with serializer, the value is obtained through serialize.", () => {
// Arrange
prepareLocation({
pathname: "/",
search: "?store-key=%7B%22foo%22%3A%22updated%22%7D",
});
const store = new URLStore("store-key", syncerMock, {
serialize: () => "not-used-value",
deserialize: () => ({
foo: "dummy-result",
}),
});
// Act
store.load();
// Assert
expect(store.get("foo")).toBe("dummy-result");
});

test("On `load` called with invalid serializer, the value is initial value.", () => {
// Arrange
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
prepareLocation({
pathname: "/",
search: "?store-key=%7B%22foo%22%3A%22updated%22%7D",
});
const store = new URLStore("store-key", syncerMock, {
serialize: JSON.stringify,
deserialize: () => {
throw new Error("deserialize error");
},
});
// Act
store.load();
// Assert
expect(store.get("foo")).toBeUndefined();
expect(syncerMock.updateURL).toHaveBeenCalledTimes(1);
expect(syncerMock.updateURL).toHaveBeenCalledWith("http://localhost/");
// Restore console
consoleSpy.mockRestore();
});

test("On `load` called, all listener notified.", async () => {
// Arrange
const store = new URLStore("store-key", syncerMock);
Expand All @@ -146,6 +233,7 @@ test("On `load` called, all listener notified.", async () => {

test("On `load` called, delete parameter if invalid JSON string.", () => {
// Arrange
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
prepareLocation({
pathname: "/",
search: "?store-key=invalid-json-string",
Expand All @@ -157,4 +245,6 @@ test("On `load` called, delete parameter if invalid JSON string.", () => {
expect(store.get("foo")).toBeUndefined();
expect(syncerMock.updateURL).toHaveBeenCalledTimes(1);
expect(syncerMock.updateURL).toHaveBeenCalledWith("http://localhost/");
// Restore console
consoleSpy.mockRestore();
});
30 changes: 21 additions & 9 deletions packages/location-state-core/src/stores/url-store.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Syncer } from "../types";
import { Listener, Store } from "./types";
import { jsonSerializer } from "./serializer";
import { Listener, Store, StateSerializer } from "./types";

export class URLStore implements Store {
private state: Record<string, unknown> = {};
// `state`'s JSON string for comparison
private stateJSON: string = "{}";
private readonly listeners: Map<string, Set<Listener>> = new Map();

constructor(private readonly key: string, private readonly syncer: Syncer) {}
constructor(
private readonly key: string,
private readonly syncer: Syncer,
private readonly stateSerializer: StateSerializer = jsonSerializer,
) {}

subscribe(name: string, listener: Listener) {
const listeners = this.listeners.get(name);
Expand Down Expand Up @@ -47,11 +52,16 @@ export class URLStore implements Store {
} else {
this.state[name] = value;
}
this.stateJSON = JSON.stringify(this.state);
// save to url
const url = new URL(location.href);
url.searchParams.set(this.key, this.stateJSON);
this.syncer.updateURL(url.toString());

try {
this.stateJSON = this.stateSerializer.serialize(this.state);
// save to url
const url = new URL(location.href);
url.searchParams.set(this.key, this.stateJSON);
this.syncer.updateURL(url.toString());
} catch (e) {
console.error(e);
}

this.notify(name);
}
Expand All @@ -61,16 +71,18 @@ export class URLStore implements Store {
const stateJSON = params.get(this.key);
if (this.stateJSON === stateJSON) return;
this.stateJSON = stateJSON!;

try {
this.state = JSON.parse(this.stateJSON || "{}");
this.state = this.stateSerializer.deserialize(this.stateJSON || "{}");
} catch (e) {
console.error(e);
this.state = {};
// remove invalid state from url.
const url = new URL(location.href);
url.searchParams.delete(this.key);
this.syncer.updateURL(url.toString());
return;
}

queueMicrotask(() => this.notifyAll());
}

Expand Down

0 comments on commit 766479c

Please sign in to comment.