Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add serializer option #9

Merged
merged 8 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from 7 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: 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 = {};
}
koichik marked this conversation as resolved.
Show resolved Hide resolved
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;
koichik marked this conversation as resolved.
Show resolved Hide resolved
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();
});
28 changes: 19 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 @@ -62,14 +72,14 @@ export class URLStore implements Store {
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);
koichik marked this conversation as resolved.
Show resolved Hide resolved
url.searchParams.delete(this.key);
this.syncer.updateURL(url.toString());
return;
}
queueMicrotask(() => this.notifyAll());
}
koichik marked this conversation as resolved.
Show resolved Hide resolved
Expand Down