Skip to content

Commit 65f7bf1

Browse files
committed
optimisticUpdate method; respect for falsy non-undefined values
1 parent 4c96eb4 commit 65f7bf1

File tree

9 files changed

+188
-39
lines changed

9 files changed

+188
-39
lines changed

.eslintrc.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"overrides": [
88
{
99
"files": [
10-
"*.ts",
11-
"*.tsx"
10+
"src/*.ts",
11+
"src/*.tsx"
1212
],
1313
"extends": [
1414
"./.eslint-configs/typescript.json"

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,16 @@
3636
"release": "np"
3737
},
3838
"devDependencies": {
39-
"@total-typescript/ts-reset": "^0.5.1",
40-
"@types/jest": "^29.5.11",
41-
"@typescript-eslint/eslint-plugin": "^6.13.2",
42-
"@typescript-eslint/parser": "^6.13.2",
43-
"eslint": "^8.55.0",
39+
"@total-typescript/ts-reset": "^0.6.1",
40+
"@types/jest": "^29.5.14",
41+
"@typescript-eslint/eslint-plugin": "^6.21.0",
42+
"@typescript-eslint/parser": "^6.21.0",
43+
"eslint": "^8.57.1",
4444
"eslint-plugin-unicorn": "^49.0.0",
4545
"jest": "^29.7.0",
46-
"np": "^9.2.0",
47-
"ts-jest": "^29.1.1",
46+
"np": "^10.2.0",
47+
"ts-jest": "^29.4.0",
4848
"ts-node": "^10.9.2",
49-
"typescript": "^5.3.3"
49+
"typescript": "^5.8.3"
5050
}
5151
}

src/BatchLoader.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,114 @@ describe('BatchLoader', () => {
8282
expect(batchFetchSpy).toHaveBeenNthCalledWith(2, ['c'])
8383
})
8484

85+
it('should work optimisticUpdate', async () => {
86+
let salt = '1'
87+
88+
const options: IBatchLoaderOptions<string, { test: string }> = {
89+
batchFetch: (ids) => new Promise((resolve) => {
90+
setTimeout(() => {
91+
resolve(
92+
ids.map((id) => ({
93+
test: `test_${id}_${salt}`,
94+
})),
95+
)
96+
}, TEST_TIMEOUT)
97+
}),
98+
refetchStrategy: 'refresh',
99+
}
100+
101+
const batchFetchSpy = jest.spyOn(options, 'batchFetch')
102+
const testBatchLoader = new BatchLoader<string, { test: string }>(options)
103+
104+
expect(testBatchLoader.getStatus('a')).toStrictEqual('unrequested')
105+
expect(testBatchLoader.getState('a')).toStrictEqual({
106+
status: 'unrequested',
107+
result: undefined,
108+
})
109+
110+
testBatchLoader.optimisticUpdate('a', { test: 'optimistic_value_before_load_a' })
111+
testBatchLoader.optimisticUpdate('b', { test: 'optimistic_value_before_load_b' })
112+
113+
expect(testBatchLoader.getState('a')).toStrictEqual({
114+
status: 'resolved',
115+
result: { test: 'optimistic_value_before_load_a' },
116+
})
117+
118+
const promiseA1 = testBatchLoader.load('a')
119+
120+
void testBatchLoader.load('b')
121+
122+
expect(testBatchLoader.getState('a')).toStrictEqual({
123+
status: 'scheduled',
124+
result: { test: 'optimistic_value_before_load_a' },
125+
})
126+
127+
testBatchLoader.optimisticUpdate('a', { test: 'optimistic_value_after_scheduled_a' })
128+
129+
expect(testBatchLoader.getState('a')).toStrictEqual({
130+
status: 'resolved',
131+
result: { test: 'optimistic_value_after_scheduled_a' },
132+
})
133+
134+
await timeout()
135+
136+
const promiseA2 = testBatchLoader.load('a')
137+
138+
expect(promiseA2 === promiseA1).toStrictEqual(true)
139+
expect(testBatchLoader.getState('a')).toStrictEqual({
140+
status: 'fetching',
141+
result: { test: 'optimistic_value_after_scheduled_a' },
142+
})
143+
expect(testBatchLoader.getStatus('a')).toStrictEqual('fetching')
144+
145+
testBatchLoader.optimisticUpdate('a', { test: 'optimistic_value_after_fetching_a' })
146+
147+
expect(testBatchLoader.getState('a')).toStrictEqual({
148+
status: 'resolved',
149+
result: { test: 'optimistic_value_after_fetching_a' },
150+
})
151+
152+
expect(await promiseA1).toStrictEqual({ test: 'test_a_1' })
153+
154+
expect(testBatchLoader.getState('a')).toStrictEqual({
155+
status: 'resolved',
156+
result: { test: 'test_a_1' },
157+
})
158+
159+
testBatchLoader.optimisticUpdate('a', { test: 'optimistic_value_after_await_a' })
160+
161+
expect(testBatchLoader.getState('a')).toStrictEqual({
162+
status: 'resolved',
163+
result: { test: 'optimistic_value_after_await_a' },
164+
})
165+
166+
const promiseA3 = testBatchLoader.load('a')
167+
const promiseC = testBatchLoader.load('c')
168+
169+
expect(promiseA3 === promiseA1).toStrictEqual(false)
170+
expect(testBatchLoader.getState('a')).toStrictEqual({
171+
status: 'scheduled',
172+
result: { test: 'optimistic_value_after_await_a' },
173+
})
174+
expect(testBatchLoader.getState('c')).toStrictEqual({
175+
status: 'scheduled',
176+
result: undefined,
177+
})
178+
179+
salt = '2'
180+
181+
expect(await promiseC).toStrictEqual({ test: 'test_c_2' })
182+
expect(await promiseA3).toStrictEqual({ test: 'test_a_2' })
183+
expect(testBatchLoader.getState('a')).toStrictEqual({
184+
status: 'resolved',
185+
result: { test: 'test_a_2' },
186+
})
187+
188+
expect(batchFetchSpy).toHaveBeenCalledTimes(2)
189+
expect(batchFetchSpy).toHaveBeenNthCalledWith(1, ['a', 'b'])
190+
expect(batchFetchSpy).toHaveBeenNthCalledWith(2, ['a', 'c'])
191+
})
192+
85193
it('should work when batchFetch throw Error', async () => {
86194
const options: IBatchLoaderOptions<string, { test: string }> = {
87195
batchFetch: () => Promise.reject(new Error('test error')),

src/BatchLoader.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ export default class BatchLoader<ID extends number | string, R> {
5353
}
5454
}
5555

56+
optimisticUpdate(id: ID, result: R): void {
57+
if (this.itemsStore.get(id)) {
58+
this.itemsStore.update(id, {
59+
status: 'resolved',
60+
result,
61+
error: undefined,
62+
})
63+
} else {
64+
const deferred = promiseWithResolvers<R>()
65+
deferred.resolve(result)
66+
this.itemsStore.add(id, {
67+
deferred,
68+
status: 'resolved',
69+
result,
70+
})
71+
}
72+
}
73+
5674
getResult(id: ID): R | undefined {
5775
return this.itemsStore.get(id)?.result
5876
}
@@ -139,23 +157,38 @@ export default class BatchLoader<ID extends number | string, R> {
139157

140158
this.batchBuffer = []
141159

142-
void this.doFetch(batchBuffer).then((batchStatus) => {
143-
this.batchStatus = batchStatus
160+
void this.doFetch(batchBuffer)
161+
.then((batchStatus) => {
162+
this.batchStatus = batchStatus
144163

145-
// eslint-disable-next-line unicorn/consistent-destructuring
146-
if (this.batchBuffer.length > 0) {
147-
this.scheduleBatchFetch()
148-
}
149-
})
164+
// eslint-disable-next-line unicorn/consistent-destructuring
165+
if (this.batchBuffer.length > 0) {
166+
this.scheduleBatchFetch()
167+
}
168+
})
169+
// .catch((error) => {
170+
// // eslint-disable-next-line unicorn/consistent-destructuring
171+
// this.options.onError?.(error)
172+
// })
150173
})
151174
}
152175

176+
// eslint-disable-next-line max-statements
153177
private async doFetch(ids: ID[]): Promise<'resolved' | 'rejected'> {
154-
this.itemsStore.batchUpdate(
155-
ids.map((id) => [id, fetchingItemPatch as IBatchLoaderItemPatch<R>]),
156-
)
178+
try {
179+
this.itemsStore.batchUpdate(
180+
ids.map((id) => [id, fetchingItemPatch as IBatchLoaderItemPatch<R>]),
181+
)
182+
} catch (error: unknown) {
183+
try {
184+
this.applyBatchError(ids, error)
185+
} catch {
186+
// noop
187+
}
188+
return 'rejected'
189+
}
157190

158-
let results: (R | Error | null | undefined)[]
191+
let results: (R | Error | undefined)[]
159192

160193
try {
161194
results = await this.options.batchFetch(ids)
@@ -164,7 +197,7 @@ export default class BatchLoader<ID extends number | string, R> {
164197
return 'rejected'
165198
}
166199

167-
this.applyBatchResult(ids, results)
200+
this.applyBatchResults(ids, results)
168201
return 'resolved'
169202
}
170203

@@ -183,11 +216,9 @@ export default class BatchLoader<ID extends number | string, R> {
183216
this.options.onError?.(error)
184217
}
185218

186-
private applyBatchResult(ids: ID[], results: (R | Error | null | undefined)[]): void {
219+
private applyBatchResults(ids: ID[], results: (R | Error | undefined)[]): void {
187220
const items = this.itemsStore.batchUpdate(
188-
// TODO: maybe dont erase prev values by `|| undefined`
189-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
190-
ids.map((id, index) => this.createBatchUpdateEntry(id, results[index] || undefined)),
221+
ids.map((id, index) => this.createBatchUpdateEntry(id, results[index])),
191222
)
192223

193224
for (const { deferred, result, error } of items) {

src/BatchLoader.types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ export interface IBatchLoaderOptions<ID extends number | string, R> {
44
batchFetch: BatchFetchFunction<ID, R>
55
batchScheduleFn?: (this: void, callback: () => void) => void
66
itemsStore?: IBatchLoaderItemsStore<ID, R>
7-
refetchStrategy?: 'unfetched' | 'refresh'
7+
refetchStrategy?: 'unfetched' | 'refresh' // default: 'unfetched'
88
onError?: (error: unknown) => void
99
}
1010

1111
type BatchFetchFunction<ID extends number | string, R> = (
1212
ids: ID[]
13-
) => Promise<(R | Error | null | undefined)[]>
13+
) => Promise<(R | Error | undefined)[]>
1414

1515
export interface IBatchLoaderItemsStore<ID extends number | string, R> {
1616
get: (id: ID) => IBatchLoaderItem<R> | undefined

src/DefaultBatchLoaderItemsStore.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
IBatchLoaderItemPatch,
44
IBatchLoaderItemsStore,
55
} from './BatchLoader.types'
6+
import ItemNotFoundExceptionError from './ItemNotFoundExceptionError'
67

78
export default class DefaultBatchLoaderItemsStore<ID extends number | string, R>
89
implements IBatchLoaderItemsStore<ID, R> {
@@ -20,7 +21,7 @@ implements IBatchLoaderItemsStore<ID, R> {
2021
const item = this.itemsMap.get(id)
2122

2223
if (!item) {
23-
throw new Error(`Item with id: ${JSON.stringify(id)} not found`)
24+
throw new ItemNotFoundExceptionError(id)
2425
}
2526

2627
return Object.assign(item, patch)
@@ -32,9 +33,12 @@ implements IBatchLoaderItemsStore<ID, R> {
3233
const items = entries.map(([id, patch]) => {
3334
try {
3435
return this.update(id, patch)
35-
} catch {
36-
notFoundIds.push(id)
37-
return undefined
36+
} catch (error) {
37+
if (error instanceof ItemNotFoundExceptionError) {
38+
notFoundIds.push(id)
39+
return undefined
40+
}
41+
throw error
3842
}
3943
})
4044

src/ItemNotFoundExceptionError.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default class ItemNotFoundExceptionError extends Error {
2+
constructor(id: unknown) {
3+
super(`Item with id: ${JSON.stringify(id)} not found`)
4+
this.name = 'ItemNotFoundExceptionError'
5+
}
6+
}

tsconfig.build.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"src/**/*.ts"
55
],
66
"exclude": [
7+
"dist/**/*",
78
"src/**/*.spec.ts",
89
"jest.config.ts"
910
],

tsconfig.json

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
{
2-
// "files": [
3-
// "node_modules/@total-typescript/ts-reset/dist/recommended.d.ts",
4-
// ],
5-
// "include": [
6-
// "typings/**/*.ts",
7-
// "src/**/*.ts"
8-
// ],
2+
"files": [
3+
"node_modules/@total-typescript/ts-reset/dist/recommended.d.ts",
4+
],
5+
"include": [
6+
"src/**/*.ts"
7+
],
98
"compilerOptions": {
109
/* Visit https://aka.ms/tsconfig to read more about this file */
1110

0 commit comments

Comments
 (0)