@marimo-team/islands 0.23.6-dev9 → 0.23.6

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.
Files changed (51) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CWU3Az6F.js → ConnectedDataExplorerComponent-PmilQqXR.js} +4 -4
  2. package/dist/assets/__vite-browser-external-rrUYDKRl.js +1 -0
  3. package/dist/assets/{worker-D-EdLKct.js → worker-Bfy15ViQ.js} +2 -2
  4. package/dist/{chat-ui-Cyca6aKX.js → chat-ui-B-gbqk_F.js} +6 -6
  5. package/dist/{code-visibility-B0kwrVA6.js → code-visibility-DNiCvIcQ.js} +678 -564
  6. package/dist/{formats-Dh5M1ZRs.js → formats-CgaK7Gmx.js} +1 -1
  7. package/dist/{glide-data-editor-DXti2axL.js → glide-data-editor-CvlvtPWJ.js} +2 -2
  8. package/dist/{html-to-image-6VI69paz.js → html-to-image-hMMPiNe_.js} +2120 -2103
  9. package/dist/{input-Drx1pguW.js → input-BAOe64zx.js} +1 -1
  10. package/dist/main.js +19 -19
  11. package/dist/{mermaid-BagLPXm9.js → mermaid-DJ1NyBGw.js} +2 -2
  12. package/dist/{process-output-SkNR_Omd.js → process-output-Bza_GK7Q.js} +1 -1
  13. package/dist/{reveal-component-DlCLweHo.js → reveal-component-BSwl7P64.js} +13 -13
  14. package/dist/{spec-BKWq0wn2.js → spec-DSIuqd3f.js} +1 -1
  15. package/dist/toDate-CHtl9vts.js +662 -0
  16. package/dist/{useAsyncData-CKYzhCis.js → useAsyncData-B6hCGywC.js} +1 -1
  17. package/dist/{useDeepCompareMemoize-je76AJS_.js → useDeepCompareMemoize-CmwDuYUH.js} +1 -1
  18. package/dist/{useLifecycle-smVfjLNI.js → useLifecycle-CjMjllqy.js} +1 -1
  19. package/dist/{useTheme-CX9pPLUH.js → useTheme-CByZUW0p.js} +1 -0
  20. package/dist/{vega-component-BnCQmtxw.js → vega-component-CC8TqWWV.js} +5 -5
  21. package/package.json +5 -5
  22. package/src/components/ai/ai-provider-icon.tsx +1 -0
  23. package/src/components/ai/ai-utils.ts +1 -0
  24. package/src/components/app-config/ai-config.tsx +30 -0
  25. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +47 -0
  26. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  27. package/src/components/editor/renderers/cell-array.tsx +14 -7
  28. package/src/components/slides/slide-form.tsx +43 -0
  29. package/src/components/terminal/terminal.tsx +16 -0
  30. package/src/components/ui/links.tsx +2 -1
  31. package/src/core/ai/ids/ids.ts +1 -0
  32. package/src/core/codemirror/markdown/__tests__/commands.test.ts +36 -0
  33. package/src/core/codemirror/markdown/commands.ts +4 -1
  34. package/src/core/config/config-schema.ts +1 -0
  35. package/src/core/edit-app.tsx +1 -0
  36. package/src/core/run-app.tsx +9 -2
  37. package/src/core/runtime/runtime.ts +3 -2
  38. package/src/core/static/static-state.ts +5 -1
  39. package/src/core/wasm/PyodideLoader.tsx +54 -16
  40. package/src/core/wasm/__tests__/PyodideLoader.test.ts +72 -0
  41. package/src/core/wasm/__tests__/bridge.test.ts +26 -1
  42. package/src/core/wasm/bridge.ts +24 -6
  43. package/src/core/wasm/state.ts +3 -0
  44. package/src/core/wasm/worker/getController.ts +7 -0
  45. package/src/core/wasm/worker/save-worker.ts +2 -1
  46. package/src/core/wasm/worker/worker.ts +2 -1
  47. package/src/plugins/core/RenderHTML.tsx +49 -3
  48. package/src/plugins/core/__test__/RenderHTML.test.ts +54 -0
  49. package/src/plugins/impl/common/labeled.tsx +1 -1
  50. package/dist/assets/__vite-browser-external-C4JkHbyY.js +0 -1
  51. package/dist/toDate-yqOcZ_tY.js +0 -638
