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

feat(SyncExternalStore): Add StatefulSyncExternalStore to extend and provide structure #499

Open
wants to merge 1 commit into
base: use-sync-external-store
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a note to update the CHANGELOG and README after we lock down the code


## [1.1.1] - 2022-10-13

Expand Down
3 changes: 3 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
} from "./context-store--stateful-indexable/index.js";
import { getNotImplementedPromise } from "./shared/index.js";
export * from "./sync-external-store/index.js";
export * from "./sync-external-store--indexable/index.js";
export * from "./sync-external-store--stateful/index.js";
export * from "./sync-external-store--stateful-indexable/index.js";

export {
ContextStore,
Expand Down
20 changes: 20 additions & 0 deletions src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,23 @@ export const errorMessages = {
};

export const getNotImplementedPromise = () => Promise.reject("Not Implemented");

export function normalizeError(error: unknown): null | string {
if (error == null) {
return null;
}

if (typeof error === "string") {
return error;
}

if (error instanceof Error) {
return error.message;
}

return JSON.stringify(error);
}

export type CreateActionParams<TParams, TResponse> = {
action: (params: TParams) => Promise<TResponse>;
};
46 changes: 46 additions & 0 deletions src/sync-external-store--indexable/create-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { normalizeError } from "../shared/index.js";
import type { IndexableStore, IndexableStoreStructureKey } from "./types.js";

export function createIndexableLoadingSnapshot<TStore extends IndexableStore<unknown>>() {
return (snapshot: TStore) => {
return {
...snapshot,
state: "loading",
};
};
}

export function createIndexableErrorSnapshot<TStore extends IndexableStore<unknown>>(error: unknown) {
const errorMessage = normalizeError(error);
return (snapshot: TStore) => {
return {
...snapshot,
error: errorMessage,
state: "error",
};
};
}

export function createIndexableSuccessSnapshot<TStore extends IndexableStore<unknown>, TData>(
key: IndexableStoreStructureKey<TStore>,
data: TData,
) {
return (snapshot: TStore) => {
if (Array.isArray(snapshot.data) && typeof key === "number") {
return {
...snapshot,
data: [...snapshot.data.slice(0, key), data, ...snapshot.data.slice(key + 1)],
state: "success",
};
} else {
return {
...snapshot,
data: {
...snapshot.data,
[key]: data,
},
state: "success",
};
}
};
}
3 changes: 3 additions & 0 deletions src/sync-external-store--indexable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./create-snapshot.js";
export * from "./store.js";
export * from "./types.js";
135 changes: 135 additions & 0 deletions src/sync-external-store--indexable/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { beforeEach, afterEach, describe, it, jest } from "@jest/globals";
import { IndexableSyncExternalStore } from "./store.js";
import { IndexableStore } from "./types.js";

describe("IndexableSyncExternalStore", () => {
describe("array data", () => {
type Item = { id: number; value: string };
const mockAction = jest.fn(async (value: Item) => value);
const mockStoreInitialState: IndexableStore<Item> = {
data: [
{ id: 2112, value: "hello world" },
{ id: 13, value: "hola mundo" },
],
error: null,
state: "unsent",
};

class MockStore extends IndexableSyncExternalStore<IndexableStore<Item>> {
constructor() {
super(mockStoreInitialState);
}

private getIndex(item: Item) {
const snapshot = this.getSnapshot();
if (!Array.isArray(snapshot.data)) {
throw new Error("IndexableSyncExternalStore: data is not an array");
}
const index = snapshot.data.findIndex((data) => data.id === item.id);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm not quite... you should snapshot.data should be an array (which is an object). You should be able to just do snapshot.data[data.id] = data to set the data. This is a map lookup. Indexible means you are looking things based on a particular index-able value.


if (index === -1) {
return snapshot.data.length;
}

return index;
}

private async updateDataAction(value: Item) {
const action = this.createAction({
action: mockAction,
key: this.getIndex(value),
});
return action(value);
}
public async updateData(value: Item) {
await this.updateDataAction(value);
}
}

let store: MockStore;

beforeEach(() => {
store = new MockStore();
});

afterEach(() => {
jest.clearAllMocks();
});

describe("updateData", () => {
it("will update the store", async () => {
//* Arrange
const item = { id: 2112, value: "goodbye world" };

//* Act
await store.updateData(item);
const updatedSnapshot = store.getSnapshot();

//* Assert
expect(mockAction).toHaveBeenCalledTimes(1);
expect(updatedSnapshot.data[0]).toMatchObject(item);
expect(updatedSnapshot.data[1]).toMatchObject(mockStoreInitialState.data[1]);
});
});
});

describe("object data", () => {
type Item = { id: number; value: string };
const mockAction = jest.fn(async (value: Item) => value);
const mockStoreInitialState: IndexableStore<Item> = {
data: {
2112: { id: 2112, value: "hello world" },
13: { id: 13, value: "hola mundo" },
},
error: null,
state: "unsent",
};

class MockStore extends IndexableSyncExternalStore<IndexableStore<Item>> {
constructor() {
super(mockStoreInitialState);
}

private getIndex(item: Item) {
return item.id;
}

private async updateDataAction(value: Item) {
const action = this.createAction({
action: mockAction,
key: this.getIndex(value),
});
return action(value);
}
public async updateData(value: Item) {
await this.updateDataAction(value);
}
}

let store: MockStore;

beforeEach(() => {
store = new MockStore();
});

afterEach(() => {
jest.clearAllMocks();
});

describe("updateData", () => {
it("will update the store", async () => {
//* Arrange
const item = { id: 2112, value: "goodbye world" };

//* Act
await store.updateData(item);
const updatedSnapshot = store.getSnapshot();

//* Assert
expect(mockAction).toHaveBeenCalledTimes(1);
expect(updatedSnapshot.data[2112]).toMatchObject(item);
expect(updatedSnapshot.data[13]).toMatchObject(mockStoreInitialState.data[13]);
});
});
});
});
35 changes: 35 additions & 0 deletions src/sync-external-store--indexable/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { SyncExternalStore } from "../sync-external-store/index.js";
import {
createIndexableErrorSnapshot,
createIndexableLoadingSnapshot,
createIndexableSuccessSnapshot,
} from "./create-snapshot.js";
import type { CreateIndexableActionParams, IndexableStore } from "./types.js";

export abstract class IndexableSyncExternalStore<
TStore extends IndexableStore<unknown>,
> extends SyncExternalStore<TStore> {
protected createAction<TParams = void, TResponse = void>(
params: CreateIndexableActionParams<TStore, TParams, TResponse>,
) {
const {
action,
key,
updateSnapshot = {
loading: createIndexableLoadingSnapshot,
success: createIndexableSuccessSnapshot,
error: createIndexableErrorSnapshot,
},
} = params;

return async (actionParams: TParams) => {
this.updateSnapshot(updateSnapshot.loading());
try {
const data = await action(actionParams);
this.updateSnapshot(updateSnapshot.success(key, data));
} catch (error) {
this.updateSnapshot(updateSnapshot.error(error));
}
};
}
}
33 changes: 33 additions & 0 deletions src/sync-external-store--indexable/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CreateActionParams, statefulStates } from "../shared/index.js";
import {
createIndexableErrorSnapshot,
createIndexableLoadingSnapshot,
createIndexableSuccessSnapshot,
} from "./create-snapshot.js";

