@marimo-team/islands 0.25.3-dev235 → 0.25.3-dev238

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev235",
3
+ "version": "0.25.3-dev238",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -1,8 +1,50 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { act, render } from "@testing-library/react";
2
+ import { act, render, renderHook } from "@testing-library/react";
3
3
  import { useRef } from "react";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { useIsFullScreen } from "../fullscreen";
5
+ import { useFullScreenClass, useIsFullScreen } from "../fullscreen";
6
+
7
+ describe("useFullScreenClass", () => {
8
+ let fullscreenElement: Element | null = null;
9
+
10
+ beforeEach(() => {
11
+ fullscreenElement = null;
12
+ Object.defineProperty(document, "fullscreenElement", {
13
+ configurable: true,
14
+ get: () => fullscreenElement,
15
+ });
16
+ });
17
+
18
+ afterEach(() => {
19
+ Reflect.deleteProperty(document, "fullscreenElement");
20
+ document.documentElement.classList.remove("marimo-fullscreen");
21
+ });
22
+
23
+ it("reflects fullscreen state at mount and on entry and exit", () => {
24
+ fullscreenElement = document.body;
25
+ const { unmount } = renderHook(useFullScreenClass);
26
+ expect(document.documentElement).toHaveClass("marimo-fullscreen");
27
+
28
+ fullscreenElement = null;
29
+ act(() => document.dispatchEvent(new Event("fullscreenchange")));
30
+ expect(document.documentElement).not.toHaveClass("marimo-fullscreen");
31
+
32
+ fullscreenElement = document.body;
33
+ act(() => document.dispatchEvent(new Event("fullscreenchange")));
34
+ expect(document.documentElement).toHaveClass("marimo-fullscreen");
35
+ unmount();
36
+ });
37
+
38
+ it("removes the class and listener on unmount", () => {
39
+ fullscreenElement = document.body;
40
+ const { unmount } = renderHook(useFullScreenClass);
41
+ unmount();
42
+ expect(document.documentElement).not.toHaveClass("marimo-fullscreen");
43
+
44
+ act(() => document.dispatchEvent(new Event("fullscreenchange")));
45
+ expect(document.documentElement).not.toHaveClass("marimo-fullscreen");
46
+ });
47
+ });
6
48
 
