From 6be275be4d788c831a7cd6e7fdc03733cfaf57f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Thu, 25 Jun 2026 11:29:33 +0200 Subject: [PATCH 01/10] feat: emit JSON payloads in exports --- marimo/_islands/_island_generator.py | 150 +++++++++++++++++++----- tests/_islands/snapshots/html.txt | 1 + tests/_islands/test_island_generator.py | 129 ++++++++++++++++++++ 3 files changed, 253 insertions(+), 27 deletions(-) diff --git a/marimo/_islands/_island_generator.py b/marimo/_islands/_island_generator.py index 37cfffb9a5d..04cb7584283 100644 --- a/marimo/_islands/_island_generator.py +++ b/marimo/_islands/_island_generator.py @@ -6,7 +6,7 @@ import os import sys from textwrap import dedent -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, TypedDict, cast from marimo import _loggers from marimo._ast.app import App, InternalApp @@ -32,6 +32,24 @@ LOGGER = _loggers.marimo_logger() IN_MEMORY_FILENAME = ".py" +ISLANDS_JSON_SCRIPT_TYPE = "application/vnd.marimo.islands+json" +ISLANDS_JSON_SCHEMA_VERSION = 1 + + +class MarimoIslandCellPayload(TypedDict): + cellId: str + code: str + outputHtml: str + reactive: bool + displayCode: bool + displayOutput: bool + + +class MarimoIslandPayload(TypedDict): + schemaVersion: int + appId: str + cells: list[MarimoIslandCellPayload] + class MarimoIslandStub: """ @@ -41,7 +59,6 @@ class MarimoIslandStub: is_reactive: Whether it is reactive. cell_id: Cell identifier. app_id: App identifier. - app_id: App identifier. code: Code. """ @@ -101,50 +118,49 @@ def render( - str: The HTML code. """ - from marimo._output.formatting import as_html, mime_to_html + from marimo._output.formatting import as_html from marimo._plugins.ui import code_editor - is_reactive = ( - is_reactive if is_reactive is not None else self._is_reactive - ) - display_code = ( - display_code if display_code is not None else self._display_code - ) - display_output = ( - display_output - if display_output is not None - else self._display_output + ( + resolved_display_code, + resolved_display_output, + resolved_is_reactive, + ) = self._resolve_render_options( + display_code=display_code, + display_output=display_output, + is_reactive=is_reactive, ) - if not (display_code or display_output or is_reactive): + if not ( + resolved_display_code + or resolved_display_output + or resolved_is_reactive + ): raise ValueError("You must include either code or output") - output = ( - mime_to_html(self.output.mimetype, self.output.data) - if self.output is not None - else None - ) + output_html = self._output_html() # Specifying display_code=False will hide the code block, but still # make it present for reactivity, unless reactivity is disabled. - if display_code: + if resolved_display_code: # TODO: Allow for non-disabled code editors. code_block = as_html( code_editor(self.code.strip(), disabled=False) ).text else: + encoded_code = ( + uri_encode_component(self.code) if resolved_is_reactive else "" + ) code_block = ( - "" + f"" ) if as_raw: # If as_raw is True, output as raw values as possible. # Used primarily for cases with no js (like pdfs) released = ( - output.text.encode().decode("unicode_escape") - if output and display_output + output_html.encode().decode("unicode_escape") + if output_html and resolved_display_output else "" ) return dedent( @@ -162,10 +178,10 @@ def render( - {output.text if output and display_output else ""} + {output_html if output_html and resolved_display_output else ""} {code_block} @@ -173,6 +189,57 @@ def render( ) ).strip() + def to_payload( + self, + display_code: bool | None = None, + display_output: bool | None = None, + is_reactive: bool | None = None, + ) -> MarimoIslandCellPayload: + """Return this cell's JSON payload for the islands runtime. + + Uses the configured render defaults unless options are provided. + """ + ( + display_code, + display_output, + is_reactive, + ) = self._resolve_render_options( + display_code=display_code, + display_output=display_output, + is_reactive=is_reactive, + ) + output_html = self._output_html() + return { + "cellId": str(self._cell_id), + "code": self.code if is_reactive or display_code else "", + "outputHtml": output_html if display_output else "", + "reactive": is_reactive, + "displayCode": display_code, + "displayOutput": display_output, + } + + def _resolve_render_options( + self, + *, + display_code: bool | None = None, + display_output: bool | None = None, + is_reactive: bool | None = None, + ) -> tuple[bool, bool, bool]: + return ( + display_code if display_code is not None else self._display_code, + display_output + if display_output is not None + else self._display_output, + is_reactive if is_reactive is not None else self._is_reactive, + ) + + def _output_html(self) -> str: + from marimo._output.formatting import mime_to_html + + if self.output is None: + return "" + return mime_to_html(self.output.mimetype, self.output.data).text + class MarimoIslandGenerator: """ @@ -547,6 +614,7 @@ def render_body( self, *, include_init_island: bool = True, + include_payload: bool = True, max_width: str | None = None, margin: str | None = None, style: str | None = None, @@ -557,6 +625,7 @@ def render_body( *Args:* - include_init_island (bool): If True, adds initialization loader. + - include_payload (bool): If True, adds a JSON payload for hydration. - max_width (str): CSS style max_width property. - margin (str): CSS style margin property. - style (str): CSS style. Overrides max_width and margin. @@ -569,6 +638,8 @@ def render_body( if include_init_island: init_island = self.render_init_island() rendered_stubs = [init_island] + rendered_stubs + if include_payload: + rendered_stubs.append(self.render_payload_script()) body = "\n".join(rendered_stubs) @@ -600,6 +671,7 @@ def render_html( version_override: str = __version__, _development_url: str | bool = False, include_init_island: bool = True, + include_payload: bool = True, max_width: str | None = None, margin: str | None = None, style: str | None = None, @@ -612,6 +684,7 @@ def render_html( - version_override (str): Marimo version to use for loaded js/css. - _development_url (str): If True, uses local marimo islands js. - include_init_island (bool): If True, adds initialization loader. + - include_payload (bool): If True, adds a JSON payload for hydration. - max_width (str): CSS style max_width property. - margin (str): CSS style margin property. - style (str): CSS style. Overrides max_width and margin. @@ -622,6 +695,7 @@ def render_html( ) body = self.render_body( include_init_island=include_init_island, + include_payload=include_payload, max_width=max_width, margin=margin, style=style, @@ -647,6 +721,19 @@ def render_html( """ ).strip() + def render_payload(self) -> MarimoIslandPayload: + """Return the JSON payload consumed by the islands runtime.""" + return { + "schemaVersion": ISLANDS_JSON_SCHEMA_VERSION, + "appId": self._app_id, + "cells": [stub.to_payload() for stub in self._stubs], + } + + def render_payload_script(self) -> str: + """Render the island app payload as an inert JSON script tag.""" + payload = _json_script_content(self.render_payload()) + return f'' + def remove_empty_lines(text: str) -> str: return "\n".join([line for line in text.split("\n") if line.strip() != ""]) @@ -683,3 +770,12 @@ def _disabled_cell_ids(cell_data: list[CellData]) -> set[CellId_t]: disabled_cell_ids.add(data.cell_id) return disabled_cell_ids + + +def _json_script_content(payload: object) -> str: + return ( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + .replace("&", "\\u0026") + .replace("<", "\\u003c") + .replace(">", "\\u003e") + ) diff --git a/tests/_islands/snapshots/html.txt b/tests/_islands/snapshots/html.txt index 3071707d42b..cfb141238c2 100644 --- a/tests/_islands/snapshots/html.txt +++ b/tests/_islands/snapshots/html.txt @@ -75,6 +75,7 @@ + \ No newline at end of file diff --git a/tests/_islands/test_island_generator.py b/tests/_islands/test_island_generator.py index c4ab31b4115..7dd134681e7 100644 --- a/tests/_islands/test_island_generator.py +++ b/tests/_islands/test_island_generator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, get_type_hints import pytest @@ -7,8 +8,10 @@ from marimo import __version__ from marimo._ast.app_config import _AppConfig from marimo._islands._island_generator import ( + ISLANDS_JSON_SCRIPT_TYPE, MarimoIslandGenerator, ) +from marimo._messaging.cell_output import CellChannel, CellOutput from marimo._schemas.serialization import ( AppInstantiation, CellDef, @@ -35,6 +38,14 @@ def _notebook( ) +def _parse_payload_script(script: str) -> dict[str, object]: + prefix = f'" + assert script.startswith(prefix) + assert script.endswith(suffix) + return json.loads(script[len(prefix) : -len(suffix)]) + + def test_add_code(): generator = MarimoIslandGenerator() generator.add_code("print('Hello, World!')") @@ -95,6 +106,124 @@ async def test_render(): ) +async def test_render_payload(): + generator = MarimoIslandGenerator() + generator.add_code("import marimo as mo") + generator.add_code("mo.md('Payload output')") + + await generator.build() + + payload = generator.render_payload() + + assert payload["schemaVersion"] == 1 + assert payload["appId"] == "main" + assert len(payload["cells"]) == 2 + assert payload["cells"][0] == { + "cellId": str(generator._stubs[0]._cell_id), + "code": "import marimo as mo", + "outputHtml": "", + "reactive": True, + "displayCode": False, + "displayOutput": True, + } + assert payload["cells"][1]["cellId"] == str(generator._stubs[1]._cell_id) + assert payload["cells"][1]["code"] == "mo.md('Payload output')" + assert "Payload output" in payload["cells"][1]["outputHtml"] + assert payload["cells"][1]["reactive"] is True + + +def test_render_payload_script_escapes_json(): + generator = MarimoIslandGenerator() + generator.add_code('value = "
&"') + + script = generator.render_payload_script() + payload = _parse_payload_script(script) + + assert "
" not in script + assert payload["cells"][0]["code"] == 'value = "
&"' + + +def test_render_payload_script_escapes_output_html(): + generator = MarimoIslandGenerator() + stub = generator.add_code("x = 1") + stub._output = CellOutput( + channel=CellChannel.OUTPUT, + mimetype="text/html", + data="
&", + ) + + script = generator.render_payload_script() + payload = _parse_payload_script(script) + + assert "
" not in script + assert payload["cells"][0]["outputHtml"] == "
&" + + +def test_render_body_includes_payload_by_default(): + generator = MarimoIslandGenerator() + generator.add_code("import marimo as mo") + + body = generator.render_body() + + assert f'`, + }); + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.appendChild(island); + + const result = parseMarimoIslandApps(container); + + expect(result).toEqual([ + { + id: "app1", + cells: [ + { + code: 'print("dom")', + idx: 0, + output: island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT) + ?.innerHTML, + }, + ], + }, + ]); + }); + + it("should use payload order for runtime cell indices", () => { + const second = createMockIslandElement({ + appId: "app1", + cellId: "cell-2", + code: "second_dom = True", + innerHTML: "
second dom
", + }); + const first = createMockIslandElement({ + appId: "app1", + cellId: "cell-1", + code: "first_dom = True", + innerHTML: "
first dom
", + }); + for (const island of [second, first]) { + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.appendChild(island); + } + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [ + createPayloadCell({ + cellId: "cell-1", + code: "first_payload = True", + outputHtml: "
first payload
", + }), + createPayloadCell({ + cellId: "cell-2", + code: "second_payload = True", + outputHtml: "
second payload
", + }), + ], + }); + + const result = parseMarimoIslandApps(container); + + expect(result[0].cells.map((cell) => cell.code)).toEqual([ + "first_payload = True", + "second_payload = True", + ]); + expect(first.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0"); + expect(second.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1"); + }); + + it("should include payload-only runtime cells", () => { + const island = createMockIslandElement({ + appId: "app1", + cellId: "cell-2", + code: "value", + innerHTML: "
value
", + }); + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.appendChild(island); + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [ + createPayloadCell({ + cellId: "cell-1", + code: "import marimo as mo", + outputHtml: "", + }), + createPayloadCell({ + cellId: "cell-2", + code: "mo.md('visible')", + outputHtml: "
visible
", + }), + ], + }); + + const result = parseMarimoIslandApps(container); + + expect(result[0].cells.map((cell) => cell.code)).toEqual([ + "import marimo as mo", + "mo.md('visible')", + ]); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1"); + }); + + it("should bind payload-backed islands by runtime index after static cells", () => { + const island = createMockIslandElement({ + appId: "app1", + cellId: "cell-2", + code: "visible_dom = True", + innerHTML: "
visible dom
", + }); + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.appendChild(island); + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [ + createPayloadCell({ + cellId: "cell-1", + code: "", + outputHtml: "
static
", + reactive: false, + }), + createPayloadCell({ + cellId: "cell-2", + code: "visible_payload = True", + outputHtml: "
visible payload
", + }), + ], + }); + + const result = parseMarimoIslandApps(container); + + expect(result[0].cells).toEqual([ + { + cellId: "cell-1", + code: "", + disabled: true, + idx: 0, + output: "
static
", + }, + { + cellId: "cell-2", + code: "visible_payload = True", + idx: 1, + output: "
visible payload
", + }, + ]); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1"); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull(); + }); + + it("should fall back to DOM when no payload cell matches an island", () => { + const island = createMockIslandElement({ + appId: "app1", + cellId: "dom-only", + code: "dom_only = True", + innerHTML: "
dom only
", + }); + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + island.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX); + container.appendChild(island); + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [ + createPayloadCell({ + cellId: "payload-only", + code: "payload_only = True", + outputHtml: "
payload only
", + }), + ], + }); + + const result = parseMarimoIslandApps(container); + + expect(result).toEqual([ + { + id: "app1", + cells: [ + { + code: "dom_only = True", + idx: 0, + output: "
dom only
", + }, + ], + }, + ]); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0"); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBe( + "dom-only", + ); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE)).toBe("true"); + }); + + it("should ignore payloads without matching islands", () => { + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [createPayloadCell()], + }); + + const result = parseMarimoIslandApps(container); + + expect(result).toEqual([]); + }); + + it("should still parse DOM-only apps when another app has payload", () => { + const payloadIsland = createMockIslandElement({ + appId: "app1", + cellId: "cell-1", + code: "payload_dom = True", + innerHTML: "
payload dom
", + }); + const domIsland = createMockIslandElement({ + appId: "app2", + code: "dom_app = True", + innerHTML: "
dom app
", + }); + for (const island of [payloadIsland, domIsland]) { + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.appendChild(island); + } + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [createPayloadCell()], + }); + + const result = parseMarimoIslandApps(container); + + expect(result.map((app) => app.id)).toEqual(["app1", "app2"]); + expect(result[0].cells[0].code).toBe('print("payload")'); + expect(result[1].cells[0].code).toBe("dom_app = True"); + }); + + it("should materialize non-reactive payload islands without runtime cells", () => { + const island = createMockIslandElement({ + appId: "app1", + cellId: "cell-1", + code: "static_dom = True", + innerHTML: "
static dom
", + }); + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false"); + island.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX); + container.appendChild(island); + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [ + createPayloadCell({ + code: "", + outputHtml: "
static payload
", + reactive: false, + }), + ], + }); + + const result = parseMarimoIslandApps(container); + + expect(result).toEqual([]); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBeNull(); + expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull(); + expect(island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)?.innerHTML).toBe( + "
static payload
", + ); + }); + + it("should keep non-reactive display code out of runtime cells", () => { + const staticIsland = createMockIslandElement({ + appId: "app1", + cellId: "cell-1", + code: "x = 0", + innerHTML: "
static
", + }); + const reactiveIsland = createMockIslandElement({ + appId: "app1", + cellId: "cell-2", + code: "y = 1", + innerHTML: "
reactive
", + }); + staticIsland.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false"); + reactiveIsland.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.append(staticIsland, reactiveIsland); + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [ + createPayloadCell({ + cellId: "cell-1", + code: "x = 0", + outputHtml: "
static payload
", + reactive: false, + displayCode: true, + }), + createPayloadCell({ + cellId: "cell-2", + code: "y = 1", + outputHtml: "
reactive payload
", + }), + ], + }); + + const result = parseMarimoIslandApps(container); + const file = createMarimoFile(result[0]); + + expect(result[0].cells).toEqual([ + { + cellId: "cell-1", + code: "", + disabled: true, + idx: 0, + output: "
static payload
", + }, + { + cellId: "cell-2", + code: "y = 1", + idx: 1, + output: "
reactive payload
", + }, + ]); + expect(file).not.toContain("x = 0"); + expect(file).toContain("@app.cell(disabled=True)\ndef __():\n pass"); + expect(reactiveIsland.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe( + "1", + ); + }); + + it("should update editor initial values from payload code", () => { + const island = createMockIslandElement({ + appId: "app1", + cellId: "cell-1", + code: 'print("dom")', + innerHTML: "
dom
", + }); + island.insertAdjacentHTML( + "beforeend", + '
', + ); + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.appendChild(island); + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [createPayloadCell()], + }); + + parseMarimoIslandApps(container); + + expect( + island + .querySelector(ISLAND_TAG_NAMES.CODE_EDITOR) + ?.getAttribute("data-initial-value"), + ).toBe(JSON.stringify('print("payload")')); + }); + + it("should match duplicate cell ids by occurrence", () => { + const first = createMockIslandElement({ + appId: "app1", + cellId: "cell-1", + code: "dom_first = True", + innerHTML: "
dom first
", + }); + const second = createMockIslandElement({ + appId: "app1", + cellId: "cell-1", + code: "dom_second = True", + innerHTML: "
dom second
", + }); + for (const island of [first, second]) { + island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true"); + container.appendChild(island); + } + appendPayload(container, { + schemaVersion: 1, + appId: "app1", + cells: [ + createPayloadCell({ + code: "payload_first = True", + outputHtml: "
payload first
", + }), + createPayloadCell({ + code: "payload_second = True", + outputHtml: "
payload second
", + }), + ], + }); + + const result = parseMarimoIslandApps(container); + + expect(result[0].cells.map((cell) => cell.code)).toEqual([ + "payload_first = True", + "payload_second = True", + ]); + expect(first.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0"); + expect(second.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1"); + }); }); diff --git a/frontend/src/core/islands/__tests__/test-utils.tsx b/frontend/src/core/islands/__tests__/test-utils.tsx index 86d82bfd38f..6178c23b697 100644 --- a/frontend/src/core/islands/__tests__/test-utils.tsx +++ b/frontend/src/core/islands/__tests__/test-utils.tsx @@ -23,12 +23,14 @@ import type { WorkerFactory } from "@/core/islands/worker-factory"; export function createMockIslandElement(options: { appId?: string; cellIdx?: string; + cellId?: string; code?: string; innerHTML?: string; }): HTMLElement { const { appId = "test-app", cellIdx = "0", + cellId, code = "import marimo as mo", innerHTML = "", } = options; @@ -36,6 +38,9 @@ export function createMockIslandElement(options: { const element = document.createElement(ISLAND_TAG_NAMES.ISLAND); element.setAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID, appId); element.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, cellIdx); + if (cellId) { + element.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID, cellId); + } if (code) { const codeElement = document.createElement(ISLAND_TAG_NAMES.CELL_CODE); @@ -146,6 +151,7 @@ export async function waitForNoError( export interface IslandSpec { appId?: string; + cellId?: string; reactive?: boolean; code?: string; output?: string; @@ -160,6 +166,9 @@ export function buildIslandHTML(islands: IslandSpec[]): string { return islands .map((spec) => { const appId = spec.appId ?? "test-app"; + const cellId = spec.cellId + ? ` ${ISLAND_DATA_ATTRIBUTES.CELL_ID}="${spec.cellId}"` + : ""; const reactive = spec.reactive ?? true; const output = spec.output ?? "
output
"; const code = spec.code ?? 'print("hello")'; @@ -169,7 +178,7 @@ export function buildIslandHTML(islands: IslandSpec[]): string { : ""; const outputTag = `<${ISLAND_TAG_NAMES.CELL_OUTPUT}>${output}`; - return `<${ISLAND_TAG_NAMES.ISLAND} ${ISLAND_DATA_ATTRIBUTES.APP_ID}="${appId}" ${ISLAND_DATA_ATTRIBUTES.REACTIVE}="${reactive}">${outputTag}${codeTag}`; + return `<${ISLAND_TAG_NAMES.ISLAND} ${ISLAND_DATA_ATTRIBUTES.APP_ID}="${appId}"${cellId} ${ISLAND_DATA_ATTRIBUTES.REACTIVE}="${reactive}">${outputTag}${codeTag}`; }) .join("\n"); } diff --git a/frontend/src/core/islands/bridge.ts b/frontend/src/core/islands/bridge.ts index 0696d08c4ee..215d4b950ed 100644 --- a/frontend/src/core/islands/bridge.ts +++ b/frontend/src/core/islands/bridge.ts @@ -124,8 +124,13 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests { `Starting sessions for ${apps.length} app(s):`, apps.map((a) => `${a.id} (${a.cells.length} cells)`), ); + // Payload-backed apps already carry the exact runtime cells and order. The + // full-notebook export context may describe a different source and would + // override the payload contract for single-app pages. const exportContext = - apps.length === 1 ? getMarimoExportContext() : undefined; + apps.length === 1 && !apps[0]?.payloadBacked + ? getMarimoExportContext() + : undefined; const notebookCode = exportContext?.notebookCode; for (const app of apps) { const file = notebookCode || createMarimoFile(app); diff --git a/frontend/src/core/islands/constants.ts b/frontend/src/core/islands/constants.ts index d163406f543..a54ca97d1de 100644 --- a/frontend/src/core/islands/constants.ts +++ b/frontend/src/core/islands/constants.ts @@ -10,6 +10,8 @@ export const ISLAND_TAG_NAMES = { CODE_EDITOR: "marimo-code-editor", } as const; +export const ISLANDS_JSON_SCRIPT_TYPE = "application/vnd.marimo.islands+json"; + /** * Data attributes for islands */ diff --git a/frontend/src/core/islands/parse.ts b/frontend/src/core/islands/parse.ts index 2518e372d0b..f8bc822112a 100644 --- a/frontend/src/core/islands/parse.ts +++ b/frontend/src/core/islands/parse.ts @@ -3,6 +3,7 @@ import { ISLAND_DATA_ATTRIBUTES, ISLAND_TAG_NAMES, + ISLANDS_JSON_SCRIPT_TYPE, } from "@/core/islands/constants"; import { Logger } from "@/utils/Logger"; @@ -23,6 +24,10 @@ export interface MarimoIslandApp { * ID since we allow multiple apps on the same page. */ id: string; + /** + * Whether cells came from a supported JSON payload instead of DOM parsing. + */ + payloadBacked?: boolean; /** * Cells in the app. */ @@ -42,6 +47,29 @@ interface MarimoIslandCell { * Index of the cell. */ idx: number; + /** + * Stable cell identifier, when provided by the island payload. + */ + cellId?: string; + /** + * Whether the generated marimo cell should be present but not executed. + */ + disabled?: boolean; +} + +interface MarimoIslandPayload { + schemaVersion: 1; + appId: string; + cells: MarimoIslandPayloadCell[]; +} + +interface MarimoIslandPayloadCell { + cellId: string; + code: string; + outputHtml: string; + reactive: boolean; + displayCode: boolean; + displayOutput: boolean; } /** @@ -51,14 +79,20 @@ interface MarimoIslandCell { export function parseMarimoIslandApps( root: Document | Element = document, ): MarimoIslandApp[] { - const embeds = root.querySelectorAll(ISLAND_TAG_NAMES.ISLAND); - if (embeds.length === 0) { + const embeds = [ + ...root.querySelectorAll(ISLAND_TAG_NAMES.ISLAND), + ]; + const payloads = parseMarimoIslandPayloads(root); + if (embeds.length === 0 && payloads.length === 0) { Logger.warn("No embedded marimo apps found."); return []; } - // eslint-disable-next-line prefer-spread - return parseIslandElementsIntoApps(Array.from(embeds)); + if (payloads.length > 0) { + return parsePayloadBackedApps(embeds, payloads); + } + + return parseIslandElementsIntoApps(embeds); } /** @@ -90,11 +124,12 @@ export function parseIslandElementsIntoApps( continue; } - if (!apps.has(appId)) { - apps.set(appId, { id: appId, cells: [] }); + let app = apps.get(appId); + if (!app) { + app = { id: appId, cells: [] }; + apps.set(appId, app); } - const app = apps.get(appId)!; const idx = app.cells.length; app.cells.push({ output: cellData.output, @@ -109,6 +144,159 @@ export function parseIslandElementsIntoApps( return [...apps.values()]; } +function parsePayloadBackedApps( + embeds: HTMLElement[], + payloads: MarimoIslandPayload[], +): MarimoIslandApp[] { + const apps = new Map(); + const matchedPayloadCells = new Map(); + const consumedEmbeds = new Set(); + const acceptedPayloads: MarimoIslandPayload[] = []; + + for (const payload of payloads) { + let hasMatchedIsland = false; + for (const cell of payload.cells) { + const embed = findMatchingIsland({ + embeds, + appId: payload.appId, + cell, + consumedEmbeds, + }); + if (!embed) { + continue; + } + consumedEmbeds.add(embed); + matchedPayloadCells.set(cell, embed); + materializeIslandPayload(embed, cell); + hasMatchedIsland = true; + } + // Only payloads matched to island anchors can start runtime apps. + if (hasMatchedIsland) { + acceptedPayloads.push(payload); + } + } + + const payloadAppIds = new Set( + acceptedPayloads.map((payload) => payload.appId), + ); + const reactivePayloadAppIds = new Set( + acceptedPayloads + .filter((payload) => payload.cells.some((cell) => cell.reactive)) + .map((payload) => payload.appId), + ); + + for (const payload of acceptedPayloads) { + for (const cell of payload.cells) { + const embed = matchedPayloadCells.get(cell); + // Static-only payload apps render from HTML and do not need a Pyodide + // session. + if (!reactivePayloadAppIds.has(payload.appId)) { + continue; + } + + let app = apps.get(payload.appId); + if (!app) { + app = { id: payload.appId, payloadBacked: true, cells: [] }; + apps.set(payload.appId, app); + } + + const idx = app.cells.length; + const appCell: MarimoIslandCell = { + cellId: cell.cellId, + output: cell.outputHtml, + code: cell.reactive ? cell.code : "", + idx: idx, + }; + // Keep static cells in the generated file so later reactive cells keep + // stable runtime indices without executing static code. + if (!cell.reactive) { + appCell.disabled = true; + } + app.cells.push(appCell); + if (cell.reactive) { + embed?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString()); + } + } + } + + // A supported payload is the runtime source for its app. Extra same-app DOM + // islands are disconnected from runtime binding. + for (const embed of embeds) { + const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID); + if (appId && payloadAppIds.has(appId) && !consumedEmbeds.has(embed)) { + embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID); + embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX); + embed.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false"); + } + } + + const domOnlyEmbeds = embeds.filter((embed) => { + const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID); + return !appId || !payloadAppIds.has(appId); + }); + + return [...apps.values(), ...parseIslandElementsIntoApps(domOnlyEmbeds)]; +} + +function findMatchingIsland({ + embeds, + appId, + cell, + consumedEmbeds, +}: { + embeds: HTMLElement[]; + appId: string; + cell: MarimoIslandPayloadCell; + consumedEmbeds: Set; +}): HTMLElement | undefined { + return embeds.find((embed) => { + if (consumedEmbeds.has(embed)) { + return false; + } + return ( + embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID) === appId && + embed.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID) === cell.cellId + ); + }); +} + +function materializeIslandPayload( + embed: HTMLElement, + cell: MarimoIslandPayloadCell, +): void { + embed.setAttribute( + ISLAND_DATA_ATTRIBUTES.REACTIVE, + JSON.stringify(cell.reactive), + ); + // The runtime file is synthesized from payload order, so DOM anchors bind + // by index. + embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID); + if (!cell.reactive) { + embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX); + } + + const output = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_OUTPUT); + output.innerHTML = cell.displayOutput ? cell.outputHtml : ""; + + const code = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_CODE); + code.hidden = true; + code.textContent = encodeURIComponent(cell.code); + + const editor = embed.querySelector(ISLAND_TAG_NAMES.CODE_EDITOR); + if (editor) { + editor.setAttribute("data-initial-value", JSON.stringify(cell.code)); + } +} + +function ensureIslandChild(embed: HTMLElement, tagName: string): HTMLElement { + let child = embed.querySelector(tagName); + if (!child) { + child = embed.ownerDocument.createElement(tagName); + embed.appendChild(child); + } + return child; +} + /** * Parses a single island element into cell data * @param embed - The island HTML element @@ -132,26 +320,35 @@ export function parseIslandElement( }; } -export function createMarimoFile(app: { cells: { code: string }[] }): string { +export function createMarimoFile(app: { + cells: { code: string; disabled?: boolean }[]; +}): string { const lines = [ "import marimo", "app = marimo.App()", app.cells .map((cell) => { - // Add 4 spaces to each line - const code = cell.code - .split("\n") - .map((line) => ` ${line}`) - .join("\n"); + // Disabled payload cells are placeholders. Emit pass so static code + // does not define names in the runtime graph. + const sourceCode = cell.disabled ? "" : cell.code; + const code = sourceCode + ? sourceCode + .split("\n") + .map((line) => ` ${line}`) + .join("\n") + : " pass"; // TODO: Handle async cells better // This is probably not the best way to check if the code is async // Ideally this is pushed into the Python code const isAsync = code.includes("await "); const prefix = isAsync ? "async def" : "def"; + const decorator = cell.disabled + ? "@app.cell(disabled=True)" + : "@app.cell"; // Wrap in a function - return `@app.cell\n${prefix} __():\n${code}\n return`; + return `${decorator}\n${prefix} __():\n${code}\n return`; }) .join("\n"), ]; @@ -204,3 +401,84 @@ export function extractIslandCodeFromEmbed(embed: HTMLElement): string { return ""; } + +function parseMarimoIslandPayloads( + root: Document | Element, +): MarimoIslandPayload[] { + const scripts = root.querySelectorAll( + `script[type="${ISLANDS_JSON_SCRIPT_TYPE}"]`, + ); + const payloads: MarimoIslandPayload[] = []; + + for (const script of scripts) { + if (isNestedIslandPayloadScript(script)) { + continue; + } + const payload = parseMarimoIslandPayload(script.textContent); + if (payload) { + payloads.push(payload); + } + } + + return payloads; +} + +function isNestedIslandPayloadScript(script: HTMLScriptElement): boolean { + return Boolean( + script.closest(ISLAND_TAG_NAMES.ISLAND) || + script.closest(ISLAND_TAG_NAMES.CELL_OUTPUT), + ); +} + +function parseMarimoIslandPayload( + text: string | undefined | null, +): MarimoIslandPayload | null { + if (!text) { + return null; + } + + try { + const payload = JSON.parse(text); + if (isMarimoIslandPayload(payload)) { + return payload; + } + } catch { + return null; + } + + return null; +} + +function isMarimoIslandPayload( + payload: unknown, +): payload is MarimoIslandPayload { + if (!isRecord(payload)) { + return false; + } + return ( + payload.schemaVersion === 1 && + typeof payload.appId === "string" && + Array.isArray(payload.cells) && + payload.cells.every(isMarimoIslandPayloadCell) + ); +} + +function isMarimoIslandPayloadCell( + cell: unknown, +): cell is MarimoIslandPayloadCell { + if (!isRecord(cell)) { + return false; + } + return ( + typeof cell.cellId === "string" && + typeof cell.code === "string" && + typeof cell.outputHtml === "string" && + typeof cell.reactive === "boolean" && + typeof cell.displayCode === "boolean" && + typeof cell.displayOutput === "boolean" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} From 4f96e0f308e15e5131ea08f0cdb06bd2468d3cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Thu, 25 Jun 2026 11:48:08 +0200 Subject: [PATCH 03/10] feat: preserve island output metadata --- .../src/core/islands/__tests__/parse.test.ts | 2 ++ frontend/src/core/islands/parse.ts | 2 ++ marimo/_islands/_island_generator.py | 5 ++++ tests/_islands/snapshots/html.txt | 2 +- tests/_islands/test_island_generator.py | 25 +++++++++++++++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/frontend/src/core/islands/__tests__/parse.test.ts b/frontend/src/core/islands/__tests__/parse.test.ts index fc30b885496..08f0d9de685 100644 --- a/frontend/src/core/islands/__tests__/parse.test.ts +++ b/frontend/src/core/islands/__tests__/parse.test.ts @@ -22,6 +22,7 @@ function createPayloadCell( cellId: string; code: string; outputHtml: string; + outputMimetype: string; reactive: boolean; displayCode: boolean; displayOutput: boolean; @@ -31,6 +32,7 @@ function createPayloadCell( cellId: "cell-1", code: 'print("payload")', outputHtml: "
payload
", + outputMimetype: "text/html", reactive: true, displayCode: false, displayOutput: true, diff --git a/frontend/src/core/islands/parse.ts b/frontend/src/core/islands/parse.ts index f8bc822112a..71669ecf51e 100644 --- a/frontend/src/core/islands/parse.ts +++ b/frontend/src/core/islands/parse.ts @@ -67,6 +67,7 @@ interface MarimoIslandPayloadCell { cellId: string; code: string; outputHtml: string; + outputMimetype: string; reactive: boolean; displayCode: boolean; displayOutput: boolean; @@ -473,6 +474,7 @@ function isMarimoIslandPayloadCell( typeof cell.cellId === "string" && typeof cell.code === "string" && typeof cell.outputHtml === "string" && + typeof cell.outputMimetype === "string" && typeof cell.reactive === "boolean" && typeof cell.displayCode === "boolean" && typeof cell.displayOutput === "boolean" diff --git a/marimo/_islands/_island_generator.py b/marimo/_islands/_island_generator.py index 04cb7584283..0c35494934a 100644 --- a/marimo/_islands/_island_generator.py +++ b/marimo/_islands/_island_generator.py @@ -40,6 +40,7 @@ class MarimoIslandCellPayload(TypedDict): cellId: str code: str outputHtml: str + outputMimetype: str reactive: bool displayCode: bool displayOutput: bool @@ -208,11 +209,15 @@ def to_payload( display_output=display_output, is_reactive=is_reactive, ) + output = self.output output_html = self._output_html() return { "cellId": str(self._cell_id), "code": self.code if is_reactive or display_code else "", "outputHtml": output_html if display_output else "", + "outputMimetype": ( + output.mimetype if output is not None else "text/plain" + ), "reactive": is_reactive, "displayCode": display_code, "displayOutput": display_output, diff --git a/tests/_islands/snapshots/html.txt b/tests/_islands/snapshots/html.txt index cfb141238c2..906fa54b076 100644 --- a/tests/_islands/snapshots/html.txt +++ b/tests/_islands/snapshots/html.txt @@ -75,7 +75,7 @@ - +
\ No newline at end of file diff --git a/tests/_islands/test_island_generator.py b/tests/_islands/test_island_generator.py index 7dd134681e7..dfe950b9860 100644 --- a/tests/_islands/test_island_generator.py +++ b/tests/_islands/test_island_generator.py @@ -12,6 +12,7 @@ MarimoIslandGenerator, ) from marimo._messaging.cell_output import CellChannel, CellOutput +from marimo._messaging.errors import MarimoExceptionRaisedError from marimo._schemas.serialization import ( AppInstantiation, CellDef, @@ -122,6 +123,7 @@ async def test_render_payload(): "cellId": str(generator._stubs[0]._cell_id), "code": "import marimo as mo", "outputHtml": "", + "outputMimetype": "text/plain", "reactive": True, "displayCode": False, "displayOutput": True, @@ -129,9 +131,31 @@ async def test_render_payload(): assert payload["cells"][1]["cellId"] == str(generator._stubs[1]._cell_id) assert payload["cells"][1]["code"] == "mo.md('Payload output')" assert "Payload output" in payload["cells"][1]["outputHtml"] + assert payload["cells"][1]["outputMimetype"] == "text/markdown" assert payload["cells"][1]["reactive"] is True +def test_render_payload_preserves_error_output_mimetype(): + generator = MarimoIslandGenerator() + stub = generator.add_code("0 / 0") + stub._output = CellOutput.errors( + [ + MarimoExceptionRaisedError( + msg="division by zero", + exception_type="ZeroDivisionError", + raising_cell=None, + ) + ] + ) + + payload = generator.render_payload() + + assert ( + payload["cells"][0]["outputMimetype"] == "application/vnd.marimo+error" + ) + assert "application/vnd.marimo+error" in payload["cells"][0]["outputHtml"] + + def test_render_payload_script_escapes_json(): generator = MarimoIslandGenerator() generator.add_code('value = "
&"') @@ -200,6 +224,7 @@ def test_render_payload_uses_configured_reactive_option(): "cellId": str(stub._cell_id), "code": "", "outputHtml": "", + "outputMimetype": "text/plain", "reactive": False, "displayCode": False, "displayOutput": True, From 6567c0c310d54593bb19578576f96e3b36c4200a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Thu, 25 Jun 2026 13:13:09 +0200 Subject: [PATCH 04/10] docs: briefly document payloads --- docs/guides/exporting/webassembly_html.md | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/guides/exporting/webassembly_html.md b/docs/guides/exporting/webassembly_html.md index dc5cd4ce942..e2b6e2c71de 100644 --- a/docs/guides/exporting/webassembly_html.md +++ b/docs/guides/exporting/webassembly_html.md @@ -176,6 +176,36 @@ Use `MarimoIslandGenerator` to generate HTML for islands Any relevant `.html` that gets generated can be run through the [`development.md`](https://github.com/marimo-team/marimo/blob/main/frontend/islands/development.md) file instructions. +### Island payloads + +`MarimoIslandGenerator.render_html()` and `render_body()` include a JSON payload by default. The payload stores each cell's code, rendered output HTML, output MIME type, and display settings. + +The islands runtime uses this payload to hydrate the page. The DOM still provides the visible island slots, and the payload provides the runtime cell code and output metadata. + +An island payload is an inert JSON script: + +```html + +``` + +If you post-process island HTML, preserve the script tag with type `application/vnd.marimo.islands+json`. + ### Islands in action !!! warning "Advanced topic!" From c67ff8feb82ee2e8bdf601fbbbf9b2f26c04ea62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Thu, 25 Jun 2026 13:38:35 +0200 Subject: [PATCH 05/10] chore: address reviews --- docs/guides/exporting/webassembly_html.md | 24 ++++------------------- marimo/_islands/_island_generator.py | 4 ++-- tests/_islands/snapshots/html.txt | 1 - tests/_islands/test_island_generator.py | 11 ++++++++++- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/docs/guides/exporting/webassembly_html.md b/docs/guides/exporting/webassembly_html.md index e2b6e2c71de..0a6b7368d89 100644 --- a/docs/guides/exporting/webassembly_html.md +++ b/docs/guides/exporting/webassembly_html.md @@ -178,33 +178,17 @@ Any relevant `.html` that gets generated can be run through the [`development.md ### Island payloads -`MarimoIslandGenerator.render_html()` and `render_body()` include a JSON payload by default. The payload stores each cell's code, rendered output HTML, output MIME type, and display settings. +`MarimoIslandGenerator.render_html(include_payload=True)` and `render_body(include_payload=True)` include a JSON payload. The payload stores each cell's code, rendered output HTML, output MIME type, and display settings. The islands runtime uses this payload to hydrate the page. The DOM still provides the visible island slots, and the payload provides the runtime cell code and output metadata. -An island payload is an inert JSON script: +An emitted payload looks like this. HTML-sensitive characters inside JSON strings are escaped before marimo writes the script tag. ```html - + ``` -If you post-process island HTML, preserve the script tag with type `application/vnd.marimo.islands+json`. +If you post-process island HTML, preserve the script tag with type `application/vnd.marimo.islands+json` and keep its contents unchanged. ### Islands in action diff --git a/marimo/_islands/_island_generator.py b/marimo/_islands/_island_generator.py index 0c35494934a..1c063e13689 100644 --- a/marimo/_islands/_island_generator.py +++ b/marimo/_islands/_island_generator.py @@ -619,7 +619,7 @@ def render_body( self, *, include_init_island: bool = True, - include_payload: bool = True, + include_payload: bool = False, max_width: str | None = None, margin: str | None = None, style: str | None = None, @@ -676,7 +676,7 @@ def render_html( version_override: str = __version__, _development_url: str | bool = False, include_init_island: bool = True, - include_payload: bool = True, + include_payload: bool = False, max_width: str | None = None, margin: str | None = None, style: str | None = None, diff --git a/tests/_islands/snapshots/html.txt b/tests/_islands/snapshots/html.txt index 906fa54b076..3071707d42b 100644 --- a/tests/_islands/snapshots/html.txt +++ b/tests/_islands/snapshots/html.txt @@ -75,7 +75,6 @@ -
\ No newline at end of file diff --git a/tests/_islands/test_island_generator.py b/tests/_islands/test_island_generator.py index dfe950b9860..ff99642c381 100644 --- a/tests/_islands/test_island_generator.py +++ b/tests/_islands/test_island_generator.py @@ -183,12 +183,21 @@ def test_render_payload_script_escapes_output_html(): assert payload["cells"][0]["outputHtml"] == "
&" -def test_render_body_includes_payload_by_default(): +def test_render_body_omits_payload_by_default(): generator = MarimoIslandGenerator() generator.add_code("import marimo as mo") body = generator.render_body() + assert ISLANDS_JSON_SCRIPT_TYPE not in body + + +def test_render_body_can_include_payload(): + generator = MarimoIslandGenerator() + generator.add_code("import marimo as mo") + + body = generator.render_body(include_payload=True) + assert f' + + + + + + + + +
+ + + +
+
+ + + +
Initializing...
+
+
+ +
+ +
+ + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/tests/_islands/test_island_generator.py b/tests/_islands/test_island_generator.py index ff99642c381..1a6b62470ca 100644 --- a/tests/_islands/test_island_generator.py +++ b/tests/_islands/test_island_generator.py @@ -712,6 +712,12 @@ async def test_render_html(): assert "" in html assert "Hello%2C%20HTML!" in html snapshot("html.txt", html.replace(__version__, "0.0.0")) + snapshot( + "html-payload.txt", + generator.render_html(include_payload=True).replace( + __version__, "0.0.0" + ), + ) def test_app_config(): From a9506866c3f578177c7a4cf37e4ab76ae71d8b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Thu, 25 Jun 2026 19:17:23 +0200 Subject: [PATCH 07/10] chore: address comments --- frontend/src/core/islands/__tests__/parse.test.ts | 11 +++++++++-- frontend/src/core/islands/parse.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/core/islands/__tests__/parse.test.ts b/frontend/src/core/islands/__tests__/parse.test.ts index e917bae253d..593170e28fb 100644 --- a/frontend/src/core/islands/__tests__/parse.test.ts +++ b/frontend/src/core/islands/__tests__/parse.test.ts @@ -1,11 +1,12 @@ /* Copyright 2026 Marimo. All rights reserved. */ import { readFileSync } from "node:fs"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ISLAND_DATA_ATTRIBUTES, ISLAND_TAG_NAMES, ISLANDS_JSON_SCRIPT_TYPE, } from "@/core/islands/constants"; +import { Logger } from "@/utils/Logger"; import { createMarimoFile, extractIslandCodeFromEmbed, @@ -519,6 +520,7 @@ describe("parseMarimoIslandApps", () => { afterEach(() => { document.body.removeChild(container); + vi.restoreAllMocks(); }); it("should parse islands from document", () => { @@ -630,7 +632,10 @@ describe("parseMarimoIslandApps", () => { it("should parse Python-generated island payload snapshots", () => { const html = readFileSync( - "../tests/_islands/snapshots/html-payload.txt", + new URL( + "../../../../../tests/_islands/snapshots/html-payload.txt", + import.meta.url, + ).pathname.replace(/^\/@fs/, ""), "utf8", ); container.innerHTML = html; @@ -904,6 +909,7 @@ describe("parseMarimoIslandApps", () => { }); it("should ignore payloads without matching islands", () => { + const warn = vi.spyOn(Logger, "warn").mockImplementation(() => undefined); appendPayload(container, { schemaVersion: 1, appId: "app1", @@ -913,6 +919,7 @@ describe("parseMarimoIslandApps", () => { const result = parseMarimoIslandApps(container); expect(result).toEqual([]); + expect(warn).toHaveBeenCalledWith("No embedded marimo apps found."); }); it("should still parse DOM-only apps when another app has payload", () => { diff --git a/frontend/src/core/islands/parse.ts b/frontend/src/core/islands/parse.ts index 71669ecf51e..8e8e4d3c66b 100644 --- a/frontend/src/core/islands/parse.ts +++ b/frontend/src/core/islands/parse.ts @@ -84,7 +84,7 @@ export function parseMarimoIslandApps( ...root.querySelectorAll(ISLAND_TAG_NAMES.ISLAND), ]; const payloads = parseMarimoIslandPayloads(root); - if (embeds.length === 0 && payloads.length === 0) { + if (embeds.length === 0) { Logger.warn("No embedded marimo apps found."); return []; } From ab5e4cadef64e6d5f42911e18da3e87da041f813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Fri, 26 Jun 2026 04:18:56 +0200 Subject: [PATCH 08/10] chore: address more comments --- marimo/_islands/_island_generator.py | 41 ++++++----------------- marimo/_schemas/islands.py | 23 +++++++++++++ tests/_islands/snapshots/html-payload.txt | 2 +- tests/_islands/test_island_generator.py | 15 +++++++-- 4 files changed, 46 insertions(+), 35 deletions(-) create mode 100644 marimo/_schemas/islands.py diff --git a/marimo/_islands/_island_generator.py b/marimo/_islands/_island_generator.py index 1c063e13689..0a88ef22875 100644 --- a/marimo/_islands/_island_generator.py +++ b/marimo/_islands/_island_generator.py @@ -6,7 +6,7 @@ import os import sys from textwrap import dedent -from typing import TYPE_CHECKING, TypedDict, cast +from typing import TYPE_CHECKING, cast from marimo import _loggers from marimo._ast.app import App, InternalApp @@ -16,7 +16,14 @@ from marimo._ast.load import load_notebook_ir from marimo._messaging.cell_output import CellOutput from marimo._output.utils import uri_encode_component +from marimo._schemas.islands import ( + ISLANDS_JSON_SCHEMA_VERSION, + ISLANDS_JSON_SCRIPT_TYPE, + MarimoIslandCellPayload, + MarimoIslandPayload, +) from marimo._schemas.serialization import NotebookSerialization +from marimo._server.templates.templates import json_script from marimo._session.notebook import AppFileManager, load_notebook from marimo._types.ids import CellId_t from marimo._utils.marimo_path import MarimoPath @@ -32,25 +39,6 @@ LOGGER = _loggers.marimo_logger() IN_MEMORY_FILENAME = ".py" -ISLANDS_JSON_SCRIPT_TYPE = "application/vnd.marimo.islands+json" -ISLANDS_JSON_SCHEMA_VERSION = 1 - - -class MarimoIslandCellPayload(TypedDict): - cellId: str - code: str - outputHtml: str - outputMimetype: str - reactive: bool - displayCode: bool - displayOutput: bool - - -class MarimoIslandPayload(TypedDict): - schemaVersion: int - appId: str - cells: list[MarimoIslandCellPayload] - class MarimoIslandStub: """ @@ -735,8 +723,8 @@ def render_payload(self) -> MarimoIslandPayload: } def render_payload_script(self) -> str: - """Render the island app payload as an inert JSON script tag.""" - payload = _json_script_content(self.render_payload()) + """Render the island app payload in a JSON script tag.""" + payload = json_script(self.render_payload()) return f'' @@ -775,12 +763,3 @@ def _disabled_cell_ids(cell_data: list[CellData]) -> set[CellId_t]: disabled_cell_ids.add(data.cell_id) return disabled_cell_ids - - -def _json_script_content(payload: object) -> str: - return ( - json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - .replace("&", "\\u0026") - .replace("<", "\\u003c") - .replace(">", "\\u003e") - ) diff --git a/marimo/_schemas/islands.py b/marimo/_schemas/islands.py new file mode 100644 index 00000000000..0f4af5f1b23 --- /dev/null +++ b/marimo/_schemas/islands.py @@ -0,0 +1,23 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from typing import Literal, TypedDict + +ISLANDS_JSON_SCRIPT_TYPE: str = "application/vnd.marimo.islands+json" +ISLANDS_JSON_SCHEMA_VERSION: Literal[1] = 1 + + +class MarimoIslandCellPayload(TypedDict): + cellId: str + code: str + outputHtml: str + outputMimetype: str + reactive: bool + displayCode: bool + displayOutput: bool + + +class MarimoIslandPayload(TypedDict): + schemaVersion: Literal[1] + appId: str + cells: list[MarimoIslandCellPayload] diff --git a/tests/_islands/snapshots/html-payload.txt b/tests/_islands/snapshots/html-payload.txt index f3a483db633..2fca31dd754 100644 --- a/tests/_islands/snapshots/html-payload.txt +++ b/tests/_islands/snapshots/html-payload.txt @@ -75,7 +75,7 @@ - +
\ No newline at end of file diff --git a/tests/_islands/test_island_generator.py b/tests/_islands/test_island_generator.py index 1a6b62470ca..fb9e47e1439 100644 --- a/tests/_islands/test_island_generator.py +++ b/tests/_islands/test_island_generator.py @@ -47,6 +47,14 @@ def _parse_payload_script(script: str) -> dict[str, object]: return json.loads(script[len(prefix) : -len(suffix)]) +def _parse_payload_from_body(body: str) -> dict[str, object]: + prefix = f'" + start = body.index(prefix) + end = body.index(suffix, start) + len(suffix) + return _parse_payload_script(body[start:end]) + + def test_add_code(): generator = MarimoIslandGenerator() generator.add_code("print('Hello, World!')") @@ -197,11 +205,12 @@ def test_render_body_can_include_payload(): generator.add_code("import marimo as mo") body = generator.render_body(include_payload=True) + payload = _parse_payload_from_body(body) assert f'