-
Notifications
You must be signed in to change notification settings - Fork 0
feat(SyncExternalStore): Add StatefulSyncExternalStore to extend and provide structure #499
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
base: use-sync-external-store
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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`. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
|
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", | ||
}; | ||
} | ||
}; | ||
} |
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"; |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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]); | ||
}); | ||
}); | ||
}); | ||
}); |
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)); | ||
} | ||
}; | ||
} | ||
} |
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; | ||
}; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import { normalizeError } from "../shared/index.js"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
}, | ||
}; | ||
}; | ||
} |
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"; |
Uh oh!
There was an error while loading. Please reload this page.