@marimo-team/islands 0.19.7-dev1 → 0.19.7-dev15

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.
@@ -435,7 +435,11 @@ const ChatPanel = () => {
435
435
  title="Chat with AI"
436
436
  description="No AI provider configured or model selected"
437
437
  action={
438
- <Button variant="outline" size="sm" onClick={() => handleClick("ai")}>
438
+ <Button
439
+ variant="outline"
440
+ size="sm"
441
+ onClick={() => handleClick("ai", "ai-providers")}
442
+ >
439
443
  Edit AI settings
440
444
  </Button>
441
445
  }
@@ -1,30 +1,66 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import type React from "react";
4
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
4
5
  import { useCellDataAtoms, useCellIds } from "@/core/cells/cells";
5
6
  import { useVariables } from "@/core/variables/state";
6
7
  import { cn } from "@/utils/cn";
7
8
  import { DependencyGraph } from "../../../dependency-graph/dependency-graph";
8
9
  import { MinimapContent } from "../../../dependency-graph/minimap-content";
9
10
  import { useDependencyPanelTab } from "../wrapper/useDependencyPanelTab";
11
+ import { usePanelSection } from "./panel-context";
10
12
 
11
13
  const DependencyGraphPanel: React.FC = () => {
12
- const { dependencyPanelTab } = useDependencyPanelTab();
14
+ const { dependencyPanelTab, setDependencyPanelTab } = useDependencyPanelTab();
13
15
  const variables = useVariables();
14
16
  const cellIds = useCellIds();
15
17
  const [cells] = useCellDataAtoms();
18
+ const panelSection = usePanelSection();
19
+
20
+ // Show toggle inside panel when in developer panel (horizontal layout)
21
+ // since the sidebar has its own header with the toggle
22
+ const showInlineToggle = panelSection === "developer-panel";
16
23
 
17
24
  return (
18
- <div className={cn("w-full h-full flex-1 mx-auto -mb-4 relative")}>
19
- {dependencyPanelTab === "minimap" ? (
20
- <MinimapContent />
21
- ) : (
22
- <DependencyGraph
23
- cellAtoms={cells}
24
- variables={variables}
25
- cellIds={cellIds.inOrderIds}
26
- />
25
+ <div className={cn("w-full h-full flex-1 mx-auto -mb-4 flex flex-col")}>
26
+ {showInlineToggle && (
27
+ <div className="p-2 shrink-0">
28
+ <Tabs
29
+ value={dependencyPanelTab}
30
+ onValueChange={(value) => {
31
+ if (value === "minimap" || value === "graph") {
32
+ setDependencyPanelTab(value);
33
+ }
34
+ }}
35
+ >
36
+ <TabsList>
37
+ <TabsTrigger
38
+ value="minimap"
39
+ className="py-0.5 text-xs uppercase tracking-wide font-bold"
40
+ >
41
+ Minimap
42
+ </TabsTrigger>
43
+ <TabsTrigger
44
+ value="graph"
45
+ className="py-0.5 text-xs uppercase tracking-wide font-bold"
46
+ >
47
+ Graph
48
+ </TabsTrigger>
49
+ </TabsList>
50
+ </Tabs>
51
+ </div>
27
52
  )}
53
+ <div className="flex-1 min-h-0 relative">
54
+ {dependencyPanelTab === "minimap" ? (
55
+ <MinimapContent />
56
+ ) : (
57
+ <DependencyGraph
58
+ cellAtoms={cells}
59
+ variables={variables}
60
+ cellIds={cellIds.inOrderIds}
61
+ />
62
+ )}
63
+ </div>
28
64
  </div>
29
65
  );
30
66
  };
@@ -132,7 +132,7 @@ export function useEnrichCellOutputs() {
132
132
  const results = await Promise.all(
133
133
  cellsToCaptureScreenshot.map(async ([cellId]) => {
134
134
  try {
135
- const dataUrl = await getImageDataUrlForCell(cellId);
135
+ const dataUrl = await getImageDataUrlForCell(cellId, false);
136
136
  if (!dataUrl) {
137
137
  Logger.error(`Failed to capture screenshot for cell ${cellId}`);
138
138
  return null;
@@ -7,15 +7,14 @@ import { Logger } from "@/utils/Logger";
7
7
 
8
8
  import "./plotly.css";
9
9
  import "./mapbox.css";
10
- import { usePrevious } from "@uidotdev/usehooks";
11
- import { isEqual, pick, set } from "lodash-es";
12
- import { type JSX, lazy, memo, useEffect, useMemo, useState } from "react";
10
+ import { pick, set } from "lodash-es";
11
+ import { type JSX, lazy, memo, useMemo } from "react";
13
12
  import useEvent from "react-use-event-hook";
14
13
  import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
15
14
  import { useScript } from "@/hooks/useScript";
16
15
  import { Arrays } from "@/utils/arrays";
17
- import { Objects } from "@/utils/objects";
18
16
  import { createParser, type PlotlyTemplateParser } from "./parse-from-template";
17
+ import { usePlotlyLayout } from "./usePlotlyLayout";
19
18
 
20
19
  interface Data {
21
20
  figure: Figure;
@@ -80,18 +79,6 @@ export const LazyPlot = lazy(() =>
80
79
  }),
81
80
  );
82
81
 
83
- function initialLayout(figure: Figure): Partial<Plotly.Layout> {
84
- // Enable autosize if width is not specified
85
- const shouldAutoSize = figure.layout.width === undefined;
86
- return {
87
- autosize: shouldAutoSize,
88
- dragmode: "select",
89
- height: 540,
90
- // Prioritize user's config
91
- ...figure.layout,
92
- };
93
- }
94
-
95
82
  const SUNBURST_DATA_KEYS: (keyof Plotly.SunburstPlotDatum)[] = [
96
83
  "color",
97
84
  "curveNumber",
@@ -111,38 +98,20 @@ const TREE_MAP_DATA_KEYS = SUNBURST_DATA_KEYS;
111
98
 
112
99
  export const PlotlyComponent = memo(
113
100
  ({ figure: originalFigure, value, setValue, config }: PlotlyPluginProps) => {
114
- const [figure, setFigure] = useState(() => {
115
- // We clone the figure since Plotly mutates the figure in place
116
- return structuredClone(originalFigure);
117
- });
118
-
119
101
  // Used for rendering LaTeX. TODO: Serve this library from Marimo
120
102
  const scriptStatus = useScript(
121
103
  "https://cdn.jsdelivr.net/npm/mathjax-full@3.2.2/es5/tex-mml-svg.min.js",
122
104
  );
123
105
  const isScriptLoaded = scriptStatus === "ready";
124
106
 
125
- useEffect(() => {
126
- const nextFigure = structuredClone(originalFigure);
127
- setFigure(nextFigure);
128
- setLayout((prev) => ({
129
- ...initialLayout(nextFigure),
130
- ...prev,
131
- }));
132
- }, [originalFigure, isScriptLoaded]);
133
-
134
- const [layout, setLayout] = useState<Partial<Plotly.Layout>>(() => {
135
- return {
136
- ...initialLayout(figure),
137
- // Override with persisted values (dragmode, xaxis, yaxis)
138
- ...value,
139
- };
107
+ const { figure, layout, handleReset } = usePlotlyLayout({
108
+ originalFigure,
109
+ initialValue: value,
110
+ isScriptLoaded,
140
111
  });
141
112
 
142
- const handleReset = useEvent(() => {
143
- const nextFigure = structuredClone(originalFigure);
144
- setFigure(nextFigure);
145
- setLayout(initialLayout(nextFigure));
113
+ const handleResetWithClear = useEvent(() => {
114
+ handleReset();
146
115
  setValue({});
147
116
  });
148
117
 
@@ -163,37 +132,13 @@ export const PlotlyComponent = memo(
163
132
  <path d="M3 3v5h5" />
164
133
  </svg>`,
165
134
  },
166
- click: handleReset,
135
+ click: handleResetWithClear,
167
136
  },
168
137
  ],
169
138
  // Prioritize user's config
170
139
  ...configMemo,
171
140
  };
172
- }, [handleReset, configMemo]);
173
-
174
- const prevFigure = usePrevious(figure) ?? figure;
175
-
176
- useEffect(() => {
177
- const omitKeys = new Set<keyof Plotly.Layout>([
178
- "autosize",
179
- "dragmode",
180
- "xaxis",
181
- "yaxis",
182
- ]);
183
-
184
- // If the key was updated externally (e.g. can be specifically passed in the config)
185
- // then we need to update the layout
186
- for (const key of omitKeys) {
187
- if (!isEqual(figure.layout[key], prevFigure.layout[key])) {
188
- omitKeys.delete(key);
189
- }
190
- }
191
-
192
- // Update layout when figure.layout changes
193
- // Omit keys that we don't want to override
194
- const layout = Objects.omit(figure.layout, omitKeys);
195
- setLayout((prev) => ({ ...prev, ...layout }));
196
- }, [figure.layout, prevFigure.layout]);
141
+ }, [handleResetWithClear, configMemo]);
197
142
 
198
143
  return (
199
144
  <LazyPlot
@@ -0,0 +1,113 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { Figure } from "react-plotly.js";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ computeLayoutOnFigureChange,
7
+ computeLayoutUpdate,
8
+ computeOmitKeys,
9
+ createInitialLayout,
10
+ } from "../usePlotlyLayout";
11
+
12
+ function createFigure(layoutOverrides: Partial<Plotly.Layout> = {}): Figure {
13
+ return {
14
+ data: [],
15
+ layout: { ...layoutOverrides } as Plotly.Layout,
16
+ frames: null,
17
+ };
18
+ }
19
+
20
+ describe("createInitialLayout", () => {
21
+ it("sets defaults and merges figure layout", () => {
22
+ const figure = createFigure({ title: { text: "Test" }, dragmode: "zoom" });
23
+ const result = createInitialLayout(figure);
24
+
25
+ expect(result.autosize).toBe(true);
26
+ expect(result.height).toBe(540);
27
+ expect(result.dragmode).toBe("zoom"); // figure overrides default
28
+ expect(result.title).toEqual({ text: "Test" });
29
+ });
30
+
31
+ it("disables autosize when width is specified", () => {
32
+ const result = createInitialLayout(createFigure({ width: 800 }));
33
+ expect(result.autosize).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("computeLayoutOnFigureChange", () => {
38
+ it("preserves only dragmode/xaxis/yaxis from previous layout (#7964)", () => {
39
+ const nextFigure = createFigure({ title: { text: "New" } });
40
+ const prevLayout: Partial<Plotly.Layout> = {
41
+ dragmode: "zoom",
42
+ xaxis: { range: [0, 10] },
43
+ yaxis: { range: [0, 100] },
44
+ shapes: [{ type: "rect", x0: 0, x1: 1, y0: 0, y1: 1 }],
45
+ annotations: [{ text: "Old", x: 0, y: 0 }],
46
+ };
47
+
48
+ const result = computeLayoutOnFigureChange(nextFigure, prevLayout);
49
+
50
+ // Preserved from prev
51
+ expect(result.dragmode).toBe("zoom");
52
+ expect(result.xaxis).toEqual({ range: [0, 10] });
53
+ expect(result.yaxis).toEqual({ range: [0, 100] });
54
+ // From new figure
55
+ expect(result.title).toEqual({ text: "New" });
56
+ // NOT preserved (the bug fix)
57
+ expect(result.shapes).toBeUndefined();
58
+ expect(result.annotations).toBeUndefined();
59
+ });
60
+
61
+ it("uses shapes from new figure, not previous layout", () => {
62
+ const nextFigure = createFigure({
63
+ shapes: [{ type: "circle", x0: 0, x1: 1, y0: 0, y1: 1 }],
64
+ });
65
+ const prevLayout: Partial<Plotly.Layout> = {
66
+ shapes: [{ type: "rect", x0: 0, x1: 1, y0: 0, y1: 1 }],
67
+ };
68
+
69
+ const result = computeLayoutOnFigureChange(nextFigure, prevLayout);
70
+
71
+ expect(result.shapes).toHaveLength(1);
72
+ expect(result.shapes?.[0].type).toBe("circle");
73
+ });
74
+ });
75
+
76
+ describe("computeOmitKeys", () => {
77
+ it("omits user-interaction keys unless they changed in figure", () => {
78
+ const unchanged = computeOmitKeys({}, {});
79
+ expect([...unchanged]).toEqual(
80
+ expect.arrayContaining(["autosize", "dragmode", "xaxis", "yaxis"]),
81
+ );
82
+
83
+ const changed = computeOmitKeys(
84
+ { dragmode: "zoom", xaxis: { range: [0, 10] } },
85
+ { dragmode: "select", xaxis: { range: [0, 5] } },
86
+ );
87
+ expect(changed.has("dragmode")).toBe(false);
88
+ expect(changed.has("xaxis")).toBe(false);
89
+ expect(changed.has("autosize")).toBe(true);
90
+ });
91
+ });
92
+
93
+ describe("computeLayoutUpdate", () => {
94
+ it("merges figure layout while respecting omit keys", () => {
95
+ // dragmode unchanged in figure -> preserve prev layout's dragmode
96
+ const result1 = computeLayoutUpdate(
97
+ { dragmode: "pan", title: { text: "New" } },
98
+ { dragmode: "pan" },
99
+ { dragmode: "zoom", height: 400 },
100
+ );
101
+ expect(result1.dragmode).toBe("zoom");
102
+ expect(result1.title).toEqual({ text: "New" });
103
+ expect(result1.height).toBe(400);
104
+
105
+ // dragmode changed in figure -> use figure's dragmode
106
+ const result2 = computeLayoutUpdate(
107
+ { dragmode: "pan" },
108
+ { dragmode: "select" },
109
+ { dragmode: "zoom" },
110
+ );
111
+ expect(result2.dragmode).toBe("pan");
112
+ });
113
+ });
@@ -0,0 +1,163 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { usePrevious } from "@uidotdev/usehooks";
4
+ import { isEqual, pick } from "lodash-es";
5
+ import { useEffect, useState } from "react";
6
+ import type { Figure } from "react-plotly.js";
7
+ import { Objects } from "@/utils/objects";
8
+
9
+ /**
10
+ * Keys that are preserved across figure updates when set by user interaction.
11
+ * These include dragmode and axis settings that users may adjust.
12
+ */
13
+ export const PERSISTED_LAYOUT_KEYS = ["dragmode", "xaxis", "yaxis"] as const;
14
+
15
+ /**
16
+ * Keys that are omitted from layout updates unless they changed in the figure.
17
+ * This prevents overwriting user interactions like zoom/pan.
18
+ */
19
+ export const LAYOUT_OMIT_KEYS: (keyof Plotly.Layout)[] = [
20
+ "autosize",
21
+ "dragmode",
22
+ "xaxis",
23
+ "yaxis",
24
+ ];
25
+
26
+ /**
27
+ * Creates the initial layout for a Plotly figure with sensible defaults.
28
+ */
29
+ export function createInitialLayout(figure: Figure): Partial<Plotly.Layout> {
30
+ // Enable autosize if width is not specified
31
+ const shouldAutoSize = figure.layout.width === undefined;
32
+ return {
33
+ autosize: shouldAutoSize,
34
+ dragmode: "select",
35
+ height: 540,
36
+ // Prioritize user's config
37
+ ...figure.layout,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Computes the updated layout when the figure changes.
43
+ * Preserves user-interaction values (dragmode, xaxis, yaxis) while
44
+ * taking everything else from the new figure's layout.
45
+ */
46
+ export function computeLayoutOnFigureChange(
47
+ nextFigure: Figure,
48
+ prevLayout: Partial<Plotly.Layout>,
49
+ ): Partial<Plotly.Layout> {
50
+ return {
51
+ ...createInitialLayout(nextFigure),
52
+ ...pick(prevLayout, PERSISTED_LAYOUT_KEYS),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Computes which keys to omit from layout updates based on what changed.
58
+ * If a key changed in the figure, we should update it even if it's normally omitted.
59
+ */
60
+ export function computeOmitKeys(
61
+ currentLayout: Partial<Plotly.Layout>,
62
+ previousLayout: Partial<Plotly.Layout>,
63
+ ): Set<keyof Plotly.Layout> {
64
+ const omitKeys = new Set<keyof Plotly.Layout>(LAYOUT_OMIT_KEYS);
65
+
66
+ // If the key was updated externally (e.g. can be specifically passed in the config)
67
+ // then we need to update the layout
68
+ for (const key of omitKeys) {
69
+ if (!isEqual(currentLayout[key], previousLayout[key])) {
70
+ omitKeys.delete(key);
71
+ }
72
+ }
73
+
74
+ return omitKeys;
75
+ }
76
+
77
+ /**
78
+ * Computes the layout update when figure.layout changes.
79
+ * Omits keys that shouldn't override user interactions unless they changed.
80
+ */
81
+ export function computeLayoutUpdate(
82
+ figureLayout: Partial<Plotly.Layout>,
83
+ previousFigureLayout: Partial<Plotly.Layout>,
84
+ prevLayout: Partial<Plotly.Layout>,
85
+ ): Partial<Plotly.Layout> {
86
+ const omitKeys = computeOmitKeys(figureLayout, previousFigureLayout);
87
+ const layoutUpdate = Objects.omit(figureLayout, omitKeys);
88
+ return { ...prevLayout, ...layoutUpdate };
89
+ }
90
+
91
+ interface UsePlotlyLayoutOptions {
92
+ originalFigure: Figure;
93
+ initialValue?: Partial<Plotly.Layout>;
94
+ isScriptLoaded?: boolean;
95
+ }
96
+
97
+ interface UsePlotlyLayoutResult {
98
+ figure: Figure;
99
+ layout: Partial<Plotly.Layout>;
100
+ setLayout: React.Dispatch<React.SetStateAction<Partial<Plotly.Layout>>>;
101
+ handleReset: () => void;
102
+ }
103
+
104
+ /**
105
+ * Hook that manages the Plotly figure and layout state.
106
+ *
107
+ * This hook handles:
108
+ * - Cloning the figure to prevent Plotly mutations
109
+ * - Managing layout state with proper preservation of user interactions
110
+ * - Syncing layout when the figure changes
111
+ * - Providing a reset function to restore original state
112
+ */
113
+ export function usePlotlyLayout({
114
+ originalFigure,
115
+ initialValue,
116
+ isScriptLoaded = true,
117
+ }: UsePlotlyLayoutOptions): UsePlotlyLayoutResult {
118
+ const [figure, setFigure] = useState(() => {
119
+ // We clone the figure since Plotly mutates the figure in place
120
+ return structuredClone(originalFigure);
121
+ });
122
+
123
+ const [layout, setLayout] = useState<Partial<Plotly.Layout>>(() => {
124
+ return {
125
+ ...createInitialLayout(figure),
126
+ // Override with persisted values (dragmode, xaxis, yaxis)
127
+ ...initialValue,
128
+ };
129
+ });
130
+
131
+ // Update figure and layout when originalFigure changes
132
+ useEffect(() => {
133
+ const nextFigure = structuredClone(originalFigure);
134
+ setFigure(nextFigure);
135
+ // Start with the new figure's layout, then only preserve user-interaction
136
+ // values (dragmode, xaxis, yaxis) from the previous layout.
137
+ // We don't want to preserve other properties like `shapes` from the previous
138
+ // layout, as they should be fully controlled by the figure prop.
139
+ setLayout((prev) => computeLayoutOnFigureChange(nextFigure, prev));
140
+ }, [originalFigure, isScriptLoaded]);
141
+
142
+ const prevFigure = usePrevious(figure) ?? figure;
143
+
144
+ // Sync layout when figure.layout changes
145
+ useEffect(() => {
146
+ setLayout((prev) =>
147
+ computeLayoutUpdate(figure.layout, prevFigure.layout, prev),
148
+ );
149
+ }, [figure.layout, prevFigure.layout]);
150
+
151
+ const handleReset = () => {
152
+ const nextFigure = structuredClone(originalFigure);
153
+ setFigure(nextFigure);
154
+ setLayout(createInitialLayout(nextFigure));
155
+ };
156
+
157
+ return {
158
+ figure,
159
+ layout,
160
+ setLayout,
161
+ handleReset,
162
+ };
163
+ }
@@ -158,7 +158,7 @@ describe("getImageDataUrlForCell", () => {
158
158
  expect(toPng).toHaveBeenCalledWith(mockElement);
159
159
  });
160
160
 
161
- it("should add printing classes before capture", async () => {
161
+ it("should add printing classes before capture when enablePrintMode is true", async () => {
162
162
  vi.mocked(toPng).mockImplementation(async () => {
163
163
  // Check classes are applied during capture
164
164
  expect(mockElement.classList.contains("printing-output")).toBe(true);
@@ -167,18 +167,42 @@ describe("getImageDataUrlForCell", () => {
167
167
  return mockDataUrl;
168
168
  });
169
169
 
170
- await getImageDataUrlForCell("cell-1" as CellId);
170
+ await getImageDataUrlForCell("cell-1" as CellId, true);
171
171
  });
172
172
 
173
- it("should remove printing classes after capture", async () => {
173
+ it("should remove printing classes after capture when enablePrintMode is true", async () => {
174
174
  vi.mocked(toPng).mockResolvedValue(mockDataUrl);
175
175
 
176
- await getImageDataUrlForCell("cell-1" as CellId);
176
+ await getImageDataUrlForCell("cell-1" as CellId, true);
177
177
 
178
178
  expect(mockElement.classList.contains("printing-output")).toBe(false);
179
179
  expect(document.body.classList.contains("printing")).toBe(false);
180
180
  });
181
181
 
182
+ it("should add printing-output but NOT body.printing when enablePrintMode is false", async () => {
183
+ vi.mocked(toPng).mockImplementation(async () => {
184
+ // printing-output should still be added to the element
185
+ expect(mockElement.classList.contains("printing-output")).toBe(true);
186
+ // but body.printing should NOT be added
187
+ expect(document.body.classList.contains("printing")).toBe(false);
188
+ expect(mockElement.style.overflow).toBe("auto");
189
+ return mockDataUrl;
190
+ });
191
+
192
+ await getImageDataUrlForCell("cell-1" as CellId, false);
193
+ });
194
+
195
+ it("should cleanup printing-output when enablePrintMode is false", async () => {
196
+ mockElement.style.overflow = "hidden";
197
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
198
+
199
+ await getImageDataUrlForCell("cell-1" as CellId, false);
200
+
201
+ expect(mockElement.classList.contains("printing-output")).toBe(false);
202
+ expect(document.body.classList.contains("printing")).toBe(false);
203
+ expect(mockElement.style.overflow).toBe("hidden");
204
+ });
205
+
182
206
  it("should restore original overflow style after capture", async () => {
183
207
  mockElement.style.overflow = "hidden";
184
208
  vi.mocked(toPng).mockResolvedValue(mockDataUrl);
@@ -207,7 +231,7 @@ describe("getImageDataUrlForCell", () => {
207
231
  expect(mockElement.style.overflow).toBe("scroll");
208
232
  });
209
233
 
210
- it("should maintain body.printing during concurrent captures", async () => {
234
+ it("should maintain body.printing during concurrent captures when enablePrintMode is true", async () => {
211
235
  // Create a second element
212
236
  const mockElement2 = document.createElement("div");
213
237
  mockElement2.id = CellOutputId.create("cell-2" as CellId);
@@ -241,9 +265,9 @@ describe("getImageDataUrlForCell", () => {
241
265
  return mockDataUrl;
242
266
  });
243
267
 
244
- // Start both captures concurrently
245
- const capture1 = getImageDataUrlForCell("cell-1" as CellId);
246
- const capture2 = getImageDataUrlForCell("cell-2" as CellId);
268
+ // Start both captures concurrently with enablePrintMode = true
269
+ const capture1 = getImageDataUrlForCell("cell-1" as CellId, true);
270
+ const capture2 = getImageDataUrlForCell("cell-2" as CellId, true);
247
271
 
248
272
  // Let second capture complete first
249
273
  resolveSecond!();
@@ -264,6 +288,30 @@ describe("getImageDataUrlForCell", () => {
264
288
 
265
289
  mockElement2.remove();
266
290
  });
291
+
292
+ it("should not interfere with body.printing during concurrent captures when enablePrintMode is false", async () => {
293
+ // Create a second element
294
+ const mockElement2 = document.createElement("div");
295
+ mockElement2.id = CellOutputId.create("cell-2" as CellId);
296
+ document.body.append(mockElement2);
297
+
298
+ vi.mocked(toPng).mockImplementation(async () => {
299
+ // body.printing should never be added when enablePrintMode is false
300
+ expect(document.body.classList.contains("printing")).toBe(false);
301
+ return mockDataUrl;
302
+ });
303
+
304
+ // Start both captures concurrently with enablePrintMode = false
305
+ const capture1 = getImageDataUrlForCell("cell-1" as CellId, false);
306
+ const capture2 = getImageDataUrlForCell("cell-2" as CellId, false);
307
+
308
+ await Promise.all([capture1, capture2]);
309
+
310
+ // body.printing should still not be present
311
+ expect(document.body.classList.contains("printing")).toBe(false);
312
+
313
+ mockElement2.remove();
314
+ });
267
315
  });
268
316
 
269
317
  describe("downloadHTMLAsImage", () => {
@@ -342,7 +390,7 @@ describe("downloadHTMLAsImage", () => {
342
390
  expect(cleanup).toHaveBeenCalled();
343
391
  });
344
392
 
345
- it("should not add body.printing when prepare is provided", async () => {
393
+ it("should delegate body.printing management to prepare function", async () => {
346
394
  let bodyPrintingDuringCapture = false;
347
395
  vi.mocked(toPng).mockImplementation(async () => {
348
396
  // Capture the state during toPng execution
@@ -350,7 +398,14 @@ describe("downloadHTMLAsImage", () => {
350
398
  return mockDataUrl;
351
399
  });
352
400
  const cleanup = vi.fn();
353
- const prepare = vi.fn().mockReturnValue(cleanup);
401
+ // Mock prepare that adds body.printing
402
+ const prepare = vi.fn().mockImplementation(() => {
403
+ document.body.classList.add("printing");
404
+ return () => {
405
+ document.body.classList.remove("printing");
406
+ cleanup();
407
+ };
408
+ });
354
409
 
355
410
  await downloadHTMLAsImage({
356
411
  element: mockElement,
@@ -358,9 +413,8 @@ describe("downloadHTMLAsImage", () => {
358
413
  prepare,
359
414
  });
360
415
 
361
- // body.printing should NOT be added by downloadHTMLAsImage when prepare is provided
362
- // (the prepare function is responsible for managing its own classes)
363
- expect(bodyPrintingDuringCapture).toBe(false);
416
+ // body.printing should be added by prepare function
417
+ expect(bodyPrintingDuringCapture).toBe(true);
364
418
  expect(document.body.classList.contains("printing")).toBe(false);
365
419
  expect(prepare).toHaveBeenCalledWith(mockElement);
366
420
  expect(cleanup).toHaveBeenCalled();