@marimo-team/islands 0.23.6-dev8 → 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 (54) 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-Dfs7A8dK.js → chat-ui-B-gbqk_F.js} +6 -6
  5. package/dist/{code-visibility-CHNdpaL9.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-DWbpEGa_.js → html-to-image-hMMPiNe_.js} +2136 -2120
  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-CeXMg9XF.js → process-output-Bza_GK7Q.js} +1 -1
  13. package/dist/{reveal-component-LAgwxVYN.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/cells/__tests__/apply-transaction.test.ts +193 -27
  33. package/src/core/cells/__tests__/document-changes.test.ts +14 -0
  34. package/src/core/cells/document-changes.ts +17 -14
  35. package/src/core/codemirror/markdown/__tests__/commands.test.ts +36 -0
  36. package/src/core/codemirror/markdown/commands.ts +4 -1
  37. package/src/core/config/config-schema.ts +1 -0
  38. package/src/core/edit-app.tsx +1 -0
  39. package/src/core/run-app.tsx +9 -2
  40. package/src/core/runtime/runtime.ts +3 -2
  41. package/src/core/static/static-state.ts +5 -1
  42. package/src/core/wasm/PyodideLoader.tsx +54 -16
  43. package/src/core/wasm/__tests__/PyodideLoader.test.ts +72 -0
  44. package/src/core/wasm/__tests__/bridge.test.ts +26 -1
  45. package/src/core/wasm/bridge.ts +24 -6
  46. package/src/core/wasm/state.ts +3 -0
  47. package/src/core/wasm/worker/getController.ts +7 -0
  48. package/src/core/wasm/worker/save-worker.ts +2 -1
  49. package/src/core/wasm/worker/worker.ts +2 -1
  50. package/src/plugins/core/RenderHTML.tsx +49 -3
  51. package/src/plugins/core/__test__/RenderHTML.test.ts +54 -0
  52. package/src/plugins/impl/common/labeled.tsx +1 -1
  53. package/dist/assets/__vite-browser-external-C4JkHbyY.js +0 -1
  54. package/dist/toDate-yqOcZ_tY.js +0 -638
@@ -203,6 +203,8 @@ describe("toDocumentChanges", () => {
203
203
  [
204
204
  {
205
205
  "cellId": "0",
206
+ "column": null,
207
+ "disabled": false,
206
208
  "hideCode": true,
207
209
  "type": "set-config",
208
210
  },
@@ -246,6 +248,8 @@ describe("toDocumentChanges", () => {
246
248
  {
247
249
  "cellId": "1",
248
250
  "column": 1,
251
+ "disabled": false,
252
+ "hideCode": false,
249
253
  "type": "set-config",
250
254
  },
251
255
  {
@@ -273,11 +277,15 @@ describe("toDocumentChanges", () => {
273
277
  {
274
278
  "cellId": "1",
275
279
  "column": 1,
280
+ "disabled": false,
281
+ "hideCode": false,
276
282
  "type": "set-config",
277
283
  },
278
284
  {
279
285
  "cellId": "2",
280
286
  "column": 1,
287
+ "disabled": false,
288
+ "hideCode": false,
281
289
  "type": "set-config",
282
290
  },
283
291
  {
@@ -310,11 +318,15 @@ describe("toDocumentChanges", () => {
310
318
  {
311
319
  "cellId": "1",
312
320
  "column": 0,
321
+ "disabled": false,
322
+ "hideCode": false,
313
323
  "type": "set-config",
314
324
  },
315
325
  {
316
326
  "cellId": "2",
317
327
  "column": 0,
328
+ "disabled": false,
329
+ "hideCode": false,
318
330
  "type": "set-config",
319
331
  },
320
332
  {
@@ -358,6 +370,8 @@ describe("toDocumentChanges", () => {
358
370
  {
359
371
  "cellId": "1",
360
372
  "column": 1,
373
+ "disabled": false,
374
+ "hideCode": false,
361
375
  "type": "set-config",
362
376
  },
363
377
  {
@@ -156,10 +156,13 @@ function columnChanges(
156
156
  for (const [cellId, newCol] of newColumns) {
157
157
  const prevCol = prevColumns.get(cellId);
158
158
  if (prevCol !== newCol) {
159
+ const cell = getCell(cellId, newState);
159
160
  changes.push({
160
161
  type: "set-config",
161
162
  cellId: cellId,
162
163
  column: newCol,
164
+ disabled: cell?.config.disabled ?? false,
165
+ hideCode: cell?.config.hide_code ?? false,
163
166
  });
164
167
  }
165
168
  }
@@ -257,18 +260,21 @@ export function toDocumentChanges(
257
260
  }
258
261
 
259
262
  // updateCellConfig → set-config
260
- // Maps CellConfig's snake_case hide_code to the change's camelCase hideCode.
261
- // Only includes fields that were actually specified in the partial config
262
- // (from the action payload, not the full cell config).
263
+ // SetConfig is full-replacement: emit the cell's complete config from
264
+ // newState (which already merged the action's partial payload).
263
265
  case "updateCellConfig": {
264
- const { cellId, config } = action.payload;
266
+ const { cellId } = action.payload;
267
+ const cell = getCell(cellId, newState);
268
+ if (!cell) {
269
+ return [];
270
+ }
265
271
  return [
266
272
  {
267
273
  type: "set-config",
268
274
  cellId: cellId,
269
- ...(config.hide_code != null && { hideCode: config.hide_code }),
270
- ...(config.disabled != null && { disabled: config.disabled }),
271
- ...(config.column != null && { column: config.column }),
275
+ column: cell.config.column ?? null,
276
+ disabled: cell.config.disabled ?? false,
277
+ hideCode: cell.config.hide_code ?? false,
272
278
  },
273
279
  ];
274
280
  }
@@ -538,18 +544,15 @@ export function fromDocumentChanges(
538
544
  break;
539
545
 
540
546
  // set-config → updateCellConfig
541
- // Maps the change's camelCase hideCode back to CellConfig's snake_case
542
- // hide_code. Only includes fields that are non-null (null means
543
- // "not specified" on the wire, not "clear the value").
544
547
  case "set-config":
545
548
  actions.push({
546
549
  type: "updateCellConfig",
547
550
  payload: {
548
551
  cellId: change.cellId,
549
552
  config: {
550
- ...(change.hideCode != null && { hide_code: change.hideCode }),
551
- ...(change.disabled != null && { disabled: change.disabled }),
552
- ...(change.column != null && { column: change.column }),
553
+ column: change.column,
554
+ disabled: change.disabled,
555
+ hide_code: change.hideCode,
553
556
  },
554
557
  },
555
558
  });
@@ -650,7 +653,7 @@ export function applyTransactionChanges(
650
653
  ) {
651
654
  continue;
652
655
  }
653
- if (change.type === "set-config" && change.column != null) {
656
+ if (change.type === "set-config") {
654
657
  hasColumnChange = true;
655
658
  }
656
659
  if (change.type === "create-cell" && change.config?.column != null) {
@@ -385,6 +385,42 @@ describe("insertImage", () => {
385
385
  );
386
386
  });
387
387
 
388
+ test("normalizes Windows backslash paths to forward slashes in image URL", async () => {
389
+ view = createEditor("Hello, world!");
390
+ view.dispatch({
391
+ selection: { anchor: 7, head: 7 },
392
+ });
393
+
394
+ vi.spyOn(store, "get").mockImplementation((atom) => {
395
+ if (atom === filenameAtom) {
396
+ return "C:\\Users\\user\\project\\notebook.py";
397
+ }
398
+ if (atom === requestClientAtom) {
399
+ return mockRequestClient;
400
+ }
401
+ });
402
+
403
+ mockRequestClient.sendCreateFileOrFolder.mockResolvedValueOnce({
404
+ success: true,
405
+ message: null,
406
+ info: {
407
+ path: "C:\\Users\\user\\project\\public\\hello.png",
408
+ name: "hello.png",
409
+ children: [],
410
+ id: "",
411
+ isDirectory: false,
412
+ isMarimoFile: false,
413
+ lastModified: null,
414
+ },
415
+ });
416
+
417
+ await insertImage(view, mockPngFile());
418
+
419
+ expect(view.state.doc.toString()).toMatchInlineSnapshot(
420
+ `"Hello, ![alt](public/hello.png)world!"`,
421
+ );
422
+ });
423
+
388
424
  test("saves image as file different extension", async () => {
389
425
  view = createEditor("Hello, world!");
390
426
  view.dispatch({
@@ -360,7 +360,10 @@ export async function insertImage(view: EditorView, file: File) {
360
360
  notebookDir &&
361
361
  savedFilePath.startsWith(notebookDir)
362
362
  ) {
363
- savedFilePath = Paths.rest(savedFilePath, notebookDir);
363
+ savedFilePath = Paths.rest(savedFilePath, notebookDir).replaceAll(
364
+ "\\",
365
+ "/",
366
+ );
364
367
  }
365
368
 
366
369
  toast({
@@ -166,6 +166,7 @@ export const UserConfigSchema = z
166
166
  ollama: AiConfigSchema.optional(),
167
167
  openrouter: AiConfigSchema.optional(),
168
168
  wandb: AiConfigSchema.optional(),
169
+ opencode_go: AiConfigSchema.optional(),
169
170
  open_ai_compatible: AiConfigSchema.optional(),
170
171
  azure: AiConfigSchema.optional(),
171
172
  bedrock: z
@@ -137,6 +137,7 @@ export const EditApp: React.FC<AppProps> = ({
137
137
  mode={viewState.mode}
138
138
  userConfig={userConfig}
139
139
  appConfig={appConfig}
140
+ hideControls={hideControls}
140
141
  />
141
142
  );
142
143
 
@@ -10,7 +10,11 @@ import { buttonVariants } from "@/components/ui/button";
10
10
  import { DelayMount } from "@/components/utils/delay-mount";
11
11
  import { cn } from "@/utils/cn";
12
12
  import { CellsRenderer } from "../components/editor/renderers/cells-renderer";
13
- import { notebookIsRunningAtom, useCellActions } from "./cells/cells";
13
+ import {
14
+ hasCellsAtom,
15
+ notebookIsRunningAtom,
16
+ useCellActions,
17
+ } from "./cells/cells";
14
18
  import type { AppConfig } from "./config/config-schema";
15
19
  import { RuntimeState } from "./kernel/RuntimeState";
16
20
  import { getSessionId } from "./kernel/session";
@@ -42,10 +46,13 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
42
46
 
43
47
  const isRunning = useAtomValue(notebookIsRunningAtom);
44
48
  const isConnecting = isAppConnecting(connection.state);
49
+ // Skip the "Connecting..." gate when we already have cells to show — from
50
+ // an embedded snapshot or a prior connection.
51
+ const hasExistingCells = useAtomValue(hasCellsAtom);
45
52
 
46
53
  const renderCells = () => {
47
54
  // If we are connecting for more than 2 seconds, show a spinner
48
- if (isConnecting) {
55
+ if (isConnecting && !hasExistingCells) {
49
56
  return (
50
57
  <DelayMount milliseconds={2000} fallback={null}>
51
58
  <Spinner className="mx-auto" />
@@ -5,6 +5,7 @@ import { Logger } from "@/utils/Logger";
5
5
  import { KnownQueryParams } from "../constants";
6
6
  import { isIslands } from "../islands/utils";
7
7
  import { getSessionId, type SessionId } from "../kernel/session";
8
+ import { isStaticNotebook } from "../static/static-state";
8
9
  import { isWasm } from "../wasm/utils";
9
10
  import type { RuntimeConfig } from "./types";
10
11
 
@@ -178,8 +179,8 @@ export class RuntimeManager {
178
179
  }
179
180
 
180
181
  async isHealthy(): Promise<boolean> {
181
- // Always healthy if WASM
182
- if (isWasm() || isIslands()) {
182
+ // Always healthy if WASM, Islands, or a static notebook (no server)
183
+ if (isWasm() || isIslands() || isStaticNotebook()) {
183
184
  return true;
184
185
  }
185
186
 
@@ -43,7 +43,11 @@ function isMarimoStaticState(
43
43
  }
44
44
 
45
45
  function getMarimoStaticState(): Readonly<MarimoStaticState> | undefined {
46
- const state = window?.__MARIMO_STATIC__;
46
+ // `typeof window` guard handles the identifier-undeclared case (e.g.
47
+ // leaked async work firing after jsdom teardown in tests); `?.` only
48
+ // short-circuits on null/undefined.
49
+ const state =
50
+ typeof window === "undefined" ? undefined : window.__MARIMO_STATIC__;
47
51
  return isMarimoStaticState(state) ? state : undefined;
48
52
  }
49
53
 
@@ -3,13 +3,18 @@
3
3
  import { useAtomValue } from "jotai";
4
4
  import type React from "react";
5
5
  import type { PropsWithChildren } from "react";
6
+ import { useEffect, useRef } from "react";
6
7
  import { LargeSpinner } from "@/components/icons/large-spinner";
8
+ import { toast } from "@/components/ui/use-toast";
9
+ import { hasCellsAtom } from "@/core/cells/cells";
7
10
  import { showCodeInRunModeAtom } from "@/core/meta/state";
8
11
  import { store } from "@/core/state/jotai";
9
12
  import { useAsyncData } from "@/hooks/useAsyncData";
13
+ import { prettyError } from "@/utils/errors";
14
+ import { Logger } from "@/utils/Logger";
10
15
  import { hasQueryParam } from "@/utils/urls";
11
16
  import { KnownQueryParams } from "../constants";
12
- import { getInitialAppMode } from "../mode";
17
+ import { type AppMode, getInitialAppMode } from "../mode";
13
18
  import { PyodideBridge } from "./bridge";
14
19
  import { hasAnyOutputAtom, wasmInitializationAtom } from "./state";
15
20
  import { isWasm } from "./utils";
@@ -26,30 +31,44 @@ export const PyodideLoader: React.FC<PropsWithChildren> = ({ children }) => {
26
31
  };
27
32
 
28
33
  const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
29
- // isPyodide() is constant, so this is safe
30
- const { isPending, error } = useAsyncData(async () => {
34
+ // Don't block render on Pyodide: a hydrated snapshot can paint immediately
35
+ // while Pyodide downloads in the background.
36
+ const { error } = useAsyncData(async () => {
31
37
  await PyodideBridge.INSTANCE.initialized.promise;
32
38
  return true;
33
39
  }, []);
34
40
 
41
+ const hasCells = useAtomValue(hasCellsAtom);
35
42
  const hasOutput = useAtomValue(hasAnyOutputAtom);
43
+ const nothingToShow = shouldShowSpinner({
44
+ hasCells,
45
+ hasOutput,
46
+ mode: getInitialAppMode(),
47
+ codeHidden: isCodeHidden(),
48
+ });
36
49
 
37
- if (isPending) {
38
- return <WasmSpinner />;
39
- }
50
+ const didToastErrorRef = useRef(false);
51
+ useEffect(() => {
52
+ // With snapshot content on-screen, toast instead of throwing so the
53
+ // snapshot stays readable. The ref ensures we only toast once even if
54
+ // nothingToShow toggles later.
55
+ if (error && !nothingToShow && !didToastErrorRef.current) {
56
+ didToastErrorRef.current = true;
57
+ Logger.error("Pyodide failed to initialize", error);
58
+ toast({
59
+ title: "Failed to start the notebook runtime",
60
+ description: prettyError(error),
61
+ variant: "danger",
62
+ });
63
+ }
64
+ }, [error, nothingToShow]);
40
65
 
41
- // If ALL are true:
42
- // - are in read mode
43
- // - we are not showing the code
44
- // - and there is no output
45
- // then show the spinner
46
- if (!hasOutput && getInitialAppMode() === "read" && isCodeHidden()) {
47
- return <WasmSpinner />;
66
+ if (error && nothingToShow) {
67
+ throw error;
48
68
  }
49
69
 
50
- // Propagate back up to our error boundary
51
- if (error) {
52
- throw error;
70
+ if (nothingToShow) {
71
+ return <WasmSpinner />;
53
72
  }
54
73
 
55
74
  return children;
@@ -65,6 +84,25 @@ function isCodeHidden() {
65
84
  );
66
85
  }
67
86
 
87
+ /**
88
+ * Pure predicate: should the WASM loader render a spinner instead of its
89
+ * children? We block render only when nothing user-visible would appear:
90
+ * - no cells have been hydrated (Pyodide hasn't parsed the notebook), or
91
+ * - we are in headless run mode (code hidden) with no outputs to display.
92
+ */
93
+ export function shouldShowSpinner(input: {
94
+ hasCells: boolean;
95
+ hasOutput: boolean;
96
+ mode: AppMode;
97
+ codeHidden: boolean;
98
+ }): boolean {
99
+ const { hasCells, hasOutput, mode, codeHidden } = input;
100
+ if (!hasCells) {
101
+ return true;
102
+ }
103
+ return !hasOutput && mode === "read" && codeHidden;
104
+ }
105
+
68
106
  export const WasmSpinner: React.FC<PropsWithChildren> = ({ children }) => {
69
107
  const wasmInitialization = useAtomValue(wasmInitializationAtom);
70
108
 
@@ -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)();