@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.
Files changed (81) hide show
  1. package/dist/assets/{JsonOutput-BGSqWZkD.js → JsonOutput-DtidtKaJ.js} +2 -2
  2. package/dist/assets/{MarimoErrorOutput-DFV7O8tN.js → MarimoErrorOutput-Cci2wITc.js} +1 -1
  3. package/dist/assets/{add-cell-with-ai-gDjJxct8.js → add-cell-with-ai-DT1ae2MK.js} +1 -1
  4. package/dist/assets/{add-database-form-CqEjMrq2.js → add-database-form-_pTLtiHN.js} +1 -1
  5. package/dist/assets/{agent-panel-D_hcgMlT.js → agent-panel-oLGqa4bG.js} +1 -1
  6. package/dist/assets/{ai-model-dropdown-C_l_zAXd.js → ai-model-dropdown-BL-PaF8o.js} +1 -1
  7. package/dist/assets/{app-config-button-CM05VD_Y.js → app-config-button-BiwYEPE8.js} +1 -1
  8. package/dist/assets/{cell-editor-ihhw9Ql3.js → cell-editor-B3U1SnYJ.js} +1 -1
  9. package/dist/assets/{chat-display-yGAtJXm0.js → chat-display-B34MaCGM.js} +1 -1
  10. package/dist/assets/{chat-panel-D3T1CjpZ.js → chat-panel-fuQFRvFm.js} +1 -1
  11. package/dist/assets/{column-preview-DbqM1diq.js → column-preview-B-dViv1i.js} +1 -1
  12. package/dist/assets/{command-palette-C2L80RlN.js → command-palette-KuNgJNix.js} +1 -1
  13. package/dist/assets/{common-D32S2RVD.js → common-DxKcMlJZ.js} +1 -1
  14. package/dist/assets/{dependency-graph-panel-C7ZXgECY.js → dependency-graph-panel-BZEIOxVz.js} +1 -1
  15. package/dist/assets/download-Bwa9P-Pz.js +6 -0
  16. package/dist/assets/{dropdown-menu-df9T83C0.js → dropdown-menu-B-6unW-7.js} +1 -1
  17. package/dist/assets/{edit-page-DFpXAt-U.js → edit-page-DoQyHH-0.js} +4 -4
  18. package/dist/assets/{error-panel-DKIWwbhe.js → error-panel-DulelhA-.js} +1 -1
  19. package/dist/assets/{file-explorer-panel-D4VHkjMW.js → file-explorer-panel-Dn9tKw3E.js} +1 -1
  20. package/dist/assets/{form-XjMNGyzu.js → form-B1n-e_X0.js} +1 -1
  21. package/dist/assets/{glide-data-editor-BBSxoBI-.js → glide-data-editor-HGkaxqOo.js} +1 -1
  22. package/dist/assets/home-page--XVUAUCM.js +4 -0
  23. package/dist/assets/hooks-B1nUQK2T.js +1 -0
  24. package/dist/assets/{html-to-image-DKvXQkl5.js → html-to-image-Cu1p0tCK.js} +2 -2
  25. package/dist/assets/{index-BRCNo5ma.js → index-BHikcWoK.js} +5 -5
  26. package/dist/assets/{index-DmMvDRRC.css → index-Bj5F80Z9.css} +1 -1
  27. package/dist/assets/{layout-DiDoTAUA.js → layout-KY92f2Sm.js} +3 -3
  28. package/dist/assets/{markdown-renderer-xQJ1KM4c.js → markdown-renderer-Dpn5NCvn.js} +1 -1
  29. package/dist/assets/{packages-panel-D53RuG3X.js → packages-panel-CBc59eNR.js} +1 -1
  30. package/dist/assets/{panels-BhznEx5N.js → panels-B0B71dYl.js} +1 -1
  31. package/dist/assets/{popover-D16ZremR.js → popover-Gz-GJzym.js} +1 -1
  32. package/dist/assets/{readonly-python-code-kImQwJ5f.js → readonly-python-code-CCwpyiLX.js} +1 -1
  33. package/dist/assets/{renderShortcut-DHc-p-_c.js → renderShortcut-DEwfrKeS.js} +1 -1
  34. package/dist/assets/run-page-Dug0EU2T.js +1 -0
  35. package/dist/assets/{scratchpad-panel-C8wx6cRl.js → scratchpad-panel-SMFZ5eRQ.js} +1 -1
  36. package/dist/assets/{secrets-panel-Br6CcsOE.js → secrets-panel-BaEqnh6m.js} +1 -1
  37. package/dist/assets/{session-panel-JOOuJNOH.js → session-panel-CR_CZBSy.js} +1 -1
  38. package/dist/assets/table-C8uQmBAN.js +1 -0
  39. package/dist/assets/{terminal-DNwT6UrR.js → terminal-C7HXI-7B.js} +1 -1
  40. package/dist/assets/{tree-BdwmBGSx.js → tree-B1vM35Zj.js} +1 -1
  41. package/dist/assets/{useAddCell-BMDEXuVk.js → useAddCell-DRmuczCx.js} +1 -1
  42. package/dist/assets/{useCellActionButton-BDMlZzyv.js → useCellActionButton-DwRoApVS.js} +1 -1
  43. package/dist/assets/{useDeleteCell-BrMXAFkS.js → useDeleteCell-CR3IczUk.js} +1 -1
  44. package/dist/assets/{useDependencyPanelTab-B63bb4YX.js → useDependencyPanelTab-CLgnO1zH.js} +1 -1
  45. package/dist/assets/useNotebookActions-DhF-uJ0P.js +1 -0
  46. package/dist/assets/{useSplitCell-Cb0lf5MV.js → useSplitCell-IQsKBoRj.js} +1 -1
  47. package/dist/assets/{utilities.esm-BVFPJPyV.js → utilities.esm-DyYLtC1k.js} +2 -2
  48. package/dist/index.html +20 -20
  49. package/package.json +1 -1
  50. package/src/components/data-table/TableActions.tsx +5 -3
  51. package/src/components/data-table/download-actions.tsx +7 -2
  52. package/src/components/data-table/pagination.tsx +4 -4
  53. package/src/components/debug/indicator.tsx +1 -1
  54. package/src/components/editor/actions/useNotebookActions.tsx +4 -2
  55. package/src/components/editor/chrome/panels/context-aware-panel/context-aware-panel.tsx +1 -1
  56. package/src/components/editor/chrome/wrapper/app-chrome.tsx +4 -4
  57. package/src/components/editor/chrome/wrapper/footer.tsx +1 -1
  58. package/src/components/editor/chrome/wrapper/sidebar.tsx +1 -1
  59. package/src/components/editor/controls/Controls.tsx +2 -2
  60. package/src/components/editor/controls/notebook-menu-dropdown.tsx +1 -1
  61. package/src/components/editor/file-tree/file-explorer.tsx +1 -1
  62. package/src/components/editor/header/status.tsx +1 -1
  63. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +13 -4
  64. package/src/components/home/components.tsx +1 -1
  65. package/src/components/static-html/static-banner.tsx +1 -1
  66. package/src/components/ui/dropdown-menu.tsx +1 -1
  67. package/src/components/ui/table.tsx +1 -1
  68. package/src/core/export/__tests__/hooks.test.ts +60 -58
  69. package/src/core/export/hooks.ts +71 -31
  70. package/src/css/app/print.css +0 -14
  71. package/src/utils/__tests__/async-capture-tracker.test.ts +353 -0
  72. package/src/utils/__tests__/download.test.tsx +5 -114
  73. package/src/utils/async-capture-tracker.ts +168 -0
  74. package/src/utils/download.ts +17 -57
  75. package/src/utils/html-to-image.ts +9 -12
  76. package/dist/assets/download-CzPV-R6Z.js +0 -6
  77. package/dist/assets/home-page-DBWXVxWa.js +0 -4
  78. package/dist/assets/hooks-CpjzmBkw.js +0 -1
  79. package/dist/assets/run-page-BCOAGhl5.js +0 -1
  80. package/dist/assets/table-BSASHvkq.js +0 -1
  81. 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 maintain body.printing during concurrent captures when snappy is false", async () => {
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 never be added when snappy is true
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
- // Start both captures concurrently with snappy = true (body.printing should NOT be added)
313
- const capture1 = getImageDataUrlForCell("cell-1" as CellId, true);
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.classList.contains("printing-output")).toBe(true);
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
+ }