7
49
  const FullScreenFlag = ({ label }: { label: string }) => {
8
50
  const ref = useRef<HTMLDivElement>(null);
@@ -4,7 +4,12 @@ import type { Popover } from "radix-ui";
4
4
 
5
5
  type PopoverContentProps = Popover.PopoverContentProps;
6
6
 
7
- import React, { type RefObject, useState, useSyncExternalStore } from "react";
7
+ import React, {
8
+ type RefObject,
9
+ useLayoutEffect,
10
+ useState,
11
+ useSyncExternalStore,
12
+ } from "react";
8
13
  import { isInVscodeExtension } from "@/core/vscode/is-in-vscode";
9
14
  import { useEventListener } from "@/hooks/useEventListener";
10
15
 
@@ -14,6 +19,25 @@ const VSCODE_OUTPUT_CONTAINER_SELECTOR = "[data-vscode-output-container]";
14
19
  // Otherwise, we give a 30px buffer to the max-height.
15
20
  export const MAX_HEIGHT_OFFSET = isInVscodeExtension() ? 0 : 30;
16
21
 
22
+ // An explicit class avoids document-wide :has(:fullscreen) style invalidation.
23
+ export function useFullScreenClass() {
24
+ useLayoutEffect(() => {
25
+ const syncFullScreenClass = () => {
26
+ document.documentElement.classList.toggle(
27
+ "marimo-fullscreen",
28
+ Boolean(document.fullscreenElement),
29
+ );
30
+ };
31
+
32
+ syncFullScreenClass();
33
+ document.addEventListener("fullscreenchange", syncFullScreenClass);
34
+ return () => {
35
+ document.removeEventListener("fullscreenchange", syncFullScreenClass);
36
+ document.documentElement.classList.remove("marimo-fullscreen");
37
+ };
38
+ }, []);
39
+ }
40
+
17
41
  /**
18
42
  * Get the full screen element if we are in full screen mode
19
43
  */
@@ -7,6 +7,7 @@ import { Provider as SlotzProvider } from "@marimo-team/react-slotz";
7
7
  import type React from "react";
8
8
  import { memo, type PropsWithChildren, Suspense } from "react";
9
9
  import { TailwindIndicator } from "@/components/debug/indicator";
10
+ import { useFullScreenClass } from "@/components/ui/fullscreen";
10
11
  import { useAppConfig, useResolvedMarimoConfig } from "@/core/config/config";
11
12
  import { getInitialAppMode } from "@/core/mode";
12
13
  import { CssVariables } from "@/theme/ThemeProvider";
@@ -58,6 +59,7 @@ export function preloadPage(mode: string) {
58
59
  * The root component of the Marimo app.
59
60
  */
60
61
  export const MarimoApp: React.FC = memo(() => {
62
+ useFullScreenClass();
61
63
  const [userConfig] = useResolvedMarimoConfig();
62
64
  const appConfig = useAppConfig();
63
65
  const editorFontSize = toRem(userConfig.display.code_editor_font_size);
@@ -31,7 +31,7 @@
31
31
  }
32
32
 
33
33
  /* When any element is fullscreen, hide the element with this class */
34
- :root:has(:fullscreen) .hide-on-fullscreen {
34
+ :root.marimo-fullscreen .hide-on-fullscreen {
35
35
  display: none !important;
36
36
  }
37
37
 
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { buttonVariants } from "@/components/ui/button";
7
7
  import { toast } from "@/components/ui/use-toast";
8
8
  import { cn } from "@/utils/cn";
9
- import { downloadByURL } from "@/utils/download";
9
+ import { downloadByURL, isDownloadUrl } from "@/utils/download";
10
10
  import { Logger } from "@/utils/Logger";
11
11
  import { createPlugin } from "../core/builder";
12
12
  import { renderHTML } from "../core/RenderHTML";
@@ -80,7 +80,7 @@ const DownloadButton = ({
80
80
  const [isLoading, setIsLoading] = useState(false);
81
81
 
82
82
  const handleClick = async (e: React.MouseEvent) => {
83
- if (data.disabled) {
83
+ if (data.disabled || isLoading) {
84
84
  e.preventDefault();
85
85
  e.stopPropagation();
86
86
  return;
@@ -88,15 +88,11 @@ const DownloadButton = ({
88
88
 
89
89
  e.preventDefault();
90
90
 
91
- if (!data.lazy) {
92
- downloadByURL(data.data, data.filename ?? undefined);
93
- return;
94
- }
95
-
96
- setIsLoading(true);
97
-
98
91
  try {
99
- const loadedData = await load({});
92
+ if (data.lazy) {
93
+ setIsLoading(true);
94
+ }
95
+ const loadedData = data.lazy ? await load({}) : data;
100
96
  downloadByURL(
101
97
  loadedData.data || data.data,
102
98
  loadedData.filename ?? data.filename ?? undefined,
@@ -117,7 +113,7 @@ const DownloadButton = ({
117
113
 
118
114
  return (
119
115
  <a
120
- href={data.data}
116
+ href={isDownloadUrl(data.data) ? data.data : undefined}
121
117
  download={data.filename ?? ""}
122
118
  onClick={handleClick}
123
119
  className={buttonVariants({
@@ -0,0 +1,196 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ act,
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { Mocks } from "@/__mocks__/common";
12
+ import { toast } from "@/components/ui/use-toast";
13
+ import { assertExists } from "@/utils/assertExists";
14
+ import { Deferred } from "@/utils/Deferred";
15
+ import { registerReactComponent } from "../../core/registerReactComponent";
16
+ import { sanitizeHtml } from "../../core/sanitize-html";
17
+ import { DownloadPlugin } from "../DownloadPlugin";
18
+
19
+ vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
20
+ vi.mock("@/utils/Logger", () => ({ Logger: Mocks.quietLogger() }));
21
+
22
+ type DownloadData = Parameters<typeof DownloadPlugin.render>[0]["data"];
23
+ type Load = Parameters<typeof DownloadPlugin.render>[0]["functions"]["load"];
24
+
25
+ function renderDownload(
26
+ data: Partial<DownloadData> = {},
27
+ load = vi.fn<Load>(),
28
+ ) {
29
+ const result = render(
30
+ DownloadPlugin.render({
31
+ host: document.createElement("div"),
32
+ value: null,
33
+ setValue: vi.fn(),
34
+ data: DownloadPlugin.validator.parse({
35
+ data: "./@file/123-results.csv",
36
+ filename: "results.csv",
37
+ ...data,
38
+ }),
39
+ functions: { load },
40
+ }),
41
+ );
42
+ const button = result.getByText("Download").closest("a");
43
+ assertExists(button);
44
+ return { ...result, load, button };
45
+ }
46
+
47
+ describe("DownloadPlugin", () => {
48
+ const downloads: { href: string | null; filename: string | null }[] = [];
49
+
50
+ beforeEach(() => {
51
+ downloads.length = 0;
52
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
53
+ this: HTMLAnchorElement,
54
+ ) {
55
+ downloads.push({
56
+ href: this.getAttribute("href"),
57
+ filename: this.getAttribute("download"),
58
+ });
59
+ });
60
+ });
61
+
62
+ afterEach(() => {
63
+ cleanup();
64
+ vi.restoreAllMocks();
65
+ vi.clearAllMocks();
66
+ });
67
+
68
+ it("downloads immediately without calling load", () => {
69
+ const { button, load } = renderDownload();
70
+ fireEvent.click(button);
71
+
72
+ expect(downloads).toEqual([
73
+ { href: "./@file/123-results.csv", filename: "results.csv" },
74
+ ]);
75
+ expect(load).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it.each([
79
+ { data: "data:text/plain,hello", filename: "loaded.txt" },
80
+ { data: "", filename: "renamed.csv" },
81
+ { data: "data:text/plain,hello", filename: null },
82
+ ])("downloads lazy data with metadata fallbacks: %j", async (loadedData) => {
83
+ const load = vi.fn<Load>().mockResolvedValue(loadedData);
84
+ const { button } = renderDownload({ lazy: true }, load);
85
+ fireEvent.click(button);
86
+
87
+ await waitFor(() => {
88
+ expect(downloads).toEqual([
89
+ {
90
+ href: loadedData.data || "./@file/123-results.csv",
91
+ filename: loadedData.filename ?? "results.csv",
92
+ },
93
+ ]);
94
+ });
95
+ expect(load).toHaveBeenCalledExactlyOnceWith({});
96
+ });
97
+
98
+ it.each([false, true])("ignores disabled clicks (lazy=%s)", (lazy) => {
99
+ const { button, load } = renderDownload({ disabled: true, lazy });
100
+ fireEvent.click(button);
101
+ expect(downloads).toEqual([]);
102
+ expect(load).not.toHaveBeenCalled();
103
+ expect(toast).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it.each([false, true])(
107
+ "handles an unsupported URL (lazy=%s)",
108
+ async (lazy) => {
109
+ const data = "java\nscript:alert(1)";
110
+ const load = vi.fn<Load>().mockResolvedValue({ data });
111
+ const { button } = renderDownload({ data, lazy }, load);
112
+ expect(button.hasAttribute("href")).toBe(false);
113
+ fireEvent.click(button);
114
+
115
+ await waitFor(() => {
116
+ expect(toast).toHaveBeenCalledWith({
117
+ title: "Failed to download",
118
+ description: "Please try again.",
119
+ });
120
+ });
121
+ expect(downloads).toEqual([]);
122
+ },
123
+ );
124
+
125
+ it("validates the lazy response even when the initial URL is valid", async () => {
126
+ const load = vi
127
+ .fn<Load>()
128
+ .mockResolvedValue({ data: "javascript:alert(1)" });
129
+ const { button } = renderDownload({ lazy: true }, load);
130
+ fireEvent.click(button);
131
+
132
+ await waitFor(() => expect(toast).toHaveBeenCalledOnce());
133
+ expect(downloads).toEqual([]);
134
+ });
135
+
136
+ it("validates the fallback URL when lazy loading only returns a filename", async () => {
137
+ const load = vi
138
+ .fn<Load>()
139
+ .mockResolvedValue({ data: "", filename: "file.txt" });
140
+ const { button } = renderDownload(
141
+ { data: "javascript:alert(1)", lazy: true },
142
+ load,
143
+ );
144
+ fireEvent.click(button);
145
+
146
+ await waitFor(() => expect(toast).toHaveBeenCalledOnce());
147
+ expect(downloads).toEqual([]);
148
+ });
149
+
150
+ it("ignores clicks while loading and allows retry after a failed load", async () => {
151
+ const pendingLoad = new Deferred<Awaited<ReturnType<Load>>>();
152
+ const load = vi
153
+ .fn<Load>()
154
+ .mockReturnValueOnce(pendingLoad.promise)
155
+ .mockResolvedValue({ data: "data:text/plain,retry" });
156
+ const { button } = renderDownload({ data: "", lazy: true }, load);
157
+ fireEvent.click(button);
158
+ fireEvent.click(button);
159
+ expect(load).toHaveBeenCalledOnce();
160
+
161
+ await act(async () => pendingLoad.reject(new Error("Load failed")));
162
+ expect(toast).toHaveBeenCalledOnce();
163
+ expect(downloads).toEqual([]);
164
+
165
+ fireEvent.click(button);
166
+ await waitFor(() =>
167
+ expect(downloads).toEqual([
168
+ { href: "data:text/plain,retry", filename: "results.csv" },
169
+ ]),
170
+ );
171
+ expect(load).toHaveBeenCalledTimes(2);
172
+ });
173
+
174
+ it("rejects an unsupported URL restored from sanitized output", async () => {
175
+ registerReactComponent(DownloadPlugin);
176
+ const container = document.createElement("div");
177
+ container.innerHTML = sanitizeHtml(
178
+ `<marimo-download data-data='"javascript:alert(1)"' data-initial-value='null' data-disabled='false' data-lazy='false'></marimo-download>`,
179
+ );
180
+ try {
181
+ await act(async () => {
182
+ document.body.append(container);
183
+ });
184
+ const button = container
185
+ .querySelector("marimo-download")
186
+ ?.shadowRoot?.querySelector("a");
187
+ assertExists(button);
188
+ expect(button.hasAttribute("href")).toBe(false);
189
+ fireEvent.click(button);
190
+ expect(toast).toHaveBeenCalledOnce();
191
+ expect(downloads).toEqual([]);
192
+ } finally {
193
+ await act(async () => container.remove());
194
+ }
195
+ });
196
+ });
@@ -595,24 +595,77 @@ describe("downloadByURL", () => {
595
595
  });
596
596
 
597
597
  it("should create anchor, set attributes, click, and remove", () => {
598
- downloadByURL("data:test", "filename.png");
598
+ downloadByURL("data:image/png;base64,AAA=", "filename.png");
599
599
 
600
600
  expect(document.createElement).toHaveBeenCalledWith("a");
601
- expect(mockAnchor.href).toBe("data:test");
601
+ expect(mockAnchor.href).toBe("data:image/png;base64,AAA=");
602
602
  expect(mockAnchor.download).toBe("filename.png");
603
603
  expect(mockAnchor.click).toHaveBeenCalled();
604
604
  expect(mockAnchor.remove).toHaveBeenCalled();
605
605
  });
606
606
 
607
+ it.each([
608
+ "https://example.com/results.csv",
609
+ "http://example.com/results.csv",
610
+ "//example.com/results.csv",
611
+ "./@file/123-results.csv",
612
+ "/files/results.csv?download=1#data",
613
+ "../results.csv",
614
+ "blob:http://localhost/123",
615
+ "data:text/csv;charset=utf-8,a%2Cb",
616
+ "DATA:TEXT/PLAIN;base64,aGVsbG8=",
617
+ "data:application/json,%7B%7D",
618
+ "data:application/octet-stream;base64,AAA=",
619
+ "data:,hello",
620
+ "data:;base64,aGVsbG8=",
621
+ "data:text/plain,",
622
+ " https://example.com/results.csv ",
623
+ ])("downloads %s without an explicit filename", (url) => {
624
+ downloadByURL(url);
625
+
626
+ expect(mockAnchor.getAttribute("href")).toBe(url);
627
+ expect(mockAnchor.getAttribute("download")).toBe("");
628
+ expect(mockAnchor.click).toHaveBeenCalledOnce();
629
+ });
630
+
631
+ it.each([
632
+ "javascript:alert(1)",
633
+ "JaVaScRiPt:alert(1)",
634
+ " \u0000javascript:alert(1)",
635
+ "java\tscript:alert(1)",
636
+ "java\nscript:alert(1)",
637
+ "java\rscript:alert(1)",
638
+ "vbscript:msgbox(1)",
639
+ "file:///tmp/results.csv",
640
+ "ftp://example.com/results.csv",
641
+ "mailto:example@example.com",
642
+ "data:text/html,<h1>hello</h1>",
643
+ "data:TEXT/HTML;charset=utf-8;base64,AAA=",
644
+ "data:image/svg+xml,<svg/>",
645
+ "data:application/xhtml+xml,<html/>",
646
+ "data:text/javascript,alert(1)",
647
+ "data:text/plain",
648
+ "https://[invalid",
649
+ "",
650
+ " \n\t",
651
+ ])("rejects %j before creating a download link", (url) => {
652
+ expect(() => downloadByURL(url)).toThrow("Unsupported download URL");
653
+ expect(document.createElement).not.toHaveBeenCalled();
654
+ expect(mockAnchor.click).not.toHaveBeenCalled();
655
+ });
656
+
607
657
  it("downloads an exported file with its returned metadata", async () => {
608
658
  let downloadedBlob: Blob | undefined;
609
- vi.stubGlobal("URL", {
610
- createObjectURL: vi.fn((blob: Blob) => {
611
- downloadedBlob = blob;
612
- return "blob:export";
613
- }),
614
- revokeObjectURL: vi.fn(),
615
- });
659
+ vi.stubGlobal(
660
+ "URL",
661
+ class extends URL {
662
+ public static override createObjectURL = vi.fn((blob: Blob) => {
663
+ downloadedBlob = blob;
664
+ return "blob:export";
665
+ });
666
+ public static override revokeObjectURL = vi.fn();
667
+ },
668
+ );
616
669
 
617
670
  downloadExportedFile({
618
671
  contents: "notebook",
@@ -12,6 +12,7 @@ import { captureExternalIframes } from "./iframe";
12
12
  import { Logger } from "./Logger";
13
13
  import { ProgressState } from "./progress";
14
14
  import { ToastProgress } from "./toast-progress";
15
+ import { asURL } from "./url";
15
16
 
16
17
  const FINISH_TOAST_DURATION_MS = 1200;
17
18
  type ToastConfig = Omit<Parameters<typeof toast>[0], "id">;
@@ -177,12 +178,63 @@ export async function downloadHTMLAsImage(opts: {
177
178
  }
178
179
  }
179
180
 
181
+ const DOWNLOAD_DATA_TYPES = new Set([
182
+ "text/plain",
183
+ "text/csv",
184
+ "text/tab-separated-values",
185
+ "application/json",
186
+ "application/octet-stream",
187
+ "application/pdf",
188
+ "application/zip",
189
+ "application/gzip",
190
+ "image/png",
191
+ "image/jpeg",
192
+ "image/gif",
193
+ "image/webp",
194
+ "image/avif",
195
+ "image/bmp",
196
+ "image/x-icon",
197
+ ]);
198
+
199
+ export function isDownloadUrl(url: string): boolean {
200
+ if (!url.trim()) {
201
+ return false;
202
+ }
203
+ try {
204
+ const parsed = asURL(url);
205
+ switch (parsed.protocol) {
206
+ case "http:":
207
+ case "https:":
208
+ case "blob:":
209
+ return true;
210
+ case "data:": {
211
+ const comma = parsed.pathname.indexOf(",");
212
+ if (comma === -1) {
213
+ return false;
214
+ }
215
+ const mimeType = parsed.pathname
216
+ .slice(0, comma)
217
+ .split(";", 1)[0]
218
+ .trim()
219
+ .toLowerCase();
220
+ // An omitted MIME type defaults to text/plain.
221
+ return DOWNLOAD_DATA_TYPES.has(mimeType || "text/plain");
222
+ }
223
+ default:
224
+ return false;
225
+ }
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
180
231
  export function downloadByURL(url: string, filename?: string) {
232
+ if (!isDownloadUrl(url)) {
233
+ throw new Error("Unsupported download URL");
234
+ }
181
235
  const a = document.createElement("a");
182
236
  a.href = url;
183
- if (filename) {
184
- a.download = filename;
185
- }
237
+ a.download = filename ?? "";
186
238
  a.click();
187
239
  a.remove();
188
240
  }