@marimo-team/islands 0.19.3-dev39 → 0.19.3-dev41

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/main.js CHANGED
@@ -39981,7 +39981,7 @@ ${c.sqlString}
39981
39981
  return (_a2 = r[e2]) == null ? void 0 : _a2.name;
39982
39982
  }).filter(Boolean);
39983
39983
  };
39984
- splitAtom(selectAtom(notebookAtom, (e) => e.cellIds.inOrderIds.map((r) => e.cellData[r]))), atom((e) => notebookIsRunning(e(notebookAtom))), atom((e) => notebookQueueOrRunningCount(e(notebookAtom))), atom((e) => e(notebookAtom).cellIds.colLength), atom((e) => e(notebookAtom).cellIds.idLength > 0), atom((e) => e(notebookAtom).cellIds.getColumnIds());
39984
+ splitAtom(selectAtom(notebookAtom, (e) => e.cellIds.inOrderIds.map((r) => e.cellData[r]))), atom((e) => e(notebookAtom).cellRuntime), atom((e) => notebookIsRunning(e(notebookAtom))), atom((e) => notebookQueueOrRunningCount(e(notebookAtom))), atom((e) => e(notebookAtom).cellIds.colLength), atom((e) => e(notebookAtom).cellIds.idLength > 0), atom((e) => e(notebookAtom).cellIds.getColumnIds());
39985
39985
  const cellDataAtom = atomFamily((e) => atom((r) => r(notebookAtom).cellData[e]));
39986
39986
  var cellRuntimeAtom = atomFamily((e) => atom((r) => r(notebookAtom).cellRuntime[e]));
39987
39987
  const cellHandleAtom = atomFamily((e) => atom((r) => r(notebookAtom).cellHandles[e]));
@@ -101153,7 +101153,7 @@ Defaulting to \`null\`.`;
101153
101153
  return Logger.warn("Failed to get version from mount config"), null;
101154
101154
  }
101155
101155
  }
101156
- const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.3-dev39"), showCodeInRunModeAtom = atom(true);
101156
+ const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.3-dev41"), showCodeInRunModeAtom = atom(true);
101157
101157
  atom(null);
