@marimo-team/frontend 0.23.3-dev13 → 0.23.3-dev16

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.
Files changed (34) hide show
  1. package/dist/assets/{JsonOutput-BGumwadB.js → JsonOutput-ChlI2Rcq.js} +11 -11
  2. package/dist/assets/{add-connection-dialog-DW9MujC-.js → add-connection-dialog-D4egHycB.js} +1 -1
  3. package/dist/assets/{agent-panel-C_laGojQ.js → agent-panel-B9qfQkjK.js} +1 -1
  4. package/dist/assets/{cell-editor-bOGguH6G.js → cell-editor-BciNtNBM.js} +1 -1
  5. package/dist/assets/{column-preview-lJCZcp8j.js → column-preview-DZGGZYAY.js} +1 -1
  6. package/dist/assets/{command-palette-CMX2_mdR.js → command-palette-C7NnRcfP.js} +1 -1
  7. package/dist/assets/{edit-page-c_cb8Sc-.js → edit-page-BX4jv2i-.js} +3 -3
  8. package/dist/assets/{file-explorer-panel-DLFVjtxQ.js → file-explorer-panel-rRNjUnK5.js} +1 -1
  9. package/dist/assets/{form-DEDyV6Nr.js → form-B5Zfx4cn.js} +1 -1
  10. package/dist/assets/{hooks-BkJSzslE.js → hooks-toE2zzDy.js} +1 -1
  11. package/dist/assets/{index-D-M3NRDk.js → index-DyhAORlA.js} +3 -3
  12. package/dist/assets/{layout-BFa1cfaZ.js → layout-CX1oKyFB.js} +3 -3
  13. package/dist/assets/{panels-lV4EyUmM.js → panels-CMep1Kk9.js} +1 -1
  14. package/dist/assets/{reveal-component-Cu8nMU5_.js → reveal-component-DNVS81N_.js} +1 -1
  15. package/dist/assets/{run-page-Dl8g4faj.js → run-page-fS3bsdcc.js} +1 -1
  16. package/dist/assets/{scratchpad-panel-D-v2WrEb.js → scratchpad-panel-2N56xPu4.js} +1 -1
  17. package/dist/assets/{session-panel-C2UpyfiB.js → session-panel-DTU0bHW4.js} +1 -1
  18. package/dist/assets/{slide-BNis4O9h.js → slide-CHcKvgFE.js} +1 -1
  19. package/dist/assets/state-wy7oHhYg.js +3 -0
  20. package/dist/assets/{useNotebookActions-DPwf9nYC.js → useNotebookActions-xdSJIXJ7.js} +1 -1
  21. package/dist/index.html +5 -5
  22. package/package.json +1 -1
  23. package/src/components/editor/output/JsonOutput.tsx +157 -4
  24. package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
  25. package/src/components/editor/output/__tests__/json-output.test.ts +147 -2
  26. package/src/core/islands/__tests__/bridge.test.ts +116 -5
  27. package/src/core/islands/bridge.ts +5 -1
  28. package/src/core/static/export-context.ts +43 -0
  29. package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
  30. package/src/plugins/core/trusted-url.ts +23 -10
  31. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
  32. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
  33. package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
  34. package/dist/assets/state-BapA2ATY.js +0 -3
