@marimo-team/islands 0.23.15-dev63 → 0.23.15-dev65
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/assets/__vite-browser-external-DltVXDv7.js +1 -0
- package/dist/assets/{worker-B3XPCb6y.js → worker-CDEKsBgX.js} +2 -2
- package/dist/{code-visibility-yIEEWI5w.js → code-visibility-CSV-51WI.js} +1 -1
- package/dist/main.js +1194 -1072
- package/dist/{reveal-component-CCyp8KHw.js → reveal-component-qjUSFCwF.js} +1 -1
- package/package.json +1 -1
- package/src/core/islands/__tests__/bootstrap.test.ts +61 -0
- package/src/core/islands/__tests__/bridge.test.ts +33 -2
- package/src/core/islands/bootstrap.ts +20 -2
- package/src/core/islands/bridge.ts +30 -23
- package/src/core/islands/components/__tests__/web-components.test.tsx +34 -0
- package/src/core/islands/components/output-wrapper.tsx +4 -1
- package/src/core/islands/state.ts +9 -0
- package/src/core/islands/worker/worker.tsx +15 -7
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +7 -1
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +26 -1
- package/src/plugins/layout/NavigationMenuPlugin.tsx +140 -44
- package/dist/assets/__vite-browser-external-CjNAy01L.js +0 -1
|
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
|
|
|
9
9
|
import { dt as kioskModeAtom, ht as outputIsStale } from "./html-to-image-DDENewl2.js";
|
|
10
10
|
import "./chunk-5FQGJX7Z-B9WoXK1R.js";
|
|
11
11
|
import { u as createLucideIcon } from "./dist-B96tMAst.js";
|
|
12
|
-
import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as Expand, ct as Panel, dn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, sn as EyeOff, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-
|
|
12
|
+
import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as Expand, ct as Panel, dn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, sn as EyeOff, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-CSV-51WI.js";
|
|
13
13
|
import { X as useDebouncedCallback } from "./input-DStQIuqD.js";
|
|
14
14
|
import "./toDate-B3IRfHhw.js";
|
|
15
15
|
import "./react-dom-BTJzcVJ9.js";
|
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { createStore } from "jotai";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { RuntimeState } from "@/core/kernel/RuntimeState";
|
|
6
|
+
import { initializeIslands } from "../bootstrap";
|
|
7
|
+
import type { IslandsPyodideBridge } from "../bridge";
|
|
8
|
+
import { islandsHydratedAtom, islandsPendingInitialRunsAtom } from "../state";
|
|
9
|
+
import type { IslandsKernelMessage } from "../worker/worker";
|
|
10
|
+
|
|
11
|
+
function completedRun(sessionGeneration: number): IslandsKernelMessage {
|
|
12
|
+
return {
|
|
13
|
+
sessionGeneration,
|
|
14
|
+
message: JSON.stringify({
|
|
15
|
+
op: "completed-run",
|
|
16
|
+
data: { op: "completed-run", run_id: null },
|
|
17
|
+
}) as IslandsKernelMessage["message"],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("initializeIslands", () => {
|
|
22
|
+
afterEach(vi.restoreAllMocks);
|
|
23
|
+
|
|
24
|
+
it("waits for each initial session to complete", async () => {
|
|
25
|
+
const root = document.createElement("div");
|
|
26
|
+
root.innerHTML =
|
|
27
|
+
'<marimo-island data-app-id="app-1" data-reactive="true"></marimo-island>';
|
|
28
|
+
|
|
29
|
+
let consumeMessage: ((message: IslandsKernelMessage) => void) | undefined;
|
|
30
|
+
const bridge = {
|
|
31
|
+
consumeMessages: (consumer: (message: IslandsKernelMessage) => void) => {
|
|
32
|
+
consumeMessage = consumer;
|
|
33
|
+
},
|
|
34
|
+
sendComponentValues: vi.fn(),
|
|
35
|
+
} as unknown as IslandsPyodideBridge;
|
|
36
|
+
const store = createStore();
|
|
37
|
+
vi.spyOn(RuntimeState.INSTANCE, "start").mockImplementation(
|
|
38
|
+
() => undefined,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await initializeIslands({
|
|
42
|
+
bridge,
|
|
43
|
+
store,
|
|
44
|
+
root,
|
|
45
|
+
autoInitializePlugins: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(store.get(islandsHydratedAtom)).toBe(false);
|
|
49
|
+
store.set(islandsPendingInitialRunsAtom, new Set([1, 2]));
|
|
50
|
+
|
|
51
|
+
consumeMessage?.(completedRun(1));
|
|
52
|
+
expect(store.get(islandsHydratedAtom)).toBe(false);
|
|
53
|
+
|
|
54
|
+
consumeMessage?.(completedRun(1));
|
|
55
|
+
consumeMessage?.(completedRun(0));
|
|
56
|
+
expect(store.get(islandsHydratedAtom)).toBe(false);
|
|
57
|
+
|
|
58
|
+
consumeMessage?.(completedRun(2));
|
|
59
|
+
expect(store.get(islandsHydratedAtom)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
uiElementId,
|
|
9
9
|
widgetModelId,
|
|
10
10
|
} from "@/__tests__/branded";
|
|
11
|
+
import { notebookAtom } from "@/core/cells/cells";
|
|
12
|
+
import { islandsPendingInitialRunsAtom } from "@/core/islands/state";
|
|
11
13
|
|
|
12
14
|
type Base64String = components["schemas"]["Base64String"];
|
|
13
15
|
interface TestIslandApp {
|
|
@@ -61,7 +63,12 @@ const {
|
|
|
61
63
|
mockStartSessionRequest: vi.fn(),
|
|
62
64
|
mockMessageListeners: new Map<string, (payload: never) => void>(),
|
|
63
65
|
mockStoreSet: vi.fn(),
|
|
64
|
-
mockParseMarimoIslandApps: vi.fn<
|
|
66
|
+
mockParseMarimoIslandApps: vi.fn<
|
|
67
|
+
(
|
|
68
|
+
root?: Document | Element,
|
|
69
|
+
options?: { materialize?: boolean },
|
|
70
|
+
) => TestIslandApp[]
|
|
71
|
+
>(() => []),
|
|
65
72
|
mockCreateMarimoFile: vi.fn(),
|
|
66
73
|
mockGetMarimoExportContext: vi.fn<() => TestExportContext | undefined>(
|
|
67
74
|
() => undefined,
|
|
@@ -235,6 +242,10 @@ describe("IslandsPyodideBridge", () => {
|
|
|
235
242
|
code: "generated app 2",
|
|
236
243
|
sessionGeneration: 2,
|
|
237
244
|
});
|
|
245
|
+
expect(mockStoreSet).toHaveBeenCalledWith(
|
|
246
|
+
islandsPendingInitialRunsAtom,
|
|
247
|
+
new Set([1, 2]),
|
|
248
|
+
);
|
|
238
249
|
|
|
239
250
|
await sendSlider();
|
|
240
251
|
await bridge.stopSession();
|
|
@@ -309,7 +320,17 @@ describe("IslandsPyodideBridge", () => {
|
|
|
309
320
|
sessionGeneration: 1,
|
|
310
321
|
}),
|
|
311
322
|
);
|
|
323
|
+
const events: string[] = [];
|
|
312
324
|
mockCreateMarimoFile.mockReturnValue("generated app 2");
|
|
325
|
+
mockParseMarimoIslandApps.mockImplementation((_root, options) => {
|
|
326
|
+
events.push(options?.materialize === false ? "inspect" : "materialize");
|
|
327
|
+
return [app()];
|
|
328
|
+
});
|
|
329
|
+
mockStoreSet.mockImplementation((target) => {
|
|
330
|
+
if (target === islandsPendingInitialRunsAtom) {
|
|
331
|
+
events.push("reset");
|
|
332
|
+
}
|
|
333
|
+
});
|
|
313
334
|
await bridge.initializeApps();
|
|
314
335
|
|
|
315
336
|
expect(mockReplaceSessionRequest).toHaveBeenCalledOnce();
|
|
@@ -318,12 +339,17 @@ describe("IslandsPyodideBridge", () => {
|
|
|
318
339
|
code: "generated app 2",
|
|
319
340
|
sessionGeneration: 2,
|
|
320
341
|
});
|
|
321
|
-
expect(mockStoreSet).
|
|
342
|
+
expect(mockStoreSet).toHaveBeenCalledWith(
|
|
343
|
+
islandsPendingInitialRunsAtom,
|
|
344
|
+
new Set([2]),
|
|
345
|
+
);
|
|
346
|
+
expect(events).toEqual(["inspect", "reset", "materialize"]);
|
|
322
347
|
});
|
|
323
348
|
|
|
324
349
|
it("stops the matching active app", async () => {
|
|
325
350
|
mockSingleApp();
|
|
326
351
|
await bridge.initializeApps();
|
|
352
|
+
mockStoreSet.mockClear();
|
|
327
353
|
|
|
328
354
|
await bridge.stopSession("other-app");
|
|
329
355
|
await bridge.stopSession("app-1");
|
|
@@ -333,6 +359,11 @@ describe("IslandsPyodideBridge", () => {
|
|
|
333
359
|
appId: "app-1",
|
|
334
360
|
sessionGeneration: 1,
|
|
335
361
|
});
|
|
362
|
+
expect(mockStoreSet.mock.calls[0]).toEqual([
|
|
363
|
+
islandsPendingInitialRunsAtom,
|
|
364
|
+
null,
|
|
365
|
+
]);
|
|
366
|
+
expect(mockStoreSet.mock.calls[1]?.[0]).toBe(notebookAtom);
|
|
336
367
|
|
|
337
368
|
mockBridge.mockClear();
|
|
338
369
|
const control = sendSlider();
|
|
@@ -42,6 +42,7 @@ import { store as defaultStore } from "../state/jotai";
|
|
|
42
42
|
import type { IslandsPyodideBridge } from "./bridge";
|
|
43
43
|
import { MarimoIslandElement } from "./components/web-components";
|
|
44
44
|
import {
|
|
45
|
+
islandsPendingInitialRunsAtom,
|
|
45
46
|
shouldShowIslandsWarningIndicatorAtom,
|
|
46
47
|
userTriedToInteractWithIslandsAtom,
|
|
47
48
|
} from "./state";
|
|
@@ -74,6 +75,7 @@ export async function initializeIslands(
|
|
|
74
75
|
// Setup networking
|
|
75
76
|
store.set(requestClientAtom, bridge);
|
|
76
77
|
store.set(initialModeAtom, "read");
|
|
78
|
+
store.set(islandsPendingInitialRunsAtom, null);
|
|
77
79
|
|
|
78
80
|
// Initialize plugins for rendering static HTML
|
|
79
81
|
if (config.autoInitializePlugins !== false) {
|
|
@@ -129,10 +131,13 @@ export async function initializeIslands(
|
|
|
129
131
|
// the envelope and the payload carry the op. The bridge types the message
|
|
130
132
|
// as NotificationPayload (just {data}), but the actual wire format
|
|
131
133
|
// includes the outer op too.
|
|
132
|
-
|
|
134
|
+
// Worker metadata identifies the session that emitted the message.
|
|
135
|
+
bridge.consumeMessages(({ message, sessionGeneration }) => {
|
|
133
136
|
handleMessage(
|
|
134
137
|
message as unknown as JsonString<IslandsNotificationMessage>,
|
|
135
138
|
actions,
|
|
139
|
+
store,
|
|
140
|
+
sessionGeneration,
|
|
136
141
|
);
|
|
137
142
|
});
|
|
138
143
|
|
|
@@ -161,9 +166,12 @@ type IslandsNotificationMessage = {
|
|
|
161
166
|
*
|
|
162
167
|
* Wire format from Python: {"op": "<name>", "data": {"op": "<name>", ...}}
|
|
163
168
|
*/
|
|
169
|
+
// oxlint-disable-next-line marimo/prefer-object-params
|
|
164
170
|
function handleMessage(
|
|
165
171
|
message: JsonString<IslandsNotificationMessage>,
|
|
166
172
|
actions: NotebookActions,
|
|
173
|
+
store: Store,
|
|
174
|
+
sessionGeneration: number,
|
|
167
175
|
): void {
|
|
168
176
|
try {
|
|
169
177
|
const msg = jsonParseWithSpecialChar(message);
|
|
@@ -192,7 +200,6 @@ function handleMessage(
|
|
|
192
200
|
case "storage-download-ready":
|
|
193
201
|
case "secret-keys-result":
|
|
194
202
|
case "startup-logs":
|
|
195
|
-
case "completed-run":
|
|
196
203
|
case "interrupted":
|
|
197
204
|
case "reconnected":
|
|
198
205
|
case "cache-cleared":
|
|
@@ -201,6 +208,17 @@ function handleMessage(
|
|
|
201
208
|
case "notebook-document-transaction":
|
|
202
209
|
return;
|
|
203
210
|
|
|
211
|
+
case "completed-run":
|
|
212
|
+
store.set(islandsPendingInitialRunsAtom, (pending) => {
|
|
213
|
+
if (!pending?.has(sessionGeneration)) {
|
|
214
|
+
return pending;
|
|
215
|
+
}
|
|
216
|
+
const next = new Set(pending);
|
|
217
|
+
next.delete(sessionGeneration);
|
|
218
|
+
return next;
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
|
|
204
222
|
case "kernel-ready":
|
|
205
223
|
handleKernelReady(msg.data, {
|
|
206
224
|
autoInstantiate: true,
|
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
import { getWorkerRPC } from "@/core/wasm/rpc";
|
|
5
5
|
import { Deferred } from "@/utils/Deferred";
|
|
6
6
|
import { throwNotImplemented } from "@/utils/functions";
|
|
7
|
-
import type { JsonString } from "@/utils/json/base64";
|
|
8
7
|
import { Logger } from "@/utils/Logger";
|
|
9
8
|
import { generateUUID } from "@/utils/uuid";
|
|
10
9
|
import { initialNotebookState, notebookAtom } from "../cells/cells";
|
|
11
|
-
import type { CommandMessage
|
|
10
|
+
import type { CommandMessage } from "../kernel/messages";
|
|
12
11
|
import type { EditRequests, RunRequests } from "../network/types";
|
|
13
12
|
import { store as defaultStore } from "../state/jotai";
|
|
14
13
|
import { getMarimoExportContext } from "../static/export-context";
|
|
15
14
|
import { createMarimoFile, parseMarimoIslandApps } from "./parse";
|
|
16
|
-
import { islandsInitializedAtom } from "./state";
|
|
17
|
-
import type { WorkerSchema } from "./worker/worker";
|
|
15
|
+
import { islandsInitializedAtom, islandsPendingInitialRunsAtom } from "./state";
|
|
16
|
+
import type { IslandsKernelMessage, WorkerSchema } from "./worker/worker";
|
|
18
17
|
import type { WorkerFactory } from "./worker-factory";
|
|
19
18
|
import { DefaultWorkerFactory } from "./worker-factory";
|
|
20
19
|
|
|
@@ -60,7 +59,7 @@ interface AppSession {
|
|
|
60
59
|
export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
61
60
|
private rpc: ReturnType<typeof getWorkerRPC<WorkerSchema>>;
|
|
62
61
|
private messageConsumer:
|
|
63
|
-
| ((message:
|
|
62
|
+
| ((message: IslandsKernelMessage) => void)
|
|
64
63
|
| undefined;
|
|
65
64
|
private readonly store: typeof defaultStore;
|
|
66
65
|
private readonly root: Document | Element;
|
|
@@ -110,12 +109,9 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
110
109
|
},
|
|
111
110
|
);
|
|
112
111
|
|
|
113
|
-
this.rpc.addMessageListener(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this.messageConsumer?.(message);
|
|
117
|
-
},
|
|
118
|
-
);
|
|
112
|
+
this.rpc.addMessageListener("kernelMessage", (message) => {
|
|
113
|
+
this.messageConsumer?.(message);
|
|
114
|
+
});
|
|
119
115
|
}
|
|
120
116
|
|
|
121
117
|
async initializeApps(): Promise<void> {
|
|
@@ -126,7 +122,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
private async startApps(): Promise<void> {
|
|
129
|
-
const apps = parseMarimoIslandApps(this.root);
|
|
125
|
+
const apps = parseMarimoIslandApps(this.root, { materialize: false });
|
|
130
126
|
const managesSingleApp = apps.length === 1;
|
|
131
127
|
Logger.debug(
|
|
132
128
|
`Starting sessions for ${apps.length} app(s):`,
|
|
@@ -140,15 +136,27 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
140
136
|
? getMarimoExportContext()
|
|
141
137
|
: undefined;
|
|
142
138
|
const notebookCode = exportContext?.notebookCode;
|
|
139
|
+
const singleApp = managesSingleApp ? apps[0] : undefined;
|
|
140
|
+
const singleAppFile = singleApp
|
|
141
|
+
? notebookCode || createMarimoFile(singleApp)
|
|
142
|
+
: undefined;
|
|
143
|
+
if (
|
|
144
|
+
singleApp &&
|
|
145
|
+
this.session?.appId === singleApp.id &&
|
|
146
|
+
this.session.code === singleAppFile
|
|
147
|
+
) {
|
|
148
|
+
parseMarimoIslandApps(this.root);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (apps.length > 0) {
|
|
152
|
+
this.store.set(
|
|
153
|
+
islandsPendingInitialRunsAtom,
|
|
154
|
+
new Set(apps.map((_, index) => this.nextSessionGeneration + index + 1)),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
parseMarimoIslandApps(this.root);
|
|
143
158
|
for (const app of apps) {
|
|
144
|
-
const file =
|
|
145
|
-
if (
|
|
146
|
-
managesSingleApp &&
|
|
147
|
-
this.session?.appId === app.id &&
|
|
148
|
-
this.session.code === file
|
|
149
|
-
) {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
159
|
+
const file = singleAppFile || createMarimoFile(app);
|
|
152
160
|
Logger.debug(`App ${app.id} marimo file:\n`, file);
|
|
153
161
|
const request = {
|
|
154
162
|
code: file,
|
|
@@ -199,6 +207,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
199
207
|
appId: session.appId,
|
|
200
208
|
sessionGeneration: session.sessionGeneration,
|
|
201
209
|
});
|
|
210
|
+
this.store.set(islandsPendingInitialRunsAtom, null);
|
|
202
211
|
this.session = undefined;
|
|
203
212
|
this.sessionReady = new Deferred<AppSession>();
|
|
204
213
|
this.store.set(notebookAtom, initialNotebookState());
|
|
@@ -225,9 +234,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
225
234
|
/**
|
|
226
235
|
* Sets up a consumer for kernel messages
|
|
227
236
|
*/
|
|
228
|
-
consumeMessages(
|
|
229
|
-
consumer: (message: JsonString<NotificationPayload>) => void,
|
|
230
|
-
): void {
|
|
237
|
+
consumeMessages(consumer: (message: IslandsKernelMessage) => void): void {
|
|
231
238
|
this.messageConsumer = consumer;
|
|
232
239
|
this.rpc.proxy.send.consumerReady({});
|
|
233
240
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
ISLAND_TAG_NAMES,
|
|
20
20
|
ISLANDS_JSON_SCRIPT_TYPE,
|
|
21
21
|
} from "@/core/islands/constants";
|
|
22
|
+
import { islandsPendingInitialRunsAtom } from "@/core/islands/state";
|
|
22
23
|
import { requestClientAtom } from "@/core/network/requests";
|
|
23
24
|
import { store } from "@/core/state/jotai";
|
|
24
25
|
import { parseMarimoIslandApps } from "../../parse";
|
|
@@ -37,6 +38,7 @@ afterAll(() => {
|
|
|
37
38
|
|
|
38
39
|
beforeEach(() => {
|
|
39
40
|
store.set(notebookAtom, MockNotebook.notebookState({ cellData: {} }));
|
|
41
|
+
store.set(islandsPendingInitialRunsAtom, new Set());
|
|
40
42
|
store.set(requestClientAtom, MockRequestClient.create());
|
|
41
43
|
});
|
|
42
44
|
|
|
@@ -46,6 +48,38 @@ afterEach(async () => {
|
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
describe("MarimoIslandElement lifecycle", () => {
|
|
51
|
+
it("keeps static output until the initial run completes", async () => {
|
|
52
|
+
store.set(islandsPendingInitialRunsAtom, new Set([1]));
|
|
53
|
+
setNotebook("runtime-cell", "runtime output");
|
|
54
|
+
const container = document.createElement("div");
|
|
55
|
+
container.innerHTML = `
|
|
56
|
+
<marimo-island
|
|
57
|
+
data-app-id="app-1"
|
|
58
|
+
data-cell-id="runtime-cell"
|
|
59
|
+
data-cell-idx="0"
|
|
60
|
+
data-reactive="true"
|
|
61
|
+
>
|
|
62
|
+
<marimo-cell-output><div>static output</div></marimo-cell-output>
|
|
63
|
+
<marimo-cell-code hidden>${encodeURIComponent("value = 1")}</marimo-cell-code>
|
|
64
|
+
</marimo-island>
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
await actAndFlush(() => document.body.append(container));
|
|
68
|
+
|
|
69
|
+
const island = container.querySelector<HTMLElement>(
|
|
70
|
+
ISLAND_TAG_NAMES.ISLAND,
|
|
71
|
+
);
|
|
72
|
+
expect(island?.textContent).toContain("static output");
|
|
73
|
+
expect(island?.textContent).not.toContain("runtime output");
|
|
74
|
+
|
|
75
|
+
await actAndFlush(() => {
|
|
76
|
+
store.set(islandsPendingInitialRunsAtom, new Set());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(island?.textContent).toContain("runtime output");
|
|
80
|
+
expect(island?.textContent).not.toContain("static output");
|
|
81
|
+
});
|
|
82
|
+
|
|
49
83
|
it("binds a reactive island after its runtime cell index is materialized", async () => {
|
|
50
84
|
setNotebook("outgoing-cell", "outgoing runtime output");
|
|
51
85
|
const container = document.createElement("div");
|
|
@@ -15,6 +15,7 @@ import type { CellId } from "@/core/cells/ids";
|
|
|
15
15
|
import { isOutputEmpty } from "@/core/cells/outputs";
|
|
16
16
|
import type { CellRuntimeState } from "@/core/cells/types";
|
|
17
17
|
import { ISLAND_TAG_NAMES } from "../constants";
|
|
18
|
+
import { islandsHydratedAtom } from "../state";
|
|
18
19
|
import { IslandControls } from "./IslandControls";
|
|
19
20
|
import { useIslandControls } from "./useIslandControls";
|
|
20
21
|
|
|
@@ -64,6 +65,7 @@ export const MarimoOutputWrapper: React.FC<MarimoOutputWrapperProps> = ({
|
|
|
64
65
|
[cellId],
|
|
65
66
|
);
|
|
66
67
|
const runtime = useAtomValue(selectAtom(notebookAtom, selector));
|
|
68
|
+
const isHydrated = useAtomValue(islandsHydratedAtom);
|
|
67
69
|
|
|
68
70
|
// Sync cell status to the host <marimo-island> element as a data attribute
|
|
69
71
|
// so downstream consumers can style based on status (e.g. [data-status="running"])
|
|
@@ -71,7 +73,8 @@ export const MarimoOutputWrapper: React.FC<MarimoOutputWrapperProps> = ({
|
|
|
71
73
|
useSyncStatusToIsland(wrapperRef, status);
|
|
72
74
|
|
|
73
75
|
// If no runtime yet, show static content
|
|
74
|
-
|
|
76
|
+
// Keep static content mounted while the initial run is pending.
|
|
77
|
+
if (!isHydrated || !runtime?.output) {
|
|
75
78
|
return (
|
|
76
79
|
<div ref={wrapperRef} className="relative min-h-6 empty:hidden">
|
|
77
80
|
{children}
|
|
@@ -11,6 +11,15 @@ import { atom } from "jotai";
|
|
|
11
11
|
*/
|
|
12
12
|
export const islandsInitializedAtom = atom<boolean | string>(false);
|
|
13
13
|
|
|
14
|
+
/** Pending initial session generations. Null waits for a session to start. */
|
|
15
|
+
export const islandsPendingInitialRunsAtom = atom<ReadonlySet<number> | null>(
|
|
16
|
+
new Set<number>(),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export const islandsHydratedAtom = atom(
|
|
20
|
+
(get) => get(islandsPendingInitialRunsAtom)?.size === 0,
|
|
21
|
+
);
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* Whether the user has tried to interact with the islands.
|
|
16
25
|
*/
|
|
@@ -45,11 +45,14 @@ async function loadPyodideAndPackages() {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const pyodideReadyPromise = loadPyodideAndPackages();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
export interface IslandsKernelMessage {
|
|
49
|
+
message: JsonString<NotificationPayload>;
|
|
50
|
+
sessionGeneration: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const messageBuffer = new MessageBuffer<IslandsKernelMessage>((payload) => {
|
|
54
|
+
rpc.send.kernelMessage(payload);
|
|
55
|
+
});
|
|
53
56
|
interface SessionRequest {
|
|
54
57
|
code: string;
|
|
55
58
|
appId: string;
|
|
@@ -78,7 +81,12 @@ async function startSession(
|
|
|
78
81
|
});
|
|
79
82
|
const nextBridge = await self.controller.startSession({
|
|
80
83
|
...notebook,
|
|
81
|
-
onMessage:
|
|
84
|
+
onMessage: (message) => {
|
|
85
|
+
messageBuffer.push({
|
|
86
|
+
message,
|
|
87
|
+
sessionGeneration: opts.sessionGeneration,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
82
90
|
});
|
|
83
91
|
if (activeSession) {
|
|
84
92
|
(nextBridge as unknown as PyProxy).destroy();
|
|
@@ -236,7 +244,7 @@ export type WorkerSchema = RPCSchema<
|
|
|
236
244
|
// Emitted when the worker is ready
|
|
237
245
|
ready: {};
|
|
238
246
|
// Emitted when the kernel sends a message
|
|
239
|
-
kernelMessage:
|
|
247
|
+
kernelMessage: IslandsKernelMessage;
|
|
240
248
|
// Emitted when the Pyodide is initialized
|
|
241
249
|
initialized: {};
|
|
242
250
|
// Emitted when the Pyodide fails to initialize
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import { useAtomValue } from "jotai";
|
|
3
4
|
import { useEffect, useRef, useState } from "react";
|
|
4
5
|
import { z } from "zod";
|
|
6
|
+
import { islandsHydratedAtom } from "@/core/islands/state";
|
|
5
7
|
import { createPlugin } from "@/plugins/core/builder";
|
|
6
8
|
import type { IPluginProps } from "@/plugins/types";
|
|
7
9
|
import { ErrorBanner } from "../common/error-banner";
|
|
@@ -46,8 +48,12 @@ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
46
48
|
const { modelId } = props.data;
|
|
47
49
|
const htmlRef = useRef<HTMLDivElement>(null);
|
|
48
50
|
const [error, setError] = useState<Error>();
|
|
51
|
+
const canCreateView = useAtomValue(islandsHydratedAtom);
|
|
49
52
|
|
|
50
53
|
useEffect(() => {
|
|
54
|
+
if (!canCreateView) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
51
57
|
const el = htmlRef.current;
|
|
52
58
|
if (!el) {
|
|
53
59
|
return;
|
|
@@ -64,7 +70,7 @@ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
64
70
|
}
|
|
65
71
|
});
|
|
66
72
|
return () => controller.abort();
|
|
67
|
-
}, [modelId]);
|
|
73
|
+
}, [canCreateView, modelId]);
|
|
68
74
|
|
|
69
75
|
return (
|
|
70
76
|
<>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { render, waitFor } from "@testing-library/react";
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { Provider, createStore } from "jotai";
|
|
4
5
|
import {
|
|
5
6
|
afterEach,
|
|
6
7
|
beforeEach,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
type MockInstance,
|
|
11
12
|
vi,
|
|
12
13
|
} from "vitest";
|
|
14
|
+
import { islandsPendingInitialRunsAtom } from "@/core/islands/state";
|
|
13
15
|
import type { IPluginProps } from "@/plugins/types";
|
|
14
16
|
import { visibleForTesting } from "../AnyWidgetPlugin";
|
|
15
17
|
import { Model } from "../model";
|
|
@@ -88,6 +90,29 @@ describe("AnyWidgetSlot", () => {
|
|
|
88
90
|
});
|
|
89
91
|
});
|
|
90
92
|
|
|
93
|
+
it("waits for islands hydration before resolving the model", async () => {
|
|
94
|
+
const islandsStore = createStore();
|
|
95
|
+
islandsStore.set(islandsPendingInitialRunsAtom, new Set([1]));
|
|
96
|
+
const createViewSpy = vi.spyOn(WIDGET_REGISTRY, "createView");
|
|
97
|
+
|
|
98
|
+
render(
|
|
99
|
+
<Provider store={islandsStore}>
|
|
100
|
+
<AnyWidgetSlot {...props(modelId)} />
|
|
101
|
+
</Provider>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(createViewSpy).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
act(() => {
|
|
107
|
+
islandsStore.set(islandsPendingInitialRunsAtom, new Set());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(createViewSpy).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
createViewSpy.mockRestore();
|
|
114
|
+
});
|
|
115
|
+
|
|
91
116
|
it("aborts the runtime-owned view on unmount", async () => {
|
|
92
117
|
const { unmount } = render(<AnyWidgetSlot {...props(modelId)} />);
|
|
93
118
|
await waitFor(() => {
|