export type CreateIndexableErrorSnapshot = typeof createIndexableErrorSnapshot;
export type CreateIndexableLoadingSnapshot = typeof createIndexableLoadingSnapshot;
export type CreateIndexableSuccessSnapshot = typeof createIndexableSuccessSnapshot;

export type IndexableStoreStructureKey<TStore extends IndexableStore<unknown>> = TStore["data"] extends Array<unknown>
? number
: keyof TStore["data"];

export type IndexableStore<TData> = {
data: Record<string | number | symbol, TData> | Array<TData>;
error: null | string;
state: keyof typeof statefulStates;
};

export type CreateIndexableActionParams<
TStore extends IndexableStore<unknown>,
TParams,
TResponse,
> = CreateActionParams<TParams, TResponse> & {
key: IndexableStoreStructureKey<TStore>;
updateSnapshot?: {
loading: CreateIndexableLoadingSnapshot;
success: CreateIndexableSuccessSnapshot;
error: CreateIndexableErrorSnapshot;
};
};
49 changes: 49 additions & 0 deletions src/sync-external-store--stateful-indexable/create-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { normalizeError } from "../shared/index.js";
Copy link
Member

@k2snowman69 k2snowman69 Nov 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one looks good to me... effectively do the same with the sync-external-store--indexable but move the state up a level and you're done... and also handle concurrent requests in your example code

import type { StatefulIndexableStore } from "./types.js";

export function createStatefulIndexableLoadingSnapshot<TStore extends StatefulIndexableStore<unknown>>(
key: keyof TStore,
) {
return (snapshot: TStore) => {
return {
...snapshot,
[key]: {
...snapshot[key],
state: "loading",
},
};
};
}

export function createStatefulIndexableErrorSnapshot<TStore extends StatefulIndexableStore<unknown>>(
key: keyof TStore,
error: unknown,
) {
const errorMessage = normalizeError(error);
return (snapshot: TStore) => {
return {
...snapshot,
[key]: {
...snapshot[key],
error: errorMessage,
state: "error",
},
};
};
}

export function createStatefulIndexableSuccessSnapshot<TStore extends StatefulIndexableStore<unknown>, TData = unknown>(
key: keyof TStore,
data: TData,
) {
return (snapshot: TStore) => {
return {
...snapshot,
[key]: {
...snapshot[key],
data,
state: "success",
},
};
};
}
3 changes: 3 additions & 0 deletions src/sync-external-store--stateful-indexable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./create-snapshot.js";
export * from "./store.js";
export * from "./types.js";
Loading