@marimo-team/islands 0.20.5-dev83 → 0.20.5-dev88

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.
@@ -2,7 +2,7 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { t as require_react } from "./react-Bs6Z0kvn.js";
3
3
  import { t as require_compiler_runtime } from "./compiler-runtime-B_OLMU9S.js";
4
4
  import { S as CircleQuestionMark, a as AlertTitle, m as asRemoteURL, n as arrow, o as isValid, r as Alert, t as useDeepCompareMemoize } from "./useDeepCompareMemoize-DLS-bHHT.js";
5
- import { d as Objects, g as Logger, h as Events } from "./button-DQpBib29.js";
5
+ import { d as Objects, g as Logger, h as Events, y as cn } from "./button-DQpBib29.js";
6
6
  import "./Combination-Dk6JxauT.js";
7
7
  import { t as require_jsx_runtime } from "./jsx-runtime-CTBg5pdT.js";
8
8
  import "./react-dom-CqtLRVZP.js";
@@ -598,7 +598,7 @@ var VegaComponent = (e) => {
598
598
  children: B.stack
599
599
  })]
600
600
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
601
- className: "relative",
601
+ className: cn("relative", "width" in W && W.width === "container" && "vega-container-width"),
602
602
  onPointerDown: Events.stopPropagation(),
603
603
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ref: R }), Q()]
604
604
  })] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.20.5-dev83",
3
+ "version": "0.20.5-dev88",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -25,7 +25,7 @@ import { CellOutputId } from "@/core/cells/ids";
25
25
  import { isOutputEmpty } from "@/core/cells/outputs";
26
26
  import { goToDefinitionAtCursorPosition } from "@/core/codemirror/go-to-definition/utils";
27
27
  import { sendToPanelManager } from "@/core/vscode/vscode-bindings";
28
- import { copyToClipboard } from "@/utils/copy";
28
+ import { copyImageToClipboard, copyToClipboard } from "@/utils/copy";
29
29
  import { getImageExtension } from "@/utils/filenames";
30
30
  import { Logger } from "@/utils/Logger";
31
31
  import type { ActionButton } from "../actions/types";
