Skip to content

Commit f38c5f7

Browse files
author
Nich Secord
committed
feat(SyncExternalStore): Add IndexableSyncExternalStore, StatefulSyncExternalStore, and StatefulIndexableSyncExternalStore
1 parent 663bfb5 commit f38c5f7

File tree

18 files changed

+597
-0
lines changed

18 files changed

+597
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
- Added `ISyncExternalStore<T>` and `SyncExternalStore<T>` to make creating external stores for `React.useSyncExternalStore` easier (Requires React@18 or higher)
11+
- Add `StatefulSyncExternalStore` to provide structured extension of `SyncExternalStore`.
1112

1213
## [1.1.1] - 2022-10-13
1314

src/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
} from "./context-store--stateful-indexable/index.js";
77
import { getNotImplementedPromise } from "./shared/index.js";
88
export * from "./sync-external-store/index.js";
9+
export * from "./sync-external-store--indexable/index.js";
10+
export * from "./sync-external-store--stateful/index.js";
11+
export * from "./sync-external-store--stateful-indexable/index.js";
912

1013
export {
1114
ContextStore,

src/shared/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,23 @@ export const errorMessages = {
1616
};
1717

1818
export const getNotImplementedPromise = () => Promise.reject("Not Implemented");
19+
20+
export function normalizeError(error: unknown): null | string {
21+
if (error == null) {
22+
return null;
23+
}
24+
25+
if (typeof error === "string") {
26+
return error;
27+
}
28+
29+
if (error instanceof Error) {
30+
return error.message;
31+
}
32+
33+
return JSON.stringify(error);
34+
}
35+
36+
export type CreateActionParams<TParams, TResponse> = {
37+
action: (params: TParams) => Promise<TResponse>;
38+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { normalizeError } from "../shared/index.js";
2+
import type { IndexableStore, IndexableStoreStructureKey } from "./types.js";
3+
4+
export function createIndexableLoadingSnapshot<TStore extends IndexableStore<unknown>>() {
5+
return (snapshot: TStore) => {
6+
return {
7+
...snapshot,
8+
state: "loading",
9+
};
10+
};
11+
}
12+
13+
export function createIndexableErrorSnapshot<TStore extends IndexableStore<unknown>>(error: unknown) {
14+
const errorMessage = normalizeError(error);
15+
return (snapshot: TStore) => {
16+
return {
17+
...snapshot,
18+
error: errorMessage,
19+
state: "error",
20+
};
21+
};
22+
}
23+
24+
export function createIndexableSuccessSnapshot<TStore extends IndexableStore<unknown>, TData>(
25+
key: IndexableStoreStructureKey<TStore>,
26+
data: TData,
27+
) {
28+
return (snapshot: TStore) => {
29+
if (Array.isArray(snapshot.data) && typeof key === "number") {
30+
return {
31+
...snapshot,
32+
data: [...snapshot.data.slice(0, key), data, ...snapshot.data.slice(key + 1)],
33+
state: "success",
34+
};
35+
} else {
36+
return {
37+
...snapshot,
38+
data: {
39+
...snapshot.data,
40+
[key]: data,
41+
},
42+
state: "success",
43+
};
44+
}
45+
};
46+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./create-snapshot.js";
2+
export * from "./store.js";
3+
export * from "./types.js";
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { beforeEach, afterEach, describe, it, jest } from "@jest/globals";
2+
import { IndexableSyncExternalStore } from "./store.js";
3+
import { IndexableStore } from "./types.js";
4+
5+
describe("IndexableSyncExternalStore", () => {
6+
describe("array data", () => {
7+
type Item = { id: number; value: string };
8+
const mockAction = jest.fn(async (value: Item) => value);
9+
const mockStoreInitialState: IndexableStore<Item> = {
10+
data: [
11+
{ id: 2112, value: "hello world" },
12+
{ id: 13, value: "hola mundo" },
13+
],
14+
error: null,
15+
state: "unsent",
16+
};
17+
18+
class MockStore extends IndexableSyncExternalStore<IndexableStore<Item>> {
19+
constructor() {
20+
super(mockStoreInitialState);
21+
}
22+
23+
private getIndex(item: Item) {
24+
const snapshot = this.getSnapshot();
25+
if (!Array.isArray(snapshot.data)) {
26+
throw new Error("IndexableSyncExternalStore: data is not an array");
27+
}
28+
const index = snapshot.data.findIndex((data) => data.id === item.id);
29+
30+
if (index === -1) {
31+
return snapshot.data.length;
32+
}
33+
34+
return index;
35+
}
36+
37+
private async updateDataAction(value: Item) {
38+
const action = this.createAction({
39+
action: mockAction,
40+
key: this.getIndex(value),
41+
});
42+
return action(value);
43+
}
44+
public async updateData(value: Item) {
45+
await this.updateDataAction(value);
46+
}
47+
}
48+
49+
let store: MockStore;
50+
51+
beforeEach(() => {
52+
store = new MockStore();
53+
});
54+
55+
afterEach(() => {
56+
jest.clearAllMocks();
57+
});
58+
59+
describe("updateData", () => {
60+
it("will update the store", async () => {
61+
//* Arrange
62+
const item = { id: 2112, value: "goodbye world" };
63+
64+
//* Act
65+
await store.updateData(item);
66+
const updatedSnapshot = store.getSnapshot();
67+
68+
//* Assert
69+
expect(mockAction).toHaveBeenCalledTimes(1);
70+
expect(updatedSnapshot.data[0]).toMatchObject(item);
71+
expect(updatedSnapshot.data[1]).toMatchObject(mockStoreInitialState.data[1]);
72+
});
73+
});
74+
});
75+
76+
describe("object data", () => {
77+
type Item = { id: number; value: string };
78+
const mockAction = jest.fn(async (value: Item) => value);
79+
const mockStoreInitialState: IndexableStore<Item> = {
80+
data: {
81+
2112: { id: 2112, value: "hello world" },
82+
13: { id: 13, value: "hola mundo" },
83+
},
84+
error: null,
85+
state: "unsent",
86+
};
87+
88+
class MockStore extends IndexableSyncExternalStore<IndexableStore<Item>> {
89+
constructor() {
90+
super(mockStoreInitialState);
91+
}
92+
93+
private getIndex(item: Item) {
94+
return item.id;
95+
}
96+
97+
private async updateDataAction(value: Item) {
98+
const action = this.createAction({
99+
action: mockAction,
100+
key: this.getIndex(value),
101+
});
102+
return action(value);
103+
}
104+
public async updateData(value: Item) {
105+
await this.updateDataAction(value);
106+
}
107+
}
108+
109+
let store: MockStore;
110+
111+
beforeEach(() => {
112+
store = new MockStore();
113+
});
114+
115+
afterEach(() => {
116+
jest.clearAllMocks();
117+
});
118+
119+
describe("updateData", () => {
120+
it("will update the store", async () => {
121+
//* Arrange
122+
const item = { id: 2112, value: "goodbye world" };
123+
124+
//* Act
125+
await store.updateData(item);
126+
const updatedSnapshot = store.getSnapshot();
127+
128+
//* Assert
129+
expect(mockAction).toHaveBeenCalledTimes(1);
130+
expect(updatedSnapshot.data[2112]).toMatchObject(item);
131+
expect(updatedSnapshot.data[13]).toMatchObject(mockStoreInitialState.data[13]);
132+
});
133+
});
134+
});
135+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { SyncExternalStore } from "../sync-external-store/index.js";
2+
import {
3+
createIndexableErrorSnapshot,
4+
createIndexableLoadingSnapshot,
5+
createIndexableSuccessSnapshot,
6+
} from "./create-snapshot.js";
7+
import type { CreateIndexableActionParams, IndexableStore } from "./types.js";
8+
9+
export abstract class IndexableSyncExternalStore<
10+
TStore extends IndexableStore<unknown>,
11+
> extends SyncExternalStore<TStore> {
12+
protected createAction<TParams = void, TResponse = void>(
13+
params: CreateIndexableActionParams<TStore, TParams, TResponse>,
14+
) {
15+
const {
16+
action,
17+
key,
18+
updateSnapshot = {
19+
loading: createIndexableLoadingSnapshot,
20+
success: createIndexableSuccessSnapshot,
21+
error: createIndexableErrorSnapshot,
22+
},
23+
} = params;
24+
25+
return async (actionParams: TParams) => {
26+
this.updateSnapshot(updateSnapshot.loading());
27+
try {
28+
const data = await action(actionParams);
29+
this.updateSnapshot(updateSnapshot.success(key, data));
30+
} catch (error) {
31+
this.updateSnapshot(updateSnapshot.error(error));
32+
}
33+
};
34+
}
35+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { CreateActionParams, statefulStates } from "../shared/index.js";
2+
import {
3+
createIndexableErrorSnapshot,
4+
createIndexableLoadingSnapshot,
5+
createIndexableSuccessSnapshot,
6+
} from "./create-snapshot.js";
7+
8+
export type CreateIndexableErrorSnapshot = typeof createIndexableErrorSnapshot;
9+
export type CreateIndexableLoadingSnapshot = typeof createIndexableLoadingSnapshot;
10+
export type CreateIndexableSuccessSnapshot = typeof createIndexableSuccessSnapshot;
11+
12+
export type IndexableStoreStructureKey<TStore extends IndexableStore<unknown>> = TStore["data"] extends Array<unknown>
13+
? number
14+
: keyof TStore["data"];
15+
16+
export type IndexableStore<TData> = {
17+
data: Record<string | number | symbol, TData> | Array<TData>;
18+
error: null | string;
19+
state: keyof typeof statefulStates;
20+
};
21+
22+
export type CreateIndexableActionParams<
23+
TStore extends IndexableStore<unknown>,
24+
TParams,
25+
TResponse,
26+
> = CreateActionParams<TParams, TResponse> & {
27+
key: IndexableStoreStructureKey<TStore>;
28+
updateSnapshot?: {
29+
loading: CreateIndexableLoadingSnapshot;
30+
success: CreateIndexableSuccessSnapshot;
31+
error: CreateIndexableErrorSnapshot;
32+
};
33+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { normalizeError } from "../shared/index.js";
2+
import type { StatefulIndexableStore } from "./types.js";
3+
4+
export function createStatefulIndexableLoadingSnapshot<TStore extends StatefulIndexableStore<unknown>>(
5+
key: keyof TStore,
6+
) {
7+
return (snapshot: TStore) => {
8+
return {
9+
...snapshot,
10+
[key]: {
11+
...snapshot[key],
12+
state: "loading",
13+
},
14+
};
15+
};
16+
}
17+
18+
export function createStatefulIndexableErrorSnapshot<TStore extends StatefulIndexableStore<unknown>>(
19+
key: keyof TStore,
20+
error: unknown,
21+
) {
22+
const errorMessage = normalizeError(error);
23+
return (snapshot: TStore) => {
24+
return {
25+
...snapshot,
26+
[key]: {
27+
...snapshot[key],
28+
error: errorMessage,
29+
state: "error",
30+
},
31+
};
32+
};
33+
}
34+
35+
export function createStatefulIndexableSuccessSnapshot<TStore extends StatefulIndexableStore<unknown>, TData = unknown>(
36+
key: keyof TStore,
37+
data: TData,
38+
) {
39+
return (snapshot: TStore) => {
40+
return {
41+
...snapshot,
42+
[key]: {
43+
...snapshot[key],
44+
data,
45+
state: "success",
46+
},
47+
};
48+
};
49+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./create-snapshot.js";
2+
export * from "./store.js";
3+
export * from "./types.js";

0 commit comments

Comments
 (0)