@marimo-team/frontend 0.19.7-dev35 → 0.19.7-dev36
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/dist/assets/{JsonOutput-BGSqWZkD.js → JsonOutput-DtidtKaJ.js} +2 -2
- package/dist/assets/{MarimoErrorOutput-DFV7O8tN.js → MarimoErrorOutput-Cci2wITc.js} +1 -1
- package/dist/assets/{add-cell-with-ai-gDjJxct8.js → add-cell-with-ai-DT1ae2MK.js} +1 -1
- package/dist/assets/{add-database-form-CqEjMrq2.js → add-database-form-_pTLtiHN.js} +1 -1
- package/dist/assets/{agent-panel-D_hcgMlT.js → agent-panel-oLGqa4bG.js} +1 -1
- package/dist/assets/{ai-model-dropdown-C_l_zAXd.js → ai-model-dropdown-BL-PaF8o.js} +1 -1
- package/dist/assets/{app-config-button-CM05VD_Y.js → app-config-button-BiwYEPE8.js} +1 -1
- package/dist/assets/{cell-editor-ihhw9Ql3.js → cell-editor-B3U1SnYJ.js} +1 -1
- package/dist/assets/{chat-display-yGAtJXm0.js → chat-display-B34MaCGM.js} +1 -1
- package/dist/assets/{chat-panel-D3T1CjpZ.js → chat-panel-fuQFRvFm.js} +1 -1
- package/dist/assets/{column-preview-DbqM1diq.js → column-preview-B-dViv1i.js} +1 -1
- package/dist/assets/{command-palette-C2L80RlN.js → command-palette-KuNgJNix.js} +1 -1
- package/dist/assets/{common-D32S2RVD.js → common-DxKcMlJZ.js} +1 -1
- package/dist/assets/{dependency-graph-panel-C7ZXgECY.js → dependency-graph-panel-BZEIOxVz.js} +1 -1
- package/dist/assets/download-Bwa9P-Pz.js +6 -0
- package/dist/assets/{dropdown-menu-df9T83C0.js → dropdown-menu-B-6unW-7.js} +1 -1
- package/dist/assets/{edit-page-DFpXAt-U.js → edit-page-DoQyHH-0.js} +4 -4
- package/dist/assets/{error-panel-DKIWwbhe.js → error-panel-DulelhA-.js} +1 -1
- package/dist/assets/{file-explorer-panel-D4VHkjMW.js → file-explorer-panel-Dn9tKw3E.js} +1 -1
- package/dist/assets/{form-XjMNGyzu.js → form-B1n-e_X0.js} +1 -1
- package/dist/assets/{glide-data-editor-BBSxoBI-.js → glide-data-editor-HGkaxqOo.js} +1 -1
- package/dist/assets/home-page--XVUAUCM.js +4 -0
- package/dist/assets/hooks-B1nUQK2T.js +1 -0
- package/dist/assets/{html-to-image-DKvXQkl5.js → html-to-image-Cu1p0tCK.js} +2 -2
- package/dist/assets/{index-BRCNo5ma.js → index-BHikcWoK.js} +5 -5
- package/dist/assets/{index-DmMvDRRC.css → index-Bj5F80Z9.css} +1 -1
- package/dist/assets/{layout-DiDoTAUA.js → layout-KY92f2Sm.js} +3 -3
- package/dist/assets/{markdown-renderer-xQJ1KM4c.js → markdown-renderer-Dpn5NCvn.js} +1 -1
- package/dist/assets/{packages-panel-D53RuG3X.js → packages-panel-CBc59eNR.js} +1 -1
- package/dist/assets/{panels-BhznEx5N.js → panels-B0B71dYl.js} +1 -1
- package/dist/assets/{popover-D16ZremR.js → popover-Gz-GJzym.js} +1 -1
- package/dist/assets/{readonly-python-code-kImQwJ5f.js → readonly-python-code-CCwpyiLX.js} +1 -1
- package/dist/assets/{renderShortcut-DHc-p-_c.js → renderShortcut-DEwfrKeS.js} +1 -1
- package/dist/assets/run-page-Dug0EU2T.js +1 -0
- package/dist/assets/{scratchpad-panel-C8wx6cRl.js → scratchpad-panel-SMFZ5eRQ.js} +1 -1
- package/dist/assets/{secrets-panel-Br6CcsOE.js → secrets-panel-BaEqnh6m.js} +1 -1
- package/dist/assets/{session-panel-JOOuJNOH.js → session-panel-CR_CZBSy.js} +1 -1
- package/dist/assets/table-C8uQmBAN.js +1 -0
- package/dist/assets/{terminal-DNwT6UrR.js → terminal-C7HXI-7B.js} +1 -1
- package/dist/assets/{tree-BdwmBGSx.js → tree-B1vM35Zj.js} +1 -1
- package/dist/assets/{useAddCell-BMDEXuVk.js → useAddCell-DRmuczCx.js} +1 -1
- package/dist/assets/{useCellActionButton-BDMlZzyv.js → useCellActionButton-DwRoApVS.js} +1 -1
- package/dist/assets/{useDeleteCell-BrMXAFkS.js → useDeleteCell-CR3IczUk.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-B63bb4YX.js → useDependencyPanelTab-CLgnO1zH.js} +1 -1
- package/dist/assets/useNotebookActions-DhF-uJ0P.js +1 -0
- package/dist/assets/{useSplitCell-Cb0lf5MV.js → useSplitCell-IQsKBoRj.js} +1 -1
- package/dist/assets/{utilities.esm-BVFPJPyV.js → utilities.esm-DyYLtC1k.js} +2 -2
- package/dist/index.html +20 -20
- package/package.json +1 -1
- package/src/components/data-table/TableActions.tsx +5 -3
- package/src/components/data-table/download-actions.tsx +7 -2
- package/src/components/data-table/pagination.tsx +4 -4
- package/src/components/debug/indicator.tsx +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +4 -2
- package/src/components/editor/chrome/panels/context-aware-panel/context-aware-panel.tsx +1 -1
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +4 -4
- package/src/components/editor/chrome/wrapper/footer.tsx +1 -1
- package/src/components/editor/chrome/wrapper/sidebar.tsx +1 -1
- package/src/components/editor/controls/Controls.tsx +2 -2
- package/src/components/editor/controls/notebook-menu-dropdown.tsx +1 -1
- package/src/components/editor/file-tree/file-explorer.tsx +1 -1
- package/src/components/editor/header/status.tsx +1 -1
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +13 -4
- package/src/components/home/components.tsx +1 -1
- package/src/components/static-html/static-banner.tsx +1 -1
- package/src/components/ui/dropdown-menu.tsx +1 -1
- package/src/components/ui/table.tsx +1 -1
- package/src/core/export/__tests__/hooks.test.ts +60 -58
- package/src/core/export/hooks.ts +71 -31
- package/src/css/app/print.css +0 -14
- package/src/utils/__tests__/async-capture-tracker.test.ts +353 -0
- package/src/utils/__tests__/download.test.tsx +5 -114
- package/src/utils/async-capture-tracker.ts +168 -0
- package/src/utils/download.ts +17 -57
- package/src/utils/html-to-image.ts +9 -12
- package/dist/assets/download-CzPV-R6Z.js +0 -6
- package/dist/assets/home-page-DBWXVxWa.js +0 -4
- package/dist/assets/hooks-CpjzmBkw.js +0 -1
- package/dist/assets/run-page-BCOAGhl5.js +0 -1
- package/dist/assets/table-BSASHvkq.js +0 -1
- package/dist/assets/useNotebookActions-Cj4FtiIb.js +0 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { AsyncCaptureTracker } from "../async-capture-tracker";
|
|
5
|
+
|
|
6
|
+
describe("AsyncCaptureTracker", () => {
|
|
7
|
+
let tracker: AsyncCaptureTracker<string, string>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tracker = new AsyncCaptureTracker();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("needsCapture", () => {
|
|
14
|
+
it("should return true for keys not yet captured", () => {
|
|
15
|
+
expect(tracker.needsCapture("a", "value1")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should return false for keys captured with the same value", () => {
|
|
19
|
+
const handle = tracker.startCapture("a", "value1");
|
|
20
|
+
handle.markCaptured("result");
|
|
21
|
+
expect(tracker.needsCapture("a", "value1")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should return true for keys captured with a different value", () => {
|
|
25
|
+
const handle = tracker.startCapture("a", "value1");
|
|
26
|
+
handle.markCaptured("result");
|
|
27
|
+
expect(tracker.needsCapture("a", "value2")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should return false for keys in-flight with the same value", () => {
|
|
31
|
+
tracker.startCapture("a", "value1");
|
|
32
|
+
expect(tracker.needsCapture("a", "value1")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should return true for keys in-flight with a different value", () => {
|
|
36
|
+
tracker.startCapture("a", "value1");
|
|
37
|
+
expect(tracker.needsCapture("a", "value2")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return true for keys that failed (returned to idle)", () => {
|
|
41
|
+
const handle = tracker.startCapture("a", "value1");
|
|
42
|
+
handle.markFailed();
|
|
43
|
+
expect(tracker.needsCapture("a", "value1")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("startCapture", () => {
|
|
48
|
+
it("should return a handle with a non-aborted signal", () => {
|
|
49
|
+
const handle = tracker.startCapture("a", "v");
|
|
50
|
+
expect(handle.signal.aborted).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should abort only the same key's previous signal", () => {
|
|
54
|
+
const handleA = tracker.startCapture("a", "v1");
|
|
55
|
+
const handleB = tracker.startCapture("b", "v1");
|
|
56
|
+
const handleA2 = tracker.startCapture("a", "v2");
|
|
57
|
+
|
|
58
|
+
expect(handleA.signal.aborted).toBe(true);
|
|
59
|
+
expect(handleB.signal.aborted).toBe(false);
|
|
60
|
+
expect(handleA2.signal.aborted).toBe(false);
|
|
61
|
+
|
|
62
|
+
// Unrelated key still completes independently
|
|
63
|
+
handleB.markCaptured("result-b");
|
|
64
|
+
expect(tracker.needsCapture("b", "v1")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should not abort other keys when one is re-captured", () => {
|
|
68
|
+
tracker.startCapture("a", "v");
|
|
69
|
+
tracker.startCapture("b", "v");
|
|
70
|
+
tracker.startCapture("c", "v");
|
|
71
|
+
|
|
72
|
+
const handleB2 = tracker.startCapture("b", "v2");
|
|
73
|
+
|
|
74
|
+
expect(tracker.needsCapture("a", "v")).toBe(false); // still in-flight
|
|
75
|
+
expect(tracker.needsCapture("c", "v")).toBe(false); // still in-flight
|
|
76
|
+
expect(handleB2.signal.aborted).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should mark key as in-flight", () => {
|
|
80
|
+
tracker.startCapture("a", "v");
|
|
81
|
+
expect(tracker.isCapturing).toBe(true);
|
|
82
|
+
expect(tracker.needsCapture("a", "v")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("handle.markCaptured", () => {
|
|
87
|
+
it("should remove key from in-flight", () => {
|
|
88
|
+
const handle = tracker.startCapture("a", "v");
|
|
89
|
+
expect(tracker.isCapturing).toBe(true);
|
|
90
|
+
handle.markCaptured("result");
|
|
91
|
+
expect(tracker.isCapturing).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should persist the captured value", () => {
|
|
95
|
+
const handle = tracker.startCapture("a", "value1");
|
|
96
|
+
handle.markCaptured("result");
|
|
97
|
+
expect(tracker.needsCapture("a", "value1")).toBe(false);
|
|
98
|
+
expect(tracker.needsCapture("a", "value2")).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should update the captured value on re-capture", () => {
|
|
102
|
+
const h1 = tracker.startCapture("a", "v1");
|
|
103
|
+
h1.markCaptured("r1");
|
|
104
|
+
|
|
105
|
+
const h2 = tracker.startCapture("a", "v2");
|
|
106
|
+
h2.markCaptured("r2");
|
|
107
|
+
|
|
108
|
+
expect(tracker.needsCapture("a", "v1")).toBe(true);
|
|
109
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should be a no-op when handle is stale (superseded)", () => {
|
|
113
|
+
const handle1 = tracker.startCapture("a", "v1");
|
|
114
|
+
const handle2 = tracker.startCapture("a", "v2");
|
|
115
|
+
|
|
116
|
+
// handle1 is stale — markCaptured should not affect state
|
|
117
|
+
handle1.markCaptured("stale-result");
|
|
118
|
+
|
|
119
|
+
// "a" should still be in-flight with v2
|
|
120
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false); // in-flight
|
|
121
|
+
expect(tracker.needsCapture("a", "v1")).toBe(true); // not captured
|
|
122
|
+
expect(tracker.isCapturing).toBe(true);
|
|
123
|
+
|
|
124
|
+
// handle2 still works
|
|
125
|
+
handle2.markCaptured("real-result");
|
|
126
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false); // captured
|
|
127
|
+
expect(tracker.isCapturing).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("handle.markFailed", () => {
|
|
132
|
+
it("should remove key from in-flight", () => {
|
|
133
|
+
const handle = tracker.startCapture("a", "v");
|
|
134
|
+
handle.markFailed();
|
|
135
|
+
expect(tracker.isCapturing).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should not add a captured entry", () => {
|
|
139
|
+
const handle = tracker.startCapture("a", "v");
|
|
140
|
+
handle.markFailed();
|
|
141
|
+
expect(tracker.needsCapture("a", "v")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should not clear a previously captured value", () => {
|
|
145
|
+
const h1 = tracker.startCapture("a", "v1");
|
|
146
|
+
h1.markCaptured("r1");
|
|
147
|
+
|
|
148
|
+
const h2 = tracker.startCapture("a", "v2");
|
|
149
|
+
h2.markFailed();
|
|
150
|
+
|
|
151
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
152
|
+
expect(tracker.needsCapture("a", "v2")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should be a no-op when handle is stale", () => {
|
|
156
|
+
const handle1 = tracker.startCapture("a", "v1");
|
|
157
|
+
tracker.startCapture("a", "v2");
|
|
158
|
+
|
|
159
|
+
handle1.markFailed();
|
|
160
|
+
|
|
161
|
+
// "a" should still be in-flight with v2
|
|
162
|
+
expect(tracker.isCapturing).toBe(true);
|
|
163
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("waitForInFlight", () => {
|
|
168
|
+
it("should return a promise for in-flight key with same value", async () => {
|
|
169
|
+
const handle = tracker.startCapture("a", "v1");
|
|
170
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
171
|
+
expect(promise).not.toBeNull();
|
|
172
|
+
|
|
173
|
+
handle.markCaptured("result-a");
|
|
174
|
+
const result = await promise;
|
|
175
|
+
expect(result).toBe("result-a");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should return null for captured key (not in-flight)", () => {
|
|
179
|
+
const handle = tracker.startCapture("a", "v1");
|
|
180
|
+
handle.markCaptured("result");
|
|
181
|
+
expect(tracker.waitForInFlight("a", "v1")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should return null for key not being tracked", () => {
|
|
185
|
+
expect(tracker.waitForInFlight("a", "v1")).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("should return null for in-flight key with different value", () => {
|
|
189
|
+
tracker.startCapture("a", "v1");
|
|
190
|
+
expect(tracker.waitForInFlight("a", "v2")).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should resolve with undefined when capture fails", async () => {
|
|
194
|
+
const handle = tracker.startCapture("a", "v1");
|
|
195
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
196
|
+
|
|
197
|
+
handle.markFailed();
|
|
198
|
+
const result = await promise;
|
|
199
|
+
expect(result).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should resolve with undefined when capture is superseded", async () => {
|
|
203
|
+
tracker.startCapture("a", "v1");
|
|
204
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
205
|
+
|
|
206
|
+
// Supersede with new capture
|
|
207
|
+
tracker.startCapture("a", "v2");
|
|
208
|
+
const result = await promise;
|
|
209
|
+
expect(result).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should resolve with undefined when aborted", async () => {
|
|
213
|
+
tracker.startCapture("a", "v1");
|
|
214
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
215
|
+
|
|
216
|
+
tracker.abort();
|
|
217
|
+
const result = await promise;
|
|
218
|
+
expect(result).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should allow multiple waiters on the same key", async () => {
|
|
222
|
+
const handle = tracker.startCapture("a", "v1");
|
|
223
|
+
const p1 = tracker.waitForInFlight("a", "v1");
|
|
224
|
+
const p2 = tracker.waitForInFlight("a", "v1");
|
|
225
|
+
|
|
226
|
+
handle.markCaptured("shared-result");
|
|
227
|
+
|
|
228
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
229
|
+
expect(r1).toBe("shared-result");
|
|
230
|
+
expect(r2).toBe("shared-result");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("prune", () => {
|
|
235
|
+
it("should remove captured entries for keys not in the set", () => {
|
|
236
|
+
const hA = tracker.startCapture("a", "v1");
|
|
237
|
+
hA.markCaptured("r1");
|
|
238
|
+
const hB = tracker.startCapture("b", "v2");
|
|
239
|
+
hB.markCaptured("r2");
|
|
240
|
+
|
|
241
|
+
tracker.prune(new Set(["a"]));
|
|
242
|
+
|
|
243
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
244
|
+
expect(tracker.needsCapture("b", "v2")).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should abort and remove in-flight entries for pruned keys", () => {
|
|
248
|
+
const handleA = tracker.startCapture("a", "v");
|
|
249
|
+
const handleB = tracker.startCapture("b", "v");
|
|
250
|
+
|
|
251
|
+
tracker.prune(new Set(["a"]));
|
|
252
|
+
|
|
253
|
+
expect(handleA.signal.aborted).toBe(false);
|
|
254
|
+
expect(handleB.signal.aborted).toBe(true);
|
|
255
|
+
expect(tracker.needsCapture("b", "v")).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should resolve pruned in-flight deferreds with undefined", async () => {
|
|
259
|
+
tracker.startCapture("b", "v");
|
|
260
|
+
const promise = tracker.waitForInFlight("b", "v");
|
|
261
|
+
|
|
262
|
+
tracker.prune(new Set(["a"]));
|
|
263
|
+
const result = await promise;
|
|
264
|
+
expect(result).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should do nothing if all keys are current", () => {
|
|
268
|
+
const hA = tracker.startCapture("a", "v1");
|
|
269
|
+
hA.markCaptured("r1");
|
|
270
|
+
const hB = tracker.startCapture("b", "v2");
|
|
271
|
+
hB.markCaptured("r2");
|
|
272
|
+
|
|
273
|
+
tracker.prune(new Set(["a", "b"]));
|
|
274
|
+
|
|
275
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
276
|
+
expect(tracker.needsCapture("b", "v2")).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("abort", () => {
|
|
281
|
+
it("should abort all in-flight signals", () => {
|
|
282
|
+
const handleA = tracker.startCapture("a", "v");
|
|
283
|
+
const handleB = tracker.startCapture("b", "v");
|
|
284
|
+
tracker.abort();
|
|
285
|
+
expect(handleA.signal.aborted).toBe(true);
|
|
286
|
+
expect(handleB.signal.aborted).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("should clear in-flight state", () => {
|
|
290
|
+
tracker.startCapture("a", "v");
|
|
291
|
+
tracker.abort();
|
|
292
|
+
expect(tracker.isCapturing).toBe(false);
|
|
293
|
+
expect(tracker.needsCapture("a", "v")).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("should not clear captured state", () => {
|
|
297
|
+
const handle = tracker.startCapture("a", "v1");
|
|
298
|
+
handle.markCaptured("result");
|
|
299
|
+
tracker.abort();
|
|
300
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("reset", () => {
|
|
305
|
+
it("should clear all state", () => {
|
|
306
|
+
const hA = tracker.startCapture("a", "v1");
|
|
307
|
+
hA.markCaptured("r1");
|
|
308
|
+
tracker.startCapture("b", "v2");
|
|
309
|
+
|
|
310
|
+
tracker.reset();
|
|
311
|
+
|
|
312
|
+
expect(tracker.isCapturing).toBe(false);
|
|
313
|
+
expect(tracker.needsCapture("a", "v1")).toBe(true);
|
|
314
|
+
expect(tracker.needsCapture("b", "v2")).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("isCapturing", () => {
|
|
319
|
+
it("should be false when nothing is in-flight", () => {
|
|
320
|
+
expect(tracker.isCapturing).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("should be true when items are in-flight", () => {
|
|
324
|
+
tracker.startCapture("a", "v");
|
|
325
|
+
expect(tracker.isCapturing).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should be false when all items are completed", () => {
|
|
329
|
+
const hA = tracker.startCapture("a", "v");
|
|
330
|
+
const hB = tracker.startCapture("b", "v");
|
|
331
|
+
hA.markCaptured("r");
|
|
332
|
+
hB.markFailed();
|
|
333
|
+
expect(tracker.isCapturing).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("granular abort", () => {
|
|
338
|
+
it("should allow independent completion of unrelated keys", () => {
|
|
339
|
+
const hA = tracker.startCapture("a", "v1");
|
|
340
|
+
tracker.startCapture("b", "v1");
|
|
341
|
+
const hC = tracker.startCapture("c", "v1");
|
|
342
|
+
|
|
343
|
+
hA.markCaptured("ra");
|
|
344
|
+
tracker.startCapture("b", "v2"); // abort old "b", start new
|
|
345
|
+
hC.markCaptured("rc");
|
|
346
|
+
|
|
347
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
348
|
+
expect(tracker.needsCapture("c", "v1")).toBe(false);
|
|
349
|
+
expect(tracker.needsCapture("b", "v2")).toBe(false); // in-flight
|
|
350
|
+
expect(tracker.needsCapture("b", "v1")).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -166,51 +166,6 @@ describe("getImageDataUrlForCell", () => {
|
|
|
166
166
|
);
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
it("should add printing classes before capture when snappy is false", async () => {
|
|
170
|
-
vi.mocked(toPng).mockImplementation(async () => {
|
|
171
|
-
// Check classes are applied during capture
|
|
172
|
-
expect(mockElement.classList.contains("printing-output")).toBe(true);
|
|
173
|
-
expect(document.body.classList.contains("printing")).toBe(true);
|
|
174
|
-
expect(mockElement.style.overflow).toBe("auto");
|
|
175
|
-
return mockDataUrl;
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
await getImageDataUrlForCell("cell-1" as CellId, false);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("should remove printing classes after capture when snappy is false", async () => {
|
|
182
|
-
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
183
|
-
|
|
184
|
-
await getImageDataUrlForCell("cell-1" as CellId, false);
|
|
185
|
-
|
|
186
|
-
expect(mockElement.classList.contains("printing-output")).toBe(false);
|
|
187
|
-
expect(document.body.classList.contains("printing")).toBe(false);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("should add printing-output but NOT body.printing when snappy is true", async () => {
|
|
191
|
-
vi.mocked(toPng).mockImplementation(async () => {
|
|
192
|
-
// printing-output should still be added to the element
|
|
193
|
-
expect(mockElement.classList.contains("printing-output")).toBe(true);
|
|
194
|
-
// but body.printing should NOT be added when snappy mode is on
|
|
195
|
-
expect(document.body.classList.contains("printing")).toBe(false);
|
|
196
|
-
expect(mockElement.style.overflow).toBe("auto");
|
|
197
|
-
return mockDataUrl;
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
await getImageDataUrlForCell("cell-1" as CellId, true);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("should cleanup printing-output when snappy is true", async () => {
|
|
204
|
-
mockElement.style.overflow = "hidden";
|
|
205
|
-
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
206
|
-
|
|
207
|
-
await getImageDataUrlForCell("cell-1" as CellId, true);
|
|
208
|
-
|
|
209
|
-
expect(mockElement.classList.contains("printing-output")).toBe(false);
|
|
210
|
-
expect(document.body.classList.contains("printing")).toBe(false);
|
|
211
|
-
expect(mockElement.style.overflow).toBe("hidden");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
169
|
it("should restore original overflow style after capture", async () => {
|
|
215
170
|
mockElement.style.overflow = "hidden";
|
|
216
171
|
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
@@ -234,88 +189,27 @@ describe("getImageDataUrlForCell", () => {
|
|
|
234
189
|
|
|
235
190
|
await expect(getImageDataUrlForCell("cell-1" as CellId)).rejects.toThrow();
|
|
236
191
|
|
|
237
|
-
expect(mockElement.classList.contains("printing-output")).toBe(false);
|
|
238
192
|
expect(document.body.classList.contains("printing")).toBe(false);
|
|
239
193
|
expect(mockElement.style.overflow).toBe("scroll");
|
|
240
194
|
});
|
|
241
195
|
|
|
242
|
-
it("should
|
|
243
|
-
// Create a second element
|
|
244
|
-
const mockElement2 = document.createElement("div");
|
|
245
|
-
mockElement2.id = CellOutputId.create("cell-2" as CellId);
|
|
246
|
-
document.body.append(mockElement2);
|
|
247
|
-
|
|
248
|
-
// Track body.printing state during each capture
|
|
249
|
-
const printingStateDuringCaptures: boolean[] = [];
|
|
250
|
-
let resolveFirst: () => void;
|
|
251
|
-
let resolveSecond: () => void;
|
|
252
|
-
|
|
253
|
-
const firstPromise = new Promise<void>((resolve) => {
|
|
254
|
-
resolveFirst = resolve;
|
|
255
|
-
});
|
|
256
|
-
const secondPromise = new Promise<void>((resolve) => {
|
|
257
|
-
resolveSecond = resolve;
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
vi.mocked(toPng).mockImplementation(async (element) => {
|
|
261
|
-
printingStateDuringCaptures.push(
|
|
262
|
-
document.body.classList.contains("printing"),
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
// Simulate async work - first capture takes longer
|
|
266
|
-
await (element.id.includes("cell-1") ? firstPromise : secondPromise);
|
|
267
|
-
|
|
268
|
-
// Check state again after waiting
|
|
269
|
-
printingStateDuringCaptures.push(
|
|
270
|
-
document.body.classList.contains("printing"),
|
|
271
|
-
);
|
|
272
|
-
|
|
273
|
-
return mockDataUrl;
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// Start both captures concurrently with snappy = false (body.printing should be added)
|
|
277
|
-
const capture1 = getImageDataUrlForCell("cell-1" as CellId, false);
|
|
278
|
-
const capture2 = getImageDataUrlForCell("cell-2" as CellId, false);
|
|
279
|
-
|
|
280
|
-
// Let second capture complete first
|
|
281
|
-
resolveSecond!();
|
|
282
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
283
|
-
|
|
284
|
-
// body.printing should still be present because cell-1 is still capturing
|
|
285
|
-
expect(document.body.classList.contains("printing")).toBe(true);
|
|
286
|
-
|
|
287
|
-
// Now let first capture complete
|
|
288
|
-
resolveFirst!();
|
|
289
|
-
await Promise.all([capture1, capture2]);
|
|
290
|
-
|
|
291
|
-
// After all captures complete, body.printing should be removed
|
|
292
|
-
expect(document.body.classList.contains("printing")).toBe(false);
|
|
293
|
-
|
|
294
|
-
// All captures should have seen body.printing = true
|
|
295
|
-
expect(printingStateDuringCaptures.every(Boolean)).toBe(true);
|
|
296
|
-
|
|
297
|
-
mockElement2.remove();
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it("should not interfere with body.printing during concurrent captures when snappy is true", async () => {
|
|
196
|
+
it("should handle concurrent captures correctly", async () => {
|
|
301
197
|
// Create a second element
|
|
302
198
|
const mockElement2 = document.createElement("div");
|
|
303
199
|
mockElement2.id = CellOutputId.create("cell-2" as CellId);
|
|
304
200
|
document.body.append(mockElement2);
|
|
305
201
|
|
|
306
202
|
vi.mocked(toPng).mockImplementation(async () => {
|
|
307
|
-
// body.printing should
|
|
203
|
+
// body.printing should not be added during cell captures
|
|
308
204
|
expect(document.body.classList.contains("printing")).toBe(false);
|
|
309
205
|
return mockDataUrl;
|
|
310
206
|
});
|
|
311
207
|
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
const capture2 = getImageDataUrlForCell("cell-2" as CellId, true);
|
|
208
|
+
const capture1 = getImageDataUrlForCell("cell-1" as CellId);
|
|
209
|
+
const capture2 = getImageDataUrlForCell("cell-2" as CellId);
|
|
315
210
|
|
|
316
211
|
await Promise.all([capture1, capture2]);
|
|
317
212
|
|
|
318
|
-
// body.printing should still not be present
|
|
319
213
|
expect(document.body.classList.contains("printing")).toBe(false);
|
|
320
214
|
|
|
321
215
|
mockElement2.remove();
|
|
@@ -515,9 +409,7 @@ describe("downloadCellOutputAsImage", () => {
|
|
|
515
409
|
it("should apply cell-specific preparation", async () => {
|
|
516
410
|
vi.mocked(toPng).mockImplementation(async () => {
|
|
517
411
|
// Check that cell-specific classes are applied
|
|
518
|
-
expect(mockElement.
|
|
519
|
-
expect(document.body.classList.contains("printing")).toBe(true);
|
|
520
|
-
expect(mockElement.style.overflow).toBe("auto");
|
|
412
|
+
expect(mockElement.style.overflow).toBe("visible");
|
|
521
413
|
return mockDataUrl;
|
|
522
414
|
});
|
|
523
415
|
|
|
@@ -530,7 +422,6 @@ describe("downloadCellOutputAsImage", () => {
|
|
|
530
422
|
|
|
531
423
|
await downloadCellOutputAsImage("cell-1" as CellId, "result");
|
|
532
424
|
|
|
533
|
-
expect(mockElement.classList.contains("printing-output")).toBe(false);
|
|
534
425
|
expect(document.body.classList.contains("printing")).toBe(false);
|
|
535
426
|
expect(mockElement.style.overflow).toBe("visible");
|
|
536
427
|
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { Deferred } from "./Deferred";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Handle returned by {@link AsyncCaptureTracker.startCapture}.
|
|
7
|
+
*
|
|
8
|
+
* The handle is scoped to one capture attempt. If the same key is re-captured
|
|
9
|
+
* before this handle completes, calling `markCaptured` or `markFailed` on
|
|
10
|
+
* the stale handle is a safe no-op.
|
|
11
|
+
*/
|
|
12
|
+
export interface CaptureHandle<R> {
|
|
13
|
+
/** Per-key AbortSignal — check between async steps. */
|
|
14
|
+
readonly signal: AbortSignal;
|
|
15
|
+
/** Mark the capture as successful and resolve waiters with the result. */
|
|
16
|
+
markCaptured(result: R): void;
|
|
17
|
+
/** Mark the capture as failed — the key returns to idle for retry. */
|
|
18
|
+
markFailed(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface InFlightEntry<R> {
|
|
22
|
+
controller: AbortController;
|
|
23
|
+
inputValue: unknown;
|
|
24
|
+
deferred: Deferred<R | undefined>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tracks async capture operations to prevent race conditions.
|
|
29
|
+
*
|
|
30
|
+
* Each key transitions through states:
|
|
31
|
+
* - **idle**: Not captured or previously failed — eligible for capture
|
|
32
|
+
* - **in-flight**: Capture started but not completed — skipped unless value changed
|
|
33
|
+
* - **captured(value)**: Successfully captured — skipped until value changes
|
|
34
|
+
*
|
|
35
|
+
* Abort is per-key: aborting one key's in-flight capture does not affect others.
|
|
36
|
+
*
|
|
37
|
+
* Guarantees:
|
|
38
|
+
* - Items are only marked "captured" after successful async completion
|
|
39
|
+
* - In-flight items with the same value are skipped (prevents duplicates)
|
|
40
|
+
* - In-flight items whose value changed are aborted and re-captured
|
|
41
|
+
* - Failed items return to idle (retried next cycle)
|
|
42
|
+
* - Concurrent callers can await an in-flight capture via {@link waitForInFlight}
|
|
43
|
+
* - Stale handles are safe no-ops (checked via entry identity)
|
|
44
|
+
*/
|
|
45
|
+
export class AsyncCaptureTracker<K, R = unknown> {
|
|
46
|
+
/** Input values for successfully captured keys */
|
|
47
|
+
private capturedInputs = new Map<K, unknown>();
|
|
48
|
+
/** Per-key in-flight state */
|
|
49
|
+
private inFlight = new Map<K, InFlightEntry<R>>();
|
|
50
|
+
|
|
51
|
+
/** Abort an in-flight entry and resolve its waiters with `undefined`. */
|
|
52
|
+
private cancelEntry(entry: InFlightEntry<R>): void {
|
|
53
|
+
entry.controller.abort();
|
|
54
|
+
if (entry.deferred.status === "pending") {
|
|
55
|
+
entry.deferred.resolve(undefined);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a key needs capturing based on its current input value.
|
|
61
|
+
* Returns false if:
|
|
62
|
+
* - Already captured with the same value
|
|
63
|
+
* - In-flight with the same value (let it finish — use {@link waitForInFlight} to get the result)
|
|
64
|
+
* Returns true if:
|
|
65
|
+
* - Never captured
|
|
66
|
+
* - Captured with a different value
|
|
67
|
+
* - In-flight with a different value (will be aborted on {@link startCapture})
|
|
68
|
+
*/
|
|
69
|
+
needsCapture(key: K, inputValue: unknown): boolean {
|
|
70
|
+
if (this.capturedInputs.get(key) === inputValue) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const flight = this.inFlight.get(key);
|
|
74
|
+
if (flight && flight.inputValue === inputValue) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* If the key is in-flight with the given input value, returns a promise
|
|
82
|
+
* that resolves when the capture completes (with the result, or `undefined`
|
|
83
|
+
* on failure/abort). Returns `null` otherwise.
|
|
84
|
+
*/
|
|
85
|
+
waitForInFlight(key: K, inputValue: unknown): Promise<R | undefined> | null {
|
|
86
|
+
const flight = this.inFlight.get(key);
|
|
87
|
+
if (flight && flight.inputValue === inputValue) {
|
|
88
|
+
return flight.deferred.promise;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start capturing a single key. If the key is already in-flight,
|
|
95
|
+
* aborts only that key's previous capture and resolves its waiters
|
|
96
|
+
* with `undefined`.
|
|
97
|
+
*
|
|
98
|
+
* @returns A {@link CaptureHandle} scoped to this attempt.
|
|
99
|
+
*/
|
|
100
|
+
startCapture(key: K, inputValue: unknown): CaptureHandle<R> {
|
|
101
|
+
const prev = this.inFlight.get(key);
|
|
102
|
+
if (prev) {
|
|
103
|
+
this.cancelEntry(prev);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const deferred = new Deferred<R | undefined>();
|
|
108
|
+
const entry: InFlightEntry<R> = { controller, inputValue, deferred };
|
|
109
|
+
this.inFlight.set(key, entry);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
markCaptured: (result: R) => {
|
|
114
|
+
// No-op if this handle was superseded by a newer startCapture
|
|
115
|
+
if (this.inFlight.get(key) !== entry) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
deferred.resolve(result);
|
|
119
|
+
this.capturedInputs.set(key, inputValue);
|
|
120
|
+
this.inFlight.delete(key);
|
|
121
|
+
},
|
|
122
|
+
markFailed: () => {
|
|
123
|
+
if (this.inFlight.get(key) !== entry) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
deferred.resolve(undefined);
|
|
127
|
+
this.inFlight.delete(key);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Remove tracking for keys not in the given set.
|
|
134
|
+
* Aborts in-flight captures and resolves their waiters with `undefined`.
|
|
135
|
+
*/
|
|
136
|
+
prune(currentKeys: Set<K>): void {
|
|
137
|
+
for (const key of this.capturedInputs.keys()) {
|
|
138
|
+
if (!currentKeys.has(key)) {
|
|
139
|
+
this.capturedInputs.delete(key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [key, entry] of this.inFlight) {
|
|
143
|
+
if (!currentKeys.has(key)) {
|
|
144
|
+
this.cancelEntry(entry);
|
|
145
|
+
this.inFlight.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether any captures are currently in-flight. */
|
|
151
|
+
get isCapturing(): boolean {
|
|
152
|
+
return this.inFlight.size > 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Abort all in-flight captures. Resolves all waiters with `undefined`. */
|
|
156
|
+
abort(): void {
|
|
157
|
+
for (const entry of this.inFlight.values()) {
|
|
158
|
+
this.cancelEntry(entry);
|
|
159
|
+
}
|
|
160
|
+
this.inFlight.clear();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Reset all state. */
|
|
164
|
+
reset(): void {
|
|
165
|
+
this.abort();
|
|
166
|
+
this.capturedInputs.clear();
|
|
167
|
+
}
|
|
168
|
+
}
|