@marimo-team/islands 0.25.3-dev234 → 0.25.3-dev237

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.
@@ -1,6 +1,6 @@
1
1
  import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
2
2
  import { t } from "./react-sHtZdjeP.js";
3
- import { a as n } from "./glide-data-editor-C4mQ2b_i.js";
3
+ import { a as n } from "./glide-data-editor-C9q9eMDX.js";
4
4
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_27cffe7f1bc30287d59ac27db3240c5b/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/private/number-overlay-editor-style.js
5
5
  var r = /* @__PURE__ */ e(t()), i = /*#__PURE__*/ n("div")({
6
6
  name: "NumberOverlayEditorStyle",
@@ -3,7 +3,7 @@ import { c as t, g as n, h as r, m as i, t as a } from "./button-B6RjDKJx.js";
3
3
  import { t as o } from "./react-sHtZdjeP.js";
4
4
  import { t as s } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { m as c, t as l } from "./tooltip-C3UnvsRe.js";
6
- import { $ as u, Q as d, Z as f, an as p, i as m, l as h, ln as g, n as _, on as v, r as y, t as b, u as x } from "./common-jgy6-GsH.js";
6
+ import { $ as u, Q as d, et as f, i as p, l as m, n as h, on as g, r as _, sn as v, t as y, u as b, un as x } from "./common-Di0CUoCv.js";
7
7
  import { M as S } from "./select-BSxlITBo.js";
8
8
  import { Y as ee } from "./dropdown-menu-BRiE7Vnp.js";
9
9
  import { Pt as C } from "./error-entries-CRPfxMqm.js";
@@ -11,7 +11,7 @@ import { t as w } from "./jsx-runtime-3BD1kT0s.js";
11
11
  import { r as T } from "./use-restore-focus-BWO0z1lo.js";
12
12
  import { D as E, t as D, x as O } from "./utils-DXkj82sK.js";
13
13
  import { o as k } from "./static-state-BldGd99T.js";
14
- import { a as A, n as te, t as ne } from "./code-visibility-D5qD3Vpw.js";
14
+ import { a as A, n as te, t as ne } from "./code-visibility-Bf42Zr3d.js";
15
15
  /* empty css */
16
16
  var j = c("sticky-note", [["path", {
17
17
  d: "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",
@@ -6493,11 +6493,11 @@ function aa(e) {
6493
6493
  }
6494
6494
  var oa = (e) => {
6495
6495
  let t = (0, N.c)(13), { cell: n } = e, r;
6496
- t[0] !== n.id || t[1] !== n.name ? (r = b(n.id, n.name), t[0] = n.id, t[1] = n.name, t[2] = r) : r = t[2];
6496
+ t[0] !== n.id || t[1] !== n.name ? (r = y(n.id, n.name), t[0] = n.id, t[1] = n.name, t[2] = r) : r = t[2];
6497
6497
  let i = n.id, a = n.status, o = n.output, s;
6498
6498
  t[3] === n ? s = t[4] : (s = C(n, !1), t[3] = n, t[4] = s);
6499
6499
  let c;
6500
- t[5] !== n.id || t[6] !== n.output || t[7] !== n.status || t[8] !== s ? (c = /* @__PURE__ */ (0, G.jsx)(x, {
6500
+ t[5] !== n.id || t[6] !== n.output || t[7] !== n.status || t[8] !== s ? (c = /* @__PURE__ */ (0, G.jsx)(b, {
6501
6501
  cellId: i,
6502
6502
  status: a,
6503
6503
  output: o,
@@ -6557,7 +6557,7 @@ var la = (e) => {
6557
6557
  let o;
6558
6558
  return t[3] === n ? o = t[4] : (o = /* @__PURE__ */ (0, G.jsx)(oa, { cell: n }), t[3] = n, t[4] = o), o;
6559
6559
  }, ua = (e) => {
6560
- let n = (0, N.c)(164), { slideCells: o, layout: s, setLayout: c, noOutputIds: b, activeIndex: x, onSlideChange: S, mode: ee, configWidth: C, isEditable: w } = e, j = w !== void 0 && w, re = (0, M.useRef)(null), P = (0, M.useRef)(null), { width: ie, height: ae } = $i(re), F = T() != null, [I, L] = (0, M.useState)(1), oe;
6560
+ let n = (0, N.c)(164), { slideCells: o, layout: s, setLayout: c, noOutputIds: y, activeIndex: b, onSlideChange: S, mode: ee, configWidth: C, isEditable: w } = e, j = w !== void 0 && w, re = (0, M.useRef)(null), P = (0, M.useRef)(null), { width: ie, height: ae } = $i(re), F = T() != null, [I, L] = (0, M.useState)(1), oe;
6561
6561
  n[0] === Symbol.for("react.memo_cache_sentinel") ? (oe = () => {
6562
6562
  let e = requestAnimationFrame(() => {
6563
6563
  let e = P.current;
@@ -6572,19 +6572,19 @@ var la = (e) => {
6572
6572
  F
6573
6573
  ], n[1] = ae, n[2] = F, n[3] = ie, n[4] = se) : se = n[4], (0, M.useEffect)(oe, se);
6574
6574
  let R = O(k), ce = O(te).kind, le = O(A), ue = ce !== "wasm" || le, de;
6575
- n[5] !== R || n[6] !== ce ? (de = R || !m(ce) ? [] : [Ui], n[5] = R, n[6] = ce, n[7] = de) : de = n[7];
6575
+ n[5] !== R || n[6] !== ce ? (de = R || !p(ce) ? [] : [Ui], n[5] = R, n[6] = ce, n[7] = de) : de = n[7];
6576
6576
  let fe = de, [pe, me] = (0, M.useState)(da), he = ne(), ge;
6577
6577
  n[8] === he ? ge = n[9] : (ge = !D() && he, n[8] = he, n[9] = ge);
6578
6578
  let _e = ge, ve;
6579
- n[10] === R ? ve = n[11] : (ve = y(R, window.location.search), n[10] = R, n[11] = ve);
6580
- let z = !ve && _e, ye = x == null ? void 0 : o[x], be;
6579
+ n[10] === R ? ve = n[11] : (ve = _(R, window.location.search), n[10] = R, n[11] = ve);
6580
+ let z = !ve && _e, ye = b == null ? void 0 : o[b], be;
6581
6581
  n[12] !== ye || n[13] !== o ? (be = ye ?? o.at(0), n[12] = ye, n[13] = o, n[14] = be) : be = n[14];
6582
6582
  let xe = be, Se;
6583
- n[15] !== ye || n[16] !== s.cells || n[17] !== b ? (Se = {
6583
+ n[15] !== ye || n[16] !== s.cells || n[17] !== y ? (Se = {
6584
6584
  activeCell: ye,
6585
6585
  slideConfigs: s.cells,
6586
- noOutputIds: b
6587
- }, n[15] = ye, n[16] = s.cells, n[17] = b, n[18] = Se) : Se = n[18];
6586
+ noOutputIds: y
6587
+ }, n[15] = ye, n[16] = s.cells, n[17] = y, n[18] = Se) : Se = n[18];
6588
6588
  let { parkedPreviewCell: B, isHeldEdit: Ce, isNoOutputPreview: we, heldEditCellId: Te, heldShowsCode: Ee, toggleHeldShowsCode: De } = ra(Se), Oe;
6589
6589
  n[19] !== _e || n[20] !== s.cells || n[21] !== pe ? (Oe = (e) => ta({
6590
6590
  cells: s.cells,
@@ -6597,12 +6597,12 @@ var la = (e) => {
6597
6597
  let je = Ae, Me;
6598
6598
  n[28] !== V || n[29] !== _e || n[30] !== s.cells ? (Me = _e && V != null && (s.cells.get(V)?.showCode ?? !1), n[28] = V, n[29] = _e, n[30] = s.cells, n[31] = Me) : Me = n[31];
6599
6599
  let Ne = Me, Pe;
6600
- n[32] !== Te || n[33] !== s.cells || n[34] !== b ? (Pe = (e) => na({
6600
+ n[32] !== Te || n[33] !== s.cells || n[34] !== y ? (Pe = (e) => na({
6601
6601
  cell: e,
6602
- noOutputIds: b,
6602
+ noOutputIds: y,
6603
6603
  heldEditCellId: Te,
6604
6604
  slideConfigs: s.cells
6605
- }), n[32] = Te, n[33] = s.cells, n[34] = b, n[35] = Pe) : Pe = n[35];
6605
+ }), n[32] = Te, n[33] = s.cells, n[34] = y, n[35] = Pe) : Pe = n[35];
6606
6606
  let Fe;
6607
6607
  n[36] !== o || n[37] !== Pe ? (Fe = nr({
6608
6608
  cells: o,
@@ -6617,7 +6617,7 @@ var la = (e) => {
6617
6617
  let { cellToTarget: Re, targetToCellIndex: ze } = Le, Be = s.deck?.transition ?? "slide", Ve = s.deck?.verticalAlign, H;
6618
6618
  n[42] === Ve ? H = n[43] : (H = aa(Ve), n[42] = Ve, n[43] = H);
6619
6619
  let U = H, He;
6620
- n[44] === Symbol.for("react.memo_cache_sentinel") ? (He = _(window.location.href), n[44] = He) : He = n[44];
6620
+ n[44] === Symbol.for("react.memo_cache_sentinel") ? (He = h(window.location.href), n[44] = He) : He = n[44];
6621
6621
  let Ue = He, We;
6622
6622
  n[45] === Be ? We = n[46] : (We = {
6623
6623
  embedded: !0,
@@ -6632,33 +6632,33 @@ var la = (e) => {
6632
6632
  scrollActivationWidth: 0
6633
6633
  }, n[45] = Be, n[46] = We);
6634
6634
  let Ge = We, Ke;
6635
- n[47] !== x || n[48] !== ue || n[49] !== Re || n[50] !== o ? (Ke = (e) => {
6636
- if (x == null && window.location.hash.startsWith("#/")) {
6635
+ n[47] !== b || n[48] !== ue || n[49] !== Re || n[50] !== o ? (Ke = (e) => {
6636
+ if (b == null && window.location.hash.startsWith("#/")) {
6637
6637
  if (!ue) return;
6638
6638
  window.dispatchEvent(new HashChangeEvent("hashchange")), Yi(e);
6639
6639
  return;
6640
6640
  }
6641
6641
  let t = or({
6642
- activeIndex: x,
6642
+ activeIndex: b,
6643
6643
  cells: o,
6644
6644
  cellToTarget: Re,
6645
6645
  getId: ma
6646
6646
  }), n = t && sr(e.getIndices(), t);
6647
6647
  n && (e.slide(n.h, n.v, n.f), Yi(e));
6648
- }, n[47] = x, n[48] = ue, n[49] = Re, n[50] = o, n[51] = Ke) : Ke = n[51];
6648
+ }, n[47] = b, n[48] = ue, n[49] = Re, n[50] = o, n[51] = Ke) : Ke = n[51];
6649
6649
  let qe = E(Ke), Je;
6650
6650
  n[52] === qe ? Je = n[53] : (Je = () => {
6651
6651
  let e = P.current;
6652
6652
  e != null && qe(e);
6653
6653
  }, n[52] = qe, n[53] = Je);
6654
6654
  let Ye;
6655
- n[54] !== x || n[55] !== ue || n[56] !== Re || n[57] !== qe || n[58] !== o ? (Ye = [
6656
- x,
6655
+ n[54] !== b || n[55] !== ue || n[56] !== Re || n[57] !== qe || n[58] !== o ? (Ye = [
6656
+ b,
6657
6657
  ue,
6658
6658
  Re,
6659
6659
  o,
6660
6660
  qe
6661
- ], n[54] = x, n[55] = ue, n[56] = Re, n[57] = qe, n[58] = o, n[59] = Ye) : Ye = n[59], (0, M.useEffect)(Je, Ye);
6661
+ ], n[54] = b, n[55] = ue, n[56] = Re, n[57] = qe, n[58] = o, n[59] = Ye) : Ye = n[59], (0, M.useEffect)(Je, Ye);
6662
6662
  let Xe;
6663
6663
  n[60] !== V || n[61] !== Ne || n[62] !== Ce || n[63] !== De ? (Xe = () => {
6664
6664
  if (!(V == null || Ne)) {
@@ -6705,14 +6705,14 @@ var la = (e) => {
6705
6705
  t instanceof HTMLElement && (t.tabIndex = -1, t.focus({ preventScroll: !0 }));
6706
6706
  }, n[75] = ue, n[76] = z, n[77] = qe, n[78] = tt, n[79] = Ze, n[80] = it) : it = n[80];
6707
6707
  let at = E(it), W;
6708
- n[81] !== x || n[82] !== S || n[83] !== B || n[84] !== o ? (W = (e) => {
6709
- if (!B || x == null || i.fromInput(e)) return;
6708
+ n[81] !== b || n[82] !== S || n[83] !== B || n[84] !== o ? (W = (e) => {
6709
+ if (!B || b == null || i.fromInput(e)) return;
6710
6710
  let t = Qi(e);
6711
6711
  if (t === 0) return;
6712
6712
  e.preventDefault(), e.stopPropagation();
6713
- let n = x + t;
6713
+ let n = b + t;
6714
6714
  n < 0 || n >= o.length || S?.(n);
6715
- }, n[81] = x, n[82] = S, n[83] = B, n[84] = o, n[85] = W) : W = n[85];
6715
+ }, n[81] = b, n[82] = S, n[83] = B, n[84] = o, n[85] = W) : W = n[85];
6716
6716
  let ot = E(W), st;
6717
6717
  n[86] === rt ? st = n[87] : (st = () => {
6718
6718
  rt();
@@ -6767,7 +6767,7 @@ var la = (e) => {
6767
6767
  "aria-label": ut ?? void 0,
6768
6768
  children: [ut && /* @__PURE__ */ (0, G.jsxs)("div", {
6769
6769
  className: "flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground border-b bg-muted/40",
6770
- children: [/* @__PURE__ */ (0, G.jsx)(p, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, G.jsx)("span", { children: ut })]
6770
+ children: [/* @__PURE__ */ (0, G.jsx)(g, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, G.jsx)("span", { children: ut })]
6771
6771
  }), /* @__PURE__ */ (0, G.jsx)("div", {
6772
6772
  className: "reveal mo-slides-theme prose-slides absolute left-1/2 top-1/2",
6773
6773
  "data-testid": "slide-preview-canvas",
@@ -6801,7 +6801,7 @@ var la = (e) => {
6801
6801
  "aria-disabled": Ne,
6802
6802
  "aria-label": Ne ? "Code always shown" : je ? "Hide code" : "Show code",
6803
6803
  onClick: Ze,
6804
- children: /* @__PURE__ */ (0, G.jsx)(g, { className: "h-4 w-4" })
6804
+ children: /* @__PURE__ */ (0, G.jsx)(x, { className: "h-4 w-4" })
6805
6805
  })
6806
6806
  }), n[128] = je, n[129] = Ne, n[130] = z, n[131] = Ze, n[132] = q) : q = n[132];
6807
6807
  let bt;
@@ -6847,7 +6847,7 @@ var la = (e) => {
6847
6847
  }), n[141] = St, n[142] = e), e;
6848
6848
  }
6849
6849
  let Y;
6850
- n[143] === St ? Y = n[144] : (Y = /* @__PURE__ */ (0, G.jsx)(f, {
6850
+ n[143] === St ? Y = n[144] : (Y = /* @__PURE__ */ (0, G.jsx)(d, {
6851
6851
  defaultSize: 92,
6852
6852
  minSize: 60,
6853
6853
  children: St
@@ -6858,13 +6858,13 @@ var la = (e) => {
6858
6858
  fine: 4
6859
6859
  }, n[145] = Ct) : Ct = n[145];
6860
6860
  let wt;
6861
- n[146] === F ? wt = n[147] : (wt = /* @__PURE__ */ (0, G.jsx)(u, {
6861
+ n[146] === F ? wt = n[147] : (wt = /* @__PURE__ */ (0, G.jsx)(f, {
6862
6862
  className: "mo-slides-notes-resize",
6863
6863
  hitAreaMargins: Ct,
6864
6864
  disabled: F
6865
6865
  }), n[146] = F, n[147] = wt);
6866
6866
  let Tt = xe?.id, Et;
6867
- n[148] !== s || n[149] !== c || n[150] !== Tt ? (Et = /* @__PURE__ */ (0, G.jsx)(f, {
6867
+ n[148] !== s || n[149] !== c || n[150] !== Tt ? (Et = /* @__PURE__ */ (0, G.jsx)(d, {
6868
6868
  defaultSize: 10,
6869
6869
  minSize: 4,
6870
6870
  collapsible: !0,
@@ -6876,7 +6876,7 @@ var la = (e) => {
6876
6876
  })
6877
6877
  }), n[148] = s, n[149] = c, n[150] = Tt, n[151] = Et) : Et = n[151];
6878
6878
  let Dt;
6879
- n[152] !== Y || n[153] !== wt || n[154] !== Et ? (Dt = /* @__PURE__ */ (0, G.jsxs)(d, {
6879
+ n[152] !== Y || n[153] !== wt || n[154] !== Et ? (Dt = /* @__PURE__ */ (0, G.jsxs)(u, {
6880
6880
  direction: "vertical",
6881
6881
  autoSaveId: "marimo:slides:notes-panel",
6882
6882
  className: "flex-1 min-w-0",
@@ -6887,7 +6887,7 @@ var la = (e) => {
6887
6887
  ]
6888
6888
  }), n[152] = Y, n[153] = wt, n[154] = Et, n[155] = Dt) : Dt = n[155];
6889
6889
  let Ot;
6890
- n[156] !== xe || n[157] !== C || n[158] !== s || n[159] !== c ? (Ot = /* @__PURE__ */ (0, G.jsx)(h, {
6890
+ n[156] !== xe || n[157] !== C || n[158] !== s || n[159] !== c ? (Ot = /* @__PURE__ */ (0, G.jsx)(m, {
6891
6891
  configWidth: C,
6892
6892
  layout: s,
6893
6893
  setLayout: c,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev234",
3
+ "version": "0.25.3-dev237",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -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
  }