101158
101158
  var VIRTUAL_FILE_REGEX = /\/@file\/([^\s"&'/]+)\.([\dA-Za-z]+)/g, VirtualFileTracker = class e {
101159
101159
  constructor() {
@@ -102286,6 +102286,7 @@ ${r}
102286
102286
  __publicField(this, "autoExportAsHTML", throwNotImplemented);
102287
102287
  __publicField(this, "autoExportAsMarkdown", throwNotImplemented);
102288
102288
  __publicField(this, "autoExportAsIPYNB", throwNotImplemented);
102289
+ __publicField(this, "updateCellOutputs", throwNotImplemented);
102289
102290
  __publicField(this, "addPackage", throwNotImplemented);
102290
102291
  __publicField(this, "removePackage", throwNotImplemented);
102291
102292
  __publicField(this, "getPackageList", throwNotImplemented);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.19.3-dev39",
3
+ "version": "0.19.3-dev41",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -63,6 +63,7 @@ export const MockRequestClient = {
63
63
  autoExportAsHTML: vi.fn().mockResolvedValue({}),
64
64
  autoExportAsMarkdown: vi.fn().mockResolvedValue({}),
65
65
  autoExportAsIPYNB: vi.fn().mockResolvedValue({}),
66
+ updateCellOutputs: vi.fn().mockResolvedValue({}),
66
67
  addPackage: vi.fn().mockResolvedValue({}),
67
68
  removePackage: vi.fn().mockResolvedValue({}),
68
69
  getPackageList: vi.fn().mockResolvedValue({ packages: [] }),
@@ -1594,6 +1594,8 @@ const cellDataAtoms = splitAtom(
1594
1594
  );
1595
1595
  export const useCellDataAtoms = () => useAtom(cellDataAtoms);
1596
1596
 
1597
+ export const cellsRuntimeAtom = atom((get) => get(notebookAtom).cellRuntime);
1598
+
1597
1599
  export const notebookIsRunningAtom = atom((get) =>
1598
1600
  notebookIsRunning(get(notebookAtom)),
1599
1601
  );
@@ -0,0 +1,504 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import type { ReactNode } from "react";
6
+ import * as React from "react";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import type { CellId } from "@/core/cells/ids";
9
+ import { CellOutputId } from "@/core/cells/ids";
10
+ import type { CellRuntimeState } from "@/core/cells/types";
11
+ import { useEnrichCellOutputs } from "../hooks";
12
+
13
+ // Mock html-to-image
14
+ vi.mock("html-to-image", () => ({
15
+ toPng: vi.fn(),
16
+ }));
17
+
18
+ // Mock Logger
19
+ vi.mock("@/utils/Logger", () => ({
20
+ Logger: {
21
+ error: vi.fn(),
22
+ },
23
+ }));
24
+
25
+ // Mock cellsRuntimeAtom - must be defined inline in the factory function
26
+ vi.mock("@/core/cells/cells", async () => {
27
+ const { atom } = await import("jotai");
28
+ return {
29
+ cellsRuntimeAtom: atom({}),
30
+ };
31
+ });
32
+
33
+ import { toPng } from "html-to-image";
34
+ import { cellsRuntimeAtom } from "@/core/cells/cells";
35
+ import { Logger } from "@/utils/Logger";
36
+
37
+ describe("useEnrichCellOutputs", () => {
38
+ let store: ReturnType<typeof createStore>;
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ store = createStore();
43
+ });
44
+
45
+ const wrapper = ({ children }: { children: ReactNode }) =>
46
+ React.createElement(Provider, { store }, children);
47
+
48
+ // Helper to set the mocked atom (cast to any to work around type mismatch)
49
+ const setCellsRuntime = (value: Record<CellId, CellRuntimeState>) => {
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ store.set(cellsRuntimeAtom as any, value);
52
+ };
53
+
54
+ const createMockCellRuntimes = (
55
+ cells: Record<string, Partial<CellRuntimeState>>,
56
+ ): Record<CellId, CellRuntimeState> => {
57
+ return Object.fromEntries(
58
+ Object.entries(cells).map(([cellId, cell]) => [
59
+ cellId as CellId,
60
+ {
61
+ output: cell.output || null,
62
+ status: cell.status || "idle",
63
+ interrupted: false,
64
+ errored: false,
65
+ runStartTimestamp: null,
66
+ runElapsedTimeMs: null,
67
+ stallTime: null as unknown as number,
68
+ ...cell,
69
+ } as CellRuntimeState,
70
+ ]),
71
+ ) as Record<CellId, CellRuntimeState>;
72
+ };
73
+
74
+ it("should return empty object when no cells need screenshots", async () => {
75
+ vi.spyOn(document, "getElementById");
76
+
77
+ // Set up cell runtimes with no text/html outputs
78
+ setCellsRuntime(
79
+ createMockCellRuntimes({
80
+ "cell-1": {
81
+ output: {
82
+ channel: "output",
83
+ mimetype: "text/plain",
84
+ data: "Hello World",
85
+ timestamp: 0,
86
+ },
87
+ },
88
+ }),
89
+ );
90
+
91
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
92
+
93
+ const enrichCellOutputs = result.current;
94
+ const output = await enrichCellOutputs();
95
+
96
+ expect(output).toEqual({});
97
+ expect(document.getElementById).not.toHaveBeenCalled();
98
+ expect(toPng).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("should capture screenshots for cells with text/html output", async () => {
102
+ const cellId = "cell-1" as CellId;
103
+ const mockElement = document.createElement("div");
104
+ const mockDataUrl = "data:image/png;base64,mockImageData";
105
+
106
+ // Mock document.getElementById
107
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
108
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
109
+
110
+ setCellsRuntime(
111
+ createMockCellRuntimes({
112
+ [cellId]: {
113
+ output: {
114
+ channel: "output",
115
+ mimetype: "text/html",
116
+ data: "<div>Chart</div>",
117
+ timestamp: 0,
118
+ },
119
+ },
120
+ }),
121
+ );
122
+
123
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
124
+
125
+ const enrichCellOutputs = result.current;
126
+ const output = await enrichCellOutputs();
127
+
128
+ expect(document.getElementById).toHaveBeenCalledWith(
129
+ CellOutputId.create(cellId),
130
+ );
131
+ expect(toPng).toHaveBeenCalledWith(mockElement);
132
+ expect(output).toEqual({
133
+ [cellId]: ["image/png", mockDataUrl],
134
+ });
135
+ });
136
+
137
+ it("should skip cells where output has not changed", async () => {
138
+ const cellId = "cell-1" as CellId;
139
+ const mockElement = document.createElement("div");
140
+ const mockDataUrl = "data:image/png;base64,mockImageData";
141
+ const htmlData = "<div>Chart</div>";
142
+
143
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
144
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
145
+
146
+ setCellsRuntime(
147
+ createMockCellRuntimes({
148
+ [cellId]: {
149
+ output: {
150
+ channel: "output",
151
+ mimetype: "text/html",
152
+ data: htmlData,
153
+ timestamp: 0,
154
+ },
155
+ },
156
+ }),
157
+ );
158
+
159
+ const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
160
+ wrapper,
161
+ });
162
+
163
+ // First call - should capture
164
+ let enrichCellOutputs = result.current;
165
+ let output = await enrichCellOutputs();
166
+ expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl] });
167
+ expect(toPng).toHaveBeenCalledTimes(1);
168
+
169
+ // Rerender to get updated atom state
170
+ rerender();
171
+
172
+ // Second call with same output - should not capture again
173
+ enrichCellOutputs = result.current;
174
+ output = await enrichCellOutputs();
175
+ expect(output).toEqual({}); // Empty because output hasn't changed
176
+ expect(toPng).toHaveBeenCalledTimes(1); // Still only 1 call
177
+ });
178
+
179
+ it("should handle screenshot errors gracefully", async () => {
180
+ const cellId = "cell-1" as CellId;
181
+ const mockElement = document.createElement("div");
182
+ const error = new Error("Screenshot failed");
183
+
184
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
185
+ vi.mocked(toPng).mockRejectedValue(error);
186
+
187
+ setCellsRuntime(
188
+ createMockCellRuntimes({
189
+ [cellId]: {
190
+ output: {
191
+ channel: "output",
192
+ mimetype: "text/html",
193
+ data: "<div>Chart</div>",
194
+ timestamp: 0,
195
+ },
196
+ },
197
+ }),
198
+ );
199
+
200
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
201
+
202
+ const enrichCellOutputs = result.current;
203
+ const output = await enrichCellOutputs();
204
+
205
+ expect(output).toEqual({}); // Failed screenshot should be filtered out
206
+ expect(Logger.error).toHaveBeenCalledWith(
207
+ `Error screenshotting cell ${cellId}:`,
208
+ error,
209
+ );
210
+ });
211
+
212
+ it("should handle missing DOM elements", async () => {
213
+ const cellId = "cell-1" as CellId;
214
+
215
+ vi.spyOn(document, "getElementById").mockReturnValue(null);
216
+
217
+ setCellsRuntime(
218
+ createMockCellRuntimes({
219
+ [cellId]: {
220
+ output: {
221
+ channel: "output",
222
+ mimetype: "text/html",
223
+ data: "<div>Chart</div>",
224
+ timestamp: 0,
225
+ },
226
+ },
227
+ }),
228
+ );
229
+
230
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
231
+
232
+ const enrichCellOutputs = result.current;
233
+ const output = await enrichCellOutputs();
234
+
235
+ expect(output).toEqual({});
236
+ expect(Logger.error).toHaveBeenCalledWith(
237
+ `Output element not found for cell ${cellId}`,
238
+ );
239
+ expect(toPng).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("should process multiple cells in parallel", async () => {
243
+ const cell1 = "cell-1" as CellId;
244
+ const cell2 = "cell-2" as CellId;
245
+ const mockElement1 = document.createElement("div");
246
+ const mockElement2 = document.createElement("div");
247
+ const mockDataUrl1 = "data:image/png;base64,image1";
248
+ const mockDataUrl2 = "data:image/png;base64,image2";
249
+
250
+ vi.spyOn(document, "getElementById")
251
+ .mockReturnValueOnce(mockElement1)
252
+ .mockReturnValueOnce(mockElement2);
253
+
254
+ vi.mocked(toPng)
255
+ .mockResolvedValueOnce(mockDataUrl1)
256
+ .mockResolvedValueOnce(mockDataUrl2);
257
+
258
+ setCellsRuntime(
259
+ createMockCellRuntimes({
260
+ [cell1]: {
261
+ output: {
262
+ channel: "output",
263
+ mimetype: "text/html",
264
+ data: "<div>Chart 1</div>",
265
+ timestamp: 0,
266
+ },
267
+ },
268
+ [cell2]: {
269
+ output: {
270
+ channel: "output",
271
+ mimetype: "text/html",
272
+ data: "<div>Chart 2</div>",
273
+ timestamp: 0,
274
+ },
275
+ },
276
+ }),
277
+ );
278
+
279
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
280
+
281
+ const enrichCellOutputs = result.current;
282
+ const output = await enrichCellOutputs();
283
+
284
+ expect(output).toEqual({
285
+ [cell1]: ["image/png", mockDataUrl1],
286
+ [cell2]: ["image/png", mockDataUrl2],
287
+ });
288
+ expect(toPng).toHaveBeenCalledTimes(2);
289
+ });
290
+
291
+ it("should filter out null results from failed screenshots", async () => {
292
+ // Setup: one successful, one failed screenshot
293
+ const cell1 = "cell-1" as CellId;
294
+ const cell2 = "cell-2" as CellId;
295
+ const mockElement1 = document.createElement("div");
296
+ const mockDataUrl = "data:image/png;base64,image1";
297
+
298
+ vi.spyOn(document, "getElementById")
299
+ .mockReturnValueOnce(mockElement1)
300
+ .mockReturnValueOnce(null); // Second cell fails to find element
301
+
302
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
303
+
304
+ setCellsRuntime(
305
+ createMockCellRuntimes({
306
+ [cell1]: {
307
+ output: {
308
+ channel: "output",
309
+ mimetype: "text/html",
310
+ data: "<div>Chart 1</div>",
311
+ timestamp: 0,
312
+ },
313
+ },
314
+ [cell2]: {
315
+ output: {
316
+ channel: "output",
317
+ mimetype: "text/html",
318
+ data: "<div>Chart 2</div>",
319
+ timestamp: 0,
320
+ },
321
+ },
322
+ }),
323
+ );
324
+
325
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
326
+
327
+ const enrichCellOutputs = result.current;
328
+ const output = await enrichCellOutputs();
329
+
330
+ // Only the successful screenshot should be in the result
331
+ expect(output).toEqual({
332
+ [cell1]: ["image/png", mockDataUrl],
333
+ });
334
+ expect(Logger.error).toHaveBeenCalledWith(
335
+ `Output element not found for cell ${cell2}`,
336
+ );
337
+ });
338
+
339
+ it("should only capture screenshots for cells with changed output", async () => {
340
+ const cellId = "cell-1" as CellId;
341
+ const mockElement = document.createElement("div");
342
+ const mockDataUrl1 = "data:image/png;base64,image1";
343
+ const mockDataUrl2 = "data:image/png;base64,image2";
344
+
345
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
346
+ vi.mocked(toPng)
347
+ .mockResolvedValueOnce(mockDataUrl1)
348
+ .mockResolvedValueOnce(mockDataUrl2);
349
+
350
+ // First call - cell should be captured
351
+ setCellsRuntime(
352
+ createMockCellRuntimes({
353
+ [cellId]: {
354
+ output: {
355
+ channel: "output",
356
+ mimetype: "text/html",
357
+ data: "<div>Chart v1</div>",
358
+ timestamp: 0,
359
+ },
360
+ },
361
+ }),
362
+ );
363
+
364
+ const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
365
+ wrapper,
366
+ });
367
+
368
+ // First screenshot
369
+ let enrichCellOutputs = result.current;
370
+ let output = await enrichCellOutputs();
371
+ expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl1] });
372
+
373
+ // Second call - same output, should not be captured
374
+ rerender();
375
+ enrichCellOutputs = result.current;
376
+ output = await enrichCellOutputs();
377
+ expect(output).toEqual({});
378
+
379
+ // Third call - output changed, should be captured
380
+ setCellsRuntime(
381
+ createMockCellRuntimes({
382
+ [cellId]: {
383
+ output: {
384
+ channel: "output",
385
+ mimetype: "text/html",
386
+ data: "<div>Chart v2</div>", // Changed!
387
+ timestamp: 0,
388
+ },
389
+ },
390
+ }),
391
+ );
392
+
393
+ rerender();
394
+ enrichCellOutputs = result.current;
395
+ output = await enrichCellOutputs();
396
+ expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl2] });
397
+ expect(toPng).toHaveBeenCalledTimes(2);
398
+ });
399
+
400
+ it("should ignore cells with non-text/html mimetype", async () => {
401
+ vi.spyOn(document, "getElementById");
402
+
403
+ setCellsRuntime(
404
+ createMockCellRuntimes({
405
+ "cell-1": {
406
+ output: {
407
+ channel: "output",
408
+ mimetype: "application/json",
409
+ data: '{"key": "value"}',
410
+ timestamp: 0,
411
+ },
412
+ },
413
+ "cell-2": {
414
+ output: {
415
+ channel: "output",
416
+ mimetype: "text/plain",
417
+ data: "Plain text",
418
+ timestamp: 0,
419
+ },
420
+ },
421
+ "cell-3": {
422
+ output: {
423
+ channel: "output",
424
+ mimetype: "image/png",
425
+ data: "data:image/png;base64,existing",
426
+ timestamp: 0,
427
+ },
428
+ },
429
+ }),
430
+ );
431
+
432
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
433
+
434
+ const enrichCellOutputs = result.current;
435
+ const output = await enrichCellOutputs();
436
+
437
+ // None of these should trigger screenshots
438
+ expect(output).toEqual({});
439
+ expect(document.getElementById).not.toHaveBeenCalled();
440
+ expect(toPng).not.toHaveBeenCalled();
441
+ });
442
+
443
+ it("should ignore cells with null or undefined output", async () => {
444
+ vi.spyOn(document, "getElementById");
445
+
446
+ setCellsRuntime(
447
+ createMockCellRuntimes({
448
+ "cell-1": {
449
+ output: null,
450
+ },
451
+ "cell-2": {
452
+ output: undefined,
453
+ },
454
+ }),
455
+ );
456
+
457
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
458
+
459
+ const enrichCellOutputs = result.current;
460
+ const output = await enrichCellOutputs();
461
+
462
+ expect(output).toEqual({});
463
+ expect(document.getElementById).not.toHaveBeenCalled();
464
+ expect(toPng).not.toHaveBeenCalled();
465
+ });
466
+
467
+ it("should return correctly formatted result with CellId and tuple", async () => {
468
+ // Expected format: Record<CellId, ["image/png", string]>
469
+ const cellId = "test-cell" as CellId;
470
+ const mockElement = document.createElement("div");
471
+ const mockDataUrl = "data:image/png;base64,testData";
472
+
473
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
474
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
475
+
476
+ setCellsRuntime(
477
+ createMockCellRuntimes({
478
+ [cellId]: {
479
+ output: {
480
+ channel: "output",
481
+ mimetype: "text/html",
482
+ data: "<div>Content</div>",
483
+ timestamp: 0,
484
+ },
485
+ },
486
+ }),
487
+ );
488
+
489
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
490
+
491
+ const enrichCellOutputs = result.current;
492
+ const output = await enrichCellOutputs();
493
+
494
+ // Verify the exact return type structure
495
+ expect(output).toHaveProperty(cellId);
496
+ const cellOutput = output[cellId];
497
+ expect(cellOutput).toBeDefined();
498
+ expect(Array.isArray(cellOutput)).toBe(true);
499
+ if (cellOutput) {
500
+ expect(cellOutput[0]).toBe("image/png");
501
+ expect(cellOutput[1]).toBe(mockDataUrl);
502
+ }
503
+ });
504
+ });
@@ -1,7 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { useAtomValue } from "jotai";
2
+ import { toPng } from "html-to-image";
3
+ import { atom, useAtom, useAtomValue } from "jotai";
3
4
  import { appConfigAtom } from "@/core/config/config";
