From d41c3e2fdd3dde467a2c1083e9dc9f994de7f3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=90=E8=97=A4=E6=98=AD=E6=96=87?= Date: Sat, 9 Sep 2023 22:54:42 +0900 Subject: [PATCH] impl store's transit option --- .../src/stores/storage-store.test.ts | 41 ++++++++++++++++++- .../src/stores/storage-store.ts | 20 ++++++--- .../location-state-core/src/stores/types.ts | 8 ++++ .../src/stores/url-store.test.ts | 40 ++++++++++++++++++ .../src/stores/url-store.ts | 20 +++++++-- 5 files changed, 119 insertions(+), 10 deletions(-) diff --git a/packages/location-state-core/src/stores/storage-store.test.ts b/packages/location-state-core/src/stores/storage-store.test.ts index 11639a80..79a0c405 100644 --- a/packages/location-state-core/src/stores/storage-store.test.ts +++ b/packages/location-state-core/src/stores/storage-store.test.ts @@ -109,6 +109,24 @@ test("On `load` called, if the value of the corresponding key is in Storage, the ); }); +test("On `load` called with transit, 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, all listener notified.", async () => { // Arrange const navigationKey = "current_location"; @@ -126,7 +144,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 transit, the state is saved in Storage with the previous Location key.", () => { // Arrange const currentLocationKey = "current_location"; const store = new StorageStore(storage); diff --git a/packages/location-state-core/src/stores/storage-store.ts b/packages/location-state-core/src/stores/storage-store.ts index 3d05c0f5..873d8b33 100644 --- a/packages/location-state-core/src/stores/storage-store.ts +++ b/packages/location-state-core/src/stores/storage-store.ts @@ -1,13 +1,21 @@ -import { Listener, Store } from "./types"; +import { Listener, Store, Transit } from "./types"; export const locationKeyPrefix = "__location_state_"; +const defaultTransit: Transit = { + deserialize: JSON.parse, + serialize: JSON.stringify, +}; + export class StorageStore implements Store { private state: Record = {}; private readonly listeners: Map> = new Map(); private currentKey: string | null = null; + private readonly transit: Transit; - constructor(private readonly storage?: Storage) {} + constructor(private readonly storage?: Storage, transit?: Transit) { + this.transit = transit ?? defaultTransit; + } subscribe(name: string, listener: Listener) { const listeners = this.listeners.get(name); @@ -55,8 +63,7 @@ export class StorageStore implements Store { this.currentKey = locationKey; const value = this.storage?.getItem(this.createStorageKey()) ?? null; if (value !== null) { - // todo: impl JSON or Transit - this.state = JSON.parse(value); + this.state = this.transit.deserialize(value); } else { this.state = {}; } @@ -71,7 +78,10 @@ export class StorageStore implements Store { this.storage?.removeItem(this.createStorageKey()); return; } - this.storage?.setItem(this.createStorageKey(), JSON.stringify(this.state)); + this.storage?.setItem( + this.createStorageKey(), + this.transit.serialize(this.state), + ); } private createStorageKey() { diff --git a/packages/location-state-core/src/stores/types.ts b/packages/location-state-core/src/stores/types.ts index df9f6fd1..397b2e97 100644 --- a/packages/location-state-core/src/stores/types.ts +++ b/packages/location-state-core/src/stores/types.ts @@ -1,5 +1,13 @@ export type Listener = () => void; +type Deserializer = (value: string) => Record; +type Serializer = (value: Record) => string; + +export type Transit = { + deserialize: Deserializer; + serialize: Serializer; +}; + export type Store = { subscribe(name: string, listener: Listener): () => void; diff --git a/packages/location-state-core/src/stores/url-store.test.ts b/packages/location-state-core/src/stores/url-store.test.ts index 3bf4b292..d07bd886 100644 --- a/packages/location-state-core/src/stores/url-store.test.ts +++ b/packages/location-state-core/src/stores/url-store.test.ts @@ -56,6 +56,28 @@ test("On `set` called, store's values are updated and reflected in the URL", () ); }); +test("On `set` called with transit, 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("listener is called when updating slice.", () => { // Arrange const store = new URLStore("store-key", syncerMock); @@ -128,6 +150,24 @@ test("On `load` called, the state is loaded from url.", () => { expect(store.get("foo")).toBe("updated"); }); +test("On `load` called with transit, 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, all listener notified.", async () => { // Arrange const store = new URLStore("store-key", syncerMock); diff --git a/packages/location-state-core/src/stores/url-store.ts b/packages/location-state-core/src/stores/url-store.ts index ed65029e..10bf5e42 100644 --- a/packages/location-state-core/src/stores/url-store.ts +++ b/packages/location-state-core/src/stores/url-store.ts @@ -1,13 +1,25 @@ import { Syncer } from "../types"; -import { Listener, Store } from "./types"; +import { Listener, Store, Transit } from "./types"; + +const defaultTransit: Transit = { + deserialize: JSON.parse, + serialize: JSON.stringify, +}; export class URLStore implements Store { private state: Record = {}; // `state`'s JSON string for comparison private stateJSON: string = "{}"; private readonly listeners: Map> = new Map(); + private readonly transit: Transit; - constructor(private readonly key: string, private readonly syncer: Syncer) {} + constructor( + private readonly key: string, + private readonly syncer: Syncer, + transit?: Transit, + ) { + this.transit = transit ?? defaultTransit; + } subscribe(name: string, listener: Listener) { const listeners = this.listeners.get(name); @@ -47,7 +59,7 @@ export class URLStore implements Store { } else { this.state[name] = value; } - this.stateJSON = JSON.stringify(this.state); + this.stateJSON = this.transit.serialize(this.state); // save to url const url = new URL(location.href); url.searchParams.set(this.key, this.stateJSON); @@ -62,7 +74,7 @@ export class URLStore implements Store { if (this.stateJSON === stateJSON) return; this.stateJSON = stateJSON!; try { - this.state = JSON.parse(this.stateJSON || "{}"); + this.state = this.transit.deserialize(this.stateJSON || "{}"); } catch (e) { this.state = {}; // remove invalid state from url.