@marimo-team/frontend 0.19.7-dev34 → 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 (107) hide show
  1. package/dist/assets/{CellStatus-DLlfsrEI.js → CellStatus-DhGipVU-.js} +1 -1
  2. package/dist/assets/{JsonOutput-DvKIRGOg.js → JsonOutput-DtidtKaJ.js} +2 -2
  3. package/dist/assets/{MarimoErrorOutput-DscugIeA.js → MarimoErrorOutput-Cci2wITc.js} +1 -1
  4. package/dist/assets/{RenderHTML-DPkeBHFB.js → RenderHTML-Co6iQEvR.js} +1 -1
  5. package/dist/assets/{add-cell-with-ai-BHgqYu8P.js → add-cell-with-ai-DT1ae2MK.js} +1 -1
  6. package/dist/assets/{add-database-form-DAMSmPZS.js → add-database-form-_pTLtiHN.js} +1 -1
  7. package/dist/assets/{agent-panel-C9codfcr.js → agent-panel-oLGqa4bG.js} +1 -1
  8. package/dist/assets/{ai-model-dropdown-DxImvtE1.js → ai-model-dropdown-BL-PaF8o.js} +1 -1
  9. package/dist/assets/{app-config-button-D4NHNYdV.js → app-config-button-BiwYEPE8.js} +1 -1
  10. package/dist/assets/{cell-editor-BxhibLVM.js → cell-editor-B3U1SnYJ.js} +1 -1
  11. package/dist/assets/{cell-link-a1r84hCk.js → cell-link-_-mIiddP.js} +1 -1
  12. package/dist/assets/{cells-Mf-pdsEh.js → cells-BW_4R0Qw.js} +1 -1
  13. package/dist/assets/{chat-components-BpunBJu9.js → chat-components-ZGfi_TyH.js} +1 -1
  14. package/dist/assets/{chat-display-B0625p01.js → chat-display-B34MaCGM.js} +1 -1
  15. package/dist/assets/{chat-panel-CkHdco_X.js → chat-panel-fuQFRvFm.js} +1 -1
  16. package/dist/assets/{column-preview-C6jEPj3t.js → column-preview-B-dViv1i.js} +1 -1
  17. package/dist/assets/{command-BmWAYrdT.js → command-B5H3BrRg.js} +1 -1
  18. package/dist/assets/{command-palette-D1g3pU47.js → command-palette-KuNgJNix.js} +1 -1
  19. package/dist/assets/{common-kVa9xjZc.js → common-DxKcMlJZ.js} +1 -1
  20. package/dist/assets/{datasource-jW7Cq5OE.js → datasource-CpcDqjf_.js} +1 -1
  21. package/dist/assets/{dependency-graph-panel-BWvUSpJI.js → dependency-graph-panel-BZEIOxVz.js} +1 -1
  22. package/dist/assets/{documentation-panel-z2oxdgzR.js → documentation-panel-BK_YuaI4.js} +1 -1
  23. package/dist/assets/download-Bwa9P-Pz.js +6 -0
  24. package/dist/assets/{dropdown-menu-df9T83C0.js → dropdown-menu-B-6unW-7.js} +1 -1
  25. package/dist/assets/{edit-page-UqaWbc_J.js → edit-page-DoQyHH-0.js} +4 -4
  26. package/dist/assets/{error-panel-BxmwSms7.js → error-panel-DulelhA-.js} +1 -1
  27. package/dist/assets/{file-explorer-panel-y7F8Uqi-.js → file-explorer-panel-Dn9tKw3E.js} +1 -1
  28. package/dist/assets/{floating-outline-mEMcQGQK.js → floating-outline-Ni_RT38T.js} +1 -1
  29. package/dist/assets/{focus-ay4g-SB6.js → focus-CsiV5LZR.js} +1 -1
  30. package/dist/assets/{form-DFq7l6gy.js → form-B1n-e_X0.js} +1 -1
  31. package/dist/assets/{glide-data-editor-BBSxoBI-.js → glide-data-editor-HGkaxqOo.js} +1 -1
  32. package/dist/assets/{globals-Du9rkBEf.js → globals-ols5LsZP.js} +1 -1
  33. package/dist/assets/home-page--XVUAUCM.js +4 -0
  34. package/dist/assets/hooks-B1nUQK2T.js +1 -0
  35. package/dist/assets/{html-to-image-Cdx1xsbU.js → html-to-image-Cu1p0tCK.js} +2 -2
  36. package/dist/assets/{index-BSBPZDCV.js → index-BHikcWoK.js} +5 -5
  37. package/dist/assets/{index-DmMvDRRC.css → index-Bj5F80Z9.css} +1 -1
  38. package/dist/assets/{kiosk-mode-Y_Rs0fTN.js → kiosk-mode-vMIC4Cbh.js} +1 -1
  39. package/dist/assets/{layout-Xf51uwDc.js → layout-KY92f2Sm.js} +3 -3
  40. package/dist/assets/{logs-panel-bOS3V2nr.js → logs-panel-DeNFSwJb.js} +1 -1
  41. package/dist/assets/{markdown-renderer-DSY-ElEE.js → markdown-renderer-Dpn5NCvn.js} +1 -1
  42. package/dist/assets/{mode-TsexQOfl.js → mode-BCr7l3Th.js} +1 -1
  43. package/dist/assets/{name-cell-input-B_OPYPFz.js → name-cell-input-D6uaGwg7.js} +1 -1
  44. package/dist/assets/{outline-panel-DkOJrPIL.js → outline-panel-DpbnlPhw.js} +1 -1
  45. package/dist/assets/{packages-panel-thVjzLK4.js → packages-panel-CBc59eNR.js} +1 -1
  46. package/dist/assets/{panels-D-yo_63g.js → panels-B0B71dYl.js} +1 -1
  47. package/dist/assets/{popover-D16ZremR.js → popover-Gz-GJzym.js} +1 -1
  48. package/dist/assets/{process-output-ccUMEFYE.js → process-output-TMO5uCHT.js} +1 -1
  49. package/dist/assets/{readonly-python-code-B1UBDfCT.js → readonly-python-code-CCwpyiLX.js} +1 -1
  50. package/dist/assets/{renderShortcut-DHc-p-_c.js → renderShortcut-DEwfrKeS.js} +1 -1
  51. package/dist/assets/run-page-Dug0EU2T.js +1 -0
  52. package/dist/assets/{scratchpad-panel-DPnpCcOn.js → scratchpad-panel-SMFZ5eRQ.js} +1 -1
  53. package/dist/assets/{secrets-panel-Br6CcsOE.js → secrets-panel-BaEqnh6m.js} +1 -1
  54. package/dist/assets/{session-panel-CjoiPf6W.js → session-panel-CR_CZBSy.js} +1 -1
  55. package/dist/assets/{snippets-panel-DeCtu_nU.js → snippets-panel-ypIwat9G.js} +1 -1
  56. package/dist/assets/{state-BXbqGYab.js → state-BrsyJBHJ.js} +1 -1
  57. package/dist/assets/{switch-NTEWSiVz.js → switch-Cch0bLxo.js} +1 -1
  58. package/dist/assets/table-C8uQmBAN.js +1 -0
  59. package/dist/assets/{terminal-DNwT6UrR.js → terminal-C7HXI-7B.js} +1 -1
  60. package/dist/assets/{textarea-Ddtm_ohJ.js → textarea-DSR2T2ft.js} +1 -1
  61. package/dist/assets/{tracing-panel-BowfBZDI.js → tracing-panel-57WGQhyd.js} +2 -2
  62. package/dist/assets/{tracing-Hw-L0vPw.js → tracing-vnJxVAtK.js} +1 -1
  63. package/dist/assets/{tree-BdwmBGSx.js → tree-B1vM35Zj.js} +1 -1
  64. package/dist/assets/{types-1gCn5Ky0.js → types-fTSozrNJ.js} +1 -1
  65. package/dist/assets/{useAddCell-M6_IuI4C.js → useAddCell-DRmuczCx.js} +1 -1
  66. package/dist/assets/{useCellActionButton-Ddy42rre.js → useCellActionButton-DwRoApVS.js} +1 -1
  67. package/dist/assets/{useDeleteCell-Ct7dOEmt.js → useDeleteCell-CR3IczUk.js} +1 -1
  68. package/dist/assets/{useDependencyPanelTab-RSRpRVKD.js → useDependencyPanelTab-CLgnO1zH.js} +1 -1
  69. package/dist/assets/useNotebookActions-DhF-uJ0P.js +1 -0
  70. package/dist/assets/{useRunCells-BgXbioPv.js → useRunCells-C50mliNM.js} +1 -1
  71. package/dist/assets/{useSplitCell-BZ2kxLtm.js → useSplitCell-IQsKBoRj.js} +1 -1
  72. package/dist/assets/{utilities.esm-mPQPstBT.js → utilities.esm-DyYLtC1k.js} +2 -2
  73. package/dist/index.html +36 -36
  74. package/package.json +1 -1
  75. package/src/components/data-table/TableActions.tsx +5 -3
  76. package/src/components/data-table/download-actions.tsx +7 -2
  77. package/src/components/data-table/pagination.tsx +4 -4
  78. package/src/components/debug/indicator.tsx +1 -1
  79. package/src/components/editor/actions/useNotebookActions.tsx +4 -2
  80. package/src/components/editor/chrome/panels/context-aware-panel/context-aware-panel.tsx +1 -1
  81. package/src/components/editor/chrome/wrapper/app-chrome.tsx +4 -4
  82. package/src/components/editor/chrome/wrapper/footer.tsx +1 -1
  83. package/src/components/editor/chrome/wrapper/sidebar.tsx +1 -1
  84. package/src/components/editor/controls/Controls.tsx +2 -2
  85. package/src/components/editor/controls/notebook-menu-dropdown.tsx +1 -1
  86. package/src/components/editor/file-tree/file-explorer.tsx +1 -1
  87. package/src/components/editor/header/status.tsx +1 -1
  88. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +13 -4
  89. package/src/components/home/components.tsx +1 -1
  90. package/src/components/static-html/static-banner.tsx +1 -1
  91. package/src/components/ui/dropdown-menu.tsx +1 -1
  92. package/src/components/ui/table.tsx +1 -1
  93. package/src/core/config/feature-flag.tsx +1 -1
  94. package/src/core/export/__tests__/hooks.test.ts +60 -58
  95. package/src/core/export/hooks.ts +71 -31
  96. package/src/css/app/print.css +0 -14
  97. package/src/utils/__tests__/async-capture-tracker.test.ts +353 -0
  98. package/src/utils/__tests__/download.test.tsx +5 -114
  99. package/src/utils/async-capture-tracker.ts +168 -0
  100. package/src/utils/download.ts +17 -57
  101. package/src/utils/html-to-image.ts +9 -12
  102. package/dist/assets/download-vBVDTXQk.js +0 -6
  103. package/dist/assets/home-page-Cg221ah6.js +0 -4
  104. package/dist/assets/hooks-D_OOStv3.js +0 -1
  105. package/dist/assets/run-page-CoCql9Xm.js +0 -1
  106. package/dist/assets/table-BSASHvkq.js +0 -1
  107. package/dist/assets/useNotebookActions-XKe-QMLa.js +0 -1