4
5
  import { useInterval } from "@/hooks/useInterval";
6
+ import { Logger } from "@/utils/Logger";
7
+ import { Objects } from "@/utils/objects";
8
+ import { cellsRuntimeAtom } from "../cells/cells";
9
+ import { type CellId, CellOutputId } from "../cells/ids";
5
10
  import { connectionAtom } from "../network/connection";
6
11
  import { useRequestClient } from "../network/requests";
7
12
  import { VirtualFileTracker } from "../static/virtual-file-tracker";
@@ -21,8 +26,13 @@ export function useAutoExport() {
21
26
  const markdownDisabled = !markdownEnabled || !isConnected;
22
27
  const htmlDisabled = !htmlEnabled || !isConnected;
23
28
  const ipynbDisabled = !ipynbEnabled || !isConnected;
24
- const { autoExportAsHTML, autoExportAsIPYNB, autoExportAsMarkdown } =
25
- useRequestClient();
29
+ const {
30
+ autoExportAsHTML,
31
+ autoExportAsIPYNB,
32
+ autoExportAsMarkdown,
33
+ updateCellOutputs,
34
+ } = useRequestClient();
35
+ const takeScreenshots = useEnrichCellOutputs();
26
36
 
27
37
  useInterval(
28
38
  async () => {
@@ -50,12 +60,91 @@ export function useAutoExport() {
50
60
 
51
61
  useInterval(
52
62
  async () => {
63
+ const cellsToOutput = await takeScreenshots();
64
+ if (Object.keys(cellsToOutput).length > 0) {
65
+ await updateCellOutputs({
66
+ cellIdsToOutput: cellsToOutput,
67
+ });
68
+ }
53
69
  await autoExportAsIPYNB({
54
70
  download: false,
55
71
  });
56
72
  },
57
73
  // Run every 5 seconds, or when the document becomes visible
58
74
  // Ignore if the document is not visible
59
- { delayMs: DELAY, whenVisible: true, disabled: ipynbDisabled },
75
+ // Skip if running to ensure no race conditions between screenshot and export
76
+ {
77
+ delayMs: DELAY,
78
+ whenVisible: true,
79
+ disabled: ipynbDisabled,
80
+ skipIfRunning: true,
81
+ },
60
82
  );
61
83
  }
84
+
85
+ // We track cells that need screenshots, these will be exported to IPYNB
86
+ const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
87
+
88
+ /**
89
+ * Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
90
+ * @returns A map of cell IDs to their screenshots data.
91
+ */
92
+ export function useEnrichCellOutputs() {
93
+ const [richCellsOutput, setRichCellsOutput] = useAtom(richCellsToOutputAtom);
94
+ const cellRuntimes = useAtomValue(cellsRuntimeAtom);
95
+
96
+ return async (): Promise<Record<CellId, ["image/png", string]>> => {
97
+ const trackedCellsOutput: Record<CellId, unknown> = {};
98
+
99
+ const cellsToCaptureScreenshot: [CellId, unknown][] = [];
100
+ for (const [cellId, runtime] of Objects.entries(cellRuntimes)) {
101
+ const outputData = runtime.output?.data;
102
+ const outputHasChanged = richCellsOutput[cellId] !== outputData;
103
+ // Track latest output for this cell
104
+ trackedCellsOutput[cellId] = outputData;
105
+ if (
106
+ runtime.output?.mimetype === "text/html" &&
107
+ outputData &&
108
+ outputHasChanged
109
+ ) {
110
+ cellsToCaptureScreenshot.push([cellId, runtime]);
111
+ }
112
+ }
113
+ // Always update tracked outputs, this ensures data is fresh for the next run
114
+ setRichCellsOutput(trackedCellsOutput);
115
+
116
+ if (cellsToCaptureScreenshot.length === 0) {
117
+ return {};
118
+ }
119
+
120
+ // Capture screenshots
121
+ const results = await Promise.all(
122
+ cellsToCaptureScreenshot.map(async ([cellId]) => {
123
+ const outputElement = document.getElementById(
124
+ CellOutputId.create(cellId),
125
+ );
126
+ if (!outputElement) {
127
+ Logger.error(`Output element not found for cell ${cellId}`);
128
+ return null;
129
+ }
130
+
131
+ try {
132
+ const dataUrl = await toPng(outputElement);
133
+ return [cellId, ["image/png", dataUrl]] as [
134
+ CellId,
135
+ ["image/png", string],
136
+ ];
137
+ } catch (error) {
138
+ Logger.error(`Error screenshotting cell ${cellId}:`, error);
139
+ return null;
140
+ }
141
+ }),
142
+ );
143
+
144
+ return Objects.fromEntries(
145
+ results.filter(
146
+ (result): result is [CellId, ["image/png", string]] => result !== null,
147
+ ),
148
+ );
149
+ };
150
+ }
@@ -171,6 +171,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
171
171
  autoExportAsHTML = throwNotImplemented;
172
172
  autoExportAsMarkdown = throwNotImplemented;
173
173
  autoExportAsIPYNB = throwNotImplemented;
174
+ updateCellOutputs = throwNotImplemented;
174
175
  addPackage = throwNotImplemented;
175
176
  removePackage = throwNotImplemented;
176
177
  getPackageList = throwNotImplemented;
@@ -67,6 +67,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
67
67
  autoExportAsHTML: "waitForConnectionOpen",
68
68
  autoExportAsMarkdown: "waitForConnectionOpen",
69
69
  autoExportAsIPYNB: "waitForConnectionOpen",
70
+ updateCellOutputs: "waitForConnectionOpen",
70
71
 
71
72
  // Sidebar operations that wait for connection
72
73
  listSecretKeys: "throwError",
@@ -405,6 +405,14 @@ export function createNetworkRequests(): EditRequests & RunRequests {
405
405
  })
406
406
  .then(handleResponseReturnNull);
407
407
  },
408
+ updateCellOutputs: async (request) => {
409
+ return getClient()
410
+ .POST("/api/export/update_cell_outputs", {
411
+ body: request,
412
+ params: getParams(),
413
+ })
414
+ .then(handleResponseReturnNull);
415
+ },
408
416
  addPackage: (request) => {
409
417
  return getClient()
410
418
  .POST("/api/packages/add", {
@@ -79,6 +79,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
79
79
  autoExportAsHTML: throwNotInEditMode,
80
80
  autoExportAsMarkdown: throwNotInEditMode,
81
81
  autoExportAsIPYNB: throwNotInEditMode,
82
+ updateCellOutputs: throwNotInEditMode,
82
83
  addPackage: throwNotInEditMode,
83
84
  removePackage: throwNotInEditMode,
84
85
  getPackageList: throwNotInEditMode,
@@ -64,6 +64,7 @@ export function createErrorToastingRequests(
64
64
  autoExportAsHTML: "", // No toast
65
65
  autoExportAsMarkdown: "", // No toast
66
66
  autoExportAsIPYNB: "", // No toast
67
+ updateCellOutputs: "", // No toast
67
68
  addPackage: "Failed to add package",
68
69
  removePackage: "Failed to remove package",
69
70
  getPackageList: "Failed to get package list",
@@ -21,6 +21,7 @@ export type ExportAsHTMLRequest = schemas["ExportAsHTMLRequest"];
21
21
  export type ExportAsMarkdownRequest = schemas["ExportAsMarkdownRequest"];
22
22
  export type ExportAsIPYNBRequest = schemas["ExportAsIPYNBRequest"];
23
23
  export type ExportAsScriptRequest = schemas["ExportAsScriptRequest"];
24
+ export type UpdateCellOutputsRequest = schemas["UpdateCellOutputsRequest"];
24
25
  export type FileCreateRequest = schemas["FileCreateRequest"];
25
26
  export type FileCreateResponse = schemas["FileCreateResponse"];
26
27
  export type FileDeleteRequest = schemas["FileDeleteRequest"];
@@ -175,6 +176,7 @@ export interface EditRequests {
175
176
  autoExportAsHTML: (request: ExportAsHTMLRequest) => Promise<null>;
176
177
  autoExportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<null>;
177
178
  autoExportAsIPYNB: (request: ExportAsIPYNBRequest) => Promise<null>;
179
+ updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>;
178
180
  // Package requests
179
181
  getPackageList: () => Promise<ListPackagesResponse>;
180
182
  getDependencyTree: () => Promise<DependencyTreeResponse>;
@@ -589,6 +589,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
589
589
  autoExportAsHTML = throwNotImplemented;
590
590
  autoExportAsMarkdown = throwNotImplemented;
591
591
  autoExportAsIPYNB = throwNotImplemented;
592
+ updateCellOutputs = throwNotImplemented;
592
593
  writeSecret = throwNotImplemented;
593
594
  invokeAiTool = throwNotImplemented;
594
595
  clearCache = throwNotImplemented;
@@ -67,4 +67,108 @@ describe("useInterval", () => {
67
67
  vi.advanceTimersByTime(1000);
68
68
  expect(callback).not.toHaveBeenCalled();
69
69
  });
70
+
71
+ describe("skipIfRunning", () => {
72
+ it("should allow overlapping async calls by default", async () => {
73
+ let concurrentCalls = 0;
74
+ let maxConcurrentCalls = 0;
75
+
76
+ const callback = vi.fn(async () => {
77
+ concurrentCalls++;
78
+ maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
79
+ // Simulate slow async work
80
+ await new Promise((resolve) => setTimeout(resolve, 2000));
81
+ concurrentCalls--;
82
+ });
83
+
84
+ renderHook(() =>
85
+ useInterval(callback, { delayMs: 500, whenVisible: false }),
86
+ );
87
+
88
+ // First call at 500ms
89
+ vi.advanceTimersByTime(500);
90
+ expect(callback).toHaveBeenCalledTimes(1);
91
+
92
+ // Second call at 1000ms (while first is still running)
93
+ vi.advanceTimersByTime(500);
94
+ expect(callback).toHaveBeenCalledTimes(2);
95
+
96
+ // Third call at 1500ms
97
+ vi.advanceTimersByTime(500);
98
+ expect(callback).toHaveBeenCalledTimes(3);
99
+
100
+ // Multiple concurrent calls should have occurred
101
+ expect(maxConcurrentCalls).toBeGreaterThan(1);
102
+ });
103
+
104
+ it("should skip calls when skipIfRunning is true", async () => {
105
+ let concurrentCalls = 0;
106
+ let maxConcurrentCalls = 0;
107
+
108
+ const callback = vi.fn(async () => {
109
+ concurrentCalls++;
110
+ maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
111
+ // Simulate slow async work (3 seconds)
112
+ await new Promise<void>((resolve) => setTimeout(resolve, 3000));
113
+ concurrentCalls--;
114
+ });
115
+
116
+ renderHook(() =>
117
+ useInterval(callback, {
118
+ delayMs: 500,
119
+ whenVisible: false,
120
+ skipIfRunning: true,
121
+ }),
122
+ );
123
+
124
+ // First call at 500ms
125
+ await vi.advanceTimersByTimeAsync(500);
126
+ expect(callback).toHaveBeenCalledTimes(1);
127
+
128
+ // Second interval tick at 1000ms - should be skipped since first is still running
129
+ await vi.advanceTimersByTimeAsync(500);
130
+ expect(callback).toHaveBeenCalledTimes(1); // Still 1, not 2
131
+
132
+ // Third interval tick at 1500ms - should still be skipped
133
+ await vi.advanceTimersByTimeAsync(500);
134
+ expect(callback).toHaveBeenCalledTimes(1); // Still 1
135
+
136
+ // Only one concurrent call should have occurred
137
+ expect(maxConcurrentCalls).toBe(1);
138
+
139
+ // Advance past the 3 second timeout to complete first callback
140
+ await vi.advanceTimersByTimeAsync(2000);
141
+
142
+ // Next interval tick should now be able to run
143
+ await vi.advanceTimersByTimeAsync(500);
144
+ expect(callback).toHaveBeenCalledTimes(2);
145
+ });
146
+
147
+ it("should allow next call after previous async call completes with skipIfRunning true", async () => {
148
+ const callback = vi.fn(async () => {
149
+ // Quick async operation
150
+ await Promise.resolve();
151
+ });
152
+
153
+ renderHook(() =>
154
+ useInterval(callback, {
155
+ delayMs: 1000,
156
+ whenVisible: false,
157
+ skipIfRunning: true,
158
+ }),
159
+ );
160
+
161
+ // First call
162
+ await vi.advanceTimersByTimeAsync(1000);
163
+ expect(callback).toHaveBeenCalledTimes(1);
164
+
165
+ // Second call - should proceed since first completed
166
+ await vi.advanceTimersByTimeAsync(1000);
167
+ expect(callback).toHaveBeenCalledTimes(2);
168
+
169
+ // Third call
170
+ await vi.advanceTimersByTimeAsync(1000);
171
+ expect(callback).toHaveBeenCalledTimes(3);
172
+ });
173
+ });
70
174
  });
@@ -1,9 +1,16 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { useEffect, useRef } from "react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
3
  import { useEventListener } from "./useEventListener";
4
4
 
5
5
  /**
6
6
  * Creates an interval that runs a callback every `delayMs` milliseconds.
7
+ *
8
+ * @param callback - The callback to run.
9
+ * @param opts - The options for the interval.
10
+ * @param opts.delayMs - The delay in milliseconds between runs.
11
+ * @param opts.whenVisible - Whether to run the callback when the document is visible.
12
+ * @param opts.disabled - Whether to disable the interval.
13
+ * @param opts.skipIfRunning - Whether to skip the callback if it is already running.
7
14
  */
8
15
  export function useInterval(
9
16
  callback: () => void,
@@ -11,16 +18,35 @@ export function useInterval(
11
18
  delayMs: number | null;
12
19
  whenVisible: boolean;
13
20
  disabled?: boolean;
21
+ skipIfRunning?: boolean;
14
22
  },
15
23
  ) {
16
- const { delayMs, whenVisible, disabled = false } = opts;
17
- const savedCallback = useRef<() => void>(undefined);
24
+ const {
25
+ delayMs,
26
+ whenVisible,
27
+ disabled = false,
28
+ skipIfRunning = false,
29
+ } = opts;
30
+ const savedCallback = useRef<() => void | Promise<void>>(undefined);
31
+ const isRunning = useRef(false);
18
32
 
19
33
  // Store the callback
20
34
  useEffect(() => {
21
35
  savedCallback.current = callback;
22
36
  }, [callback]);
23
37
 
38
+ const runCallback = useCallback(async () => {
39
+ if (isRunning.current && skipIfRunning) {
40
+ return;
41
+ }
42
+ isRunning.current = true;
43
+ try {
44
+ await savedCallback.current?.();
45
+ } finally {
46
+ isRunning.current = false;
47
+ }
48
+ }, [skipIfRunning]);
49
+
24
50
  // Run the interval
25
51
  useEffect(() => {
26
52
  if (delayMs === null || disabled) {
@@ -32,16 +58,16 @@ export function useInterval(
32
58
  return;
33
59
  }
34
60
 
35
- savedCallback.current?.();
61
+ runCallback();
36
62
  }, delayMs);
37
63
 
38
64
  return () => clearInterval(id);
39
- }, [delayMs, whenVisible, disabled]);
65
+ }, [delayMs, whenVisible, disabled, runCallback]);
40
66
 
41
67
  // When the document becomes visible, run the callback
42
68
  useEventListener(document, "visibilitychange", () => {
43
69
  if (document.visibilityState === "visible" && whenVisible && !disabled) {
44
- savedCallback.current?.();
70
+ runCallback();
45
71
  }
46
72
  });
47
73