@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.
- package/dist/{ConnectedDataExplorerComponent-CWU3Az6F.js → ConnectedDataExplorerComponent-PmilQqXR.js} +4 -4
- package/dist/assets/__vite-browser-external-rrUYDKRl.js +1 -0
- package/dist/assets/{worker-D-EdLKct.js → worker-Bfy15ViQ.js} +2 -2
- package/dist/{chat-ui-Dfs7A8dK.js → chat-ui-B-gbqk_F.js} +6 -6
- package/dist/{code-visibility-CHNdpaL9.js → code-visibility-DNiCvIcQ.js} +678 -564
- package/dist/{formats-Dh5M1ZRs.js → formats-CgaK7Gmx.js} +1 -1
- package/dist/{glide-data-editor-DXti2axL.js → glide-data-editor-CvlvtPWJ.js} +2 -2
- package/dist/{html-to-image-DWbpEGa_.js → html-to-image-hMMPiNe_.js} +2136 -2120
- package/dist/{input-Drx1pguW.js → input-BAOe64zx.js} +1 -1
- package/dist/main.js +19 -19
- package/dist/{mermaid-BagLPXm9.js → mermaid-DJ1NyBGw.js} +2 -2
- package/dist/{process-output-CeXMg9XF.js → process-output-Bza_GK7Q.js} +1 -1
- package/dist/{reveal-component-LAgwxVYN.js → reveal-component-BSwl7P64.js} +13 -13
- package/dist/{spec-BKWq0wn2.js → spec-DSIuqd3f.js} +1 -1
- package/dist/toDate-CHtl9vts.js +662 -0
- package/dist/{useAsyncData-CKYzhCis.js → useAsyncData-B6hCGywC.js} +1 -1
- package/dist/{useDeepCompareMemoize-je76AJS_.js → useDeepCompareMemoize-CmwDuYUH.js} +1 -1
- package/dist/{useLifecycle-smVfjLNI.js → useLifecycle-CjMjllqy.js} +1 -1
- package/dist/{useTheme-CX9pPLUH.js → useTheme-CByZUW0p.js} +1 -0
- package/dist/{vega-component-BnCQmtxw.js → vega-component-CC8TqWWV.js} +5 -5
- package/package.json +5 -5
- package/src/components/ai/ai-provider-icon.tsx +1 -0
- package/src/components/ai/ai-utils.ts +1 -0
- package/src/components/app-config/ai-config.tsx +30 -0
- package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +47 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
- package/src/components/editor/renderers/cell-array.tsx +14 -7
- package/src/components/slides/slide-form.tsx +43 -0
- package/src/components/terminal/terminal.tsx +16 -0
- package/src/components/ui/links.tsx +2 -1
- package/src/core/ai/ids/ids.ts +1 -0
- package/src/core/cells/__tests__/apply-transaction.test.ts +193 -27
- package/src/core/cells/__tests__/document-changes.test.ts +14 -0
- package/src/core/cells/document-changes.ts +17 -14
- package/src/core/codemirror/markdown/__tests__/commands.test.ts +36 -0
- package/src/core/codemirror/markdown/commands.ts +4 -1
- package/src/core/config/config-schema.ts +1 -0
- package/src/core/edit-app.tsx +1 -0
- package/src/core/run-app.tsx +9 -2
- package/src/core/runtime/runtime.ts +3 -2
- package/src/core/static/static-state.ts +5 -1
- package/src/core/wasm/PyodideLoader.tsx +54 -16
- package/src/core/wasm/__tests__/PyodideLoader.test.ts +72 -0
- package/src/core/wasm/__tests__/bridge.test.ts +26 -1
- package/src/core/wasm/bridge.ts +24 -6
- package/src/core/wasm/state.ts +3 -0
- package/src/core/wasm/worker/getController.ts +7 -0
- package/src/core/wasm/worker/save-worker.ts +2 -1
- package/src/core/wasm/worker/worker.ts +2 -1
- package/src/plugins/core/RenderHTML.tsx +49 -3
- package/src/plugins/core/__test__/RenderHTML.test.ts +54 -0
- package/src/plugins/impl/common/labeled.tsx +1 -1
- package/dist/assets/__vite-browser-external-C4JkHbyY.js +0 -1
- 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
|
-
//
|
|
261
|
-
//
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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"
|
|
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, 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
|
package/src/core/edit-app.tsx
CHANGED
package/src/core/run-app.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
+
});
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
+
}
|
package/src/core/wasm/state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)();
|