@@ -0,0 +1,72 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { shouldShowSpinner } from "../PyodideLoader";
5
+
6
+ describe("shouldShowSpinner", () => {
7
+ it("shows the spinner when there are no cells yet (Pyodide hasn't parsed)", () => {
8
+ expect(
9
+ shouldShowSpinner({
10
+ hasCells: false,
11
+ hasOutput: false,
12
+ mode: "read",
13
+ codeHidden: false,
14
+ }),
15
+ ).toBe(true);
16
+
17
+ expect(
18
+ shouldShowSpinner({
19
+ hasCells: false,
20
+ hasOutput: true,
21
+ mode: "edit",
22
+ codeHidden: false,
23
+ }),
24
+ ).toBe(true);
25
+ });
26
+
27
+ it("renders children once cells exist with code visible", () => {
28
+ // run mode, code visible, no outputs yet — the user can read the code
29
+ expect(
30
+ shouldShowSpinner({
31
+ hasCells: true,
32
+ hasOutput: false,
33
+ mode: "read",
34
+ codeHidden: false,
35
+ }),
36
+ ).toBe(false);
37
+ });
38
+
39
+ it("renders children once cells exist with cached outputs (snapshot case)", () => {
40
+ expect(
41
+ shouldShowSpinner({
42
+ hasCells: true,
43
+ hasOutput: true,
44
+ mode: "read",
45
+ codeHidden: true,
46
+ }),
47
+ ).toBe(false);
48
+ });
49
+
50
+ it("keeps the spinner up in headless run mode with no outputs", () => {
51
+ // read mode + code hidden + no outputs = nothing visible to render
52
+ expect(
53
+ shouldShowSpinner({
54
+ hasCells: true,
55
+ hasOutput: false,
56
+ mode: "read",
57
+ codeHidden: true,
58
+ }),
59
+ ).toBe(true);
60
+ });
61
+
62
+ it("never blocks edit mode once cells exist", () => {
63
+ expect(
64
+ shouldShowSpinner({
65
+ hasCells: true,
66
+ hasOutput: false,
67
+ mode: "edit",
68
+ codeHidden: true,
69
+ }),
70
+ ).toBe(false);
71
+ });
72
+ });
@@ -58,7 +58,7 @@ vi.mock("@/core/wasm/store", () => ({
58
58
  // Import after all mocks are set up
59
59
  import { store } from "@/core/state/jotai";
60
60
  import { initialModeAtom } from "@/core/mode";
61
- import { PyodideBridge } from "../bridge";
61
+ import { getWasmWorkerName, PyodideBridge } from "../bridge";
62
62
 
63
63
  // Access INSTANCE once at module level so the constructor runs (and
64
64
  // addMessageListener populates rpcListeners) before any test executes.
@@ -111,3 +111,28 @@ describe("PyodideBridge.readCode", () => {
111
111
  expect(mockNotebookReadFile).not.toHaveBeenCalled();
112
112
  });
113
113
  });
114
+
115
+ describe("getWasmWorkerName", () => {
116
+ afterEach(() => {
117
+ delete (window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean })
118
+ .__MARIMO_HAS_WASM_CONTROLLER__;
119
+ });
120
+
121
+ it("returns the version without suffix by default", () => {
122
+ expect(getWasmWorkerName()).toBe("0.0.0-test");
123
+ });
124
+
125
+ it("appends ::controller when the host opts in", () => {
126
+ (
127
+ window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean }
128
+ ).__MARIMO_HAS_WASM_CONTROLLER__ = true;
129
+ expect(getWasmWorkerName()).toBe("0.0.0-test::controller");
130
+ });
131
+
132
+ it("does not append the suffix for non-true values", () => {
133
+ (
134
+ window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: unknown }
135
+ ).__MARIMO_HAS_WASM_CONTROLLER__ = "true";
136
+ expect(getWasmWorkerName()).toBe("0.0.0-test");
137
+ });
138
+ });
@@ -37,7 +37,7 @@ import type { IConnectionTransport } from "../websocket/transports/transport";
37
37
  import { PyodideRouter } from "./router";
38
38
  import { getWorkerRPC } from "./rpc";
39
39
  import { createShareableLink } from "./share";
40
- import { wasmInitializationAtom } from "./state";
40
+ import { wasmInitializationAtom, wasmInitStatusAtom } from "./state";
41
41
  import { fallbackFileStore, notebookFileStore } from "./store";
