@marimo-team/islands 0.23.15-dev30 → 0.23.15-dev32
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-CjNAy01L.js +1 -0
- package/dist/assets/worker-B3XPCb6y.js +98 -0
- package/dist/{chat-ui-CbyDW7Rb.js → chat-ui-C0wkSwxB.js} +33 -33
- package/dist/{code-visibility-D1Y9p_ja.js → code-visibility-CEHWVGz1.js} +162 -162
- package/dist/{html-to-image-CtsZpKdg.js → html-to-image-D8Q5SLSJ.js} +119 -118
- package/dist/main.js +1178 -1075
- package/dist/{process-output-DApSJpc6.js → process-output-C_A12w9J.js} +1 -1
- package/dist/{reveal-component-Clfz4vIR.js → reveal-component-kLDhMoa2.js} +2 -2
- package/package.json +1 -1
- package/src/core/islands/__tests__/bridge.test.ts +341 -46
- package/src/core/islands/__tests__/parse.test.ts +105 -0
- package/src/core/islands/bootstrap.ts +8 -3
- package/src/core/islands/bridge.ts +116 -23
- package/src/core/islands/components/__tests__/web-components.test.tsx +214 -0
- package/src/core/islands/components/web-components.tsx +76 -15
- package/src/core/islands/constants.ts +1 -0
- package/src/core/islands/main.ts +35 -3
- package/src/core/islands/parse.ts +70 -23
- package/src/core/islands/worker/__tests__/controller.test.ts +173 -0
- package/src/core/islands/worker/worker.tsx +145 -57
- package/src/core/wasm/worker/bootstrap.ts +113 -20
- package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
- package/dist/assets/worker-DAWRHcPq.js +0 -73
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
parseIslandElement,
|
|
16
16
|
parseIslandElementsIntoApps,
|
|
17
17
|
parseMarimoIslandApps,
|
|
18
|
+
retainIslandSource,
|
|
18
19
|
} from "../parse";
|
|
19
20
|
import { createMockIslandElement, createMockIslands } from "./test-utils.tsx";
|
|
20
21
|
|
|
@@ -53,6 +54,7 @@ function appendPayload(
|
|
|
53
54
|
script.type = ISLANDS_JSON_SCRIPT_TYPE;
|
|
54
55
|
script.textContent = JSON.stringify(payload);
|
|
55
56
|
root.appendChild(script);
|
|
57
|
+
return script;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
describe("createMarimoFile", () => {
|
|
@@ -317,6 +319,39 @@ describe("parseIslandElement", () => {
|
|
|
317
319
|
|
|
318
320
|
expect(result).toBeNull();
|
|
319
321
|
});
|
|
322
|
+
|
|
323
|
+
it("uses retained source after the custom element renders", () => {
|
|
324
|
+
const element = document.createElement(ISLAND_TAG_NAMES.ISLAND);
|
|
325
|
+
retainIslandSource(element, {
|
|
326
|
+
code: 'print("retained")',
|
|
327
|
+
output: "<div>retained output</div>",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(parseIslandElement(element)).toEqual({
|
|
331
|
+
code: 'print("retained")',
|
|
332
|
+
output: "<div>retained output</div>",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
retainIslandSource(element, { code: "", output: "<div>static</div>" });
|
|
336
|
+
expect(parseIslandElement(element)).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("prefers new live source when an island element is reused", () => {
|
|
340
|
+
const element = createMockIslandElement({
|
|
341
|
+
code: 'print("new")',
|
|
342
|
+
innerHTML: "<div>new output</div>",
|
|
343
|
+
});
|
|
344
|
+
element.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
|
|
345
|
+
retainIslandSource(element, {
|
|
346
|
+
code: 'print("retained")',
|
|
347
|
+
output: "<div>retained output</div>",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(parseIslandElement(element)).toEqual({
|
|
351
|
+
code: 'print("new")',
|
|
352
|
+
output: "<div>new output</div>",
|
|
353
|
+
});
|
|
354
|
+
});
|
|
320
355
|
});
|
|
321
356
|
|
|
322
357
|
describe("parseIslandElementsIntoApps", () => {
|
|
@@ -630,6 +665,76 @@ describe("parseMarimoIslandApps", () => {
|
|
|
630
665
|
);
|
|
631
666
|
});
|
|
632
667
|
|
|
668
|
+
it("preserves payload-backed cells after a non-materializing capability probe", () => {
|
|
669
|
+
const island = createMockIslandElement({
|
|
670
|
+
appId: "app1",
|
|
671
|
+
cellId: "cell-2",
|
|
672
|
+
code: "dom_code = True",
|
|
673
|
+
innerHTML: "<div>dom output</div>",
|
|
674
|
+
});
|
|
675
|
+
island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
|
|
676
|
+
container.appendChild(island);
|
|
677
|
+
appendPayload(container, {
|
|
678
|
+
schemaVersion: 1,
|
|
679
|
+
appId: "app1",
|
|
680
|
+
cells: [createPayloadCell({ cellId: "cell-2" })],
|
|
681
|
+
});
|
|
682
|
+
const originalIsland = island.outerHTML;
|
|
683
|
+
|
|
684
|
+
const probed = parseMarimoIslandApps(container, { materialize: false });
|
|
685
|
+
|
|
686
|
+
expect(probed).toHaveLength(1);
|
|
687
|
+
expect(island.outerHTML).toBe(originalIsland);
|
|
688
|
+
|
|
689
|
+
expect(parseMarimoIslandApps(container)).toEqual(probed);
|
|
690
|
+
expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull();
|
|
691
|
+
expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("refreshes a reused payload anchor when its content changes", () => {
|
|
695
|
+
const island = createMockIslandElement({
|
|
696
|
+
appId: "app1",
|
|
697
|
+
cellId: "cell-1",
|
|
698
|
+
code: 'print("dom")',
|
|
699
|
+
innerHTML: "<div>dom output</div>",
|
|
700
|
+
});
|
|
701
|
+
island.insertAdjacentHTML(
|
|
702
|
+
"beforeend",
|
|
703
|
+
'<div data-marimo-element><marimo-code-editor data-initial-value="old"></marimo-code-editor></div>',
|
|
704
|
+
);
|
|
705
|
+
island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
|
|
706
|
+
container.appendChild(island);
|
|
707
|
+
const script = appendPayload(container, {
|
|
708
|
+
schemaVersion: 1,
|
|
709
|
+
appId: "app1",
|
|
710
|
+
cells: [createPayloadCell()],
|
|
711
|
+
});
|
|
712
|
+
parseMarimoIslandApps(container);
|
|
713
|
+
|
|
714
|
+
script.textContent = JSON.stringify({
|
|
715
|
+
schemaVersion: 1,
|
|
716
|
+
appId: "app1",
|
|
717
|
+
cells: [
|
|
718
|
+
createPayloadCell({
|
|
719
|
+
code: 'print("updated")',
|
|
720
|
+
outputHtml: "<div>updated output</div>",
|
|
721
|
+
}),
|
|
722
|
+
],
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
parseMarimoIslandApps(container);
|
|
726
|
+
|
|
727
|
+
expect(extractIslandCodeFromEmbed(island)).toBe('print("updated")');
|
|
728
|
+
expect(island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)?.innerHTML).toBe(
|
|
729
|
+
"<div>updated output</div>",
|
|
730
|
+
);
|
|
731
|
+
expect(
|
|
732
|
+
island
|
|
733
|
+
.querySelector(ISLAND_TAG_NAMES.CODE_EDITOR)
|
|
734
|
+
?.getAttribute("data-initial-value"),
|
|
735
|
+
).toBe(JSON.stringify('print("updated")'));
|
|
736
|
+
});
|
|
737
|
+
|
|
633
738
|
it("should parse Python-generated island payload snapshots", () => {
|
|
634
739
|
const html = readFileSync(
|
|
635
740
|
new URL(
|
|
@@ -108,14 +108,17 @@ export async function initializeIslands(
|
|
|
108
108
|
// Loading indicator: dim islands while Pyodide initializes
|
|
109
109
|
store.sub(shouldShowIslandsWarningIndicatorAtom, () => {
|
|
110
110
|
const showing = store.get(shouldShowIslandsWarningIndicatorAtom);
|
|
111
|
+
const currentIslands = root.querySelectorAll<HTMLElement>(
|
|
112
|
+
ISLAND_TAG_NAMES.ISLAND,
|
|
113
|
+
);
|
|
111
114
|
if (showing) {
|
|
112
115
|
toastIslandsLoading();
|
|
113
|
-
for (const island of
|
|
116
|
+
for (const island of currentIslands) {
|
|
114
117
|
island.style.setProperty("opacity", "0.5");
|
|
115
118
|
}
|
|
116
119
|
} else {
|
|
117
120
|
dismissIslandsLoadingToast();
|
|
118
|
-
for (const island of
|
|
121
|
+
for (const island of currentIslands) {
|
|
119
122
|
island.style.removeProperty("opacity");
|
|
120
123
|
}
|
|
121
124
|
}
|
|
@@ -208,7 +211,9 @@ function handleMessage(
|
|
|
208
211
|
setKernelState: Functions.NOOP,
|
|
209
212
|
onError: Logger.error,
|
|
210
213
|
});
|
|
211
|
-
|
|
214
|
+
if (!window.customElements?.get(ISLAND_TAG_NAMES.ISLAND)) {
|
|
215
|
+
defineCustomElement(ISLAND_TAG_NAMES.ISLAND, MarimoIslandElement);
|
|
216
|
+
}
|
|
212
217
|
return;
|
|
213
218
|
|
|
214
219
|
case "send-ui-element-message":
|
|
@@ -7,6 +7,7 @@ import { throwNotImplemented } from "@/utils/functions";
|
|
|
7
7
|
import type { JsonString } from "@/utils/json/base64";
|
|
8
8
|
import { Logger } from "@/utils/Logger";
|
|
9
9
|
import { generateUUID } from "@/utils/uuid";
|
|
10
|
+
import { initialNotebookState, notebookAtom } from "../cells/cells";
|
|
10
11
|
import type { CommandMessage, NotificationPayload } from "../kernel/messages";
|
|
11
12
|
import type { EditRequests, RunRequests } from "../network/types";
|
|
12
13
|
import { store as defaultStore } from "../state/jotai";
|
|
@@ -35,11 +36,12 @@ export interface IslandsBridgeConfig {
|
|
|
35
36
|
* Optional root element for parsing islands (for testing)
|
|
36
37
|
*/
|
|
37
38
|
root?: Document | Element;
|
|
39
|
+
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
interface AppSession {
|
|
42
|
+
appId: string;
|
|
43
|
+
code?: string;
|
|
44
|
+
sessionGeneration: number;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -51,8 +53,8 @@ export interface IslandsBridgeConfig {
|
|
|
51
53
|
* @example
|
|
52
54
|
* ```ts
|
|
53
55
|
* const bridge = new IslandsPyodideBridge();
|
|
54
|
-
* await bridge.initialized;
|
|
55
56
|
* bridge.consumeMessages(message => console.log(message));
|
|
57
|
+
* await bridge.initializeApps();
|
|
56
58
|
* ```
|
|
57
59
|
*/
|
|
58
60
|
export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
@@ -62,14 +64,17 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
62
64
|
| undefined;
|
|
63
65
|
private readonly store: typeof defaultStore;
|
|
64
66
|
private readonly root: Document | Element;
|
|
65
|
-
private
|
|
67
|
+
private session: AppSession | undefined;
|
|
68
|
+
private sessionReady = new Deferred<AppSession>();
|
|
69
|
+
private nextSessionGeneration = 0;
|
|
70
|
+
private appTransition = Promise.resolve();
|
|
71
|
+
private workerReady = new Deferred<void>();
|
|
66
72
|
|
|
67
73
|
public initialized = new Deferred<void>();
|
|
68
74
|
|
|
69
75
|
constructor(config: IslandsBridgeConfig = {}) {
|
|
70
76
|
this.store = config.store || defaultStore;
|
|
71
77
|
this.root = config.root || document;
|
|
72
|
-
this.autoStartSessions = config.autoStartSessions ?? true;
|
|
73
78
|
|
|
74
79
|
try {
|
|
75
80
|
const factory = config.workerFactory || new DefaultWorkerFactory();
|
|
@@ -88,9 +93,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
88
93
|
*/
|
|
89
94
|
private setupMessageListeners(): void {
|
|
90
95
|
this.rpc.addMessageListener("ready", () => {
|
|
91
|
-
|
|
92
|
-
this.startSessionsForAllApps();
|
|
93
|
-
}
|
|
96
|
+
this.workerReady.resolve();
|
|
94
97
|
});
|
|
95
98
|
|
|
96
99
|
this.rpc.addMessageListener("initialized", () => {
|
|
@@ -115,11 +118,16 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
115
118
|
);
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
async initializeApps(): Promise<void> {
|
|
122
|
+
await this.enqueueAppTransition(async () => {
|
|
123
|
+
await this.workerReady.promise;
|
|
124
|
+
await this.startApps();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async startApps(): Promise<void> {
|
|
122
129
|
const apps = parseMarimoIslandApps(this.root);
|
|
130
|
+
const managesSingleApp = apps.length === 1;
|
|
123
131
|
Logger.debug(
|
|
124
132
|
`Starting sessions for ${apps.length} app(s):`,
|
|
125
133
|
apps.map((a) => `${a.id} (${a.cells.length} cells)`),
|
|
@@ -134,20 +142,83 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
134
142
|
const notebookCode = exportContext?.notebookCode;
|
|
135
143
|
for (const app of apps) {
|
|
136
144
|
const file = notebookCode || createMarimoFile(app);
|
|
145
|
+
if (
|
|
146
|
+
managesSingleApp &&
|
|
147
|
+
this.session?.appId === app.id &&
|
|
148
|
+
this.session.code === file
|
|
149
|
+
) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
137
152
|
Logger.debug(`App ${app.id} marimo file:\n`, file);
|
|
138
|
-
|
|
153
|
+
const request = {
|
|
139
154
|
code: file,
|
|
140
155
|
appId: app.id,
|
|
141
|
-
|
|
156
|
+
sessionGeneration: ++this.nextSessionGeneration,
|
|
157
|
+
};
|
|
158
|
+
const previousSession = this.session;
|
|
159
|
+
const replacesSession = managesSingleApp && previousSession !== undefined;
|
|
160
|
+
if (replacesSession) {
|
|
161
|
+
this.store.set(notebookAtom, initialNotebookState());
|
|
162
|
+
}
|
|
163
|
+
if (managesSingleApp || !previousSession) {
|
|
164
|
+
if (managesSingleApp && this.sessionReady.status !== "pending") {
|
|
165
|
+
this.sessionReady = new Deferred<AppSession>();
|
|
166
|
+
}
|
|
167
|
+
this.session = {
|
|
168
|
+
...request,
|
|
169
|
+
code: managesSingleApp ? file : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const operation = replacesSession
|
|
173
|
+
? this.rpc.proxy.request.replaceSession(request)
|
|
174
|
+
: this.startSession(request);
|
|
175
|
+
try {
|
|
176
|
+
await operation;
|
|
177
|
+
if (this.sessionReady.status === "pending" && this.session) {
|
|
178
|
+
this.sessionReady.resolve(this.session);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (this.session?.sessionGeneration === request.sessionGeneration) {
|
|
182
|
+
this.session = previousSession;
|
|
183
|
+
}
|
|
142
184
|
Logger.error(`Failed to start session for app ${app.id}:`, error);
|
|
143
|
-
|
|
185
|
+
if (managesSingleApp) {
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
144
189
|
}
|
|
145
190
|
}
|
|
146
191
|
|
|
192
|
+
async stopSession(appId?: string): Promise<void> {
|
|
193
|
+
await this.enqueueAppTransition(async () => {
|
|
194
|
+
const session = this.session;
|
|
195
|
+
if (session?.code === undefined || (appId && session.appId !== appId)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await this.rpc.proxy.request.stopSession({
|
|
199
|
+
appId: session.appId,
|
|
200
|
+
sessionGeneration: session.sessionGeneration,
|
|
201
|
+
});
|
|
202
|
+
this.session = undefined;
|
|
203
|
+
this.sessionReady = new Deferred<AppSession>();
|
|
204
|
+
this.store.set(notebookAtom, initialNotebookState());
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private enqueueAppTransition(operation: () => Promise<void>): Promise<void> {
|
|
209
|
+
const result = this.appTransition.then(operation);
|
|
210
|
+
this.appTransition = result.catch(() => undefined);
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
147
214
|
/**
|
|
148
215
|
* Starts a new Python session for an app
|
|
149
216
|
*/
|
|
150
|
-
async startSession(opts: {
|
|
217
|
+
async startSession(opts: {
|
|
218
|
+
code: string;
|
|
219
|
+
appId: string;
|
|
220
|
+
sessionGeneration: number;
|
|
221
|
+
}): Promise<void> {
|
|
151
222
|
await this.rpc.proxy.request.startSession(opts);
|
|
152
223
|
}
|
|
153
224
|
|
|
@@ -203,11 +274,19 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
203
274
|
// ============================================================================
|
|
204
275
|
|
|
205
276
|
sendRun: EditRequests["sendRun"] = async (request): Promise<null> => {
|
|
206
|
-
await this.
|
|
207
|
-
await this.
|
|
208
|
-
|
|
209
|
-
|
|
277
|
+
const session = await this.getActiveSession();
|
|
278
|
+
await this.rpc.proxy.request.loadPackages({
|
|
279
|
+
appId: session.appId,
|
|
280
|
+
code: request.codes.join("\n"),
|
|
281
|
+
sessionGeneration: session.sessionGeneration,
|
|
210
282
|
});
|
|
283
|
+
await this.putControlRequest(
|
|
284
|
+
{
|
|
285
|
+
type: "execute-cells",
|
|
286
|
+
...request,
|
|
287
|
+
},
|
|
288
|
+
session,
|
|
289
|
+
);
|
|
211
290
|
return null;
|
|
212
291
|
};
|
|
213
292
|
|
|
@@ -278,13 +357,27 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
278
357
|
|
|
279
358
|
// The kernel uses msgspec to parse control requests, which requires a 'type'
|
|
280
359
|
// field for discriminated union deserialization.
|
|
281
|
-
private async putControlRequest(
|
|
360
|
+
private async putControlRequest(
|
|
361
|
+
operation: CommandMessage,
|
|
362
|
+
session?: AppSession,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
session ??= await this.getActiveSession();
|
|
282
365
|
await this.rpc.proxy.request.bridge({
|
|
366
|
+
appId: session.appId,
|
|
283
367
|
functionName: "put_control_request",
|
|
284
368
|
payload: operation,
|
|
369
|
+
sessionGeneration: session.sessionGeneration,
|
|
285
370
|
});
|
|
286
371
|
}
|
|
287
372
|
|
|
373
|
+
private async getActiveSession(): Promise<AppSession> {
|
|
374
|
+
const transition = this.appTransition;
|
|
375
|
+
const session = this.session;
|
|
376
|
+
const ready = this.sessionReady;
|
|
377
|
+
await transition;
|
|
378
|
+
return session ?? ready.promise;
|
|
379
|
+
}
|
|
380
|
+
|
|
288
381
|
/**
|
|
289
382
|
* Cleans up resources (for testing)
|
|
290
383
|
*/
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeAll,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
it,
|
|
12
|
+
} from "vitest";
|
|
13
|
+
import { cellId } from "@/__tests__/branded";
|
|
14
|
+
import { MockNotebook } from "@/__mocks__/notebook";
|
|
15
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
16
|
+
import { notebookAtom } from "@/core/cells/cells";
|
|
17
|
+
import {
|
|
18
|
+
ISLAND_DATA_ATTRIBUTES,
|
|
19
|
+
ISLAND_TAG_NAMES,
|
|
20
|
+
ISLANDS_JSON_SCRIPT_TYPE,
|
|
21
|
+
} from "@/core/islands/constants";
|
|
22
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
23
|
+
import { store } from "@/core/state/jotai";
|
|
24
|
+
import { parseMarimoIslandApps } from "../../parse";
|
|
25
|
+
import { MarimoIslandElement } from "../web-components";
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
setReactActEnvironment(true);
|
|
29
|
+
if (!customElements.get(ISLAND_TAG_NAMES.ISLAND)) {
|
|
30
|
+
customElements.define(ISLAND_TAG_NAMES.ISLAND, MarimoIslandElement);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
setReactActEnvironment(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
store.set(notebookAtom, MockNotebook.notebookState({ cellData: {} }));
|
|
40
|
+
store.set(requestClientAtom, MockRequestClient.create());
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await actAndFlush(() => document.body.replaceChildren());
|
|
45
|
+
store.set(requestClientAtom, null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("MarimoIslandElement lifecycle", () => {
|
|
49
|
+
it("binds a reactive island after its runtime cell index is materialized", async () => {
|
|
50
|
+
setNotebook("outgoing-cell", "outgoing runtime output");
|
|
51
|
+
const container = document.createElement("div");
|
|
52
|
+
container.innerHTML = `
|
|
53
|
+
<marimo-island data-app-id="app-1" data-reactive="true">
|
|
54
|
+
<marimo-cell-output><div>initial output</div></marimo-cell-output>
|
|
55
|
+
<marimo-cell-code hidden>${encodeURIComponent("value = 1")}</marimo-cell-code>
|
|
56
|
+
</marimo-island>
|
|
57
|
+
`;
|
|
58
|
+
await actAndFlush(() => document.body.append(container));
|
|
59
|
+
|
|
60
|
+
const island = container.querySelector<HTMLElement>(
|
|
61
|
+
ISLAND_TAG_NAMES.ISLAND,
|
|
62
|
+
);
|
|
63
|
+
expect(island).not.toBeNull();
|
|
64
|
+
expect(island?.getAttribute("data-status")).toBeNull();
|
|
65
|
+
|
|
66
|
+
await actAndFlush(() => {
|
|
67
|
+
parseMarimoIslandApps(container);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(island?.textContent).toContain("outgoing runtime output");
|
|
71
|
+
|
|
72
|
+
await actAndFlush(() => {
|
|
73
|
+
setNotebook("runtime-cell");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(island?.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
|
|
77
|
+
expect(island?.getAttribute("data-status")).toBe("idle");
|
|
78
|
+
|
|
79
|
+
await actAndFlush(() => {
|
|
80
|
+
island?.remove();
|
|
81
|
+
if (island) {
|
|
82
|
+
container.append(island);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
expect(island?.textContent).toContain("initial output");
|
|
86
|
+
|
|
87
|
+
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID, "source-cell");
|
|
88
|
+
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
|
|
89
|
+
expect((island as MarimoIslandElement | null)?.cellId).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("refreshes a reused payload island and clears an old app binding", async () => {
|
|
93
|
+
const container = document.createElement("div");
|
|
94
|
+
container.innerHTML = `
|
|
95
|
+
<marimo-island
|
|
96
|
+
data-app-id="app-1"
|
|
97
|
+
data-cell-id="cell-1"
|
|
98
|
+
data-reactive="true"
|
|
99
|
+
>
|
|
100
|
+
<marimo-cell-output><div>old output</div></marimo-cell-output>
|
|
101
|
+
<marimo-cell-code hidden>${encodeURIComponent('print("old")')}</marimo-cell-code>
|
|
102
|
+
</marimo-island>
|
|
103
|
+
`;
|
|
104
|
+
const script = document.createElement("script");
|
|
105
|
+
script.type = ISLANDS_JSON_SCRIPT_TYPE;
|
|
106
|
+
script.textContent = payloadSource({
|
|
107
|
+
code: 'print("unchanged")',
|
|
108
|
+
outputHtml: "<div>old output</div>",
|
|
109
|
+
});
|
|
110
|
+
container.append(script);
|
|
111
|
+
await actAndFlush(() => document.body.append(container));
|
|
112
|
+
|
|
113
|
+
await actAndFlush(() => {
|
|
114
|
+
parseMarimoIslandApps(container);
|
|
115
|
+
script.textContent = payloadSource({
|
|
116
|
+
code: 'print("unchanged")',
|
|
117
|
+
outputHtml: "<div>updated output</div>",
|
|
118
|
+
});
|
|
119
|
+
parseMarimoIslandApps(container);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const island = container.querySelector<HTMLElement>(
|
|
123
|
+
ISLAND_TAG_NAMES.ISLAND,
|
|
124
|
+
);
|
|
125
|
+
expect(island?.textContent).toContain("updated output");
|
|
126
|
+
expect(island?.textContent).not.toContain("old output");
|
|
127
|
+
expect(island?.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)).toBeNull();
|
|
128
|
+
|
|
129
|
+
await actAndFlush(() => {
|
|
130
|
+
setNotebook("outgoing-cell", "outgoing runtime output");
|
|
131
|
+
});
|
|
132
|
+
expect(island?.textContent).toContain("outgoing runtime output");
|
|
133
|
+
|
|
134
|
+
await actAndFlush(() => {
|
|
135
|
+
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID, "app-2");
|
|
136
|
+
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID, "cell-2");
|
|
137
|
+
script.textContent = payloadSource({
|
|
138
|
+
appId: "app-2",
|
|
139
|
+
cellId: "cell-2",
|
|
140
|
+
code: 'print("next")',
|
|
141
|
+
outputHtml: "<div>next app output</div>",
|
|
142
|
+
});
|
|
143
|
+
parseMarimoIslandApps(container);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(island?.textContent).toContain("next app output");
|
|
147
|
+
expect(island?.textContent).not.toContain("outgoing runtime output");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function setNotebook(id: string, output?: string): void {
|
|
152
|
+
const runtimeCellId = cellId(id);
|
|
153
|
+
store.set(
|
|
154
|
+
notebookAtom,
|
|
155
|
+
MockNotebook.notebookState({
|
|
156
|
+
cellData: { [runtimeCellId]: {} },
|
|
157
|
+
cellRuntime: output
|
|
158
|
+
? {
|
|
159
|
+
[runtimeCellId]: {
|
|
160
|
+
output: {
|
|
161
|
+
channel: "output",
|
|
162
|
+
data: `<div>${output}</div>`,
|
|
163
|
+
mimetype: "text/html",
|
|
164
|
+
timestamp: 0,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
: undefined,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function payloadSource({
|
|
174
|
+
appId = "app-1",
|
|
175
|
+
cellId = "cell-1",
|
|
176
|
+
code,
|
|
177
|
+
outputHtml,
|
|
178
|
+
}: {
|
|
179
|
+
appId?: string;
|
|
180
|
+
cellId?: string;
|
|
181
|
+
code: string;
|
|
182
|
+
outputHtml: string;
|
|
183
|
+
}): string {
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
schemaVersion: 1,
|
|
186
|
+
appId,
|
|
187
|
+
cells: [
|
|
188
|
+
{
|
|
189
|
+
cellId,
|
|
190
|
+
code,
|
|
191
|
+
outputHtml,
|
|
192
|
+
outputMimetype: "text/html",
|
|
193
|
+
reactive: true,
|
|
194
|
+
displayCode: false,
|
|
195
|
+
displayOutput: true,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function actAndFlush(action: () => void): Promise<void> {
|
|
202
|
+
await act(async () => {
|
|
203
|
+
action();
|
|
204
|
+
await Promise.resolve();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function setReactActEnvironment(value: boolean): void {
|
|
209
|
+
(
|
|
210
|
+
globalThis as typeof globalThis & {
|
|
211
|
+
IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
212
|
+
}
|
|
213
|
+
).IS_REACT_ACT_ENVIRONMENT = value;
|
|
214
|
+
}
|