@marimo-team/islands 0.24.3-dev37 → 0.24.3-dev39
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/{common-BsQjZnO0.js → common-eNTtBSo2.js} +2 -2
- package/dist/main.js +6 -2
- package/dist/{reveal-component-baBJ3nFu.js → reveal-component-CNqmC7b6.js} +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +7 -0
- package/src/components/editor/alerts/connecting-alert.tsx +1 -66
- package/src/components/editor/alerts/connection-notice.tsx +140 -0
- package/src/components/editor/app-container.tsx +9 -3
- package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +114 -7
- package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +165 -0
- package/src/components/editor/chrome/panels/packages-panel.tsx +93 -166
- package/src/components/editor/chrome/panels/sandbox-controller.tsx +106 -0
- package/src/components/editor/chrome/panels/sandbox-panel.tsx +173 -0
- package/src/components/editor/chrome/wrapper/footer-items/backend-status.tsx +3 -1
- package/src/components/editor/header/__tests__/status.test.tsx +8 -20
- package/src/components/editor/header/status.tsx +5 -3
- package/src/components/editor/renderers/cell-array.tsx +1 -5
- package/src/core/MarimoApp.tsx +0 -2
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/state.ts +1 -2
- package/src/core/islands/bootstrap.ts +1 -0
- package/src/core/islands/bridge.ts +3 -0
- package/src/core/network/connection-notice.ts +84 -0
- package/src/core/network/requests-lazy.ts +3 -0
- package/src/core/network/requests-network.ts +12 -0
- package/src/core/network/requests-static.ts +3 -0
- package/src/core/network/requests-toasting.tsx +3 -0
- package/src/core/network/resolve.ts +2 -1
- package/src/core/network/types.ts +7 -0
- package/src/core/packages/__tests__/usePackageAction.test.tsx +129 -0
- package/src/core/packages/__tests__/useSandboxController.test.tsx +165 -0
- package/src/core/packages/package-data.ts +35 -0
- package/src/core/packages/sandbox-state.ts +15 -0
- package/src/core/packages/toast-components.tsx +11 -0
- package/src/core/packages/useInstallPackage.ts +1 -1
- package/src/core/packages/usePackageAction.ts +51 -0
- package/src/core/packages/usePackageDependencies.ts +22 -0
- package/src/core/packages/useSandboxController.ts +188 -0
- package/src/core/run-app.tsx +2 -0
- package/src/core/wasm/bridge.ts +11 -0
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +212 -2
- package/src/core/websocket/types.ts +11 -1
- package/src/core/websocket/useMarimoKernelConnection.tsx +43 -8
- package/src/stories/sandbox.stories.tsx +308 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { atom } from "jotai";
|
|
3
|
+
import type {
|
|
4
|
+
EditRequests,
|
|
5
|
+
PackageOperationResponse,
|
|
6
|
+
RunRequests,
|
|
7
|
+
} from "@/core/network/types";
|
|
8
|
+
import { store } from "@/core/state/jotai";
|
|
9
|
+
|
|
10
|
+
export const packageDataVersionAtom = atom(0);
|
|
11
|
+
export function invalidatePackageData() {
|
|
12
|
+
store.set(packageDataVersionAtom, (version) => version + 1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Refresh package consumers regardless of which UI initiated a mutation. */
|
|
16
|
+
export function withPackageInvalidation(
|
|
17
|
+
client: EditRequests & RunRequests,
|
|
18
|
+
): EditRequests & RunRequests {
|
|
19
|
+
function invalidateAfter<Request, Response extends PackageOperationResponse>(
|
|
20
|
+
request: (body: Request) => Promise<Response>,
|
|
21
|
+
) {
|
|
22
|
+
return async (body: Request) => {
|
|
23
|
+
const result = await request(body);
|
|
24
|
+
if (result.success || result.restartRequired) {
|
|
25
|
+
invalidatePackageData();
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
...client,
|
|
32
|
+
addPackage: invalidateAfter(client.addPackage),
|
|
33
|
+
removePackage: invalidateAfter(client.removePackage),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { atom } from "jotai";
|
|
3
|
+
import type { SandboxResponse } from "@/core/network/types";
|
|
4
|
+
|
|
5
|
+
export const sandboxAtom = atom<SandboxResponse | null>(null);
|
|
6
|
+
export const sandboxSyncAtom = atom<{ pending: boolean; error: string | null }>(
|
|
7
|
+
{
|
|
8
|
+
pending: false,
|
|
9
|
+
error: null,
|
|
10
|
+
},
|
|
11
|
+
);
|
|
12
|
+
export const sandboxActionsAtom = atom<{
|
|
13
|
+
sync: () => Promise<boolean>;
|
|
14
|
+
editManifest: () => Promise<void>;
|
|
15
|
+
} | null>(null);
|
|
@@ -29,6 +29,17 @@ export const showPackageRestartToast = () => {
|
|
|
29
29
|
});
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
export const showSandboxSyncToast = () => {
|
|
33
|
+
toast({
|
|
34
|
+
title: "Sandbox environment synced",
|
|
35
|
+
description: (
|
|
36
|
+
<div className="text-xs text-muted-foreground">
|
|
37
|
+
Some Python packages may require a kernel restart to see changes.
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
32
43
|
export const showAddPackageToast = (
|
|
33
44
|
packageName: string | string[],
|
|
34
45
|
error?: string | null,
|
|
@@ -37,10 +37,10 @@ export function useInstallPackages(): {
|
|
|
37
37
|
showPackageRestartToast();
|
|
38
38
|
} else if (response.success) {
|
|
39
39
|
showAddPackageToast(packages);
|
|
40
|
+
onSuccess?.();
|
|
40
41
|
} else {
|
|
41
42
|
showAddPackageToast(packages, response.error);
|
|
42
43
|
}
|
|
43
|
-
onSuccess?.();
|
|
44
44
|
} catch (error) {
|
|
45
45
|
Logger.error(error);
|
|
46
46
|
} finally {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
import useEvent from "react-use-event-hook";
|
|
4
|
+
import { useRequestClient } from "@/core/network/requests";
|
|
5
|
+
import type { DependencyTreeNode } from "@/core/network/types";
|
|
6
|
+
import { prettyError } from "@/utils/errors";
|
|
7
|
+
import {
|
|
8
|
+
showPackageRestartToast,
|
|
9
|
+
showRemovePackageToast,
|
|
10
|
+
showUpgradePackageToast,
|
|
11
|
+
} from "./toast-components";
|
|
12
|
+
|
|
13
|
+
/** Shared mutation lifecycle for package actions in both list and tree views. */
|
|
14
|
+
export function usePackageAction(
|
|
15
|
+
action: "upgrade" | "remove",
|
|
16
|
+
packageName: string,
|
|
17
|
+
tags?: DependencyTreeNode["tags"],
|
|
18
|
+
) {
|
|
19
|
+
const { addPackage, removePackage } = useRequestClient();
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const inFlight = useRef(false);
|
|
22
|
+
const run = useEvent(async () => {
|
|
23
|
+
if (inFlight.current) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
inFlight.current = true;
|
|
27
|
+
setLoading(true);
|
|
28
|
+
const showResult =
|
|
29
|
+
action === "upgrade" ? showUpgradePackageToast : showRemovePackageToast;
|
|
30
|
+
try {
|
|
31
|
+
const group = tags?.find((tag) => tag.kind === "group")?.value;
|
|
32
|
+
const request = { package: packageName, group };
|
|
33
|
+
const response = await (action === "upgrade"
|
|
34
|
+
? addPackage({ ...request, upgrade: true })
|
|
35
|
+
: removePackage(request));
|
|
36
|
+
if (response.restartRequired) {
|
|
37
|
+
showPackageRestartToast();
|
|
38
|
+
} else if (response.success) {
|
|
39
|
+
showResult(packageName);
|
|
40
|
+
} else {
|
|
41
|
+
showResult(packageName, response.error || "Package operation failed.");
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
showResult(packageName, prettyError(error));
|
|
45
|
+
} finally {
|
|
46
|
+
inFlight.current = false;
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return { loading, run };
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { useAtomValue } from "jotai";
|
|
3
|
+
import { useResolvedMarimoConfig } from "@/core/config/config";
|
|
4
|
+
import { useRequestClient } from "@/core/network/requests";
|
|
5
|
+
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
6
|
+
import { packageDataVersionAtom } from "./package-data";
|
|
7
|
+
|
|
8
|
+
/** Load the available package views and refresh them after package mutations. */
|
|
9
|
+
export function usePackageDependencies() {
|
|
10
|
+
const packageDataVersion = useAtomValue(packageDataVersionAtom);
|
|
11
|
+
const [config] = useResolvedMarimoConfig();
|
|
12
|
+
const packageManager = config.package_management.manager;
|
|
13
|
+
const { getDependencyTree, getPackageList } = useRequestClient();
|
|
14
|
+
|
|
15
|
+
return useAsyncData(async () => {
|
|
16
|
+
const { context, tree } = await getDependencyTree();
|
|
17
|
+
// Both sandbox endpoints inspect the same environment; only fetch it once.
|
|
18
|
+
const list =
|
|
19
|
+
context.kind === "sandbox" ? [] : (await getPackageList()).packages;
|
|
20
|
+
return { list, context, tree };
|
|
21
|
+
}, [getDependencyTree, getPackageList, packageManager, packageDataVersion]);
|
|
22
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import useEvent from "react-use-event-hook";
|
|
5
|
+
import { kernelStartupErrorAtom } from "@/core/errors/state";
|
|
6
|
+
import { connectionAtom } from "@/core/network/connection";
|
|
7
|
+
import { useRequestClient } from "@/core/network/requests";
|
|
8
|
+
import { useFilename } from "@/core/saving/filename";
|
|
9
|
+
import { waitFor } from "@/core/state/jotai";
|
|
10
|
+
import { isWasm } from "@/core/wasm/utils";
|
|
11
|
+
import { WebSocketState } from "@/core/websocket/types";
|
|
12
|
+
import { HTTPError, prettyError } from "@/utils/errors";
|
|
13
|
+
import { invalidatePackageData } from "./package-data";
|
|
14
|
+
import {
|
|
15
|
+
sandboxActionsAtom,
|
|
16
|
+
sandboxAtom,
|
|
17
|
+
sandboxSyncAtom,
|
|
18
|
+
} from "./sandbox-state";
|
|
19
|
+
import {
|
|
20
|
+
showPackageRestartToast,
|
|
21
|
+
showSandboxSyncToast,
|
|
22
|
+
} from "./toast-components";
|
|
23
|
+
|
|
24
|
+
/** Mount once with the editor so manifest drafts survive closing Packages. */
|
|
25
|
+
export function useSandboxController(onReconnect: () => Promise<void>) {
|
|
26
|
+
const requests = useRequestClient();
|
|
27
|
+
const filename = useFilename();
|
|
28
|
+
const connection = useAtomValue(connectionAtom);
|
|
29
|
+
const startupError = useAtomValue(kernelStartupErrorAtom);
|
|
30
|
+
const setSandbox = useSetAtom(sandboxAtom);
|
|
31
|
+
const [operation, setOperation] = useAtom(sandboxSyncAtom);
|
|
32
|
+
const setActions = useSetAtom(sandboxActionsAtom);
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const [document, setDocument] = useState<{
|
|
35
|
+
original: string;
|
|
36
|
+
draft: string;
|
|
37
|
+
} | null>(null);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
const [conflict, setConflict] = useState(false);
|
|
42
|
+
const inFlight = useRef(false);
|
|
43
|
+
const dirty = document !== null && document.original !== document.draft;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (isWasm()) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
requests
|
|
51
|
+
.getSandbox({ fileKey: filename })
|
|
52
|
+
.then((value) => {
|
|
53
|
+
if (!cancelled) {
|
|
54
|
+
setSandbox(value);
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
/* A connection error is already shown by the editor. */
|
|
59
|
+
});
|
|
60
|
+
return () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
};
|
|
63
|
+
}, [requests, filename, connection.state, setSandbox]);
|
|
64
|
+
|
|
65
|
+
const sync = useEvent(async () => {
|
|
66
|
+
if (inFlight.current || connection.state === WebSocketState.CONNECTING) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
inFlight.current = true;
|
|
70
|
+
setOperation((value) => ({ ...value, pending: true, error: null }));
|
|
71
|
+
try {
|
|
72
|
+
const result = await requests.syncSandbox({ fileKey: filename });
|
|
73
|
+
if (result.restartRequired) {
|
|
74
|
+
showPackageRestartToast();
|
|
75
|
+
}
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
throw new Error(result.error ?? "Sandbox sync failed.");
|
|
78
|
+
}
|
|
79
|
+
if (result.reconnect) {
|
|
80
|
+
await onReconnect();
|
|
81
|
+
const status = await waitFor(
|
|
82
|
+
connectionAtom,
|
|
83
|
+
(value) =>
|
|
84
|
+
value.state === WebSocketState.OPEN ||
|
|
85
|
+
value.state === WebSocketState.CLOSED,
|
|
86
|
+
);
|
|
87
|
+
if (status.state === WebSocketState.CLOSED) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!result.reconnect) {
|
|
92
|
+
invalidatePackageData();
|
|
93
|
+
}
|
|
94
|
+
showSandboxSyncToast();
|
|
95
|
+
return true;
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
setOperation((value) => ({ ...value, error: prettyError(cause) }));
|
|
98
|
+
return false;
|
|
99
|
+
} finally {
|
|
100
|
+
inFlight.current = false;
|
|
101
|
+
setOperation((value) => ({ ...value, pending: false }));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const loadManifest = useEvent(async () => {
|
|
106
|
+
setLoading(true);
|
|
107
|
+
setError(null);
|
|
108
|
+
setConflict(false);
|
|
109
|
+
try {
|
|
110
|
+
const result = await requests.getSandbox({ fileKey: filename });
|
|
111
|
+
setSandbox(result);
|
|
112
|
+
if (result.manifest === null) {
|
|
113
|
+
throw new Error("No notebook manifest is available yet.");
|
|
114
|
+
}
|
|
115
|
+
setDocument({ original: result.manifest, draft: result.manifest });
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
setError(prettyError(cause));
|
|
118
|
+
} finally {
|
|
119
|
+
setLoading(false);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const editManifest = useEvent(async () => {
|
|
124
|
+
setOpen(true);
|
|
125
|
+
if (!dirty) {
|
|
126
|
+
await loadManifest();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
setActions({ sync, editManifest });
|
|
132
|
+
return () => {
|
|
133
|
+
setActions(null);
|
|
134
|
+
};
|
|
135
|
+
}, [sync, editManifest, setActions]);
|
|
136
|
+
|
|
137
|
+
const saveAndSync = async () => {
|
|
138
|
+
if (!document || loading || saving || operation.pending) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setSaving(true);
|
|
142
|
+
setError(null);
|
|
143
|
+
try {
|
|
144
|
+
if (dirty) {
|
|
145
|
+
const result = await requests.updateManifest({
|
|
146
|
+
fileKey: filename,
|
|
147
|
+
contents: document.draft,
|
|
148
|
+
previous: document.original,
|
|
149
|
+
});
|
|
150
|
+
setSandbox(result);
|
|
151
|
+
if (result.manifest !== null) {
|
|
152
|
+
setDocument({ original: result.manifest, draft: result.manifest });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (await sync()) {
|
|
156
|
+
setOpen(false);
|
|
157
|
+
}
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
setError(prettyError(cause));
|
|
160
|
+
setConflict(cause instanceof HTTPError && cause.status === 409);
|
|
161
|
+
} finally {
|
|
162
|
+
setSaving(false);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const pending =
|
|
166
|
+
saving ||
|
|
167
|
+
loading ||
|
|
168
|
+
operation.pending ||
|
|
169
|
+
connection.state === WebSocketState.CONNECTING;
|
|
170
|
+
const diagnostic = error ?? operation.error ?? startupError;
|
|
171
|
+
return {
|
|
172
|
+
filename,
|
|
173
|
+
open,
|
|
174
|
+
setOpen,
|
|
175
|
+
draft: document?.draft ?? null,
|
|
176
|
+
setDraft: (draft: string) =>
|
|
177
|
+
setDocument((value) => value && { ...value, draft }),
|
|
178
|
+
dirty,
|
|
179
|
+
loading,
|
|
180
|
+
saving,
|
|
181
|
+
pending,
|
|
182
|
+
error,
|
|
183
|
+
diagnostic,
|
|
184
|
+
conflict,
|
|
185
|
+
loadManifest,
|
|
186
|
+
saveAndSync,
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/core/run-app.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { atom, useAtomValue } from "jotai";
|
|
4
4
|
import { ArrowLeftIcon } from "lucide-react";
|
|
5
5
|
import { useEffect } from "react";
|
|
6
|
+
import { KernelStartupErrorModal } from "@/components/editor/KernelStartupErrorModal";
|
|
6
7
|
import { AppContainer } from "@/components/editor/app-container";
|
|
7
8
|
import { AppHeader } from "@/components/editor/header/app-header";
|
|
8
9
|
import { ProgressiveBoundary } from "@/components/lifecycle/ProgressiveBoundary";
|
|
@@ -78,6 +79,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig, hideHeader }) => {
|
|
|
78
79
|
width={appConfig.width}
|
|
79
80
|
onReconnect={reconnect}
|
|
80
81
|
>
|
|
82
|
+
<KernelStartupErrorModal />
|
|
81
83
|
<AppHeader
|
|
82
84
|
connection={connection}
|
|
83
85
|
className={hideHeader ? "hidden" : "sm:pt-8"}
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -640,6 +640,17 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
640
640
|
return response;
|
|
641
641
|
};
|
|
642
642
|
|
|
643
|
+
getSandbox: EditRequests["getSandbox"] = async () => ({
|
|
644
|
+
backend: null,
|
|
645
|
+
manifest: null,
|
|
646
|
+
filename: null,
|
|
647
|
+
});
|
|
648
|
+
updateManifest: EditRequests["updateManifest"] = async () => {
|
|
649
|
+
throw new Error("Sandboxes are not supported in WebAssembly");
|
|
650
|
+
};
|
|
651
|
+
syncSandbox: EditRequests["syncSandbox"] = async () => {
|
|
652
|
+
throw new Error("Sandboxes are not supported in WebAssembly");
|
|
653
|
+
};
|
|
643
654
|
getDependencyTree: EditRequests["getDependencyTree"] = async () => {
|
|
644
655
|
// WASM doesn't support dependency trees yet
|
|
645
656
|
return {
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
// @vitest-environment jsdom
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
act,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
renderHook,
|
|
9
|
+
screen,
|
|
10
|
+
} from "@testing-library/react";
|
|
5
11
|
import { createStore, Provider as JotaiProvider } from "jotai";
|
|
6
12
|
import type React from "react";
|
|
7
13
|
import { ErrorBoundary } from "react-error-boundary";
|
|
8
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
afterEach,
|
|
16
|
+
beforeEach,
|
|
17
|
+
describe,
|
|
18
|
+
expect,
|
|
19
|
+
it,
|
|
20
|
+
type Mock,
|
|
21
|
+
vi,
|
|
22
|
+
} from "vitest";
|
|
9
23
|
|
|
10
24
|
vi.mock("@/core/websocket/useWebSocket", async () => {
|
|
11
25
|
const actual =
|
|
@@ -26,6 +40,13 @@ vi.mock("@/core/runtime/config", async () => {
|
|
|
26
40
|
};
|
|
27
41
|
});
|
|
28
42
|
|
|
43
|
+
import { MockNotebook } from "@/__mocks__/notebook";
|
|
44
|
+
import { cellId } from "@/__tests__/branded";
|
|
45
|
+
import { notebookAtom } from "@/core/cells/cells";
|
|
46
|
+
import { AppConfigSchema } from "@/core/config/config-schema";
|
|
47
|
+
import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
|
|
48
|
+
import { kernelStartupErrorAtom } from "@/core/errors/state";
|
|
49
|
+
import type { NotificationPayload } from "@/core/kernel/messages";
|
|
29
50
|
import { useRuntimeManager } from "@/core/runtime/config";
|
|
30
51
|
import { initialRunCompletedAtom } from "../../kernel/state";
|
|
31
52
|
import { connectionAtom } from "../../network/connection";
|
|
@@ -188,3 +209,192 @@ describe("useMarimoKernelConnection messages", () => {
|
|
|
188
209
|
expect(store.get(initialRunCompletedAtom)).toBe(true);
|
|
189
210
|
});
|
|
190
211
|
});
|
|
212
|
+
|
|
213
|
+
it.each(["kernel-ready", "reconnected"])(
|
|
214
|
+
"waits for %s before marking the session connected",
|
|
215
|
+
async (op) => {
|
|
216
|
+
const store = createStore();
|
|
217
|
+
store.set(connectionAtom, { state: WebSocketState.CONNECTING });
|
|
218
|
+
vi.mocked(useConnectionTransport).mockClear();
|
|
219
|
+
vi.mocked(useConnectionTransport).mockReturnValue(
|
|
220
|
+
makeTransport(WebSocket.OPEN),
|
|
221
|
+
);
|
|
222
|
+
vi.mocked(useRuntimeManager).mockReturnValue(
|
|
223
|
+
makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
|
|
224
|
+
);
|
|
225
|
+
renderConnectionHook(store);
|
|
226
|
+
const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
|
|
227
|
+
|
|
228
|
+
await act(async () => {
|
|
229
|
+
await options.onOpen(new Event("open"));
|
|
230
|
+
});
|
|
231
|
+
expect(store.get(connectionAtom).state).toBe(WebSocketState.CONNECTING);
|
|
232
|
+
|
|
233
|
+
act(() => {
|
|
234
|
+
options.onMessage(
|
|
235
|
+
new MessageEvent("message", {
|
|
236
|
+
data: JSON.stringify({
|
|
237
|
+
op,
|
|
238
|
+
data: {
|
|
239
|
+
op,
|
|
240
|
+
cell_ids: [],
|
|
241
|
+
codes: [],
|
|
242
|
+
names: [],
|
|
243
|
+
configs: [],
|
|
244
|
+
layout: null,
|
|
245
|
+
resumed: true,
|
|
246
|
+
ui_values: {},
|
|
247
|
+
last_executed_code: {},
|
|
248
|
+
last_execution_time: {},
|
|
249
|
+
app_config: { width: "normal" },
|
|
250
|
+
kiosk: false,
|
|
251
|
+
capabilities: { terminal: false },
|
|
252
|
+
auto_instantiated: false,
|
|
253
|
+
consumer_capabilities: { edit: true, interact: true },
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
expect(store.get(connectionAtom).state).toBe(WebSocketState.OPEN);
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
describe("connection notice", () => {
|
|
264
|
+
beforeEach(() => vi.useFakeTimers());
|
|
265
|
+
afterEach(() => vi.useRealTimers());
|
|
266
|
+
|
|
267
|
+
function renderNotice() {
|
|
268
|
+
const store = createStore();
|
|
269
|
+
store.set(connectionAtom, { state: WebSocketState.CONNECTING });
|
|
270
|
+
const transport = makeTransport(WebSocket.OPEN);
|
|
271
|
+
vi.mocked(useConnectionTransport).mockReturnValue(transport);
|
|
272
|
+
vi.mocked(useRuntimeManager).mockReturnValue(
|
|
273
|
+
makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
|
|
274
|
+
);
|
|
275
|
+
const Connection = () => {
|
|
276
|
+
const { reconnect } = useMarimoKernelConnection({
|
|
277
|
+
sessionId: "test-session" as SessionId,
|
|
278
|
+
autoInstantiate: false,
|
|
279
|
+
setCells: () => {},
|
|
280
|
+
});
|
|
281
|
+
return (
|
|
282
|
+
<ConnectionNotice
|
|
283
|
+
appConfig={AppConfigSchema.parse({})}
|
|
284
|
+
onRetry={reconnect}
|
|
285
|
+
/>
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
render(
|
|
289
|
+
<JotaiProvider store={store}>
|
|
290
|
+
<ErrorBoundary fallback={null}>
|
|
291
|
+
<Connection />
|
|
292
|
+
</ErrorBoundary>
|
|
293
|
+
</JotaiProvider>,
|
|
294
|
+
);
|
|
295
|
+
const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
|
|
296
|
+
const send = (data: NotificationPayload["data"]) =>
|
|
297
|
+
act(() => {
|
|
298
|
+
options.onMessage(
|
|
299
|
+
new MessageEvent("message", {
|
|
300
|
+
data: JSON.stringify({ op: data.op, data }),
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
return { store, transport, options, send };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
it("keeps elapsed time across startup phases and leaves reconnection to the footer once cells are available", () => {
|
|
308
|
+
const { store, options, send } = renderNotice();
|
|
309
|
+
send({ op: "startup-progress", phase: "preparing-environment" });
|
|
310
|
+
expect(
|
|
311
|
+
screen.queryByRole("region", { name: "Notebook startup" }),
|
|
312
|
+
).not.toBeInTheDocument();
|
|
313
|
+
act(() => vi.advanceTimersByTime(500));
|
|
314
|
+
expect(screen.getByRole("status")).toHaveTextContent(
|
|
315
|
+
"Preparing environment",
|
|
316
|
+
);
|
|
317
|
+
act(() => vi.advanceTimersByTime(35_000));
|
|
318
|
+
expect(screen.getByText("Elapsed 35s")).toBeInTheDocument();
|
|
319
|
+
act(() =>
|
|
320
|
+
store.set(
|
|
321
|
+
notebookAtom,
|
|
322
|
+
MockNotebook.notebookState({
|
|
323
|
+
cellData: { [cellId("test")]: { code: "answer = 42" } },
|
|
324
|
+
}),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
send({ op: "startup-progress", phase: "starting-kernel" });
|
|
328
|
+
expect(screen.getByRole("status")).toHaveTextContent("Starting notebook");
|
|
329
|
+
expect(screen.getByText("Elapsed 35s")).toBeInTheDocument();
|
|
330
|
+
send({ op: "reconnected" });
|
|
331
|
+
expect(
|
|
332
|
+
screen.queryByRole("region", { name: "Notebook startup" }),
|
|
333
|
+
).not.toBeInTheDocument();
|
|
334
|
+
act(() => options.onClose(new CloseEvent("close")));
|
|
335
|
+
act(() => vi.advanceTimersByTime(500));
|
|
336
|
+
expect(store.get(connectionAtom)).toEqual({
|
|
337
|
+
state: WebSocketState.CONNECTING,
|
|
338
|
+
phase: "reconnecting",
|
|
339
|
+
});
|
|
340
|
+
expect(
|
|
341
|
+
screen.queryByRole("region", { name: "Notebook startup" }),
|
|
342
|
+
).not.toBeInTheDocument();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("does not flash a notice when startup finishes within the delay", () => {
|
|
346
|
+
const { send } = renderNotice();
|
|
347
|
+
send({ op: "startup-progress", phase: "preparing-environment" });
|
|
348
|
+
act(() => vi.advanceTimersByTime(200));
|
|
349
|
+
send({ op: "startup-progress", phase: "starting-kernel" });
|
|
350
|
+
act(() => vi.advanceTimersByTime(200));
|
|
351
|
+
expect(
|
|
352
|
+
screen.queryByRole("region", { name: "Notebook startup" }),
|
|
353
|
+
).not.toBeInTheDocument();
|
|
354
|
+
send({ op: "reconnected" });
|
|
355
|
+
act(() => vi.advanceTimersByTime(500));
|
|
356
|
+
expect(
|
|
357
|
+
screen.queryByRole("region", { name: "Notebook startup" }),
|
|
358
|
+
).not.toBeInTheDocument();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it.each([
|
|
362
|
+
["preparing-environment", "Sandbox setup failed"],
|
|
363
|
+
["starting-kernel", "Kernel failed to start"],
|
|
364
|
+
] as const)(
|
|
365
|
+
"keeps a %s failure available until the user retries",
|
|
366
|
+
async (phase, title) => {
|
|
367
|
+
const { store, transport, options, send } = renderNotice();
|
|
368
|
+
send({ op: "startup-progress", phase });
|
|
369
|
+
const error = "A full diagnostic\nwith <stderr> details";
|
|
370
|
+
send({ op: "kernel-startup-error", error });
|
|
371
|
+
transport.readyState = WebSocket.CLOSED;
|
|
372
|
+
act(() =>
|
|
373
|
+
options.onClose(
|
|
374
|
+
new CloseEvent("close", { reason: "MARIMO_KERNEL_STARTUP_ERROR" }),
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
expect(screen.getByRole("status")).toHaveTextContent(title);
|
|
378
|
+
act(() => vi.advanceTimersByTime(30_000));
|
|
379
|
+
expect(screen.getByRole("status")).toHaveTextContent(title);
|
|
380
|
+
expect(screen.getByLabelText("Error details")).toHaveTextContent(error, {
|
|
381
|
+
normalizeWhitespace: false,
|
|
382
|
+
});
|
|
383
|
+
expect(transport.reconnect).not.toHaveBeenCalled();
|
|
384
|
+
await act(async () =>
|
|
385
|
+
fireEvent.click(screen.getByRole("button", { name: "Try again" })),
|
|
386
|
+
);
|
|
387
|
+
expect(transport.reconnect).toHaveBeenCalledOnce();
|
|
388
|
+
expect(store.get(kernelStartupErrorAtom)).toBeNull();
|
|
389
|
+
expect(
|
|
390
|
+
screen.queryByRole("button", { name: "Try again" }),
|
|
391
|
+
).not.toBeInTheDocument();
|
|
392
|
+
send({ op: "startup-progress", phase: "preparing-environment" });
|
|
393
|
+
act(() => vi.advanceTimersByTime(500));
|
|
394
|
+
expect(screen.getByRole("status")).toHaveTextContent(
|
|
395
|
+
"Preparing environment",
|
|
396
|
+
);
|
|
397
|
+
expect(screen.getByText("Elapsed 0s")).toBeInTheDocument();
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { NotificationMessageData } from "../kernel/messages";
|
|
4
|
+
|
|
5
|
+
type ConnectionPhase =
|
|
6
|
+
| NotificationMessageData<"startup-progress">["phase"]
|
|
7
|
+
| "reconnecting";
|
|
8
|
+
|
|
3
9
|
// TODO: rename to ConnectionState
|
|
4
10
|
export const WebSocketState = {
|
|
5
11
|
NOT_STARTED: "NOT_STARTED",
|
|
@@ -28,10 +34,14 @@ export type ConnectionStatus =
|
|
|
28
34
|
* Human-readable reason for closing the connection.
|
|
29
35
|
*/
|
|
30
36
|
reason: string;
|
|
37
|
+
phase?: ConnectionPhase;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
state: typeof WebSocketState.CONNECTING;
|
|
41
|
+
phase?: ConnectionPhase;
|
|
31
42
|
}
|
|
32
43
|
| {
|
|
33
44
|
state:
|
|
34
|
-
| typeof WebSocketState.CONNECTING
|
|
35
45
|
| typeof WebSocketState.OPEN
|
|
36
46
|
| typeof WebSocketState.CLOSING
|
|
37
47
|
| typeof WebSocketState.NOT_STARTED;
|