42
42
  import { isWasm } from "./utils";
43
43
  import type { SaveWorkerSchema } from "./worker/save-worker";
@@ -81,9 +81,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
81
81
  new URL("./worker/save-worker.ts", import.meta.url),
82
82
  {
83
83
  type: "module",
84
- // Pass the version to the worker
84
+ // Pass the version (and optional capability suffix) to the worker
85
85
  /* @vite-ignore */
86
- name: getMarimoVersion(),
86
+ name: getWasmWorkerName(),
87
87
  },
88
88
  );
89
89
 
@@ -101,9 +101,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
101
101
  new URL("./worker/worker.ts", import.meta.url),
102
102
  {
103
103
  type: "module",
104
- // Pass the version to the worker
104
+ // Pass the version (and optional capability suffix) to the worker
105
105
  /* @vite-ignore */
106
- name: getMarimoVersion(),
106
+ name: getWasmWorkerName(),
107
107
  },
108
108
  );
109
109
 
@@ -119,13 +119,15 @@ export class PyodideBridge implements RunRequests, EditRequests {
119
119
  // By initializing after, we get hits on cached network requests
120
120
  this.saveRpc = this.getSaveWorker();
121
121
  this.setInterruptBuffer();
122
+ store.set(wasmInitStatusAtom, "ready");
122
123
  this.initialized.resolve();
123
124
  });
124
125
  this.rpc.addMessageListener("initializingMessage", ({ message }) => {
125
126
  store.set(wasmInitializationAtom, message);
126
127
  });
127
128
  this.rpc.addMessageListener("initializedError", ({ error }) => {
128
- // If already resolved, show a toast
129
+ // If already initialized, surface as a toast and leave the deferred /
130
+ // init status alone — the worker is healthy, this is a runtime error.
129
131
  if (this.initialized.status === "resolved") {
130
132
  Logger.error(error);
131
133
  toast({
@@ -133,7 +135,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
133
135
  description: error,
134
136
  variant: "danger",
135
137
  });
138
+ return;
136
139
  }
140
+ store.set(wasmInitStatusAtom, "error");
137
141
  this.initialized.reject(new Error(error));
138
142
  });
139
143
  this.rpc.addMessageListener("kernelMessage", ({ message }) => {
@@ -634,3 +638,17 @@ export function createPyodideConnection(): IConnectionTransport {
634
638
  PyodideBridge.INSTANCE.attachMessageConsumer(callback);
635
639
  });
636
640
  }
641
+
642
+ // Compose the worker name. The version prefix is read by getMarimoVersion()
643
+ // in the worker; the optional "::controller" suffix tells getController.ts
644
+ // that the host page provides a custom /wasm/controller.js and that the
645
+ // dynamic import should be attempted. Hosts opt in by setting
646
+ // `window.__MARIMO_HAS_WASM_CONTROLLER__ = true` before
647
+ // PyodideBridge/worker initialization.
648
+ export function getWasmWorkerName(): string {
649
+ const hasCustomController =
650
+ typeof window !== "undefined" &&
651
+ (window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean })
652
+ .__MARIMO_HAS_WASM_CONTROLLER__ === true;
653
+ return getMarimoVersion() + (hasCustomController ? "::controller" : "");
654
+ }
@@ -5,6 +5,9 @@ import { isOutputEmpty } from "../cells/outputs";
5
5
 
6
6
  export const wasmInitializationAtom = atom<string>("Initializing...");
7
7
 
