@cplieger/actions 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/jsr.json +8 -12
- package/package.json +7 -9
- package/src/__test-helpers__/action-test-setup.ts +34 -0
- package/src/api-idempotency.test.ts +163 -0
- package/src/api.test.ts +96 -0
- package/src/api.ts +0 -0
- package/src/cleanup.test.ts +102 -0
- package/src/cleanup.ts +0 -0
- package/src/configure-api.test.ts +219 -0
- package/src/cross-action-coordination.test.ts +176 -0
- package/src/cross-action-races-extra.test.ts +231 -0
- package/src/cross-action-races.test.ts +213 -0
- package/src/cross-action-scope-serial.test.ts +121 -0
- package/src/debounce.test.ts +121 -0
- package/src/debounce.ts +0 -0
- package/src/define-helpers.ts +0 -0
- package/src/define.property.test.ts +133 -0
- package/src/define.test.ts +476 -0
- package/src/define.ts +0 -0
- package/src/dispatch-callbacks.test.ts +141 -0
- package/src/dispatch-options.test.ts +197 -0
- package/src/edge-cases.test.ts +81 -0
- package/src/error.test.ts +268 -0
- package/src/error.ts +0 -0
- package/src/index.ts +0 -0
- package/src/leak-stress.test.ts +117 -0
- package/src/loading.test.ts +146 -0
- package/src/loading.ts +0 -0
- package/src/new-features.test.ts +246 -0
- package/src/notifier.ts +0 -0
- package/src/poll.test.ts +385 -0
- package/src/poll.ts +0 -0
- package/src/primitive-edge-cases.test.ts +105 -0
- package/src/primitive-interactions.test.ts +112 -0
- package/src/primitives.test.ts +215 -0
- package/src/race-dedupe-cancel.test.ts +88 -0
- package/src/redteam.test.ts +254 -0
- package/src/redteam2.test.ts +337 -0
- package/src/redteam3.test.ts +454 -0
- package/src/registry.test.ts +271 -0
- package/src/registry.ts +0 -0
- package/src/retry-network-delay.test.ts +155 -0
- package/src/retry.ts +0 -0
- package/src/transport.test.ts +139 -0
- package/src/transport.ts +0 -0
- package/src/types.ts +0 -0
- package/.editorconfig +0 -19
- package/.htmlvalidate.json +0 -28
- package/.stylelintrc.json +0 -67
- package/dist/src/api.d.ts +0 -56
- package/dist/src/api.js +0 -158
- package/dist/src/cleanup.d.ts +0 -11
- package/dist/src/cleanup.js +0 -63
- package/dist/src/debounce.d.ts +0 -28
- package/dist/src/debounce.js +0 -91
- package/dist/src/define-helpers.d.ts +0 -20
- package/dist/src/define-helpers.js +0 -83
- package/dist/src/define.d.ts +0 -14
- package/dist/src/define.js +0 -627
- package/dist/src/error.d.ts +0 -42
- package/dist/src/error.js +0 -174
- package/dist/src/index.d.ts +0 -17
- package/dist/src/index.js +0 -22
- package/dist/src/loading.d.ts +0 -15
- package/dist/src/loading.js +0 -114
- package/dist/src/notifier.d.ts +0 -21
- package/dist/src/notifier.js +0 -21
- package/dist/src/poll.d.ts +0 -23
- package/dist/src/poll.js +0 -120
- package/dist/src/registry.d.ts +0 -18
- package/dist/src/registry.js +0 -210
- package/dist/src/retry.d.ts +0 -9
- package/dist/src/retry.js +0 -78
- package/dist/src/transport.d.ts +0 -46
- package/dist/src/transport.js +0 -70
- package/dist/src/types.d.ts +0 -157
- package/dist/src/types.js +0 -7
- package/eslint.config.base.mjs +0 -186
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
vi.mock("./notifier.js", () => ({
|
|
4
|
+
configure: vi.fn(),
|
|
5
|
+
notifySuccess: vi.fn(),
|
|
6
|
+
notifyError: vi.fn(),
|
|
7
|
+
_resetNotifierForTest: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
import { defineAction, _resetForTest as resetDefine, _internalsForTest } from "./define.js";
|
|
10
|
+
import { _resetForTest as resetRegistry } from "./registry.js";
|
|
11
|
+
import { _resetForTest as resetCleanup } from "./cleanup.js";
|
|
12
|
+
import { ActionError } from "./error.js";
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetDefine();
|
|
16
|
+
resetRegistry();
|
|
17
|
+
resetCleanup();
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("dedupe + retry interaction", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("deduped caller's onError fires with the real error after retries exhaust", async () => {
|
|
30
|
+
let attempt = 0;
|
|
31
|
+
const action = defineAction<string, string>({
|
|
32
|
+
name: "test.dedupe_retry",
|
|
33
|
+
dedupe: true,
|
|
34
|
+
retryable: (err) => err.code !== "cancelled",
|
|
35
|
+
retry: { count: 2, delay: 50 },
|
|
36
|
+
error: false,
|
|
37
|
+
run: () => {
|
|
38
|
+
attempt++;
|
|
39
|
+
throw new ActionError("server down", { status: 503 });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const onError1 = vi.fn();
|
|
43
|
+
const onError2 = vi.fn();
|
|
44
|
+
const p1 = action.dispatch("x", { onError: onError1 });
|
|
45
|
+
const p2 = action.dispatch("x", { onError: onError2 });
|
|
46
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
47
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
48
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
49
|
+
expect(r1).toBeNull();
|
|
50
|
+
expect(r2).toBeNull();
|
|
51
|
+
expect(attempt).toBe(3);
|
|
52
|
+
expect(onError1).toHaveBeenCalledWith(
|
|
53
|
+
expect.objectContaining({ message: "server down", status: 503 }),
|
|
54
|
+
"x",
|
|
55
|
+
);
|
|
56
|
+
expect(onError2).toHaveBeenCalledWith(
|
|
57
|
+
expect.objectContaining({ message: "server down", status: 503 }),
|
|
58
|
+
"x",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("dedupe map is cleaned up after retry exhaustion", async () => {
|
|
63
|
+
const action = defineAction<string, string>({
|
|
64
|
+
name: "test.dedupe_cleanup",
|
|
65
|
+
dedupe: true,
|
|
66
|
+
retryable: (err) => err.code !== "cancelled",
|
|
67
|
+
retry: { count: 1, delay: 20 },
|
|
68
|
+
error: false,
|
|
69
|
+
run: () => {
|
|
70
|
+
throw new ActionError("fail", { status: 500 });
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const p = action.dispatch("z");
|
|
74
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
75
|
+
await p;
|
|
76
|
+
const { activeDedupes } = _internalsForTest();
|
|
77
|
+
expect(activeDedupes).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("cancel during scope-queued wait", () => {
|
|
82
|
+
it("cancelling a queued action that hasn't started lets subsequent actions proceed", async () => {
|
|
83
|
+
let resolveOccupant: (() => void) | null = null;
|
|
84
|
+
const order: string[] = [];
|
|
85
|
+
const occupant = defineAction<void, string>({
|
|
86
|
+
name: "test.queue_cancel_occ",
|
|
87
|
+
scope: "q-cancel",
|
|
88
|
+
run: () =>
|
|
89
|
+
new Promise<string>((r) => {
|
|
90
|
+
resolveOccupant = () => {
|
|
91
|
+
r("occ");
|
|
92
|
+
};
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
const victim = defineAction<void, string>({
|
|
96
|
+
name: "test.queue_cancel_victim",
|
|
97
|
+
scope: "q-cancel",
|
|
98
|
+
error: false,
|
|
99
|
+
run: () => {
|
|
100
|
+
order.push("victim-run");
|
|
101
|
+
return Promise.resolve("victim");
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const follower = defineAction<void, string>({
|
|
105
|
+
name: "test.queue_cancel_follower",
|
|
106
|
+
scope: "q-cancel",
|
|
107
|
+
run: () => {
|
|
108
|
+
order.push("follower-run");
|
|
109
|
+
return Promise.resolve("follower");
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const pOcc = occupant.dispatch();
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
const pVictim = victim.dispatch();
|
|
115
|
+
const pFollower = follower.dispatch();
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
victim.cancel();
|
|
118
|
+
resolveOccupant!();
|
|
119
|
+
await pOcc;
|
|
120
|
+
const rVictim = await pVictim;
|
|
121
|
+
expect(rVictim).toBeNull();
|
|
122
|
+
expect(order).not.toContain("victim-run");
|
|
123
|
+
const rFollower = await pFollower;
|
|
124
|
+
expect(rFollower).toBe("follower");
|
|
125
|
+
expect(order).toContain("follower-run");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("dedupe + cancel interaction", () => {
|
|
130
|
+
it("cancelling the original dispatch propagates cancellation to deduped caller", async () => {
|
|
131
|
+
const action = defineAction<string, string>({
|
|
132
|
+
name: "test.dedupe_cancel",
|
|
133
|
+
dedupe: true,
|
|
134
|
+
error: false,
|
|
135
|
+
run: (_args, signal) =>
|
|
136
|
+
new Promise<string>((_, reject) => {
|
|
137
|
+
signal.addEventListener("abort", () => {
|
|
138
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
139
|
+
});
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
const onSettled1 = vi.fn();
|
|
143
|
+
const onSettled2 = vi.fn();
|
|
144
|
+
const p1 = action.dispatch("a", { onSettled: onSettled1 });
|
|
145
|
+
const p2 = action.dispatch("a", { onSettled: onSettled2 });
|
|
146
|
+
action.cancel();
|
|
147
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
148
|
+
expect(r1).toBeNull();
|
|
149
|
+
expect(r2).toBeNull();
|
|
150
|
+
expect(onSettled1).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(onSettled2).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("dedupe map is cleaned up after cancellation", async () => {
|
|
155
|
+
const action = defineAction<string, string>({
|
|
156
|
+
name: "test.dedupe_cancel_cleanup",
|
|
157
|
+
dedupe: true,
|
|
158
|
+
error: false,
|
|
159
|
+
run: (_args, signal) =>
|
|
160
|
+
new Promise<string>((_, reject) => {
|
|
161
|
+
signal.addEventListener("abort", () => {
|
|
162
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
163
|
+
});
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
const p = action.dispatch("b");
|
|
167
|
+
action.cancel();
|
|
168
|
+
await p;
|
|
169
|
+
const { activeDedupes } = _internalsForTest();
|
|
170
|
+
expect(activeDedupes).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("interleaved cross-action scope chain ordering", () => {
|
|
175
|
+
it("A-B-A-B dispatches serialize in dispatch order", async () => {
|
|
176
|
+
const order: string[] = [];
|
|
177
|
+
const actionA = defineAction<number, string>({
|
|
178
|
+
name: "test.interleave_A",
|
|
179
|
+
scope: "interleave",
|
|
180
|
+
run: (n) => {
|
|
181
|
+
order.push(`A-${String(n)}`);
|
|
182
|
+
return Promise.resolve(`A-${String(n)}`);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const actionB = defineAction<number, string>({
|
|
186
|
+
name: "test.interleave_B",
|
|
187
|
+
scope: "interleave",
|
|
188
|
+
run: (n) => {
|
|
189
|
+
order.push(`B-${String(n)}`);
|
|
190
|
+
return Promise.resolve(`B-${String(n)}`);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
const p1 = actionA.dispatch(1);
|
|
194
|
+
const p2 = actionB.dispatch(1);
|
|
195
|
+
const p3 = actionA.dispatch(2);
|
|
196
|
+
const p4 = actionB.dispatch(2);
|
|
197
|
+
await Promise.all([p1, p2, p3, p4]);
|
|
198
|
+
expect(order).toEqual(["A-1", "B-1", "A-2", "B-2"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("scope chain drains completely — no leaked promises", async () => {
|
|
202
|
+
const action = defineAction<number, number>({
|
|
203
|
+
name: "test.drain",
|
|
204
|
+
scope: "drain",
|
|
205
|
+
run: (n) => Promise.resolve(n),
|
|
206
|
+
});
|
|
207
|
+
await action.dispatch(1);
|
|
208
|
+
await action.dispatch(2);
|
|
209
|
+
await action.dispatch(3);
|
|
210
|
+
const { scopeChains } = _internalsForTest();
|
|
211
|
+
expect(scopeChains).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
vi.mock("./notifier.js", () => ({
|
|
4
|
+
configure: vi.fn(),
|
|
5
|
+
notifySuccess: vi.fn(),
|
|
6
|
+
notifyError: vi.fn(),
|
|
7
|
+
_resetNotifierForTest: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
import { defineAction, _resetForTest as resetDefine } from "./define.js";
|
|
10
|
+
import { _resetForTest as resetRegistry } from "./registry.js";
|
|
11
|
+
import { _resetForTest as resetCleanup } from "./cleanup.js";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetDefine();
|
|
15
|
+
resetRegistry();
|
|
16
|
+
resetCleanup();
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("cross-action scope serialization after cancel", () => {
|
|
21
|
+
it("C waits for A when B is cancelled (middle entry)", async () => {
|
|
22
|
+
const order: string[] = [];
|
|
23
|
+
let resolveA!: (v: string) => void;
|
|
24
|
+
const actionA = defineAction<void, string>({
|
|
25
|
+
name: "test.serial_A",
|
|
26
|
+
scope: "serial",
|
|
27
|
+
run: () => {
|
|
28
|
+
order.push("A-start");
|
|
29
|
+
return new Promise<string>((r) => {
|
|
30
|
+
resolveA = r;
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const actionB = defineAction<void, string>({
|
|
35
|
+
name: "test.serial_B",
|
|
36
|
+
scope: "serial",
|
|
37
|
+
error: false,
|
|
38
|
+
run: (_args, signal) => {
|
|
39
|
+
order.push("B-start");
|
|
40
|
+
if (signal.aborted) {
|
|
41
|
+
throw new DOMException("aborted", "AbortError");
|
|
42
|
+
}
|
|
43
|
+
return Promise.resolve("B");
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const actionC = defineAction<void, string>({
|
|
47
|
+
name: "test.serial_C",
|
|
48
|
+
scope: "serial",
|
|
49
|
+
run: () => {
|
|
50
|
+
order.push("C-start");
|
|
51
|
+
return Promise.resolve("C");
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const pA = actionA.dispatch();
|
|
55
|
+
await Promise.resolve();
|
|
56
|
+
expect(order).toEqual(["A-start"]);
|
|
57
|
+
const pB = actionB.dispatch();
|
|
58
|
+
const pC = actionC.dispatch();
|
|
59
|
+
actionB.cancel();
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
await Promise.resolve();
|
|
62
|
+
await Promise.resolve();
|
|
63
|
+
expect(order).toEqual(["A-start"]);
|
|
64
|
+
resolveA("A-done");
|
|
65
|
+
const [rA, rB, rC] = await Promise.all([pA, pB, pC]);
|
|
66
|
+
expect(rA).toBe("A-done");
|
|
67
|
+
expect(rB).toBeNull();
|
|
68
|
+
expect(rC).toBe("C");
|
|
69
|
+
expect(order).toEqual(["A-start", "C-start"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("D waits for A when B (last entry) is cancelled then D dispatches", async () => {
|
|
73
|
+
const order: string[] = [];
|
|
74
|
+
let resolveA!: (v: string) => void;
|
|
75
|
+
const actionA = defineAction<void, string>({
|
|
76
|
+
name: "test.last_A",
|
|
77
|
+
scope: "last-cancel",
|
|
78
|
+
run: () => {
|
|
79
|
+
order.push("A-start");
|
|
80
|
+
return new Promise<string>((r) => {
|
|
81
|
+
resolveA = r;
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const actionB = defineAction<void, string>({
|
|
86
|
+
name: "test.last_B",
|
|
87
|
+
scope: "last-cancel",
|
|
88
|
+
error: false,
|
|
89
|
+
run: (_args, signal) => {
|
|
90
|
+
order.push("B-start");
|
|
91
|
+
if (signal.aborted) {
|
|
92
|
+
throw new DOMException("aborted", "AbortError");
|
|
93
|
+
}
|
|
94
|
+
return Promise.resolve("B");
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const actionD = defineAction<void, string>({
|
|
98
|
+
name: "test.last_D",
|
|
99
|
+
scope: "last-cancel",
|
|
100
|
+
run: () => {
|
|
101
|
+
order.push("D-start");
|
|
102
|
+
return Promise.resolve("D");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const pA = actionA.dispatch();
|
|
106
|
+
await Promise.resolve();
|
|
107
|
+
const pB = actionB.dispatch();
|
|
108
|
+
actionB.cancel();
|
|
109
|
+
const pD = actionD.dispatch();
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
expect(order).toEqual(["A-start"]);
|
|
114
|
+
resolveA("A-done");
|
|
115
|
+
const [rA, rB, rD] = await Promise.all([pA, pB, pD]);
|
|
116
|
+
expect(rA).toBe("A-done");
|
|
117
|
+
expect(rB).toBeNull();
|
|
118
|
+
expect(rD).toBe("D");
|
|
119
|
+
expect(order).toEqual(["A-start", "D-start"]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
vi.mock("./notifier.js", () => ({
|
|
4
|
+
configure: vi.fn(),
|
|
5
|
+
notifySuccess: vi.fn(),
|
|
6
|
+
notifyError: vi.fn(),
|
|
7
|
+
_resetNotifierForTest: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
import { defineAction, _resetForTest as resetDefine } from "./define.js";
|
|
10
|
+
import { _resetForTest as resetRegistry } from "./registry.js";
|
|
11
|
+
import { _resetForTest as resetCleanup } from "./cleanup.js";
|
|
12
|
+
import { debouncedDispatch } from "./debounce.js";
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetDefine();
|
|
16
|
+
resetRegistry();
|
|
17
|
+
resetCleanup();
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function makeAction() {
|
|
22
|
+
const run = vi.fn(async (args: string) => args);
|
|
23
|
+
const action = defineAction({ name: "test.debounce", run });
|
|
24
|
+
return { action, run };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("debouncedDispatch — trailing (default)", () => {
|
|
28
|
+
it("dispatches after the wait period", () => {
|
|
29
|
+
const { action, run } = makeAction();
|
|
30
|
+
const debounced = debouncedDispatch(action, { wait: 100 });
|
|
31
|
+
debounced("a");
|
|
32
|
+
expect(run).not.toHaveBeenCalled();
|
|
33
|
+
vi.advanceTimersByTime(100);
|
|
34
|
+
expect(run).toHaveBeenCalledWith("a", expect.anything(), expect.anything());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("coalesces rapid calls — only last args dispatched", () => {
|
|
38
|
+
const { action, run } = makeAction();
|
|
39
|
+
const debounced = debouncedDispatch(action, { wait: 50 });
|
|
40
|
+
debounced("a");
|
|
41
|
+
debounced("b");
|
|
42
|
+
debounced("c");
|
|
43
|
+
vi.advanceTimersByTime(50);
|
|
44
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(run).toHaveBeenCalledWith("c", expect.anything(), expect.anything());
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("cancel() prevents the pending dispatch", () => {
|
|
49
|
+
const { action, run } = makeAction();
|
|
50
|
+
const debounced = debouncedDispatch(action, { wait: 100 });
|
|
51
|
+
debounced("a");
|
|
52
|
+
debounced.cancel();
|
|
53
|
+
vi.advanceTimersByTime(200);
|
|
54
|
+
expect(run).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("flush() fires immediately with pending args", () => {
|
|
58
|
+
const { action, run } = makeAction();
|
|
59
|
+
const debounced = debouncedDispatch(action, { wait: 100 });
|
|
60
|
+
debounced("x");
|
|
61
|
+
debounced.flush();
|
|
62
|
+
expect(run).toHaveBeenCalledWith("x", expect.anything(), expect.anything());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("flush(args) overrides pending args", () => {
|
|
66
|
+
const { action, run } = makeAction();
|
|
67
|
+
const debounced = debouncedDispatch(action, { wait: 100 });
|
|
68
|
+
debounced("old");
|
|
69
|
+
debounced.flush("override");
|
|
70
|
+
expect(run).toHaveBeenCalledWith("override", expect.anything(), expect.anything());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("isPending() reflects scheduled state", () => {
|
|
74
|
+
const { action } = makeAction();
|
|
75
|
+
const debounced = debouncedDispatch(action, { wait: 100 });
|
|
76
|
+
expect(debounced.isPending()).toBe(false);
|
|
77
|
+
debounced("a");
|
|
78
|
+
expect(debounced.isPending()).toBe(true);
|
|
79
|
+
vi.advanceTimersByTime(100);
|
|
80
|
+
expect(debounced.isPending()).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("debouncedDispatch — leading", () => {
|
|
85
|
+
it("fires immediately on first call", () => {
|
|
86
|
+
const { action, run } = makeAction();
|
|
87
|
+
const debounced = debouncedDispatch(action, { wait: 100, leading: true });
|
|
88
|
+
debounced("first");
|
|
89
|
+
expect(run).toHaveBeenCalledWith("first", expect.anything(), expect.anything());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("suppresses calls within the cooldown window", () => {
|
|
93
|
+
const { action, run } = makeAction();
|
|
94
|
+
const debounced = debouncedDispatch(action, { wait: 100, leading: true });
|
|
95
|
+
debounced("a");
|
|
96
|
+
debounced("b");
|
|
97
|
+
debounced("c");
|
|
98
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(run).toHaveBeenCalledWith("a", expect.anything(), expect.anything());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("fires trailing with last suppressed args after cooldown", () => {
|
|
103
|
+
const { action, run } = makeAction();
|
|
104
|
+
const debounced = debouncedDispatch(action, { wait: 100, leading: true });
|
|
105
|
+
debounced("a");
|
|
106
|
+
debounced("b");
|
|
107
|
+
vi.advanceTimersByTime(100);
|
|
108
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
109
|
+
expect(run).toHaveBeenLastCalledWith("b", expect.anything(), expect.anything());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("cancel after leading fire prevents trailing fire", () => {
|
|
113
|
+
const { action, run } = makeAction();
|
|
114
|
+
const debounced = debouncedDispatch(action, { wait: 100, leading: true });
|
|
115
|
+
debounced("a");
|
|
116
|
+
debounced("b");
|
|
117
|
+
debounced.cancel();
|
|
118
|
+
vi.advanceTimersByTime(200);
|
|
119
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/debounce.ts
CHANGED
|
File without changes
|
package/src/define-helpers.ts
CHANGED
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import fc from "fast-check";
|
|
4
|
+
vi.mock("./notifier.js", () => ({
|
|
5
|
+
configure: vi.fn(),
|
|
6
|
+
notifySuccess: vi.fn(),
|
|
7
|
+
notifyError: vi.fn(),
|
|
8
|
+
_resetNotifierForTest: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
import { defineAction, _resetForTest as resetDefine } from "./define.js";
|
|
11
|
+
import { _resetForTest as resetRegistry } from "./registry.js";
|
|
12
|
+
import { _resetForTest as resetCleanup } from "./cleanup.js";
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetDefine();
|
|
16
|
+
resetRegistry();
|
|
17
|
+
resetCleanup();
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("scope serialization property", () => {
|
|
22
|
+
it("for any N dispatches with same scope key, runs execute sequentially and all resolve", async () => {
|
|
23
|
+
await fc.assert(
|
|
24
|
+
fc.asyncProperty(fc.integer({ min: 2, max: 8 }), async (n) => {
|
|
25
|
+
resetDefine();
|
|
26
|
+
resetRegistry();
|
|
27
|
+
resetCleanup();
|
|
28
|
+
const timeline: { idx: number; event: "start" | "end" }[] = [];
|
|
29
|
+
let counter = 0;
|
|
30
|
+
const action = defineAction<number, number>({
|
|
31
|
+
name: "prop.scope_serial",
|
|
32
|
+
scope: "serial",
|
|
33
|
+
error: false,
|
|
34
|
+
run: async (args) => {
|
|
35
|
+
const idx = args;
|
|
36
|
+
timeline.push({ idx, event: "start" });
|
|
37
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
38
|
+
timeline.push({ idx, event: "end" });
|
|
39
|
+
return counter++;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const promises = Array.from({ length: n }, (_, i) => action.dispatch(i));
|
|
43
|
+
const results = await Promise.all(promises);
|
|
44
|
+
expect(results).toHaveLength(n);
|
|
45
|
+
for (const r of results) {
|
|
46
|
+
expect(r).not.toBeNull();
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < timeline.length; i += 2) {
|
|
49
|
+
expect(timeline[i]!.event).toBe("start");
|
|
50
|
+
expect(timeline[i + 1]?.event).toBe("end");
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
{ numRuns: 20 },
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("dedupe property", () => {
|
|
59
|
+
it("for any N dispatches with dedupe:true and same args, at most one run is in-flight", async () => {
|
|
60
|
+
await fc.assert(
|
|
61
|
+
fc.asyncProperty(fc.integer({ min: 2, max: 6 }), async (n) => {
|
|
62
|
+
resetDefine();
|
|
63
|
+
resetRegistry();
|
|
64
|
+
resetCleanup();
|
|
65
|
+
let inFlight = 0;
|
|
66
|
+
let maxInFlight = 0;
|
|
67
|
+
let runCount = 0;
|
|
68
|
+
const action = defineAction<string, string>({
|
|
69
|
+
name: "prop.dedupe",
|
|
70
|
+
dedupe: true,
|
|
71
|
+
error: false,
|
|
72
|
+
run: async () => {
|
|
73
|
+
inFlight++;
|
|
74
|
+
runCount++;
|
|
75
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
76
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
77
|
+
inFlight--;
|
|
78
|
+
return "ok";
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const promises = Array.from({ length: n }, () => action.dispatch("same"));
|
|
82
|
+
const results = await Promise.all(promises);
|
|
83
|
+
expect(maxInFlight).toBe(1);
|
|
84
|
+
for (const r of results) {
|
|
85
|
+
expect(r).toBe("ok");
|
|
86
|
+
}
|
|
87
|
+
expect(runCount).toBe(1);
|
|
88
|
+
}),
|
|
89
|
+
{ numRuns: 20 },
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("cancellation property", () => {
|
|
95
|
+
it("cancel during any lifecycle phase transitions to cancelled and returns null", async () => {
|
|
96
|
+
await fc.assert(
|
|
97
|
+
fc.asyncProperty(fc.constantFrom("immediate", "during-run"), async (phase) => {
|
|
98
|
+
resetDefine();
|
|
99
|
+
resetRegistry();
|
|
100
|
+
resetCleanup();
|
|
101
|
+
let runStarted = false;
|
|
102
|
+
const action = defineAction<string, string>({
|
|
103
|
+
name: "prop.cancel",
|
|
104
|
+
error: false,
|
|
105
|
+
run: async (_args, signal) => {
|
|
106
|
+
runStarted = true;
|
|
107
|
+
return new Promise<string>((_resolve, reject) => {
|
|
108
|
+
signal.addEventListener(
|
|
109
|
+
"abort",
|
|
110
|
+
() => {
|
|
111
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
112
|
+
},
|
|
113
|
+
{ once: true },
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
if (phase === "immediate") {
|
|
119
|
+
const p = action.dispatch("x");
|
|
120
|
+
action.cancel();
|
|
121
|
+
expect(await p).toBeNull();
|
|
122
|
+
} else {
|
|
123
|
+
const p = action.dispatch("x");
|
|
124
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
125
|
+
expect(runStarted).toBe(true);
|
|
126
|
+
action.cancel();
|
|
127
|
+
expect(await p).toBeNull();
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
{ numRuns: 15 },
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|