@marimo-team/islands 0.23.12-dev10 → 0.23.12-dev11

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.
@@ -23,12 +23,14 @@ import type { WorkerFactory } from "@/core/islands/worker-factory";
23
23
  export function createMockIslandElement(options: {
24
24
  appId?: string;
25
25
  cellIdx?: string;
26
+ cellId?: string;
26
27
  code?: string;
27
28
  innerHTML?: string;
28
29
  }): HTMLElement {
29
30
  const {
30
31
  appId = "test-app",
31
32
  cellIdx = "0",
33
+ cellId,
32
34
  code = "import marimo as mo",
33
35
  innerHTML = "",
34
36
  } = options;
@@ -36,6 +38,9 @@ export function createMockIslandElement(options: {
36
38
  const element = document.createElement(ISLAND_TAG_NAMES.ISLAND);
37
39
  element.setAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID, appId);
38
40
  element.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, cellIdx);
41
+ if (cellId) {
42
+ element.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID, cellId);
43
+ }
39
44
 
40
45
  if (code) {
41
46
  const codeElement = document.createElement(ISLAND_TAG_NAMES.CELL_CODE);
@@ -146,6 +151,7 @@ export async function waitForNoError<T>(
146
151
 
147
152
  export interface IslandSpec {
148
153
  appId?: string;
154
+ cellId?: string;
149
155
  reactive?: boolean;
150
156
  code?: string;
151
157
  output?: string;
@@ -160,6 +166,9 @@ export function buildIslandHTML(islands: IslandSpec[]): string {
160
166
  return islands
161
167
  .map((spec) => {
162
168
  const appId = spec.appId ?? "test-app";
169
+ const cellId = spec.cellId
170
+ ? ` ${ISLAND_DATA_ATTRIBUTES.CELL_ID}="${spec.cellId}"`
171
+ : "";
163
172
  const reactive = spec.reactive ?? true;
164
173
  const output = spec.output ?? "<div>output</div>";
165
174
  const code = spec.code ?? 'print("hello")';
@@ -169,7 +178,7 @@ export function buildIslandHTML(islands: IslandSpec[]): string {
169
178
  : "";
170
179
  const outputTag = `<${ISLAND_TAG_NAMES.CELL_OUTPUT}>${output}</${ISLAND_TAG_NAMES.CELL_OUTPUT}>`;
171
180
 
172
- return `<${ISLAND_TAG_NAMES.ISLAND} ${ISLAND_DATA_ATTRIBUTES.APP_ID}="${appId}" ${ISLAND_DATA_ATTRIBUTES.REACTIVE}="${reactive}">${outputTag}${codeTag}</${ISLAND_TAG_NAMES.ISLAND}>`;
181
+ return `<${ISLAND_TAG_NAMES.ISLAND} ${ISLAND_DATA_ATTRIBUTES.APP_ID}="${appId}"${cellId} ${ISLAND_DATA_ATTRIBUTES.REACTIVE}="${reactive}">${outputTag}${codeTag}</${ISLAND_TAG_NAMES.ISLAND}>`;
173
182
  })
174
183
  .join("\n");
175
184
  }
@@ -124,8 +124,13 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
124
124
  `Starting sessions for ${apps.length} app(s):`,
125
125
  apps.map((a) => `${a.id} (${a.cells.length} cells)`),
126
126
  );
127
+ // Payload-backed apps already carry the exact runtime cells and order. The
128
+ // full-notebook export context may describe a different source and would
129
+ // override the payload contract for single-app pages.
127
130
  const exportContext =
128
- apps.length === 1 ? getMarimoExportContext() : undefined;
131
+ apps.length === 1 && !apps[0]?.payloadBacked
132
+ ? getMarimoExportContext()
133
+ : undefined;
129
134
  const notebookCode = exportContext?.notebookCode;
130
135
  for (const app of apps) {
131
136
  const file = notebookCode || createMarimoFile(app);
@@ -10,6 +10,8 @@ export const ISLAND_TAG_NAMES = {
10
10
  CODE_EDITOR: "marimo-code-editor",
11
11
  } as const;
12
12
 
13
+ export const ISLANDS_JSON_SCRIPT_TYPE = "application/vnd.marimo.islands+json";
14
+
13
15
  /**
14
16
  * Data attributes for islands
15
17
  */
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  ISLAND_DATA_ATTRIBUTES,
5
5
  ISLAND_TAG_NAMES,
6
+ ISLANDS_JSON_SCRIPT_TYPE,
6
7
  } from "@/core/islands/constants";
7
8
  import { Logger } from "@/utils/Logger";
8
9
 
@@ -23,6 +24,10 @@ export interface MarimoIslandApp {
23
24
  * ID since we allow multiple apps on the same page.
24
25
  */
25
26
  id: string;
27
+ /**
28
+ * Whether cells came from a supported JSON payload instead of DOM parsing.
29
+ */
30
+ payloadBacked?: boolean;
26
31
  /**
27
32
  * Cells in the app.
28
33
  */
@@ -42,6 +47,30 @@ interface MarimoIslandCell {
42
47
  * Index of the cell.
43
48
  */
44
49
  idx: number;
50
+ /**
51
+ * Stable cell identifier, when provided by the island payload.
52
+ */
53
+ cellId?: string;
54
+ /**
55
+ * Whether the generated marimo cell should be present but not executed.
56
+ */
57
+ disabled?: boolean;
58
+ }
59
+
60
+ interface MarimoIslandPayload {
61
+ schemaVersion: 1;
62
+ appId: string;
63
+ cells: MarimoIslandPayloadCell[];
64
+ }
65
+
66
+ interface MarimoIslandPayloadCell {
67
+ cellId: string;
68
+ code: string;
69
+ outputHtml: string;
70
+ outputMimetype: string;
71
+ reactive: boolean;
72
+ displayCode: boolean;
73
+ displayOutput: boolean;
45
74
  }
46
75
 
47
76
  /**
@@ -51,14 +80,20 @@ interface MarimoIslandCell {
51
80
  export function parseMarimoIslandApps(
52
81
  root: Document | Element = document,
53
82
  ): MarimoIslandApp[] {
54
- const embeds = root.querySelectorAll<HTMLElement>(ISLAND_TAG_NAMES.ISLAND);
83
+ const embeds = [
84
+ ...root.querySelectorAll<HTMLElement>(ISLAND_TAG_NAMES.ISLAND),
85
+ ];
86
+ const payloads = parseMarimoIslandPayloads(root);
55
87
  if (embeds.length === 0) {
56
88
  Logger.warn("No embedded marimo apps found.");
57
89
  return [];
58
90
  }
59
91
 
60
- // eslint-disable-next-line prefer-spread
61
- return parseIslandElementsIntoApps(Array.from(embeds));
92
+ if (payloads.length > 0) {
93
+ return parsePayloadBackedApps(embeds, payloads);
94
+ }
95
+
96
+ return parseIslandElementsIntoApps(embeds);
62
97
  }
63
98
 
64
99
  /**
@@ -90,11 +125,12 @@ export function parseIslandElementsIntoApps(
90
125
  continue;
91
126
  }
92
127
 
93
- if (!apps.has(appId)) {
94
- apps.set(appId, { id: appId, cells: [] });
128
+ let app = apps.get(appId);
129
+ if (!app) {
130
+ app = { id: appId, cells: [] };
131
+ apps.set(appId, app);
95
132
  }
96
133
 
97
- const app = apps.get(appId)!;
98
134
  const idx = app.cells.length;
99
135
  app.cells.push({
100
136
  output: cellData.output,
@@ -109,6 +145,159 @@ export function parseIslandElementsIntoApps(
109
145
  return [...apps.values()];
110
146
  }
111
147
 
148
+ function parsePayloadBackedApps(
149
+ embeds: HTMLElement[],
150
+ payloads: MarimoIslandPayload[],
151
+ ): MarimoIslandApp[] {
152
+ const apps = new Map<string, MarimoIslandApp>();
153
+ const matchedPayloadCells = new Map<MarimoIslandPayloadCell, HTMLElement>();
154
+ const consumedEmbeds = new Set<HTMLElement>();
155
+ const acceptedPayloads: MarimoIslandPayload[] = [];
156
+
157
+ for (const payload of payloads) {
158
+ let hasMatchedIsland = false;
159
+ for (const cell of payload.cells) {
160
+ const embed = findMatchingIsland({
161
+ embeds,
162
+ appId: payload.appId,
163
+ cell,
164
+ consumedEmbeds,
165
+ });
166
+ if (!embed) {
167
+ continue;
168
+ }
169
+ consumedEmbeds.add(embed);
170
+ matchedPayloadCells.set(cell, embed);
171
+ materializeIslandPayload(embed, cell);
172
+ hasMatchedIsland = true;
173
+ }
174
+ // Only payloads matched to island anchors can start runtime apps.
175
+ if (hasMatchedIsland) {
176
+ acceptedPayloads.push(payload);
177
+ }
178
+ }
179
+
180
+ const payloadAppIds = new Set(
181
+ acceptedPayloads.map((payload) => payload.appId),
182
+ );
183
+ const reactivePayloadAppIds = new Set(
184
+ acceptedPayloads
185
+ .filter((payload) => payload.cells.some((cell) => cell.reactive))
186
+ .map((payload) => payload.appId),
187
+ );
188
+
189
+ for (const payload of acceptedPayloads) {
190
+ for (const cell of payload.cells) {
191
+ const embed = matchedPayloadCells.get(cell);
192
+ // Static-only payload apps render from HTML and do not need a Pyodide
193
+ // session.
194
+ if (!reactivePayloadAppIds.has(payload.appId)) {
195
+ continue;
196
+ }
197
+
198
+ let app = apps.get(payload.appId);
199
+ if (!app) {
200
+ app = { id: payload.appId, payloadBacked: true, cells: [] };
201
+ apps.set(payload.appId, app);
202
+ }
203
+
204
+ const idx = app.cells.length;
205
+ const appCell: MarimoIslandCell = {
206
+ cellId: cell.cellId,
207
+ output: cell.outputHtml,
208
+ code: cell.reactive ? cell.code : "",
209
+ idx: idx,
210
+ };
211
+ // Keep static cells in the generated file so later reactive cells keep
212
+ // stable runtime indices without executing static code.
213
+ if (!cell.reactive) {
214
+ appCell.disabled = true;
215
+ }
216
+ app.cells.push(appCell);
217
+ if (cell.reactive) {
218
+ embed?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString());
219
+ }
220
+ }
221
+ }
222
+
223
+ // A supported payload is the runtime source for its app. Extra same-app DOM
224
+ // islands are disconnected from runtime binding.
225
+ for (const embed of embeds) {
226
+ const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
227
+ if (appId && payloadAppIds.has(appId) && !consumedEmbeds.has(embed)) {
228
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
229
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
230
+ embed.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
231
+ }
232
+ }
233
+
234
+ const domOnlyEmbeds = embeds.filter((embed) => {
235
+ const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
236
+ return !appId || !payloadAppIds.has(appId);
237
+ });
238
+
239
+ return [...apps.values(), ...parseIslandElementsIntoApps(domOnlyEmbeds)];
240
+ }
241
+
242
+ function findMatchingIsland({
243
+ embeds,
244
+ appId,
245
+ cell,
246
+ consumedEmbeds,
247
+ }: {
248
+ embeds: HTMLElement[];
249
+ appId: string;
250
+ cell: MarimoIslandPayloadCell;
251
+ consumedEmbeds: Set<HTMLElement>;
252
+ }): HTMLElement | undefined {
253
+ return embeds.find((embed) => {
254
+ if (consumedEmbeds.has(embed)) {
255
+ return false;
256
+ }
257
+ return (
258
+ embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID) === appId &&
259
+ embed.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID) === cell.cellId
260
+ );
261
+ });
262
+ }
263
+
264
+ function materializeIslandPayload(
265
+ embed: HTMLElement,
266
+ cell: MarimoIslandPayloadCell,
267
+ ): void {
268
+ embed.setAttribute(
269
+ ISLAND_DATA_ATTRIBUTES.REACTIVE,
270
+ JSON.stringify(cell.reactive),
271
+ );
272
+ // The runtime file is synthesized from payload order, so DOM anchors bind
273
+ // by index.
274
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
275
+ if (!cell.reactive) {
276
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
277
+ }
278
+
279
+ const output = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_OUTPUT);
280
+ output.innerHTML = cell.displayOutput ? cell.outputHtml : "";
281
+
282
+ const code = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_CODE);
283
+ code.hidden = true;
284
+ code.textContent = encodeURIComponent(cell.code);
285
+
286
+ const editor = embed.querySelector<HTMLElement>(ISLAND_TAG_NAMES.CODE_EDITOR);
287
+ if (editor) {
288
+ editor.setAttribute("data-initial-value", JSON.stringify(cell.code));
289
+ }
290
+ }
291
+
292
+ function ensureIslandChild(embed: HTMLElement, tagName: string): HTMLElement {
293
+ let child = embed.querySelector<HTMLElement>(tagName);
294
+ if (!child) {
295
+ child = embed.ownerDocument.createElement(tagName);
296
+ embed.appendChild(child);
297
+ }
298
+ return child;
299
+ }
300
+
112
301
  /**
113
302
  * Parses a single island element into cell data
114
303
  * @param embed - The island HTML element
@@ -132,26 +321,35 @@ export function parseIslandElement(
132
321
  };
133
322
  }
134
323
 
135
- export function createMarimoFile(app: { cells: { code: string }[] }): string {
324
+ export function createMarimoFile(app: {
325
+ cells: { code: string; disabled?: boolean }[];
326
+ }): string {
136
327
  const lines = [
137
328
  "import marimo",
138
329
  "app = marimo.App()",
139
330
  app.cells
140
331
  .map((cell) => {
141
- // Add 4 spaces to each line
142
- const code = cell.code
143
- .split("\n")
144
- .map((line) => ` ${line}`)
145
- .join("\n");
332
+ // Disabled payload cells are placeholders. Emit pass so static code
333
+ // does not define names in the runtime graph.
334
+ const sourceCode = cell.disabled ? "" : cell.code;
335
+ const code = sourceCode
336
+ ? sourceCode
337
+ .split("\n")
338
+ .map((line) => ` ${line}`)
339
+ .join("\n")
340
+ : " pass";
146
341
 
147
342
  // TODO: Handle async cells better
148
343
  // This is probably not the best way to check if the code is async
149
344
  // Ideally this is pushed into the Python code
150
345
  const isAsync = code.includes("await ");
151
346
  const prefix = isAsync ? "async def" : "def";
347
+ const decorator = cell.disabled
348
+ ? "@app.cell(disabled=True)"
349
+ : "@app.cell";
152
350
 
153
351
  // Wrap in a function
154
- return `@app.cell\n${prefix} __():\n${code}\n return`;
352
+ return `${decorator}\n${prefix} __():\n${code}\n return`;
155
353
  })
156
354
  .join("\n"),
157
355
  ];
@@ -204,3 +402,85 @@ export function extractIslandCodeFromEmbed(embed: HTMLElement): string {
204
402
 
205
403
  return "";
206
404
  }
405
+
406
+ function parseMarimoIslandPayloads(
407
+ root: Document | Element,
408
+ ): MarimoIslandPayload[] {
409
+ const scripts = root.querySelectorAll<HTMLScriptElement>(
410
+ `script[type="${ISLANDS_JSON_SCRIPT_TYPE}"]`,
411
+ );
412
+ const payloads: MarimoIslandPayload[] = [];
413
+
414
+ for (const script of scripts) {
415
+ if (isNestedIslandPayloadScript(script)) {
416
+ continue;
417
+ }
418
+ const payload = parseMarimoIslandPayload(script.textContent);
419
+ if (payload) {
420
+ payloads.push(payload);
421
+ }
422
+ }
423
+
424
+ return payloads;
425
+ }
426
+
427
+ function isNestedIslandPayloadScript(script: HTMLScriptElement): boolean {
428
+ return Boolean(
429
+ script.closest(ISLAND_TAG_NAMES.ISLAND) ||
430
+ script.closest(ISLAND_TAG_NAMES.CELL_OUTPUT),
431
+ );
432
+ }
433
+
434
+ function parseMarimoIslandPayload(
435
+ text: string | undefined | null,
436
+ ): MarimoIslandPayload | null {
437
+ if (!text) {
438
+ return null;
439
+ }
440
+
441
+ try {
442
+ const payload = JSON.parse(text);
443
+ if (isMarimoIslandPayload(payload)) {
444
+ return payload;
445
+ }
446
+ } catch {
447
+ return null;
448
+ }
449
+
450
+ return null;
451
+ }
452
+
453
+ function isMarimoIslandPayload(
454
+ payload: unknown,
455
+ ): payload is MarimoIslandPayload {
456
+ if (!isRecord(payload)) {
457
+ return false;
458
+ }
459
+ return (
460
+ payload.schemaVersion === 1 &&
461
+ typeof payload.appId === "string" &&
462
+ Array.isArray(payload.cells) &&
463
+ payload.cells.every(isMarimoIslandPayloadCell)
464
+ );
465
+ }
466
+
467
+ function isMarimoIslandPayloadCell(
468
+ cell: unknown,
469
+ ): cell is MarimoIslandPayloadCell {
470
+ if (!isRecord(cell)) {
471
+ return false;
472
+ }
473
+ return (
474
+ typeof cell.cellId === "string" &&
475
+ typeof cell.code === "string" &&
476
+ typeof cell.outputHtml === "string" &&
477
+ typeof cell.outputMimetype === "string" &&
478
+ typeof cell.reactive === "boolean" &&
479
+ typeof cell.displayCode === "boolean" &&
480
+ typeof cell.displayOutput === "boolean"
481
+ );
482
+ }
483
+
484
+ function isRecord(value: unknown): value is Record<string, unknown> {
485
+ return typeof value === "object" && value !== null;
486
+ }