Skip to content

Commit b6a77b3

Browse files
committed
fix(core): make execute sync timeout configurable
1 parent 48ba0e6 commit b6a77b3

File tree

4 files changed

+78
-3
lines changed

4 files changed

+78
-3
lines changed

packages/core/src/getRawState.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,24 @@ import type { Internal } from './internal';
1616
import { handleDraft } from './asyncClientStore';
1717
import { uuid } from './utils';
1818

19-
const clientExecuteSyncTimeoutMs = 1500;
19+
const defaultClientExecuteSyncTimeoutMs = 1500;
2020
const transportErrorMarker = '__coactionTransportError__';
2121

22+
const getClientExecuteSyncTimeoutMs = (
23+
options: StoreOptions<any> | ClientStoreOptions<any>
24+
) => {
25+
const timeout = (options as ClientStoreOptions<any>).executeSyncTimeoutMs;
26+
if (typeof timeout === 'undefined') {
27+
return defaultClientExecuteSyncTimeoutMs;
28+
}
29+
if (!Number.isFinite(timeout) || timeout < 0) {
30+
throw new Error(
31+
'executeSyncTimeoutMs must be a finite number greater than or equal to 0'
32+
);
33+
}
34+
return timeout;
35+
};
36+
2237
const isTransportErrorEnvelope = (
2338
value: unknown
2439
): value is Record<string, unknown> & { message: string } => {
@@ -51,6 +66,7 @@ export const getRawState = <T extends CreateState>(
5166
initialState: any,
5267
options: StoreOptions<T> | ClientStoreOptions<T>
5368
) => {
69+
const clientExecuteSyncTimeoutMs = getClientExecuteSyncTimeoutMs(options);
5470
const rawState = {} as Record<string, any>;
5571
const handle = (_rawState: any, _initialState: any, sliceKey?: string) => {
5672
internal.mutableInstance = internal.toMutableRaw?.(_initialState);

packages/core/src/interface.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,16 @@ export interface ClientTransportOptions {
234234
* Prefer passing `clientTransport` or `worker`.
235235
*/
236236
workerType?: 'WebWorkerClient' | 'SharedWorkerClient';
237+
/**
238+
* How long the client should wait for sequence catch-up before falling back
239+
* to `fullSync`.
240+
*
241+
* Increase this when worker-side execution can complete before the matching
242+
* incremental `update` message arrives under heavy load.
243+
*
244+
* @default 1500
245+
*/
246+
executeSyncTimeoutMs?: number;
237247
/**
238248
* Inject a pre-built client transport.
239249
*/

packages/core/test/getRawState.branch.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ type ClientStoreContext = {
99
};
1010

1111
const createClientStoreContext = (
12-
emitImpl: (...args: any[]) => Promise<any>
12+
emitImpl: (...args: any[]) => Promise<any>,
13+
options: Record<string, unknown> = {}
1314
): ClientStoreContext => {
1415
const subscriptions = new Set<() => void>();
1516
const internal = {
@@ -41,7 +42,7 @@ const createClientStoreContext = (
4142
return step + 1;
4243
}
4344
},
44-
{}
45+
options
4546
);
4647
return {
4748
internal,
@@ -187,6 +188,41 @@ test('client action performs fullSync when sequence catch-up times out', async (
187188
}
188189
});
189190

191+
test('client action uses custom executeSyncTimeoutMs before falling back to fullSync', async () => {
192+
vi.useFakeTimers();
193+
try {
194+
const { store, internal, trigger } = createClientStoreContext(
195+
async (event) => {
196+
if (event === 'execute') {
197+
return ['ok', 2];
198+
}
199+
if (event === 'fullSync') {
200+
return {
201+
state: JSON.stringify({
202+
count: 9
203+
}),
204+
sequence: 2
205+
};
206+
}
207+
throw new Error(`Unexpected event: ${String(event)}`);
208+
},
209+
{
210+
executeSyncTimeoutMs: 5_000
211+
}
212+
);
213+
internal.sequence = 0;
214+
const pending = store.getState().increment(1);
215+
await Promise.resolve();
216+
await vi.advanceTimersByTimeAsync(1_600);
217+
expect(store.transport.emit).not.toHaveBeenCalledWith('fullSync');
218+
internal.sequence = 2;
219+
trigger();
220+
await expect(pending).resolves.toBe('ok');
221+
} finally {
222+
vi.useRealTimers();
223+
}
224+
});
225+
190226
test('client action rejects when fullSync fallback fails', async () => {
191227
vi.useFakeTimers();
192228
try {
@@ -210,6 +246,16 @@ test('client action rejects when fullSync fallback fails', async () => {
210246
}
211247
});
212248

249+
test('client action rejects invalid executeSyncTimeoutMs configuration', () => {
250+
expect(() => {
251+
createClientStoreContext(async () => ['ok', 0], {
252+
executeSyncTimeoutMs: -1
253+
});
254+
}).toThrow(
255+
'executeSyncTimeoutMs must be a finite number greater than or equal to 0'
256+
);
257+
});
258+
213259
test('client action rejects when fullSync payload is invalid', async () => {
214260
vi.useFakeTimers();
215261
try {

packages/core/test/types.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ test('preserves deprecated public compatibility fields', () => {
2222
expectTypeOf<
2323
ClientStoreOptions<{ count: number }>['workerType']
2424
>().toEqualTypeOf<'SharedWorkerClient' | 'WebWorkerClient' | undefined>();
25+
expectTypeOf<
26+
ClientStoreOptions<{ count: number }>['executeSyncTimeoutMs']
27+
>().toEqualTypeOf<number | undefined>();
2528
});

0 commit comments

Comments
 (0)