@@ -8,6 +8,7 @@ import {
8
8
  floatType,
9
9
  intType,
10
10
  JsonViewer,
11
+ type JsonViewerKeyRenderer,
11
12
  nullType,
12
13
  objectType,
13
14
  stringType,
@@ -130,6 +131,9 @@ export const JsonOutput: React.FC<Props> = memo(
130
131
  collapseStringsAfterLength={COLLAPSED_TEXT_LENGTH}
131
132
  // leave the default valueTypes as it was - 'python', only 'json' is changed
132
133
  valueTypes={valueTypesMap[valueTypes]}
134
+ // Render dict keys that carry Python type info (e.g. `int`, `tuple`).
135
+ // See `_key_formatter` in marimo/_output/formatters/structures.py.
136
+ keyRenderer={valueTypes === "python" ? keyRenderer : undefined}
133
137
  // Don't group arrays, it will make the tree view look like there are nested arrays
134
138
  groupArraysAfterLength={Number.MAX_SAFE_INTEGER}
135
139
  // Built-in clipboard shifts content on hover
@@ -207,7 +211,10 @@ const LEAF_RENDERERS: Record<string, LeafRenderer> = {
207
211
  ),
208
212
  "text/plain+float:": (value) => <span>{value}</span>,
209
213
  "text/plain+bigint:": (value) => <span>{value}</span>,
210
- "text/plain+set:": (value) => <span>set{value}</span>,
214
+ "text/plain+set:": (value) => <span>{formatSetPayload(value)}</span>,
215
+ "text/plain+frozenset:": (value) => (
216
+ <span>{formatFrozensetPayload(value)}</span>
217
+ ),
211
218
  "text/plain+tuple:": (value) => <span>{value}</span>,
212
219
  "text/plain:": (value) => <CollapsibleTextOutput text={value} />,
213
220
  "application/json:": (value) => (
@@ -375,6 +382,99 @@ function renderLeaf(leaf: string, render: LeafRenderer): React.ReactNode {
375
382
  return <span>{leaf}</span>;
376
383
  }
377
384
 
385
+ // Prefix marking keys that carry encoded type information from Python.
386
+ // See `_key_formatter` in marimo/_output/formatters/structures.py.
387
+ const KEY_ENCODED_PREFIX = "text/plain+";
388
+
389
+ // Format a JSON-list payload as a Python tuple literal. 1-element tuples
390
+ // need a trailing comma — `(1)` is just `1` in Python, `(1,)` is the tuple.
391
+ function formatTuplePayload(jsonList: string): string {
392
+ const items = JSON.parse(jsonList) as unknown[];
393
+ const inner = items.map((x) => JSON.stringify(x)).join(", ");
394
+ if (items.length === 0) {
395
+ return "()";
396
+ }
397
+ if (items.length === 1) {
398
+ return `(${inner},)`;
399
+ }
400
+ return `(${inner})`;
401
+ }
402
+
403
+ // Format a JSON-list payload as a Python frozenset literal. Empty → `frozenset()`
404
+ // rather than `frozenset({})` (which reads like a dict).
405
+ function formatFrozensetPayload(jsonList: string): string {
406
+ const items = JSON.parse(jsonList) as unknown[];
407
+ if (items.length === 0) {
408
+ return "frozenset()";
409
+ }
410
+ const inner = items.map((x) => JSON.stringify(x)).join(", ");
411
+ return `frozenset({${inner}})`;
412
+ }
413
+
414
+ // Format a JSON-list payload as a Python set literal. Empty → `set()`
415
+ // (not `{}`, which is a dict literal in Python).
416
+ function formatSetPayload(jsonList: string): string {
417
+ try {
418
+ const items = JSON.parse(jsonList) as unknown[];
419
+ if (items.length === 0) {
420
+ return "set()";
421
+ }
422
+ const inner = items.map((x) => JSON.stringify(x)).join(", ");
423
+ return `{${inner}}`;
424
+ } catch {
425
+ // Back-compat: older wire form was `text/plain+set:{1, 2, 3}` (Python
426
+ // set-literal string, not JSON). Pass it through as-is rather than crash.
427
+ return jsonList;
428
+ }
429
+ }
430
+
431
+ // Renderers for decoded non-string keys. Visual affordances match Python:
432
+ // unquoted primitives, parens for tuple, `frozenset({...})` for frozenset,
433
+ // and the `text/plain+str:` escape re-quotes the original string.
434
+ const KEY_DECODERS: Record<string, (data: string) => React.ReactNode> = {
435
+ "text/plain+int:": (v) => <span>{v}</span>,
436
+ "text/plain+float:": (v) => <span>{v}</span>,
437
+ "text/plain+bool:": (v) => <span>{v === "True" ? "True" : "False"}</span>,
438
+ "text/plain+none:": () => <span>None</span>,
439
+ "text/plain+tuple:": (v) => <span>{formatTuplePayload(v)}</span>,
440
+ "text/plain+frozenset:": (v) => <span>{formatFrozensetPayload(v)}</span>,
441
+ "text/plain+str:": (v) => <span>"{v}"</span>,
442
+ };
443
+
444
+ function isEncodedKey(key: unknown): key is string {
445
+ return typeof key === "string" && key.startsWith(KEY_ENCODED_PREFIX);
446
+ }
447
+
448
+ // `@textea/json-viewer` drops quotes from integer-like string keys, which
449
+ // makes the string `"2"` visually identical to the decoded int `2`. Match
450
+ // the same keys the viewer strips and render them with explicit quotes.
451
+ const INT_LIKE_STRING = /^-?\d+$/;
452
+
453
+ const keyRenderer: JsonViewerKeyRenderer = Object.assign(
454
+ ({ path }: DataItemProps) => {
455
+ const key = path[path.length - 1];
456
+ if (typeof key !== "string") {
457
+ return <span>{String(key)}</span>;
458
+ }
459
+ if (isEncodedKey(key)) {
460
+ const [data, mimeType] = leafDataAndMimeType(key);
461
+ const render = KEY_DECODERS[`${mimeType}:`];
462
+ return render ? render(data) : <span>{key}</span>;
463
+ }
464
+ // Plain integer-like string — quote it so it's distinct from a decoded int.
465
+ return <span>"{key}"</span>;
466
+ },
467
+ {
468
+ when: ({ path }: DataItemProps) => {
469
+ const key = path[path.length - 1];
470
+ return (
471
+ isEncodedKey(key) ||
472
+ (typeof key === "string" && INT_LIKE_STRING.test(key))
473
+ );
474
+ },
475
+ },
476
+ );
477
+
378
478
  const MIME_PREFIXES = Object.keys(LEAF_RENDERERS);
379
479
  const REPLACE_PREFIX = "<marimo-replace>";
380
480
  const REPLACE_SUFFIX = "</marimo-replace>";
@@ -413,8 +513,10 @@ function pythonJsonReplacer(_key: string, value: unknown): unknown {
413
513
  return `${REPLACE_PREFIX}(${leafData(value).slice(1, -1)})${REPLACE_SUFFIX}`;
414
514
  }
415
515
  if (value.startsWith("text/plain+set:")) {
416
- // replace first and last characters [] with {}
417
- return `${REPLACE_PREFIX}{${leafData(value).slice(1, -1)}}${REPLACE_SUFFIX}`;
516
+ return `${REPLACE_PREFIX}${formatSetPayload(leafData(value))}${REPLACE_SUFFIX}`;
517
+ }
518
+ if (value.startsWith("text/plain+frozenset:")) {
519
+ return `${REPLACE_PREFIX}${formatFrozensetPayload(leafData(value))}${REPLACE_SUFFIX}`;
418
520
  }
419
521
 
420
522
  if (MIME_PREFIXES.some((prefix) => value.startsWith(prefix))) {
@@ -428,10 +530,61 @@ function pythonJsonReplacer(_key: string, value: unknown): unknown {
428
530
  return value;
429
531
  }
430
532
 
533
+ // Rewrite an encoded key string into the Python literal that should appear
534
+ // unquoted in the copy output. Wrapping in REPLACE_PREFIX/SUFFIX makes the
535
+ // final regex pass strip the surrounding JSON quotes.
536
+ function decodeKeyForCopy(key: string): string {
537
+ const [data, mimeType] = leafDataAndMimeType(key);
538
+ const wrap = (s: string) => `${REPLACE_PREFIX}${s}${REPLACE_SUFFIX}`;
539
+ switch (`${mimeType}:`) {
540
+ case "text/plain+int:":
541
+ return wrap(data);
542
+ case "text/plain+float:":
543
+ if (data === "nan") {
544
+ return wrap("float('nan')");
545
+ }
546
+ if (data === "inf") {
547
+ return wrap("float('inf')");
548
+ }
549
+ if (data === "-inf") {
550
+ return wrap("-float('inf')");
551
+ }
552
+ return wrap(data);
553
+ case "text/plain+bool:":
554
+ return wrap(data === "True" ? "True" : "False");
555
+ case "text/plain+none:":
556
+ return wrap("None");
557
+ case "text/plain+tuple:":
558
+ return wrap(formatTuplePayload(data));
559
+ case "text/plain+frozenset:":
560
+ return wrap(formatFrozensetPayload(data));
561
+ case "text/plain+str:":
562
+ // `data` is the original Python string; it stays quoted.
563
+ return data;
564
+ default:
565
+ return key;
566
+ }
567
+ }
568
+
569
+ function rewriteEncodedKeys(value: unknown): unknown {
570
+ if (Array.isArray(value)) {
571
+ return value.map(rewriteEncodedKeys);
572
+ }
573
+ if (typeof value === "object" && value !== null) {
574
+ const out: Record<string, unknown> = {};
575
+ for (const [k, v] of Object.entries(value)) {
576
+ const newKey = isEncodedKey(k) ? decodeKeyForCopy(k) : k;
577
+ out[newKey] = rewriteEncodedKeys(v);
578
+ }
579
+ return out;
580
+ }
581
+ return value;
582
+ }
583
+
431
584
  export function getCopyValue(value: unknown): string {
432
585
  // Because this results in valid json, it adds quotes around None and True/False.
433
586
  // but we want to make this look like Python, so we remove the quotes.
434
- return JSON.stringify(value, pythonJsonReplacer, 2)
587
+ return JSON.stringify(rewriteEncodedKeys(value), pythonJsonReplacer, 2)
435
588
  .replaceAll(`"${REPLACE_PREFIX}`, "")
436
589
  .replaceAll(`${REPLACE_SUFFIX}"`, "");
437
590
  }
@@ -39,4 +39,84 @@ describe("JsonOutput with enhanced mimetype handling", () => {
39
39
  expect(container).toBeInTheDocument();
40
40
  expect(container.querySelector(".marimo-json-output")).toBeInTheDocument();
41
41
  });
42
+
43
+ it("renders encoded non-string keys with Python-style affordances", () => {
44
+ // Server-side `_key_formatter` encodes non-string dict keys with
45
+ // mimetype prefixes; the frontend `keyRenderer` must decode them
46
+ // so users see the original Python types (unquoted ints, parens for
47
+ // tuples, etc.) instead of the raw encoded strings.
48
+ const data = {
49
+ "text/plain+int:2": "int_val",
50
+ "text/plain+float:2.5": "float_val",
51
+ "text/plain+bool:True": "bool_val",
52
+ "text/plain+none:": "none_val",
53
+ "text/plain+tuple:[1, 2]": "tuple_val",
54
+ "text/plain+frozenset:[3, 4]": "fs_val",
55
+ "text/plain+str:text/plain+int:2": "escaped_str_val",
56
+ plain: "unchanged",
57
+ };
58
+
59
+ const { container } = render(<JsonOutput data={data} format="tree" />);
60
+ const text = container.textContent ?? "";
61
+
62
+ // `text/plain+str:` is the escape prefix — must never survive in output.
63
+ expect(text).not.toContain("text/plain+str:");
64
+ // Other encoded prefixes must not leak as-is. (They can still appear
65
+ // inside the unescaped original string key `"text/plain+int:2"`,
66
+ // which is intentional — but not for types *other* than int.)
67
+ expect(text).not.toContain("text/plain+bool:True");
68
+ expect(text).not.toContain("text/plain+tuple:[");
69
+ expect(text).not.toContain("text/plain+frozenset:[");
70
+ expect(text).not.toContain("text/plain+none:");
71
+
72
+ // Decoded visual forms are present with Python-style affordances.
73
+ expect(text).toContain('None:"none_val"');
74
+ expect(text).toContain('True:"bool_val"');
75
+ expect(text).toContain('2:"int_val"');
76
+ expect(text).toContain('2.5:"float_val"');
77
+ expect(text).toContain('(1, 2):"tuple_val"');
78
+ expect(text).toContain('frozenset({3, 4}):"fs_val"');
79
+ // Escaped str key renders as the original literal string (quoted).
80
+ expect(text).toContain('"text/plain+int:2":"escaped_str_val"');
81
+ // Plain string key unchanged.
82
+ expect(text).toContain('"plain":"unchanged"');
83
+ });
84
+
85
+ it("renders 1-tuple and empty-frozenset keys with correct Python syntax", () => {
86
+ // Regressions caught in review: `(1)` is not a tuple (needs `(1,)`),
87
+ // and `frozenset({})` reads like constructing from an empty dict
88
+ // (should be `frozenset()`). Locks in the tree-view rendering so these
89
+ // don't slip back.
90
+ const data = {
91
+ "text/plain+tuple:[42]": "one_tuple",
92
+ "text/plain+tuple:[]": "empty_tuple",
93
+ "text/plain+frozenset:[]": "empty_fs",
94
+ "text/plain+frozenset:[1]": "one_fs",
95
+ };
96
+
97
+ const { container } = render(<JsonOutput data={data} format="tree" />);
98
+ const text = container.textContent ?? "";
99
+
100
+ expect(text).toContain('(42,):"one_tuple"'); // trailing comma
101
+ expect(text).toContain('():"empty_tuple"');
102
+ expect(text).toContain('frozenset():"empty_fs"'); // not `frozenset({})`
103
+ expect(text).toContain('frozenset({1}):"one_fs"');
104
+ });
105
+
106
+ it("quotes integer-like string keys to distinguish them from int keys", () => {
107
+ // Without this, `"2"` and the decoded int `2` look identical — the
108
+ // textea viewer drops quotes from integer-like string keys by default.
109
+ const data = {
110
+ "2": "string_two",
111
+ "text/plain+int:2": "int_two",
112
+ };
113
+
114
+ const { container } = render(<JsonOutput data={data} format="tree" />);
115
+ const text = container.textContent ?? "";
116
+
117
+ expect(text).toContain('"2":"string_two"'); // quoted
118
+ expect(text).toContain('2:"int_two"'); // unquoted
119
+ // Non-integer string keys still render without our intervention.
120
+ expect(text).not.toContain("text/plain+"); // prefix stripped from int key
121
+ });
42
122
  });
@@ -174,7 +174,21 @@ describe("getCopyValue", () => {
174
174
  it("should handle sets", () => {
175
175
  const value = "text/plain+set:[1,2,3]";
176
176
  const result = getCopyValue(value);
177
- expect(result).toMatchInlineSnapshot(`"{1,2,3}"`);
177
+ expect(result).toMatchInlineSnapshot(`"{1, 2, 3}"`);
178
+ });
179
+
180
+ it("should handle empty set", () => {
181
+ // Empty set literal in Python is `set()`, not `{}` (which is a dict).
182
+ expect(getCopyValue("text/plain+set:[]")).toMatchInlineSnapshot(`"set()"`);
183
+ });
184
+
185
+ it("should handle frozenset values", () => {
186
+ expect(getCopyValue("text/plain+frozenset:[1,2]")).toMatchInlineSnapshot(
187
+ `"frozenset({1, 2})"`,
188
+ );
189
+ expect(getCopyValue("text/plain+frozenset:[]")).toMatchInlineSnapshot(
190
+ `"frozenset()"`,
191
+ );
178
192
  });
179
193
 
180
194
  it("should handle sets in mixed types", () => {
@@ -188,7 +202,7 @@ describe("getCopyValue", () => {
188
202
  `
189
203
  "{
190
204
  "key1": 42,
191
- "key2": {1,2,3},
205
+ "key2": {1, 2, 3},
192
206
  "key3": True
193
207
  }"
194
208
  `,
@@ -311,6 +325,137 @@ describe("determineMaxDisplayLength", () => {
311
325
  });
312
326
  });
313
327
 
328
+ describe("getCopyValue with encoded non-string keys", () => {
329
+ // Keys are encoded by _key_formatter in
330
+ // marimo/_output/formatters/structures.py. Frontend must round-trip them
331
+ // to Python literals in the copy output.
332
+
333
+ it("decodes int keys unquoted", () => {
334
+ // JS reorders integer-like string keys to the front of object iteration
335
+ // (spec-mandated), so `"2"` appears before `"text/plain+int:2"` here.
336
+ // This is pre-existing and unrelated to the encoding — both entries
337
+ // survive, which is the regression this guards.
338
+ const value = { "text/plain+int:2": "no", "2": "oh" };
339
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
340
+ "{
341
+ "2": "oh",
342
+ 2: "no"
343
+ }"
344
+ `);
345
+ });
346
+
347
+ it("decodes large int keys unquoted (no BigInt precision concern)", () => {
348
+ const value = { "text/plain+int:18446744073709551616": "v" };
349
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
350
+ "{
351
+ 18446744073709551616: "v"
352
+ }"
353
+ `);
354
+ });
355
+
356
+ it("decodes float, bool, None, tuple, frozenset keys", () => {
357
+ const value = {
358
+ "text/plain+float:2.5": "f",
359
+ "text/plain+bool:True": "t",
360
+ "text/plain+bool:False": "b",
361
+ "text/plain+none:": "n",
362
+ "text/plain+tuple:[1, 2]": "tup",
363
+ "text/plain+frozenset:[3, 4]": "fs",
364
+ };
365
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
366
+ "{
367
+ 2.5: "f",
368
+ True: "t",
369
+ False: "b",
370
+ None: "n",
371
+ (1, 2): "tup",
372
+ frozenset({3, 4}): "fs"
373
+ }"
374
+ `);
375
+ });
376
+
377
+ it("emits 1-element tuple keys with a trailing comma (Python syntax)", () => {
378
+ // `(1)` is just `1` in Python — a 1-tuple needs `(1,)`.
379
+ const value = {
380
+ "text/plain+tuple:[1]": "one",
381
+ "text/plain+tuple:[]": "empty",
382
+ };
383
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
384
+ "{
385
+ (1,): "one",
386
+ (): "empty"
387
+ }"
388
+ `);
389
+ });
390
+
391
+ it("emits empty frozenset keys as `frozenset()` not `frozenset({})`", () => {
392
+ // `frozenset({})` reads like it's constructing from an empty dict.
393
+ const value = {
394
+ "text/plain+frozenset:[]": "empty",
395
+ "text/plain+frozenset:[1]": "single",
396
+ };
397
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
398
+ "{
399
+ frozenset(): "empty",
400
+ frozenset({1}): "single"
401
+ }"
402
+ `);
403
+ });
404
+
405
+ it("decodes NaN/Inf float keys to valid Python literals", () => {
406
+ const value = {
407
+ "text/plain+float:nan": "n",
408
+ "text/plain+float:inf": "p",
409
+ "text/plain+float:-inf": "m",
410
+ };
411
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
412
+ "{
413
+ float('nan'): "n",
414
+ float('inf'): "p",
415
+ -float('inf'): "m"
416
+ }"
417
+ `);
418
+ });
419
+
420
+ it("unescapes string keys that looked encoded", () => {
421
+ const value = {
422
+ "text/plain+str:text/plain+int:2": "hello",
423
+ };
424
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
425
+ "{
426
+ "text/plain+int:2": "hello"
427
+ }"
428
+ `);
429
+ });
430
+
431
+ it("decodes keys at every nesting level", () => {
432
+ const value = {
433
+ outer: {
434
+ "text/plain+int:1": "inner",
435
+ "text/plain+tuple:[2, 3]": "tup",
436
+ },
437
+ };
438
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
439
+ "{
440
+ "outer": {
441
+ 1: "inner",
442
+ (2, 3): "tup"
443
+ }
444
+ }"
445
+ `);
446
+ });
447
+
448
+ it("leaves plain string keys untouched", () => {
449
+ const value = { foo: 1, bar: 2 };
450
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
451
+ "{
452
+ "foo": 1,
453
+ "bar": 2
454
+ }"
455
+ `);
456
+ });
457
+ });
458
+
314
459
  describe("getCopyValue with application/ mimetypes", () => {
315
460
  it("should strip application/ mimetype prefix from leaf data", () => {
316
461
  expect(getCopyValue("application/json:{data}")).toBe('"{data}"');
@@ -10,6 +10,14 @@ import {
10
10
  } from "@/__tests__/branded";
11
11
 
12
12
  type Base64String = components["schemas"]["Base64String"];
13
+ interface TestIslandApp {
14
+ id: string;
15
+ cells: { code: string; idx: number; output: string }[];
16
+ }
17
+ interface TestExportContext {
18
+ trusted: true;
19
+ notebookCode?: string;
20
+ }
13
21
 
14
22
  // Mock browser APIs before any imports
15
23
  vi.stubGlobal(
@@ -33,8 +41,23 @@ class MockURL {
33
41
  vi.stubGlobal("URL", MockURL);
34
42
 
35
43
  // Mock the worker RPC before importing the bridge
36
- const mockBridge = vi.fn();
37
- const mockLoadPackages = vi.fn();
44
+ const {
45
+ mockBridge,
46
+ mockLoadPackages,
47
+ mockStartSessionRequest,
48
+ mockParseMarimoIslandApps,
49
+ mockCreateMarimoFile,
50
+ mockGetMarimoExportContext,
51
+ } = vi.hoisted(() => ({
52
+ mockBridge: vi.fn(),
53
+ mockLoadPackages: vi.fn(),
54
+ mockStartSessionRequest: vi.fn(),
55
+ mockParseMarimoIslandApps: vi.fn<() => TestIslandApp[]>(() => []),
56
+ mockCreateMarimoFile: vi.fn(),
57
+ mockGetMarimoExportContext: vi.fn<() => TestExportContext | undefined>(
58
+ () => undefined,
59
+ ),
60
+ }));
38
61
 
39
62
  vi.mock("@/core/wasm/rpc", () => ({
40
63
  getWorkerRPC: () => ({
@@ -42,7 +65,7 @@ vi.mock("@/core/wasm/rpc", () => ({
42
65
  request: {
43
66
  bridge: mockBridge,
44
67
  loadPackages: mockLoadPackages,
45
- startSession: vi.fn(),
68
+ startSession: mockStartSessionRequest,
46
69
  },
47
70
  send: {
48
71
  consumerReady: vi.fn(),
@@ -54,8 +77,8 @@ vi.mock("@/core/wasm/rpc", () => ({
54
77
 
55
78
  // Mock the parse module to avoid DOM dependencies
56
79
  vi.mock("../parse", () => ({
57
- parseMarimoIslandApps: () => [],
58
- createMarimoFile: vi.fn(),
80
+ parseMarimoIslandApps: mockParseMarimoIslandApps,
81
+ createMarimoFile: mockCreateMarimoFile,
59
82
  }));
60
83
 
61
84
  // Mock uuid to have predictable tokens
@@ -63,6 +86,10 @@ vi.mock("@/utils/uuid", () => ({
63
86
  generateUUID: () => "test-uuid-12345",
64
87
  }));
65
88
 
89
+ vi.mock("@/core/static/export-context", () => ({
90
+ getMarimoExportContext: mockGetMarimoExportContext,
91
+ }));
92
+
66
93
  // Mock getMarimoVersion
67
94
  vi.mock("@/core/meta/globals", () => ({
68
95
  getMarimoVersion: () => "0.0.0-test",
@@ -71,6 +98,7 @@ vi.mock("@/core/meta/globals", () => ({
71
98
  // Mock the jotai store
72
99
  vi.mock("@/core/state/jotai", () => ({
73
100
  store: {
101
+ get: vi.fn(),
74
102
  set: vi.fn(),
75
103
  },
76
104
  }));
@@ -83,9 +111,92 @@ describe("IslandsPyodideBridge", () => {
83
111
 
84
112
  beforeEach(() => {
85
113
  vi.clearAllMocks();
114
+ mockParseMarimoIslandApps.mockReturnValue([]);
115
+ mockCreateMarimoFile.mockReset();
116
+ mockGetMarimoExportContext.mockReturnValue(undefined);
86
117
  bridge = new IslandsPyodideBridge({ autoStartSessions: false });
87
118
  });
88
119
 
120
+ describe("startSessionsForAllApps", () => {
121
+ it("should prefer trusted export notebook code when there is exactly one reactive app", async () => {
122
+ mockParseMarimoIslandApps.mockReturnValue([
123
+ {
124
+ id: "app-1",
125
+ cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
126
+ },
127
+ ]);
128
+ mockGetMarimoExportContext.mockReturnValue({
129
+ trusted: true,
130
+ notebookCode:
131
+ "import marimo\napp = marimo.App()\n@app.cell\ndef __():\n x = 1\n return",
132
+ });
133
+
134
+ await (
135
+ bridge as unknown as { startSessionsForAllApps(): Promise<void> }
136
+ ).startSessionsForAllApps();
137
+
138
+ expect(mockCreateMarimoFile).not.toHaveBeenCalled();
139
+ expect(mockStartSessionRequest).toHaveBeenCalledWith({
140
+ appId: "app-1",
141
+ code: "import marimo\napp = marimo.App()\n@app.cell\ndef __():\n x = 1\n return",
142
+ });
143
+ });
144
+
145
+ it("should keep synthesized per-app files for multiple reactive apps even when export context exists", async () => {
146
+ mockParseMarimoIslandApps.mockReturnValue([
147
+ {
148
+ id: "app-1",
149
+ cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
150
+ },
151
+ {
152
+ id: "app-2",
153
+ cells: [{ code: "y = 2", idx: 0, output: "<div>2</div>" }],
154
+ },
155
+ ]);
156
+ mockGetMarimoExportContext.mockReturnValue({
157
+ trusted: true,
158
+ notebookCode: "full notebook should be ignored",
159
+ });
160
+ mockCreateMarimoFile
161
+ .mockReturnValueOnce("generated app 1")
162
+ .mockReturnValueOnce("generated app 2");
163
+
164
+ await (
165
+ bridge as unknown as { startSessionsForAllApps(): Promise<void> }
166
+ ).startSessionsForAllApps();
167
+
168
+ expect(mockCreateMarimoFile).toHaveBeenCalledTimes(2);
169
+ expect(mockStartSessionRequest).toHaveBeenNthCalledWith(1, {
170
+ appId: "app-1",
171
+ code: "generated app 1",
172
+ });
173
+ expect(mockStartSessionRequest).toHaveBeenNthCalledWith(2, {
174
+ appId: "app-2",
175
+ code: "generated app 2",
176
+ });
177
+ });
178
+
179
+ it("should synthesize a file for a single app when no trusted export context is present", async () => {
180
+ mockParseMarimoIslandApps.mockReturnValue([
181
+ {
182
+ id: "app-1",
183
+ cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
184
+ },
185
+ ]);
186
+ mockCreateMarimoFile.mockReturnValue("generated app 1");
187
+
188
+ await (
189
+ bridge as unknown as { startSessionsForAllApps(): Promise<void> }
190
+ ).startSessionsForAllApps();
191
+
192
+ expect(mockCreateMarimoFile).toHaveBeenCalledTimes(1);
193
+ expect(mockStartSessionRequest).toHaveBeenCalledWith({
194
+ appId: "app-1",
195
+ code: "generated app 1",
196
+ });
197
+ });
198
+ });
199
+
89
200
  describe("sendComponentValues", () => {
90
201
  it("should include type field and token in control request", async () => {
91
202
  const request = {
@@ -10,6 +10,7 @@ import { generateUUID } from "@/utils/uuid";
10
10
  import type { CommandMessage, NotificationPayload } from "../kernel/messages";
11
11
  import type { EditRequests, RunRequests } from "../network/types";
12
12
  import { store as defaultStore } from "../state/jotai";
13
+ import { getMarimoExportContext } from "../static/export-context";
13
14
  import { createMarimoFile, parseMarimoIslandApps } from "./parse";
14
15
  import { islandsInitializedAtom } from "./state";
15
16
  import type { WorkerSchema } from "./worker/worker";
@@ -123,8 +124,11 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
123
124
  `Starting sessions for ${apps.length} app(s):`,
124
125
  apps.map((a) => `${a.id} (${a.cells.length} cells)`),
125
126
  );
127
+ const exportContext =
128
+ apps.length === 1 ? getMarimoExportContext() : undefined;
129
+ const notebookCode = exportContext?.notebookCode;
126
130
  for (const app of apps) {
127
- const file = createMarimoFile(app);
131
+ const file = notebookCode || createMarimoFile(app);
128
132
  Logger.debug(`App ${app.id} marimo file:\n`, file);
129
133
  this.startSession({
130
134
  code: file,