@@ -127,11 +127,7 @@ export const CellActionsContextMenu = ({
127
127
  icon: <ClipboardCopyIcon size={13} strokeWidth={1.5} />,
128
128
  handle: async () => {
129
129
  if (imageRightClicked) {
130
- const response = await fetch(imageRightClicked.src);
131
- const blob = await response.blob();
132
- const item = new ClipboardItem({ [blob.type]: blob });
133
- await navigator.clipboard
134
- .write([item])
130
+ await copyImageToClipboard(imageRightClicked.src)
135
131
  .then(() => {
136
132
  toast({
137
133
  title: "Copied image to clipboard",
@@ -6,7 +6,7 @@ import {
6
6
  ChevronsUpDownIcon,
7
7
  WrapTextIcon,
8
8
  } from "lucide-react";
9
- import React, { useLayoutEffect } from "react";
9
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
10
10
  import { ToggleButton } from "react-aria-components";
11
11
  import { DebuggerControls } from "@/components/debugger/debugger-code";
12
12
  import { CopyClipboardIcon } from "@/components/icons/copy-icon";
@@ -33,6 +33,52 @@ import { useWrapText } from "../useWrapText";
33
33
  import { processOutput } from "./process-output";
34
34
  import { RenderTextWithLinks } from "./text-rendering";
35
35
 
36
+ /**
37
+ * Delay in ms before clearing console outputs.
38
+ * This prevents flickering when a cell re-runs and outputs are briefly cleared
39
+ * before new outputs arrive (e.g., plt.show() with a slider).
40
+ */
41
+ export const CONSOLE_CLEAR_DEBOUNCE_MS = 200;
42
+
43
+ /**
44
+ * Debounces the clearing of console outputs.
45
+ * - Non-empty updates are applied immediately.
46
+ * - Transitions to empty are delayed by CONSOLE_CLEAR_DEBOUNCE_MS,
47
+ * giving new outputs a chance to arrive and replace the old ones
48
+ * without a visible flicker.
49
+ */
50
+ function useDebouncedConsoleOutputs<T>(outputs: T[]): T[] {
51
+ const [debouncedOutputs, setDebouncedOutputs] = useState(outputs);
52
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+
54
+ // Non-empty outputs: apply immediately and cancel any pending clear
55
+ if (outputs.length > 0 && debouncedOutputs !== outputs) {
56
+ if (timerRef.current !== null) {
57
+ clearTimeout(timerRef.current);
58
+ timerRef.current = null;
59
+ }
60
+ setDebouncedOutputs(outputs);
61
+ }
62
+
63
+ // Empty outputs: delay the clear so new outputs can arrive first
64
+ useEffect(() => {
65
+ if (outputs.length === 0 && timerRef.current === null) {
66
+ timerRef.current = setTimeout(() => {
67
+ timerRef.current = null;
68
+ setDebouncedOutputs([]);
69
+ }, CONSOLE_CLEAR_DEBOUNCE_MS);
70
+ }
71
+ return () => {
72
+ if (timerRef.current !== null) {
73
+ clearTimeout(timerRef.current);
74
+ timerRef.current = null;
75
+ }
76
+ };
77
+ }, [outputs]);
78
+
79
+ return debouncedOutputs;
80
+ }
81
+
36
82
  interface Props {
37
83
  cellId: CellId;
38
84
  cellName: string;
@@ -63,7 +109,7 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
63
109
  setValue: setStdinValue,
64
110
  });
65
111
  const {
66
- consoleOutputs,
112
+ consoleOutputs: rawConsoleOutputs,
67
113
  stale,
68
114
  cellName,
69
115
  cellId,
@@ -73,6 +119,9 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
73
119
  className,
74
120
  } = props;
75
121
 
122
+ // Debounce clearing to prevent flickering when cells re-run
123
+ const consoleOutputs = useDebouncedConsoleOutputs(rawConsoleOutputs);
124
+
76
125
  /* The debugger UI needs some work. For now just use the regular
77
126
  /* console output. */
78
127
  /* if (debuggerActive) {
@@ -1,13 +1,13 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { fireEvent, render, screen } from "@testing-library/react";
4
- import { describe, expect, it, vi } from "vitest";
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { SetupMocks } from "@/__mocks__/common";
6
6
  import { TooltipProvider } from "@/components/ui/tooltip";
7
7
  import type { CellId } from "@/core/cells/ids";
8
8
  import type { WithResponse } from "@/core/cells/types";
9
9
  import type { OutputMessage } from "@/core/kernel/messages";
10
- import { ConsoleOutput } from "../ConsoleOutput";
10
+ import { CONSOLE_CLEAR_DEBOUNCE_MS, ConsoleOutput } from "../ConsoleOutput";
11
11
 
12
12
  SetupMocks.resizeObserver();
13
13
 
@@ -193,3 +193,94 @@ describe("ConsoleOutput pdb history", () => {
193
193
  expect(input).toHaveValue("");
194
194
  });
195
195
  });
196
+
197
+ describe("ConsoleOutput debounced clearing", () => {
198
+ beforeEach(() => {
199
+ vi.useFakeTimers();
200
+ });
201
+
202
+ afterEach(() => {
203
+ vi.useRealTimers();
204
+ });
205
+
206
+ const createOutput = (
207
+ data: string,
208
+ channel = "stdout",
209
+ ): WithResponse<OutputMessage> => ({
210
+ channel: channel as "stdout" | "stderr",
211
+ mimetype: "text/plain",
212
+ data,
213
+ timestamp: 0,
214
+ response: undefined,
215
+ });
216
+
217
+ const defaultProps = {
218
+ cellId: "cell-1" as CellId,
219
+ cellName: "test_cell",
220
+ consoleOutputs: [] as WithResponse<OutputMessage>[],
221
+ stale: false,
222
+ debuggerActive: false,
223
+ onSubmitDebugger: vi.fn(),
224
+ };
225
+
226
+ it("should keep old outputs visible when cleared, then show new outputs immediately", () => {
227
+ const outputs1 = [createOutput("hello world")];
228
+
229
+ const { rerender } = renderWithProvider(
230
+ <ConsoleOutput {...defaultProps} consoleOutputs={outputs1} />,
231
+ );
232
+
233
+ // Old output is visible
234
+ expect(screen.getByText("hello world")).toBeInTheDocument();
235
+
236
+ // Clear outputs (simulates cell re-run)
237
+ rerender(
238
+ <TooltipProvider>
239
+ <ConsoleOutput {...defaultProps} consoleOutputs={[]} />
240
+ </TooltipProvider>,
241
+ );
242
+
243
+ // Old output should still be visible during debounce period
244
+ expect(screen.getByText("hello world")).toBeInTheDocument();
245
+
246
+ // New outputs arrive before debounce fires
247
+ const outputs2 = [createOutput("new output")];
248
+ rerender(
249
+ <TooltipProvider>
250
+ <ConsoleOutput {...defaultProps} consoleOutputs={outputs2} />
251
+ </TooltipProvider>,
252
+ );
253
+
254
+ // New output should be shown immediately
255
+ expect(screen.getByText("new output")).toBeInTheDocument();
256
+ expect(screen.queryByText("hello world")).not.toBeInTheDocument();
257
+ });
258
+
259
+ it("should clear outputs after debounce period if no new outputs arrive", () => {
260
+ const outputs1 = [createOutput("old output")];
261
+
262
+ const { rerender } = renderWithProvider(
263
+ <ConsoleOutput {...defaultProps} consoleOutputs={outputs1} />,
264
+ );
265
+
266
+ expect(screen.getByText("old output")).toBeInTheDocument();
267
+
268
+ // Clear outputs
269
+ rerender(
270
+ <TooltipProvider>
271
+ <ConsoleOutput {...defaultProps} consoleOutputs={[]} />
272
+ </TooltipProvider>,
273
+ );
274
+
275
+ // Still visible during debounce
276
+ expect(screen.getByText("old output")).toBeInTheDocument();
277
+
278
+ // Advance past debounce period
279
+ act(() => {
280
+ vi.advanceTimersByTime(CONSOLE_CLEAR_DEBOUNCE_MS + 1);
281
+ });
282
+
283
+ // Now the output should be cleared
284
+ expect(screen.queryByText("old output")).not.toBeInTheDocument();
285
+ });
286
+ });
@@ -13,6 +13,7 @@ import { Tooltip } from "@/components/ui/tooltip";
13
13
  import { useAsyncData } from "@/hooks/useAsyncData";
14
14
  import { useOnMount } from "@/hooks/useLifecycle";
15
15
  import { type ResolvedTheme, useTheme } from "@/theme/useTheme";
16
+ import { cn } from "@/utils/cn";
16
17
  import { Objects } from "@/utils/objects";
17
18
  import { ErrorBanner } from "../common/error-banner";
18
19
  import { vegaLoadData } from "../vega/loader";
@@ -139,9 +140,15 @@ export const DataExplorerComponent = ({
139
140
  const responsiveSpec = makeResponsive(spec);
140
141
  // TODO: We can optimize by updating the data dynamically. https://github.com/vega/react-vega?tab=readme-ov-file#recipes
141
142
  const augmentedSpec = augmentSpecWithData(responsiveSpec, chartData);
143
+ const isContainerWidth = responsiveSpec.width === "container";
142
144
 
143
145
  return (
144
- <div className="flex overflow-y-auto justify-center items-center flex-1 w-[90%]">
146
+ <div
147
+ className={cn(
148
+ "flex overflow-y-auto justify-center items-center flex-1 w-[90%]",
149
+ isContainerWidth && "vega-container-width",
150
+ )}
151
+ >
145
152
  <VegaEmbed spec={augmentedSpec} options={chartOptions(theme)} />
146
153
  </div>
147
154
  );
@@ -16,6 +16,7 @@ import { Tooltip } from "@/components/ui/tooltip";
16
16
  import { useAsyncData } from "@/hooks/useAsyncData";
17
17
  import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
18
18
  import { useTheme } from "@/theme/useTheme";
19
+ import { cn } from "@/utils/cn";
19
20
  import { Events } from "@/utils/events";
20
21
  import { Logger } from "@/utils/Logger";
21
22
  import { Objects } from "@/utils/objects";
@@ -304,7 +305,12 @@ const LoadedVegaComponent = ({
304
305
  </Alert>
305
306
  )}
306
307
  <div
307
- className="relative"
308
+ className={cn(
309
+ "relative",
310
+ "width" in selectableSpec &&
311
+ selectableSpec.width === "container" &&
312
+ "vega-container-width",
313
+ )}
308
314
  // Capture the pointer down event to prevent the parent from handling it
309
315
  onPointerDown={Events.stopPropagation()}
310
316
  >
@@ -1,18 +1,11 @@
1
1
  @reference "../../../css/globals.css";
2
2
 
3
3
  .vega-embed {
4
- width: 100%;
5
- flex: 1;
6
- display: inline-block;
7
-
8
- @media (min-width: 500px) {
9
- min-width: 300px;
10
- }
4
+ max-width: 100%;
5
+ }
11
6
 
12
- /* For vega embeds in slots, reset the styles to let the user set the width */
13
- @container style(--slot: true) {
14
- min-width: unset;
15
- }
7
+ .vega-container-width .vega-embed {
8
+ width: 100%;
16
9
  }
17
10
 
18
11
  .vega-embed > .chart-wrapper {
@@ -0,0 +1,129 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { copyImageToClipboard, isSafari } from "../copy";
4
+
5
+ describe("isSafari", () => {
6
+ afterEach(() => {
7
+ vi.restoreAllMocks();
8
+ });
9
+
10
+ it("returns true for Safari on macOS", () => {
11
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
12
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
13
+ );
14
+ expect(isSafari()).toBe(true);
15
+ });
16
+
17
+ it("returns true for Safari on iOS", () => {
18
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
19
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
20
+ );
21
+ expect(isSafari()).toBe(true);
22
+ });
23
+
24
+ it("returns false for Chrome", () => {
25
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
26
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
27
+ );
28
+ expect(isSafari()).toBe(false);
29
+ });
30
+
31
+ it("returns false for Chrome on iOS (CriOS)", () => {
32
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
33
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1",
34
+ );
35
+ expect(isSafari()).toBe(false);
36
+ });
37
+
38
+ it("returns false for Firefox on iOS (FxiOS)", () => {
39
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
40
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148 Safari/604.1",
41
+ );
42
+ expect(isSafari()).toBe(false);
43
+ });
44
+
45
+ it("returns false for Edge on iOS (EdgiOS)", () => {
46
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
47
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/120.0.0.0 Mobile/15E148 Safari/604.1",
48
+ );
49
+ expect(isSafari()).toBe(false);
50
+ });
51
+
52
+ it("returns false for Firefox on desktop", () => {
53
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
54
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
55
+ );
56
+ expect(isSafari()).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("copyImageToClipboard", () => {
61
+ let writeMock: ReturnType<typeof vi.fn>;
62
+ let clipboardItemSpy: ReturnType<typeof vi.fn>;
63
+
64
+ beforeEach(() => {
65
+ writeMock = vi.fn().mockResolvedValue(undefined);
66
+ Object.assign(navigator, {
67
+ clipboard: { write: writeMock },
68
+ });
69
+
70
+ // ClipboardItem is not available in jsdom, so we mock it
71
+ clipboardItemSpy = vi.fn().mockImplementation((data) => ({ data }));
72
+ vi.stubGlobal("ClipboardItem", clipboardItemSpy);
73
+ });
74
+
75
+ afterEach(() => {
76
+ vi.restoreAllMocks();
77
+ vi.unstubAllGlobals();
78
+ });
79
+
80
+ it("uses blob type from response on non-Safari browsers", async () => {
81
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
82
+ "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
83
+ );
84
+
85
+ const fakeBlob = new Blob(["fake"], { type: "image/jpeg" });
86
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
87
+ new Response(fakeBlob, {
88
+ headers: { "Content-Type": "image/jpeg" },
89
+ }),
90
+ );
91
+
92
+ await copyImageToClipboard("https://example.com/image.jpg");
93
+
94
+ expect(writeMock).toHaveBeenCalledOnce();
95
+ // Non-Safari path: awaits blob, uses blob.type as key
96
+ const arg = clipboardItemSpy.mock.calls[0][0];
97
+ expect(arg).toHaveProperty("image/jpeg");
98
+ expect(arg["image/jpeg"].type).toBe("image/jpeg");
99
+ });
100
+
101
+ it("uses image/png on Safari", async () => {
102
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
103
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
104
+ );
105
+
106
+ const fakeBlob = new Blob(["fake"], { type: "image/png" });
107
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(fakeBlob));
108
+
109
+ await copyImageToClipboard("https://example.com/image.png");
110
+
111
+ expect(writeMock).toHaveBeenCalledOnce();
112
+ // Safari path: uses "image/png" key with a Promise<Blob>
113
+ expect(clipboardItemSpy).toHaveBeenCalledWith({
114
+ "image/png": expect.any(Promise),
115
+ });
116
+ });
117
+
118
+ it("propagates fetch errors", async () => {
119
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
120
+ "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
121
+ );
122
+
123
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
124
+
125
+ await expect(
126
+ copyImageToClipboard("https://example.com/image.png"),
127
+ ).rejects.toThrow("Network error");
128
+ });
129
+ });
package/src/utils/copy.ts CHANGED
@@ -21,3 +21,46 @@ export async function copyToClipboard(text: string) {
21
21
  window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
22
22
  });
