Skip to content

Commit f1e4d92

Browse files
authored
feat: add --execute to marimo export html-wasm for session previews (#9437)
## 📝 Summary This PR introduces `--execute` to `marimo export html-wasm` enabling previews before running. Example: <img width="1158" height="995" alt="image" src="https://github.com/user-attachments/assets/68fa947d-89c8-4314-adc9-ef350530b467" />
1 parent 2919400 commit f1e4d92

6 files changed

Lines changed: 211 additions & 3 deletions

File tree

frontend/src/core/cells/__tests__/cells.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ vi.mock("@/core/codemirror/editing/commands", () => ({
4646
foldAllBulk: vi.fn(),
4747
unfoldAllBulk: vi.fn(),
4848
}));
49+
vi.mock("@/core/wasm/utils", () => ({
50+
isWasm: vi.fn(() => false),
51+
}));
4952
vi.mock("../scrollCellIntoView", async (importOriginal) => {
5053
const actual = await importOriginal();
5154
return {
@@ -3428,3 +3431,99 @@ describe("createTracebackInfoAtom", () => {
34283431
expect(traceback).toBeUndefined();
34293432
});
34303433
});
3434+
3435+
describe("setCells snapshot preservation", () => {
3436+
const CELL_A = cellId("A");
3437+
const CELL_B = cellId("B");
3438+
const newCells: CellData[] = [
3439+
{
3440+
id: CELL_A,
3441+
name: "a",
3442+
code: "1",
3443+
edited: false,
3444+
lastCodeRun: null,
3445+
lastExecutionTime: null,
3446+
config: { hide_code: false, disabled: false, column: null },
3447+
serializedEditorState: null,
3448+
},
3449+
{
3450+
id: CELL_B,
3451+
name: "b",
3452+
code: "2",
3453+
edited: false,
3454+
lastCodeRun: null,
3455+
lastExecutionTime: null,
3456+
config: { hide_code: false, disabled: false, column: null },
3457+
serializedEditorState: null,
3458+
},
3459+
];
3460+
3461+
const hydratedState = () =>
3462+
MockNotebook.notebookState({
3463+
cellData: {
3464+
[CELL_A]: { id: CELL_A, code: "1" },
3465+
[CELL_B]: { id: CELL_B, code: "2" },
3466+
},
3467+
cellRuntime: {
3468+
[CELL_A]: {
3469+
output: {
3470+
channel: "output",
3471+
mimetype: "text/plain",
3472+
data: "hydrated-A",
3473+
timestamp: 0,
3474+
},
3475+
},
3476+
[CELL_B]: {
3477+
consoleOutputs: [
3478+
{
3479+
channel: "stdout",
3480+
mimetype: "text/plain",
3481+
data: "hydrated-B-stdout",
3482+
timestamp: 0,
3483+
},
3484+
],
3485+
},
3486+
},
3487+
});
3488+
3489+
beforeEach(async () => {
3490+
const { isWasm } = await import("@/core/wasm/utils");
3491+
vi.mocked(isWasm).mockReturnValue(true);
3492+
});
3493+
3494+
it("preserves hydrated output in WASM", () => {
3495+
const next = exportedForTesting.reducer(hydratedState(), {
3496+
type: "setCells",
3497+
payload: newCells,
3498+
});
3499+
3500+
expect(next.cellRuntime[CELL_A].output).toMatchObject({
3501+
data: "hydrated-A",
3502+
});
3503+
});
3504+
3505+
it("preserves console-only hydration in WASM", () => {
3506+
const next = exportedForTesting.reducer(hydratedState(), {
3507+
type: "setCells",
3508+
payload: newCells,
3509+
});
3510+
3511+
expect(next.cellRuntime[CELL_B].consoleOutputs).toHaveLength(1);
3512+
expect(next.cellRuntime[CELL_B].consoleOutputs[0]).toMatchObject({
3513+
data: "hydrated-B-stdout",
3514+
});
3515+
});
3516+
3517+
it("resets cells with no prior runtime even in WASM", () => {
3518+
const empty = MockNotebook.notebookState({ cellData: {} });
3519+
const next = exportedForTesting.reducer(empty, {
3520+
type: "setCells",
3521+
payload: newCells,
3522+
});
3523+
3524+
expect(next.cellRuntime[CELL_A].output).toBeNull();
3525+
expect(next.cellRuntime[CELL_A].consoleOutputs).toEqual([]);
3526+
expect(next.cellRuntime[CELL_B].output).toBeNull();
3527+
expect(next.cellRuntime[CELL_B].consoleOutputs).toEqual([]);
3528+
});
3529+
});

frontend/src/core/cells/cells.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { isErrorMime } from "../mime";
2929
import type { CellConfig } from "../network/types";
3030
import { isRtcEnabled } from "../rtc/state";
3131
import { createDeepEqualAtom, store } from "../state/jotai";
32+
import { isWasm } from "../wasm/utils";
3233
import { prepareCellForExecution, transitionCell } from "./cell";
3334
import { documentTransactionMiddleware } from "./document-changes";
3435
import { CellId, SCRATCH_CELL_ID, SETUP_CELL_ID } from "./ids";
@@ -1032,8 +1033,20 @@ const {
10321033
setCells: (state, cells: CellData[]) => {
10331034
const cellData = Object.fromEntries(cells.map((cell) => [cell.id, cell]));
10341035

1036+
// WASM has no server-side SessionView to replay outputs, so the
1037+
// snapshot hydrated by notebookStateFromSession is the only source.
1038+
const preserveSnapshot = isWasm();
1039+
const runtimeFor = (cellId: CellId): CellRuntimeState => {
1040+
if (!preserveSnapshot) {
1041+
return createCellRuntimeState();
1042+
}
1043+
const prev = state.cellRuntime[cellId];
1044+
const hasSnapshot =
1045+
prev && (prev.output != null || prev.consoleOutputs.length > 0);
1046+
return hasSnapshot ? prev : createCellRuntimeState();
1047+
};
10351048
const cellRuntime = Object.fromEntries(
1036-
cells.map((cell) => [cell.id, createCellRuntimeState()]),
1049+
cells.map((cell) => [cell.id, runtimeFor(cell.id)]),
10371050
);
10381051

10391052
return withScratchCell({

marimo/_cli/export/commands.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
run_app_then_export_as_html,
3333
run_app_then_export_as_ipynb,
3434
run_app_then_export_as_pdf,
35+
run_app_then_export_as_wasm,
3536
)
3637
from marimo._server.export._status import PDFExportStatusEvent
3738
from marimo._server.export.exporter import Exporter
@@ -870,11 +871,20 @@ def export_callback_impl(file_path: MarimoPath) -> ExportResult:
870871
default=False,
871872
help="Force overwrite of the output file if it already exists.",
872873
)
874+
@click.option(
875+
"--execute/--no-execute",
876+
default=False,
877+
help=(
878+
"Execute the notebook before exporting and embed outputs as a "
879+
"preview. Runs in the current Python environment."
880+
),
881+
)
873882
@click.argument(
874883
"name",
875884
required=True,
876885
type=click.Path(exists=True, file_okay=True, dir_okay=False),
877886
)
887+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
878888
def html_wasm(
879889
name: str,
880890
output: Path,
@@ -884,10 +894,17 @@ def html_wasm(
884894
include_cloudflare: bool,
885895
sandbox: bool | None,
886896
force: bool,
897+
execute: bool,
898+
args: tuple[str, ...],
887899
) -> None:
888900
"""Export a notebook as a WASM-powered standalone HTML file."""
889901
import sys
890902

903+
if execute and watch:
904+
raise click.UsageError(
905+
"--execute and --watch cannot be used together."
906+
)
907+
891908
# Set default, if not provided
892909
if sandbox is None:
893910
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox
@@ -909,8 +926,25 @@ def html_wasm(
909926

910927
marimo_file = MarimoPath(name)
911928

912-
def export_callback(file_path: MarimoPath) -> ExportResult:
913-
return export_as_wasm(file_path, mode, show_code=show_code)
929+
if execute:
930+
cli_args = parse_args(args)
931+
932+
def export_callback(file_path: MarimoPath) -> ExportResult:
933+
return asyncio_run(
934+
run_app_then_export_as_wasm(
935+
file_path,
936+
mode=mode,
937+
show_code=show_code,
938+
cli_args=cli_args,
939+
argv=list(args),
940+
)
941+
)
942+
943+
echo("Executing notebook...")
944+
else:
945+
946+
def export_callback(file_path: MarimoPath) -> ExportResult:
947+
return export_as_wasm(file_path, mode, show_code=show_code)
914948

915949
# Export assets first
916950
Exporter().export_assets(out_dir)

marimo/_server/export/__init__.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,60 @@ async def run_app_then_export_as_html(
355355
)
356356

357357

358+
async def run_app_then_export_as_wasm(
359+
path: MarimoPath,
360+
mode: Literal["edit", "run"],
361+
show_code: bool,
362+
cli_args: SerializedCLIArgs,
363+
argv: list[str],
364+
*,
365+
asset_url: str | None = None,
366+
) -> ExportResult:
367+
"""Execute notebook and export as WASM HTML with embedded session."""
368+
from marimo._session.state.serialize import (
369+
serialize_notebook,
370+
serialize_session_view,
371+
)
372+
373+
file_manager = load_notebook(path.absolute_name)
374+
file_manager.app.inline_layout_file()
375+
376+
config = get_default_config_manager(current_path=file_manager.path)
377+
display_config = cast(DisplayConfig, config.get_config()["display"])
378+
379+
session_view, did_error = await run_app_until_completion(
380+
file_manager,
381+
cli_args,
382+
argv=argv,
383+
)
384+
385+
session_snapshot = serialize_session_view(
386+
session_view,
387+
cell_ids=file_manager.app.cell_manager.cell_ids(),
388+
drop_virtual_file_outputs=True,
389+
)
390+
notebook_snapshot = serialize_notebook(
391+
session_view, file_manager.app.cell_manager
392+
)
393+
394+
html, filename = Exporter().export_as_wasm(
395+
filename=file_manager.filename,
396+
app=file_manager.app,
397+
display_config=display_config,
398+
code=file_manager.app.to_py(),
399+
mode=mode,
400+
show_code=show_code,
401+
asset_url=asset_url,
402+
session_snapshot=session_snapshot,
403+
notebook_snapshot=notebook_snapshot,
404+
)
405+
return ExportResult(
406+
contents=html,
407+
download_filename=filename,
408+
did_error=did_error,
409+
)
410+
411+
358412
async def export_as_html_without_execution(
359413
path: MarimoPath,
360414
include_code: bool,

marimo/_server/export/exporter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ def export_as_wasm(
328328
mode: Literal["edit", "run"],
329329
show_code: bool,
330330
asset_url: str | None = None,
331+
session_snapshot: NotebookSessionV1 | None = None,
332+
notebook_snapshot: NotebookV1 | None = None,
331333
) -> tuple[str, str]:
332334
"""Export notebook as a WASM-powered standalone HTML file."""
333335
index_html = get_html_contents()
@@ -350,6 +352,8 @@ def export_as_wasm(
350352
code=code,
351353
asset_url=asset_url,
352354
show_code=show_code,
355+
session_snapshot=session_snapshot,
356+
notebook_snapshot=notebook_snapshot,
353357
)
354358

355359
download_filename = get_download_filename(filename, "wasm.html")

marimo/_server/templates/templates.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,8 @@ def wasm_notebook_template(
503503
code: str,
504504
show_code: bool,
505505
asset_url: str | None = None,
506+
session_snapshot: NotebookSessionV1 | None = None,
507+
notebook_snapshot: NotebookV1 | None = None,
506508
) -> str:
507509
"""Template for WASM notebooks."""
508510
import re
@@ -536,6 +538,8 @@ def wasm_notebook_template(
536538
version=version,
537539
show_app_code=show_code,
538540
runtime_config=None,
541+
session_snapshot=session_snapshot,
542+
notebook_snapshot=notebook_snapshot,
539543
),
540544
)
541545

0 commit comments

Comments
 (0)