8
+ export type WasmInitStatus = "loading" | "ready" | "error";
9
+ export const wasmInitStatusAtom = atom<WasmInitStatus>("loading");
10
+
8
11
  export const hasAnyOutputAtom = atom<boolean>((get) => {
9
12
  const notebook = get(notebookAtom);
10
13
  const runtimeStates = Object.values(notebook.cellRuntime);
@@ -5,6 +5,13 @@ import type { WasmController } from "./types";
5
5
  // Load the controller
6
6
  // Falls back to the default controller
7
7
  export async function getController(version: string): Promise<WasmController> {
8
+ // Hosts that provide a custom /wasm/controller.js opt in via the worker
9
+ // name (see bridge.ts). Default: skip the dynamic import to avoid a
10
+ // guaranteed-404 round trip on the standard build.
11
+ const hasCustomController = self.name?.includes("::controller") ?? false;
12
+ if (!hasCustomController) {
13
+ return new DefaultWasmController();
14
+ }
8
15
  try {
9
16
  const controller = await import(
10
17
  /* @vite-ignore */ `/wasm/controller.js?version=${version}`
@@ -103,5 +103,6 @@ const rpc = createRPC<SaveWorkerSchema, ParentSchema>({
103
103
  rpc.send("ready", {});
104
104
 
105
105
  function getMarimoVersion() {
106
- return self.name; // We store the version in the worker name
106
+ // Worker name is "<version>" or "<version>::<capability>" see bridge.ts.
107
+ return self.name.split("::")[0];
107
108
  }
@@ -375,7 +375,8 @@ const namesThatRequireSync = new Set<keyof RawBridge>([
375
375
  ]);
376
376
 
377
377
  function getMarimoVersion() {
378
- return self.name; // We store the version in the worker name
378
+ // Worker name is "<version>" or "<version>::<capability>" see bridge.ts.
379
+ return self.name.split("::")[0];
379
380
  }
380
381
 
381
382
  const pyodideReadyPromise = t.wrapAsync(loadPyodideAndPackages)();
@@ -6,6 +6,7 @@ import parse, {
6
6
  type HTMLReactParserOptions,
7
7
  } from "html-react-parser";
8
8
  import React, {
9
+ cloneElement,
9
10
  isValidElement,
10
11
  type JSX,
11
12
  type ReactNode,
@@ -169,6 +170,35 @@ const addCopyButtonToCodehilite: TransformFn = (
169
170
  }
170
171
  };
171
172
 
173
+ // Decorator (not a match-and-replace transform): applies a src-based key
174
+ // to <img> elements so they remount on src change. Reusing an <img> across
175
+ // src changes can leave the previous image painted (e.g. when the new
176
+ // request is slow/blocked, served stale by a CDN, or fails CORS), so the
177
+ // user sees the old image even though the HTML source is up to date.
178
+ //
179
+ // Runs unconditionally after the match-and-replace transforms so it still
180
+ // applies when an <img> was already wrapped by, say, wrapTooltipTargets.
181
+ const keyImagesBySrc: TransformFn = (
182
+ reactNode: ReactNode,
183
+ domNode: DOMNode,
184
+ index: number,
185
+ ): JSX.Element | undefined => {
186
+ if (!(domNode instanceof Element) || domNode.name !== "img") {
187
+ return undefined;
188
+ }
189
+ const src = domNode.attribs?.src;
190
+ if (!src || !isValidElement(reactNode)) {
191
+ return undefined;
192
+ }
193
+ // data: URIs are inline — no network fetch — so they can't go stale.
194
+ // Skip to avoid bloating the React key with a megabyte base64 payload.
195
+ // URI schemes are case-insensitive per RFC 3986.
196
+ if (/^data:/i.test(src)) {
197
+ return undefined;
198
+ }
199
+ return cloneElement(reactNode, { key: `${src}-${index}` });
200
+ };
201
+
172
202
  // Wrap elements with data-marimo-doc attribute in a DocHoverTarget
173
203
  const wrapDocHoverTargets: TransformFn = (
174
204
  reactNode: ReactNode,
@@ -281,6 +311,8 @@ function parseHtml({
281
311
  ...additionalReplacements,
282
312
  ];
283
313
 
314
+ // Match-and-replace transforms: the first one that returns a value wins
315
+ // (short-circuits the rest).
284
316
  const transformFunctions: TransformFn[] = [
285
317
  addCopyButtonToCodehilite,
286
318
  preserveQueryParamsInAnchorLinks,
@@ -290,6 +322,12 @@ function parseHtml({
290
322
  removeWrappingHtmlTags,
291
323
  ];
292
324
 
325
+ // Decorators: run unconditionally on the result of the transform pipeline
326
+ // and may further wrap/clone it. Used for cross-cutting concerns that
327
+ // should apply regardless of which (if any) match-and-replace transform
328
+ // ran above.
329
+ const decoratorFunctions: TransformFn[] = [keyImagesBySrc];
330
+
293
331
  return parse(html, {
294
332
  replace: (domNode: DOMNode, index: number) => {
295
333
  for (const renderFunction of renderFunctions) {
@@ -301,13 +339,21 @@ function parseHtml({
301
339
  return domNode;
302
340
  },
303
341
  transform: (reactNode: ReactNode, domNode: DOMNode, index: number) => {
342
+ let result: ReactNode = reactNode as JSX.Element;
304
343
  for (const transformFunction of transformFunctions) {
305
- const transformed = transformFunction(reactNode, domNode, index);
344
+ const transformed = transformFunction(result, domNode, index);
306
345
  if (transformed) {
307
- return transformed;
346
+ result = transformed;
347
+ break;
348
+ }
349
+ }
350
+ for (const decorate of decoratorFunctions) {
351
+ const decorated = decorate(result, domNode, index);
352
+ if (decorated) {
353
+ result = decorated;
308
354
  }
309
355
  }
310
- return reactNode as JSX.Element;
356
+ return result as JSX.Element;
311
357
  },
312
358
  });
313
359
  }
@@ -60,6 +60,60 @@ describe("parseHtml", () => {
60
60
  `);
61
61
  });
62
62
 
63
+ test("img has key derived from src so React remounts on src change", () => {
64
+ const html = '<img src="https://cdn.example.com/a.png" alt="a">';
65
+ const result = parseHtml({ html }) as React.ReactElement;
66
+ expect(result.key).toBe("https://cdn.example.com/a.png-0");
67
+ });
68
+
69
+ test("multiple imgs each get distinct keys", () => {
70
+ const html =
71
+ '<div><img src="https://cdn.example.com/a.png"><img src="https://cdn.example.com/b.png"></div>';
72
+ const result = parseHtml({ html }) as React.ReactElement<{
73
+ children: React.ReactElement[];
74
+ }>;
75
+ const children = result.props.children;
76
+ expect(children[0].key).toBe("https://cdn.example.com/a.png-0");
77
+ expect(children[1].key).toBe("https://cdn.example.com/b.png-1");
78
+ });
79
+
80
+ test("img without src is left alone", () => {
81
+ const html = "<img>";
82
+ const result = parseHtml({ html }) as React.ReactElement;
83
+ expect(result.key).toBeNull();
84
+ });
85
+
86
+ test("img with data: URI is not keyed (inline, no network fetch)", () => {
87
+ const longPayload = "A".repeat(10_000);
88
+ const html = `<img src="data:image/png;base64,${longPayload}">`;
89
+ const result = parseHtml({ html }) as React.ReactElement;
90
+ // No remount-on-src needed for inline images, so we leave the key
91
+ // unset rather than bloat it with the base64 payload.
92
+ expect(result.key).toBeNull();
93
+ });
94
+
95
+ test("img with uppercase DATA: URI is also skipped (scheme is case-insensitive)", () => {
96
+ const html = `<img src="DATA:image/png;base64,${"A".repeat(100)}">`;
97
+ const result = parseHtml({ html }) as React.ReactElement;
98
+ expect(result.key).toBeNull();
99
+ });
100
+
101
+ test("img wrapped by data-tooltip is still keyed by src", () => {
102
+ const html =
103
+ '<img src="https://cdn.example.com/a.png" data-tooltip="hi" alt="a">';
104
+ const result = parseHtml({ html }) as React.ReactElement;
105
+ // Outer Tooltip carries the src-based key so it remounts on src change,
106
+ // forcing the inner <img> to remount as well.
107
+ expect(result.key).toBe("https://cdn.example.com/a.png-0");
108
+ });
109
+
110
+ test("img wrapped by data-marimo-doc is still keyed by src", () => {
111
+ const html =
112
+ '<img src="https://cdn.example.com/b.png" data-marimo-doc="foo.bar">';
113
+ const result = parseHtml({ html }) as React.ReactElement;
114
+ expect(result.key).toBe("https://cdn.example.com/b.png-0");
115
+ });
116
+
63
117
  test("codehilite with copy button", () => {
64
118
  const html =
65
119
  '<div class="codehilite"><pre><code>console.log("Hello");</code></pre></div>';
@@ -44,7 +44,7 @@ export const Labeled: React.FC<PropsWithChildren<Props>> = ({
44
44
  }
45
45
 
46
46
  const labelElement = (
47
- <div part="label" className="m-0 p-0">
47
+ <div part="label" className="inline-flex items-center m-0 p-0">
48
48
  <Label htmlFor={id} className={cn("font-prose", labelClassName)}>
49
49
  {renderHTML({ html: label })}
50
50
  </Label>
@@ -1 +0,0 @@
1
- import{t as e}from"./worker-D-EdLKct.js";var t=e(((e,t)=>{t.exports={}}));export default t();