23
23
  }
24
+
25
+ /**
26
+ * Returns true if the current browser is Safari.
27
+ *
28
+ * Safari requires special handling for clipboard operations because it
29
+ * drops the user-activation context during async operations like fetch.
30
+ */
31
+ export function isSafari(): boolean {
32
+ const ua = navigator.userAgent;
33
+ // Safari includes "Safari" but not "Chrome"/"Chromium" in its UA string.
34
+ // iOS in-app browsers (CriOS, FxiOS, EdgiOS) also include "Safari"
35
+ // but are excluded by checking for their specific tokens.
36
+ return (
37
+ /safari/i.test(ua) && !/chrome|chromium|crios|fxios|edgios|opios/i.test(ua)
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Copies an image to the clipboard from a URL.
43
+ *
44
+ * On Safari, the ClipboardItem is constructed synchronously with a
45
+ * Promise<Blob> to preserve the user-activation context, which Safari
46
+ * drops during async operations like fetch. This means we must assume
47
+ * the MIME type (image/png) since we can't inspect the response first.
48
+ *
49
+ * On other browsers, we await the fetch and use the actual MIME type.
50
+ */
51
+ export async function copyImageToClipboard(imageSrc: string): Promise<void> {
52
+ let item: ClipboardItem;
53
+ if (isSafari()) {
54
+ // Safari drops user-activation context during await, so we must
55
+ // construct the ClipboardItem synchronously with a Promise<Blob>.
56
+ item = new ClipboardItem({
57
+ "image/png": fetch(imageSrc).then((response) => response.blob()),
58
+ });
59
+ } else {
60
+ const response = await fetch(imageSrc);
61
+ const blob = await response.blob();
62
+ item = new ClipboardItem({ [blob.type]: blob });
63
+ }
64
+
65
+ await navigator.clipboard.write([item]);
66
+ }