@marimo-team/islands 0.23.15-dev4 → 0.23.15-dev40
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-CU4fZJzG.js → ConnectedDataExplorerComponent-7p7rt9iw.js} +4 -4
- package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-B_CAG7_e.js} +1 -1
- package/dist/{ImageComparisonComponent-CHrI72em.js → ImageComparisonComponent-1i5IBhon.js} +173 -129
- package/dist/assets/__vite-browser-external-CjNAy01L.js +1 -0
- package/dist/assets/worker-B3XPCb6y.js +98 -0
- package/dist/{chat-ui-DinOvbWu.js → chat-ui-C0z13gJB.js} +3111 -3111
- package/dist/{code-visibility-Dk8cVOny.js → code-visibility-CSsHnVZu.js} +946 -864
- package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
- package/dist/{formats-Dzx4J_z1.js → formats-CitsMtUm.js} +1 -1
- package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-CwZz71BD.js} +3 -3
- package/dist/{html-to-image-_wGfk8V-.js → html-to-image-CEo5pRYw.js} +2291 -2250
- package/dist/{input-DtsN7xm-.js → input-BGPrFH3g.js} +2 -2
- package/dist/main.js +1297 -1201
- package/dist/{mermaid-BYqXy_NE.js → mermaid-UdmxG2PZ.js} +2 -2
- package/dist/{process-output-Mh4UrjwM.js → process-output-BOvvilRG.js} +1 -1
- package/dist/{reveal-component-B5DXLyO7.js → reveal-component-DGjJVNMQ.js} +618 -605
- package/dist/{spec-Cz-Bj1JI.js → spec-DSs9v0xx.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-CWNNlFEX.js → toDate-BRJgtOGm.js} +14 -5
- package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-BSOyAbac.js} +1 -1
- package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-BTLeWzuR.js} +1 -1
- package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-C6wHjkhW.js} +1 -1
- package/dist/{useTheme-6eZ3GOTS.js → useTheme-Df_vGflw.js} +62 -27
- package/dist/{vega-component-DzyyM9fc.js → vega-component-BAvmvTjO.js} +6 -6
- package/package.json +1 -1
- package/src/components/chat/__tests__/chat-utils.test.ts +244 -1
- package/src/components/chat/__tests__/message-queue.test.tsx +121 -0
- package/src/components/chat/chat-panel.tsx +196 -67
- package/src/components/chat/chat-utils.ts +111 -2
- package/src/components/editor/SortableCell.tsx +6 -2
- package/src/components/editor/__tests__/output-persistence.test.tsx +241 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +153 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +109 -0
- package/src/components/editor/actions/pair-with-agent-modal.tsx +20 -64
- package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
- package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
- package/src/components/editor/cell/cell-context-menu.tsx +7 -0
- package/src/components/editor/chrome/panels/packages-panel.tsx +11 -1
- package/src/components/editor/columns/__tests__/cell-column.test.tsx +105 -0
- package/src/components/editor/columns/cell-column.tsx +34 -4
- package/src/components/editor/columns/sortable-column.tsx +24 -4
- package/src/components/editor/file-tree/download.ts +46 -0
- package/src/components/editor/file-tree/file-explorer.tsx +3 -21
- package/src/components/editor/file-tree/file-viewer.tsx +4 -27
- package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
- package/src/components/editor/navigation/navigation.ts +8 -1
- package/src/components/editor/notebook-cell.tsx +203 -106
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +66 -0
- package/src/components/editor/renderers/cell-array.tsx +26 -13
- package/src/components/editor/renderers/cells-renderer.tsx +8 -2
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +29 -0
- package/src/components/editor/renderers/slides-layout/types.ts +4 -0
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
- package/src/components/slides/reveal-component.tsx +37 -4
- package/src/components/slides/slide-form.tsx +72 -0
- package/src/components/ui/toast.tsx +11 -3
- package/src/components/ui/toaster.tsx +65 -9
- package/src/core/cells/__tests__/utils.test.ts +59 -0
- package/src/core/cells/utils.ts +51 -0
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
- package/src/core/codemirror/completion/signature-hint.ts +78 -11
- package/src/core/constants.ts +6 -0
- package/src/core/islands/__tests__/bridge.test.ts +341 -46
- package/src/core/islands/__tests__/main.test.ts +176 -0
- 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 +69 -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/runtime/__tests__/runtime.test.ts +64 -0
- package/src/core/runtime/runtime.ts +30 -10
- package/src/core/wasm/worker/bootstrap.ts +113 -20
- package/src/css/app/Cell.css +10 -0
- package/src/hooks/__tests__/useHotkey.test.tsx +88 -0
- package/src/hooks/useHotkey.ts +29 -4
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +6 -9
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +22 -61
- package/src/plugins/impl/anywidget/model-proxy.ts +0 -13
- package/src/plugins/impl/anywidget/widget-binding.ts +4 -34
- package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +53 -2
- package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +71 -0
- package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +71 -3
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -0
- package/src/theme/__tests__/useTheme.test.ts +68 -0
- package/src/theme/useTheme.ts +16 -1
- package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
- package/dist/assets/worker-DAWRHcPq.js +0 -73
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import { isValidElement, type JSX } from "react";
|
|
|
5
5
|
import ReactDOM, { type Root } from "react-dom/client";
|
|
6
6
|
import { ErrorBoundary } from "@/components/editor/boundary/ErrorBoundary";
|
|
7
7
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
8
|
-
import { notebookAtom } from "@/core/cells/cells";
|
|
8
|
+
import { cellIdsAtom, notebookAtom } from "@/core/cells/cells";
|
|
9
9
|
import { OBJECT_ID_ATTR } from "@/core/dom/ui-element-constants";
|
|
10
10
|
import { UI_ELEMENT_REGISTRY } from "@/core/dom/uiregistry";
|
|
11
11
|
import { LocaleProvider } from "@/core/i18n/locale-provider";
|
|
@@ -16,9 +16,10 @@ import { store } from "../../state/jotai";
|
|
|
16
16
|
import {
|
|
17
17
|
ISLAND_CSS_CLASSES,
|
|
18
18
|
ISLAND_DATA_ATTRIBUTES,
|
|
19
|
+
ISLAND_SOURCE_CHANGED_EVENT,
|
|
19
20
|
ISLAND_TAG_NAMES,
|
|
20
21
|
} from "../constants";
|
|
21
|
-
import { extractIslandCodeFromEmbed } from "../parse";
|
|
22
|
+
import { extractIslandCodeFromEmbed, retainIslandSource } from "../parse";
|
|
22
23
|
import { MarimoOutputWrapper } from "./output-wrapper";
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -28,7 +29,6 @@ export interface IslandRenderConfig {
|
|
|
28
29
|
html: string;
|
|
29
30
|
codeCallback: () => string;
|
|
30
31
|
editor: JSX.Element | null;
|
|
31
|
-
cellId: CellId | undefined;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -38,7 +38,12 @@ export interface IslandRenderConfig {
|
|
|
38
38
|
* functionality like re-running cells and copying code.
|
|
39
39
|
*/
|
|
40
40
|
export class MarimoIslandElement extends HTMLElement {
|
|
41
|
+
private connectionGeneration = 0;
|
|
41
42
|
private root?: Root;
|
|
43
|
+
private renderConfig?: IslandRenderConfig;
|
|
44
|
+
private runtimeAppId?: string;
|
|
45
|
+
private runtimeCellId?: CellId;
|
|
46
|
+
private unsubscribeCellIds?: () => void;
|
|
42
47
|
|
|
43
48
|
public static readonly tagName = ISLAND_TAG_NAMES.ISLAND;
|
|
44
49
|
public static readonly outputTagName = ISLAND_TAG_NAMES.CELL_OUTPUT;
|
|
@@ -72,6 +77,9 @@ export class MarimoIslandElement extends HTMLElement {
|
|
|
72
77
|
* Returns undefined for non-reactive islands (they have no corresponding cell).
|
|
73
78
|
*/
|
|
74
79
|
get cellId(): CellId | undefined {
|
|
80
|
+
if (!this.isReactive) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
75
83
|
const cellId = this.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
|
|
76
84
|
if (cellId) {
|
|
77
85
|
return cellId as CellId;
|
|
@@ -94,11 +102,9 @@ export class MarimoIslandElement extends HTMLElement {
|
|
|
94
102
|
/**
|
|
95
103
|
* Looks up a cell ID from the notebook state by index
|
|
96
104
|
*/
|
|
97
|
-
private getCellIdFromIndex(idx: number): CellId {
|
|
105
|
+
private getCellIdFromIndex(idx: number): CellId | undefined {
|
|
98
106
|
const { cellIds } = store.get(notebookAtom);
|
|
99
|
-
|
|
100
|
-
invariant(cellId, `Missing cell ID at index ${idx}`);
|
|
101
|
-
return cellId;
|
|
107
|
+
return cellIds.inOrderIds.at(idx);
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/**
|
|
@@ -110,18 +116,62 @@ export class MarimoIslandElement extends HTMLElement {
|
|
|
110
116
|
* synchronously from there causes "unmount during render" warnings.
|
|
111
117
|
*/
|
|
112
118
|
connectedCallback(): void {
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
const connectionGeneration = ++this.connectionGeneration;
|
|
120
|
+
this.addEventListener(
|
|
121
|
+
ISLAND_SOURCE_CHANGED_EVENT,
|
|
122
|
+
this.handleSourceChanged,
|
|
123
|
+
);
|
|
124
|
+
// Capture config synchronously (before children get cleared by createRoot).
|
|
125
|
+
// A reconnected element reuses the source captured on its first mount.
|
|
126
|
+
if (this.querySelector(MarimoIslandElement.outputTagName)) {
|
|
127
|
+
this.renderConfig = this.extractRenderConfig();
|
|
128
|
+
}
|
|
129
|
+
invariant(this.renderConfig, "Missing island render source");
|
|
130
|
+
this.runtimeAppId = this.appId;
|
|
131
|
+
this.runtimeCellId = this.hasAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)
|
|
132
|
+
? this.cellId
|
|
133
|
+
: undefined;
|
|
134
|
+
this.syncCellIdsSubscription();
|
|
115
135
|
queueMicrotask(() => {
|
|
116
136
|
// Guard against disconnect between connectedCallback and microtask
|
|
117
|
-
if (
|
|
137
|
+
if (
|
|
138
|
+
!this.isConnected ||
|
|
139
|
+
connectionGeneration !== this.connectionGeneration
|
|
140
|
+
) {
|
|
118
141
|
return;
|
|
119
142
|
}
|
|
120
143
|
this.root = ReactDOM.createRoot(this);
|
|
121
|
-
this.renderIsland(
|
|
144
|
+
this.renderIsland();
|
|
122
145
|
});
|
|
123
146
|
}
|
|
124
147
|
|
|
148
|
+
private handleSourceChanged = (): void => {
|
|
149
|
+
if (this.querySelector(MarimoIslandElement.outputTagName)) {
|
|
150
|
+
this.renderConfig = this.extractRenderConfig();
|
|
151
|
+
this.querySelector(MarimoIslandElement.outputTagName)?.remove();
|
|
152
|
+
this.querySelector(MarimoIslandElement.codeTagName)?.remove();
|
|
153
|
+
}
|
|
154
|
+
this.runtimeCellId =
|
|
155
|
+
this.isReactive && this.runtimeAppId === this.appId
|
|
156
|
+
? this.cellId
|
|
157
|
+
: undefined;
|
|
158
|
+
this.runtimeAppId = this.appId;
|
|
159
|
+
this.syncCellIdsSubscription();
|
|
160
|
+
this.renderIsland();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
private syncCellIdsSubscription(): void {
|
|
164
|
+
if (this.isReactive) {
|
|
165
|
+
this.unsubscribeCellIds ??= store.sub(cellIdsAtom, () => {
|
|
166
|
+
this.runtimeCellId = this.cellId;
|
|
167
|
+
this.renderIsland();
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.unsubscribeCellIds?.();
|
|
172
|
+
this.unsubscribeCellIds = undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
125
175
|
/**
|
|
126
176
|
* Extracts configuration needed for rendering
|
|
127
177
|
*/
|
|
@@ -130,7 +180,7 @@ export class MarimoIslandElement extends HTMLElement {
|
|
|
130
180
|
const initialOutput = output.innerHTML;
|
|
131
181
|
const optionalEditor = this.getOptionalEditor();
|
|
132
182
|
const code = this.code;
|
|
133
|
-
|
|
183
|
+
retainIslandSource(this, { code, output: initialOutput });
|
|
134
184
|
|
|
135
185
|
// Read objectId directly from the DOM before createRoot clears children.
|
|
136
186
|
// optionalEditor is a <RenderHTML> wrapper, so its .props don't carry the
|
|
@@ -152,15 +202,19 @@ export class MarimoIslandElement extends HTMLElement {
|
|
|
152
202
|
html: initialOutput,
|
|
153
203
|
codeCallback,
|
|
154
204
|
editor: optionalEditor,
|
|
155
|
-
cellId,
|
|
156
205
|
};
|
|
157
206
|
}
|
|
158
207
|
|
|
159
208
|
/**
|
|
160
209
|
* Renders the island with React
|
|
161
210
|
*/
|
|
162
|
-
private renderIsland(
|
|
163
|
-
const
|
|
211
|
+
private renderIsland(): void {
|
|
212
|
+
const config = this.renderConfig;
|
|
213
|
+
if (!config) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const { html, codeCallback, editor } = config;
|
|
217
|
+
const cellId = this.runtimeCellId;
|
|
164
218
|
const alwaysShowRun = !!editor;
|
|
165
219
|
const trimmedHtml = html.trim();
|
|
166
220
|
const isEmpty = trimmedHtml === "<span></span>" || trimmedHtml === "";
|
|
@@ -168,6 +222,7 @@ export class MarimoIslandElement extends HTMLElement {
|
|
|
168
222
|
|
|
169
223
|
// Non-reactive islands have no cell in the kernel — just render static HTML
|
|
170
224
|
if (!cellId) {
|
|
225
|
+
this.removeAttribute("data-status");
|
|
171
226
|
this.root?.render(
|
|
172
227
|
<ErrorBoundary>
|
|
173
228
|
<Provider store={store}>
|
|
@@ -242,6 +297,12 @@ export class MarimoIslandElement extends HTMLElement {
|
|
|
242
297
|
* Cleanup when element is removed from DOM
|
|
243
298
|
*/
|
|
244
299
|
disconnectedCallback(): void {
|
|
300
|
+
this.removeEventListener(
|
|
301
|
+
ISLAND_SOURCE_CHANGED_EVENT,
|
|
302
|
+
this.handleSourceChanged,
|
|
303
|
+
);
|
|
304
|
+
this.unsubscribeCellIds?.();
|
|
305
|
+
this.unsubscribeCellIds = undefined;
|
|
245
306
|
const root = this.root;
|
|
246
307
|
this.root = undefined;
|
|
247
308
|
// Defer unmount to avoid "unmount during render" race
|
package/src/core/islands/main.ts
CHANGED
|
@@ -14,12 +14,78 @@ import "iconify-icon";
|
|
|
14
14
|
import { Logger } from "@/utils/Logger";
|
|
15
15
|
import { initializeIslands } from "./bootstrap";
|
|
16
16
|
import { getGlobalBridge } from "./bridge";
|
|
17
|
+
import { ISLAND_CSS_CLASSES, ISLAND_TAG_NAMES } from "./constants";
|
|
18
|
+
import { parseMarimoIslandApps } from "./parse";
|
|
19
|
+
|
|
20
|
+
const bridge = getGlobalBridge();
|
|
21
|
+
let bootstrapPromise: Promise<void> | undefined;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Public entry point for the islands bundle.
|
|
25
|
+
*
|
|
26
|
+
* Islands auto-initialize on load. Hosts that keep the browser realm alive
|
|
27
|
+
* across client-side navigation (retaining this worker and its Pyodide
|
|
28
|
+
* environment) can drive page transitions with:
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* if (canReplaceApp()) {
|
|
32
|
+
* await stopApp();
|
|
33
|
+
* updatePage();
|
|
34
|
+
* await initialize();
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* The retained lifecycle (`canReplaceApp`/`stopApp`) only applies to pages
|
|
39
|
+
* whose islands resolve to a single app; multi-app pages are not replaceable.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns whether the current document can replace its app in this worker.
|
|
44
|
+
*
|
|
45
|
+
* True only when the document contains exactly one replaceable app. This is a
|
|
46
|
+
* read-only probe: it does not materialize island payloads or mutate the DOM.
|
|
47
|
+
*/
|
|
48
|
+
export function canReplaceApp(): boolean {
|
|
49
|
+
if (!document.querySelector(ISLAND_TAG_NAMES.ISLAND)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return parseMarimoIslandApps(document, { materialize: false }).length === 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mounts marimo custom elements and starts the apps in the current document.
|
|
57
|
+
*
|
|
58
|
+
* Safe to call repeatedly, including after client-side navigation: the worker
|
|
59
|
+
* bootstrap is memoized (and cleared if it fails, so a later call retries),
|
|
60
|
+
* while app discovery re-runs on every call to pick up the current DOM.
|
|
61
|
+
*/
|
|
62
|
+
export async function initialize(): Promise<void> {
|
|
63
|
+
const islands = document.querySelectorAll<HTMLElement>(
|
|
64
|
+
ISLAND_TAG_NAMES.ISLAND,
|
|
65
|
+
);
|
|
66
|
+
if (islands.length === 0) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const island of islands) {
|
|
70
|
+
island.classList.add(ISLAND_CSS_CLASSES.NAMESPACE);
|
|
71
|
+
}
|
|
72
|
+
bootstrapPromise ??= initializeIslands({ bridge }).catch((error: unknown) => {
|
|
73
|
+
bootstrapPromise = undefined;
|
|
74
|
+
throw error;
|
|
75
|
+
});
|
|
76
|
+
await bootstrapPromise;
|
|
77
|
+
await bridge.initializeApps();
|
|
78
|
+
}
|
|
17
79
|
|
|
18
80
|
/**
|
|
19
|
-
*
|
|
81
|
+
* Stops the matching active app session.
|
|
82
|
+
*
|
|
83
|
+
* With no `appId`, stops the current retained single-app session; with an
|
|
84
|
+
* `appId`, stops it only if it matches the active app. No-op when there is no
|
|
85
|
+
* retained session to stop.
|
|
20
86
|
*/
|
|
21
|
-
export async function
|
|
22
|
-
await
|
|
87
|
+
export async function stopApp(appId?: string): Promise<void> {
|
|
88
|
+
await bridge.stopSession(appId);
|
|
23
89
|
}
|
|
24
90
|
|
|
25
91
|
// Auto-initialize on module load
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ISLAND_DATA_ATTRIBUTES,
|
|
5
|
+
ISLAND_SOURCE_CHANGED_EVENT,
|
|
5
6
|
ISLAND_TAG_NAMES,
|
|
6
7
|
ISLANDS_JSON_SCRIPT_TYPE,
|
|
7
8
|
} from "@/core/islands/constants";
|
|
@@ -74,12 +75,15 @@ interface MarimoIslandPayloadCell {
|
|
|
74
75
|
displayOutput: boolean;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
const retainedPayloadCellIds = new WeakMap<HTMLElement, string>();
|
|
79
|
+
|
|
77
80
|
/**
|
|
78
81
|
* Parses marimo island apps from the DOM
|
|
79
82
|
* @param root - Root element to search within (defaults to document)
|
|
80
83
|
*/
|
|
81
84
|
export function parseMarimoIslandApps(
|
|
82
85
|
root: Document | Element = document,
|
|
86
|
+
options: { materialize?: boolean } = {},
|
|
83
87
|
): MarimoIslandApp[] {
|
|
84
88
|
const embeds = [
|
|
85
89
|
...root.querySelectorAll<HTMLElement>(ISLAND_TAG_NAMES.ISLAND),
|
|
@@ -90,11 +94,12 @@ export function parseMarimoIslandApps(
|
|
|
90
94
|
return [];
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
const materialize = options.materialize ?? true;
|
|
93
98
|
if (payloads.length > 0) {
|
|
94
|
-
return parsePayloadBackedApps(embeds, payloads);
|
|
99
|
+
return parsePayloadBackedApps({ embeds, materialize, payloads });
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
return parseIslandElementsIntoApps(embeds);
|
|
102
|
+
return parseIslandElementsIntoApps(embeds, materialize);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
/**
|
|
@@ -103,6 +108,7 @@ export function parseMarimoIslandApps(
|
|
|
103
108
|
*/
|
|
104
109
|
export function parseIslandElementsIntoApps(
|
|
105
110
|
embeds: HTMLElement[],
|
|
111
|
+
materialize = true,
|
|
106
112
|
): MarimoIslandApp[] {
|
|
107
113
|
const apps = new Map<string, MarimoIslandApp>();
|
|
108
114
|
|
|
@@ -140,16 +146,24 @@ export function parseIslandElementsIntoApps(
|
|
|
140
146
|
});
|
|
141
147
|
|
|
142
148
|
// Add data-cell-idx attribute to the island element
|
|
143
|
-
|
|
149
|
+
if (materialize) {
|
|
150
|
+
embed.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString());
|
|
151
|
+
embed.dispatchEvent(new Event(ISLAND_SOURCE_CHANGED_EVENT));
|
|
152
|
+
}
|
|
144
153
|
}
|
|
145
154
|
|
|
146
155
|
return [...apps.values()];
|
|
147
156
|
}
|
|
148
157
|
|
|
149
|
-
function parsePayloadBackedApps(
|
|
150
|
-
embeds
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
function parsePayloadBackedApps({
|
|
159
|
+
embeds,
|
|
160
|
+
materialize,
|
|
161
|
+
payloads,
|
|
162
|
+
}: {
|
|
163
|
+
embeds: HTMLElement[];
|
|
164
|
+
materialize: boolean;
|
|
165
|
+
payloads: MarimoIslandPayload[];
|
|
166
|
+
}): MarimoIslandApp[] {
|
|
153
167
|
const apps = new Map<string, MarimoIslandApp>();
|
|
154
168
|
const matchedPayloadCells = new Map<MarimoIslandPayloadCell, HTMLElement>();
|
|
155
169
|
const consumedEmbeds = new Set<HTMLElement>();
|
|
@@ -169,7 +183,9 @@ function parsePayloadBackedApps(
|
|
|
169
183
|
}
|
|
170
184
|
consumedEmbeds.add(embed);
|
|
171
185
|
matchedPayloadCells.set(cell, embed);
|
|
172
|
-
|
|
186
|
+
if (materialize) {
|
|
187
|
+
materializeIslandPayload(embed, cell);
|
|
188
|
+
}
|
|
173
189
|
hasMatchedIsland = true;
|
|
174
190
|
}
|
|
175
191
|
// Only payloads matched to island anchors can start runtime apps.
|
|
@@ -215,7 +231,7 @@ function parsePayloadBackedApps(
|
|
|
215
231
|
appCell.disabled = true;
|
|
216
232
|
}
|
|
217
233
|
app.cells.push(appCell);
|
|
218
|
-
if (cell.reactive) {
|
|
234
|
+
if (materialize && cell.reactive) {
|
|
219
235
|
embed?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString());
|
|
220
236
|
}
|
|
221
237
|
}
|
|
@@ -223,12 +239,15 @@ function parsePayloadBackedApps(
|
|
|
223
239
|
|
|
224
240
|
// A supported payload is the runtime source for its app. Extra same-app DOM
|
|
225
241
|
// islands are disconnected from runtime binding.
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
242
|
+
if (materialize) {
|
|
243
|
+
for (const embed of embeds) {
|
|
244
|
+
const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
|
|
245
|
+
if (appId && payloadAppIds.has(appId) && !consumedEmbeds.has(embed)) {
|
|
246
|
+
embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
|
|
247
|
+
embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
|
|
248
|
+
embed.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
|
|
249
|
+
consumedEmbeds.add(embed);
|
|
250
|
+
}
|
|
232
251
|
}
|
|
233
252
|
}
|
|
234
253
|
|
|
@@ -237,7 +256,16 @@ function parsePayloadBackedApps(
|
|
|
237
256
|
return !appId || !payloadAppIds.has(appId);
|
|
238
257
|
});
|
|
239
258
|
|
|
240
|
-
|
|
259
|
+
if (materialize) {
|
|
260
|
+
for (const embed of consumedEmbeds) {
|
|
261
|
+
embed.dispatchEvent(new Event(ISLAND_SOURCE_CHANGED_EVENT));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return [
|
|
266
|
+
...apps.values(),
|
|
267
|
+
...parseIslandElementsIntoApps(domOnlyEmbeds, materialize),
|
|
268
|
+
];
|
|
241
269
|
}
|
|
242
270
|
|
|
243
271
|
function findMatchingIsland({
|
|
@@ -257,7 +285,8 @@ function findMatchingIsland({
|
|
|
257
285
|
}
|
|
258
286
|
return (
|
|
259
287
|
embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID) === appId &&
|
|
260
|
-
embed.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)
|
|
288
|
+
(embed.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID) ??
|
|
289
|
+
retainedPayloadCellIds.get(embed)) === cell.cellId
|
|
261
290
|
);
|
|
262
291
|
});
|
|
263
292
|
}
|
|
@@ -266,6 +295,7 @@ function materializeIslandPayload(
|
|
|
266
295
|
embed: HTMLElement,
|
|
267
296
|
cell: MarimoIslandPayloadCell,
|
|
268
297
|
): void {
|
|
298
|
+
retainedPayloadCellIds.set(embed, cell.cellId);
|
|
269
299
|
embed.setAttribute(
|
|
270
300
|
ISLAND_DATA_ATTRIBUTES.REACTIVE,
|
|
271
301
|
JSON.stringify(cell.reactive),
|
|
@@ -304,6 +334,22 @@ function ensureIslandChild(embed: HTMLElement, tagName: string): HTMLElement {
|
|
|
304
334
|
* @param embed - The island HTML element
|
|
305
335
|
* @returns Cell data or null if invalid
|
|
306
336
|
*/
|
|
337
|
+
const retainedIslandSources = new WeakMap<
|
|
338
|
+
HTMLElement,
|
|
339
|
+
{ output: string; code: string }
|
|
340
|
+
>();
|
|
341
|
+
|
|
342
|
+
export function retainIslandSource(
|
|
343
|
+
embed: HTMLElement,
|
|
344
|
+
source: { output: string; code: string },
|
|
345
|
+
): void {
|
|
346
|
+
if (source.code) {
|
|
347
|
+
retainedIslandSources.set(embed, source);
|
|
348
|
+
} else {
|
|
349
|
+
retainedIslandSources.delete(embed);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
307
353
|
export function parseIslandElement(
|
|
308
354
|
embed: HTMLElement,
|
|
309
355
|
): { output: string; code: string } | null {
|
|
@@ -312,14 +358,15 @@ export function parseIslandElement(
|
|
|
312
358
|
);
|
|
313
359
|
const code = extractIslandCodeFromEmbed(embed);
|
|
314
360
|
|
|
315
|
-
if (
|
|
316
|
-
return
|
|
361
|
+
if (cellOutput && code) {
|
|
362
|
+
return {
|
|
363
|
+
output: cellOutput.innerHTML,
|
|
364
|
+
code: code,
|
|
365
|
+
};
|
|
317
366
|
}
|
|
318
367
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
code: code,
|
|
322
|
-
};
|
|
368
|
+
const retained = retainedIslandSources.get(embed);
|
|
369
|
+
return retained?.code ? retained : null;
|
|
323
370
|
}
|
|
324
371
|
|
|
325
372
|
export function createMarimoFile(app: {
|