@@ -41,7 +41,11 @@ import { downloadAsHTML } from "@/core/static/download-html";
41
41
  import { isStaticNotebook } from "@/core/static/static-state";
42
42
  import { isWasm } from "@/core/wasm/utils";
43
43
  import { cn } from "@/utils/cn";
44
- import { downloadBlob, downloadHTMLAsImage } from "@/utils/download";
44
+ import {
45
+ ADD_PRINTING_CLASS,
46
+ downloadBlob,
47
+ downloadHTMLAsImage,
48
+ } from "@/utils/download";
45
49
  import { Filenames } from "@/utils/filenames";
46
50
  import { FloatingOutline } from "../../chrome/panels/outline/floating-outline";
47
51
  import { cellDomProps } from "../../common";
@@ -185,7 +189,12 @@ const ActionButtons: React.FC<{
185
189
  if (!app) {
186
190
  return;
187
191
  }
188
- await downloadHTMLAsImage({ element: app, filename: document.title });
192
+ await downloadHTMLAsImage({
193
+ element: app,
194
+ filename: document.title,
195
+ // Add body.printing ONLY when converting the whole notebook to a screenshot
196
+ prepare: ADD_PRINTING_CLASS,
197
+ });
189
198
  };
190
199
 
191
200
  const handleDownloadAsHTML = async () => {
@@ -271,7 +280,7 @@ const ActionButtons: React.FC<{
271
280
  <div
272
281
  data-testid="notebook-actions-dropdown"
273
282
  className={cn(
274
- "right-0 top-0 z-50 m-4 no-print flex gap-2 print:hidden",
283
+ "right-0 top-0 z-50 m-4 print:hidden flex gap-2",
275
284
  // If the notebook is static, we have a banner at the top, so
276
285
  // we can't use fixed positioning. Ideally this is sticky, but the
277
286
  // current dom structure makes that difficult.
@@ -284,7 +293,7 @@ const ActionButtons: React.FC<{
284
293
  <MoreHorizontalIcon className="w-4 h-4" />
285
294
  </Button>
286
295
  </DropdownMenuTrigger>
287
- <DropdownMenuContent align="end" className="no-print w-[220px]">
296
+ <DropdownMenuContent align="end" className="print:hidden w-[220px]">
288
297
  {actions}
289
298
  </DropdownMenuContent>
290
299
  </DropdownMenu>
@@ -80,7 +80,7 @@ export const OpenTutorialDropDown: React.FC = () => {
80
80
  <CaretDownIcon className="w-3 h-3 ml-1" />
81
81
  </Button>
82
82
  </DropdownMenuTrigger>
83
- <DropdownMenuContent side="bottom" align="end" className="no-print">
83
+ <DropdownMenuContent side="bottom" align="end" className="print:hidden">
84
84
  {Objects.entries(TUTORIALS).map(
85
85
  ([tutorialId, [label, Icon, description]]) => (
86
86
  <DropdownMenuItem
@@ -36,7 +36,7 @@ export const StaticBanner: React.FC = () => {
36
36
 
37
37
  return (
38
38
  <div
39
- className="px-4 py-2 bg-(--sky-2) border-b border-(--sky-7) text-(--sky-11) flex justify-between items-center gap-4 no-print text-sm"
39
+ className="px-4 py-2 bg-(--sky-2) border-b border-(--sky-7) text-(--sky-11) flex justify-between items-center gap-4 print:hidden text-sm"
40
40
  data-testid="static-notebook-banner"
41
41
  >
42
42
  <span>
@@ -83,7 +83,7 @@ const DropdownMenuContent = React.forwardRef<
83
83
  sideOffset={sideOffset}
84
84
  className={cn(
85
85
  menuContentCommon(),
86
- "animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
86
+ "animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 print:hidden",
87
87
  scrollable && "overflow-auto",
88
88
  className,
89
89
  )}
@@ -7,7 +7,7 @@ const Table = React.forwardRef<
7
7
  HTMLTableElement,
8
8
  React.HTMLAttributes<HTMLTableElement>
9
9
  >(({ className, ...props }, ref) => (
10
- <div className="w-full overflow-auto scrollbar-thin flex-1">
10
+ <div className="w-full overflow-auto scrollbar-thin flex-1 print:overflow-hidden">
11
11
  <table
12
12
  ref={ref}
13
13
  className={cn("w-full caption-bottom text-sm", className)}
@@ -25,7 +25,7 @@ const defaultValues: ExperimentalFeatures = {
25
25
  chat_modes: false,
26
26
  cache_panel: false,
27
27
  external_agents: import.meta.env.DEV,
28
- server_side_pdf_export: false,
28
+ server_side_pdf_export: true,
29
29
  };
30
30
 
31
31
  export function getFeatureFlag<T extends keyof ExperimentalFeatures>(
@@ -10,6 +10,7 @@ import { CellOutputId } from "@/core/cells/ids";
10
10
  import type { CellRuntimeState } from "@/core/cells/types";
11
11
  import { ProgressState } from "@/utils/progress";
12
12
  import {
13
+ captureTracker,
13
14
  updateCellOutputsWithScreenshots,
14
15
  useEnrichCellOutputs,
15
16
  } from "../hooks";
@@ -52,6 +53,7 @@ describe("useEnrichCellOutputs", () => {
52
53
  beforeEach(() => {
53
54
  vi.clearAllMocks();
54
55
  store = createStore();
56
+ captureTracker.reset();
55
57
  });
56
58
 
57
59
  const wrapper = ({ children }: { children: ReactNode }) =>
@@ -103,7 +105,7 @@ describe("useEnrichCellOutputs", () => {
103
105
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
104
106
 
105
107
  const takeScreenshots = result.current;
106
- const output = await takeScreenshots({ progress, snappy: false });
108
+ const output = await takeScreenshots({ progress });
107
109
 
108
110
  expect(output).toEqual({});
109
111
  expect(document.getElementById).not.toHaveBeenCalled();
@@ -135,7 +137,7 @@ describe("useEnrichCellOutputs", () => {
135
137
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
136
138
 
137
139
  const takeScreenshots = result.current;
138
- const output = await takeScreenshots({ progress, snappy: false });
140
+ const output = await takeScreenshots({ progress });
139
141
 
140
142
  expect(document.getElementById).toHaveBeenCalledWith(
141
143
  CellOutputId.create(cellId),
@@ -152,50 +154,6 @@ describe("useEnrichCellOutputs", () => {
152
154
  });
153
155
  });
154
156
 
155
- it("should pass snappy=true to toPng with includeStyleProperties", async () => {
156
- const cellId = "cell-1" as CellId;
157
- const mockElement = document.createElement("div");
158
- const mockDataUrl = "data:image/png;base64,mockImageData";
159
-
160
- // Mock document.getElementById
161
- vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
162
- vi.mocked(toPng).mockResolvedValue(mockDataUrl);
163
-
164
- setCellsRuntime(
165
- createMockCellRuntimes({
166
- [cellId]: {
167
- output: {
168
- channel: "output",
169
- mimetype: "text/html",
170
- data: "<div>Chart</div>",
171
- timestamp: 0,
172
- },
173
- },
174
- }),
175
- );
176
-
177
- const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
178
-
179
- const takeScreenshots = result.current;
180
- const output = await takeScreenshots({ progress, snappy: true });
181
-
182
- expect(document.getElementById).toHaveBeenCalledWith(
183
- CellOutputId.create(cellId),
184
- );
185
- // When snappy=true, includeStyleProperties should be set
186
- expect(toPng).toHaveBeenCalledWith(
187
- mockElement,
188
- expect.objectContaining({
189
- filter: expect.any(Function),
190
- onImageErrorHandler: expect.any(Function),
191
- includeStyleProperties: expect.any(Array),
192
- }),
193
- );
194
- expect(output).toEqual({
195
- [cellId]: ["image/png", mockDataUrl],
196
- });
197
- });
198
-
199
157
  it("should skip cells where output has not changed", async () => {
200
158
  const cellId = "cell-1" as CellId;
201
159
  const mockElement = document.createElement("div");
@@ -224,7 +182,7 @@ describe("useEnrichCellOutputs", () => {
224
182
 
225
183
  // First call - should capture
226
184
  let takeScreenshots = result.current;
227
- let output = await takeScreenshots({ progress, snappy: false });
185
+ let output = await takeScreenshots({ progress });
228
186
  expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl] });
229
187
  expect(toPng).toHaveBeenCalledTimes(1);
230
188
 
@@ -233,7 +191,7 @@ describe("useEnrichCellOutputs", () => {
233
191
 
234
192
  // Second call with same output - should not capture again
235
193
  takeScreenshots = result.current;
236
- output = await takeScreenshots({ progress, snappy: false });
194
+ output = await takeScreenshots({ progress });
237
195
  expect(output).toEqual({}); // Empty because output hasn't changed
238
196
  expect(toPng).toHaveBeenCalledTimes(1); // Still only 1 call
239
197
  });
@@ -262,7 +220,7 @@ describe("useEnrichCellOutputs", () => {
262
220
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
263
221
 
264
222
  const takeScreenshots = result.current;
265
- const output = await takeScreenshots({ progress, snappy: false });
223
+ const output = await takeScreenshots({ progress });
266
224
 
267
225
  expect(output).toEqual({}); // Failed screenshot should be filtered out
268
226
  expect(Logger.error).toHaveBeenCalledWith(
@@ -271,6 +229,50 @@ describe("useEnrichCellOutputs", () => {
271
229
  );
272
230
  });
273
231
 
232
+ it("should retry failed screenshots on next call", async () => {
233
+ const cellId = "cell-1" as CellId;
234
+ const mockElement = document.createElement("div");
235
+ const error = new Error("Screenshot failed");
236
+ const mockDataUrl = "data:image/png;base64,retrySuccess";
237
+
238
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
239
+ // First call fails, second call succeeds
240
+ vi.mocked(toPng)
241
+ .mockRejectedValueOnce(error)
242
+ .mockResolvedValueOnce(mockDataUrl);
243
+
244
+ setCellsRuntime(
245
+ createMockCellRuntimes({
246
+ [cellId]: {
247
+ output: {
248
+ channel: "output",
249
+ mimetype: "text/html",
250
+ data: "<div>Chart</div>",
251
+ timestamp: 0,
252
+ },
253
+ },
254
+ }),
255
+ );
256
+
257
+ const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
258
+ wrapper,
259
+ });
260
+
261
+ // First call - screenshot fails
262
+ let takeScreenshots = result.current;
263
+ let output = await takeScreenshots({ progress });
264
+ expect(output).toEqual({});
265
+ expect(Logger.error).toHaveBeenCalled();
266
+
267
+ rerender();
268
+
269
+ // Second call - should retry since the first one failed
270
+ takeScreenshots = result.current;
271
+ output = await takeScreenshots({ progress });
272
+ expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl] });
273
+ expect(toPng).toHaveBeenCalledTimes(2);
274
+ });
275
+
274
276
  it("should handle missing DOM elements", async () => {
275
277
  const cellId = "cell-1" as CellId;
276
278
 
@@ -292,7 +294,7 @@ describe("useEnrichCellOutputs", () => {
292
294
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
293
295
 
294
296
  const takeScreenshots = result.current;
295
- const output = await takeScreenshots({ progress, snappy: false });
297
+ const output = await takeScreenshots({ progress });
296
298
 
297
299
  expect(output).toEqual({});
298
300
  expect(Logger.error).toHaveBeenCalledWith(
@@ -341,7 +343,7 @@ describe("useEnrichCellOutputs", () => {
341
343
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
342
344
 
343
345
  const takeScreenshots = result.current;
344
- const output = await takeScreenshots({ progress, snappy: false });
346
+ const output = await takeScreenshots({ progress });
345
347
 
346
348
  expect(output).toEqual({
347
349
  [cell1]: ["image/png", mockDataUrl1],
@@ -387,7 +389,7 @@ describe("useEnrichCellOutputs", () => {
387
389
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
388
390
 
389
391
  const takeScreenshots = result.current;
390
- const output = await takeScreenshots({ progress, snappy: false });
392
+ const output = await takeScreenshots({ progress });
391
393
 
392
394
  // Only the successful screenshot should be in the result
393
395
  expect(output).toEqual({
@@ -429,13 +431,13 @@ describe("useEnrichCellOutputs", () => {
429
431
 
430
432
  // First screenshot
431
433
  let takeScreenshots = result.current;
432
- let output = await takeScreenshots({ progress, snappy: false });
434
+ let output = await takeScreenshots({ progress });
433
435
  expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl1] });
434
436
 
435
437
  // Second call - same output, should not be captured
436
438
  rerender();
437
439
  takeScreenshots = result.current;
438
- output = await takeScreenshots({ progress, snappy: false });
440
+ output = await takeScreenshots({ progress });
439
441
  expect(output).toEqual({});
440
442
 
441
443
  // Third call - output changed, should be captured
@@ -454,7 +456,7 @@ describe("useEnrichCellOutputs", () => {
454
456
 
455
457
  rerender();
456
458
  takeScreenshots = result.current;
457
- output = await takeScreenshots({ progress, snappy: false });
459
+ output = await takeScreenshots({ progress });
458
460
  expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl2] });
459
461
  expect(toPng).toHaveBeenCalledTimes(2);
460
462
  });
@@ -494,7 +496,7 @@ describe("useEnrichCellOutputs", () => {
494
496
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
495
497
 
496
498
  const takeScreenshots = result.current;
497
- const output = await takeScreenshots({ progress, snappy: false });
499
+ const output = await takeScreenshots({ progress });
498
500
 
499
501
  // None of these should trigger screenshots
500
502
  expect(output).toEqual({});
@@ -519,7 +521,7 @@ describe("useEnrichCellOutputs", () => {
519
521
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
520
522
 
521
523
  const takeScreenshots = result.current;
522
- const output = await takeScreenshots({ progress, snappy: false });
524
+ const output = await takeScreenshots({ progress });
523
525
 
524
526
  expect(output).toEqual({});
525
527
  expect(document.getElementById).not.toHaveBeenCalled();
@@ -551,7 +553,7 @@ describe("useEnrichCellOutputs", () => {
551
553
  const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
552
554
 
553
555
  const takeScreenshots = result.current;
554
- const output = await takeScreenshots({ progress, snappy: false });
556
+ const output = await takeScreenshots({ progress });
555
557
 
556
558
  // Verify the exact return type structure
557
559
  expect(output).toHaveProperty(cellId);
@@ -1,9 +1,10 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { atom, useAtom, useAtomValue } from "jotai";
2
+ import { useAtomValue } from "jotai";
3
3
  import type { MimeType } from "@/components/editor/Output";
4
4
  import { toast } from "@/components/ui/use-toast";
5
5
  import { appConfigAtom } from "@/core/config/config";
6
6
  import { useInterval } from "@/hooks/useInterval";
7
+ import { AsyncCaptureTracker } from "@/utils/async-capture-tracker";
7
8
  import { getImageDataUrlForCell } from "@/utils/download";
8
9
  import { Logger } from "@/utils/Logger";
9
10
  import { Objects } from "@/utils/objects";
@@ -67,7 +68,6 @@ export function useAutoExport() {
67
68
  const screenshotFn = () =>
68
69
  takeScreenshots({
69
70
  progress: ProgressState.indeterminate(),
70
- snappy: true,
71
71
  });
72
72
  await updateCellOutputsWithScreenshots({
73
73
  takeScreenshots: screenshotFn,
@@ -89,9 +89,6 @@ export function useAutoExport() {
89
89
  );
90
90
  }
91
91
 
92
- // We track cells that need screenshots, these will be exported to IPYNB
93
- const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
94
-
95
92
  // MIME types to capture screenshots for
96
93
  const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
97
94
  "text/html",
@@ -101,66 +98,109 @@ const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
101
98
  "application/vnd.vega.v6+json",
102
99
  ]);
103
100
 
104
- type ScreenshotResults = Record<CellId, ["image/png", string]>;
101
+ type ScreenshotResult = ["image/png", string];
102
+ type ScreenshotResults = Record<CellId, ScreenshotResult>;
103
+
104
+ // Only marks cells as captured after successful screenshot.
105
+ export const captureTracker = new AsyncCaptureTracker<
106
+ CellId,
107
+ ScreenshotResult
108
+ >();
109
+
110
+ interface UseEnrichCellOutputsOptions {
111
+ progress: ProgressState;
112
+ }
105
113
 
106
114
  /**
107
115
  * Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
108
116
  * @returns A map of cell IDs to their screenshots data.
109
117
  */
110
- export function useEnrichCellOutputs() {
111
- const [richCellsOutput, setRichCellsOutput] = useAtom(richCellsToOutputAtom);
118
+ export function useEnrichCellOutputs(): (
119
+ opts: UseEnrichCellOutputsOptions,
120
+ ) => Promise<ScreenshotResults> {
112
121
  const cellRuntimes = useAtomValue(cellsRuntimeAtom);
113
122
 
114
- return async ({
115
- progress,
116
- snappy,
117
- }: {
118
- progress: ProgressState;
119
- snappy: boolean;
120
- }): Promise<ScreenshotResults> => {
121
- const trackedCellsOutput: Record<CellId, unknown> = {};
123
+ return async (
124
+ opts: UseEnrichCellOutputsOptions,
125
+ ): Promise<ScreenshotResults> => {
126
+ const { progress } = opts;
127
+
128
+ // Prune tracked state for cells that no longer exist
129
+ const currentCellIds = new Set(Objects.keys(cellRuntimes));
130
+ captureTracker.prune(currentCellIds);
122
131
 
123
132
  const cellsToCaptureScreenshot: [CellId, unknown][] = [];
133
+ const inFlightWaiters: {
134
+ cellId: CellId;
135
+ promise: Promise<ScreenshotResult | undefined>;
136
+ }[] = [];
137
+
124
138
  for (const [cellId, runtime] of Objects.entries(cellRuntimes)) {
125
139
  const outputData = runtime.output?.data;
126
- const outputHasChanged = richCellsOutput[cellId] !== outputData;
127
- // Track latest output for this cell
128
- trackedCellsOutput[cellId] = outputData;
129
140
  if (
130
141
  runtime.output?.mimetype &&
131
142
  MIME_TYPES_TO_CAPTURE_SCREENSHOTS.has(runtime.output.mimetype) &&
132
- outputData &&
133
- outputHasChanged
143
+ outputData
134
144
  ) {
135
- cellsToCaptureScreenshot.push([cellId, runtime]);
145
+ if (captureTracker.needsCapture(cellId, outputData)) {
146
+ cellsToCaptureScreenshot.push([cellId, outputData]);
147
+ } else {
148
+ // If already in-flight with the same value, await its result
149
+ const promise = captureTracker.waitForInFlight(cellId, outputData);
150
+ if (promise) {
151
+ inFlightWaiters.push({ cellId, promise });
152
+ }
153
+ }
136
154
  }
137
155
  }
138
- // Always update tracked outputs, this ensures data is fresh for the next run
139
- setRichCellsOutput(trackedCellsOutput);
140
156
 
141
- if (cellsToCaptureScreenshot.length === 0) {
157
+ if (cellsToCaptureScreenshot.length === 0 && inFlightWaiters.length === 0) {
142
158
  return {};
143
159
  }
144
160
 
145
- // Capture screenshots
146
- const total = cellsToCaptureScreenshot.length;
147
- progress.addTotal(total);
161
+ // Start the progress bar for new captures only
162
+ if (cellsToCaptureScreenshot.length > 0) {
163
+ progress.addTotal(cellsToCaptureScreenshot.length);
164
+ }
165
+
166
+ // Capture screenshots — each key gets its own AbortSignal so
167
+ // aborting one cell does not affect the others.
148
168
  const results: ScreenshotResults = {};
149
- for (const [cellId] of cellsToCaptureScreenshot) {
169
+ for (const [cellId, outputData] of cellsToCaptureScreenshot) {
170
+ const handle = captureTracker.startCapture(cellId, outputData);
150
171
  try {
151
- const dataUrl = await getImageDataUrlForCell(cellId, snappy);
172
+ const dataUrl = await getImageDataUrlForCell(cellId);
173
+ if (handle.signal.aborted) {
174
+ continue;
175
+ }
152
176
  if (!dataUrl) {
153
177
  Logger.error(`Failed to capture screenshot for cell ${cellId}`);
178
+ handle.markFailed();
154
179
  continue;
155
180
  }
156
- results[cellId] = ["image/png", dataUrl];
181
+ const result: ScreenshotResult = ["image/png", dataUrl];
182
+ results[cellId] = result;
183
+ handle.markCaptured(result);
157
184
  } catch (error) {
158
185
  Logger.error(`Error screenshotting cell ${cellId}:`, error);
186
+ handle.markFailed();
159
187
  } finally {
160
188
  progress.increment(1);
161
189
  }
162
190
  }
163
191
 
192
+ // Await in-flight captures started by concurrent callers
193
+ const settled = await Promise.allSettled(
194
+ inFlightWaiters.map(({ promise }) => promise),
195
+ );
196
+ for (const [i, { cellId }] of inFlightWaiters.entries()) {
197
+ const result =
198
+ settled[i].status === "fulfilled" ? settled[i].value : undefined;
199
+ if (result) {
200
+ results[cellId] = result;
201
+ }
202
+ }
203
+
164
204
  return results;
165
205
  };
166
206
  }
@@ -1,24 +1,10 @@
1
1
  @reference "../globals.css";
2
2
 
3
- /* Hide on print
4
- To hide an element on print, add the class `no-print` to it.
5
- When printing, the class `printing` is added to the body element to enable this rule. */
6
- body.printing .no-print {
7
- display: none !important;
8
- }
9
-
10
3
  body.printing #App {
11
4
  /* Full screen print */
12
5
  height: fit-content !important;
13
6
  }
14
7
 
15
- /* When printing the output of a cell, this unset the max-height set by the notebook to capture the full output */
16
- .printing-output {
17
- max-height: none !important;
18
-
19
- @apply bg-background;
20
- }
21
-
22
8
  @media print {
23
9
  * {
24
10
  -webkit-print-color-adjust: exact;