@marimo-team/frontend 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/frontend",
3
- "version": "0.23.12-dev10",
3
+ "version": "0.23.12-dev11",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import {
12
12
  type Base64String = components["schemas"]["Base64String"];
13
13
  interface TestIslandApp {
14
14
  id: string;
15
+ payloadBacked?: boolean;
15
16
  cells: { code: string; idx: number; output: string }[];
16
17
  }
17
18
  interface TestExportContext {
@@ -142,6 +143,30 @@ describe("IslandsPyodideBridge", () => {
142
143
  });
143
144
  });
144
145
 
146
+ it("should ignore trusted export notebook code for a payload-backed app", async () => {
147
+ const payloadApp = {
148
+ id: "app-1",
149
+ payloadBacked: true,
150
+ cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
151
+ };
152
+ mockParseMarimoIslandApps.mockReturnValue([payloadApp]);
153
+ mockGetMarimoExportContext.mockReturnValue({
154
+ trusted: true,
155
+ notebookCode: "full notebook should be ignored",
156
+ });
157
+ mockCreateMarimoFile.mockReturnValue("generated payload app");
158
+
159
+ await (
160
+ bridge as unknown as { startSessionsForAllApps(): Promise<void> }
161
+ ).startSessionsForAllApps();
162
+
163
+ expect(mockCreateMarimoFile).toHaveBeenCalledWith(payloadApp);
164
+ expect(mockStartSessionRequest).toHaveBeenCalledWith({
165
+ appId: "app-1",
166
+ code: "generated payload app",
167
+ });
168
+ });
169
+
145
170
  it("should keep synthesized per-app files for multiple reactive apps even when export context exists", async () => {
146
171
  mockParseMarimoIslandApps.mockReturnValue([
147
172
  {
@@ -1,9 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
4
  import {
4
5
  ISLAND_DATA_ATTRIBUTES,
5
6
  ISLAND_TAG_NAMES,
7
+ ISLANDS_JSON_SCRIPT_TYPE,
6
8
  } from "@/core/islands/constants";
9
+ import { Logger } from "@/utils/Logger";
7
10
  import {
8
11
  createMarimoFile,
9
12
  extractIslandCodeFromEmbed,
@@ -15,6 +18,43 @@ import {
15
18
  } from "../parse";
16
19
  import { createMockIslandElement, createMockIslands } from "./test-utils.tsx";
17
20
 
21
+ function createPayloadCell(
22
+ overrides: Partial<{
23
+ cellId: string;
24
+ code: string;
25
+ outputHtml: string;
26
+ outputMimetype: string;
27
+ reactive: boolean;
28
+ displayCode: boolean;
29
+ displayOutput: boolean;
30
+ }> = {},
31
+ ) {
32
+ return {
33
+ cellId: "cell-1",
34
+ code: 'print("payload")',
35
+ outputHtml: "<div>payload</div>",
36
+ outputMimetype: "text/html",
37
+ reactive: true,
38
+ displayCode: false,
39
+ displayOutput: true,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function appendPayload(
45
+ root: HTMLElement,
46
+ payload: {
47
+ schemaVersion: number;
48
+ appId: string;
49
+ cells: ReturnType<typeof createPayloadCell>[];
50
+ },
51
+ ) {
52
+ const script = document.createElement("script");
53
+ script.type = ISLANDS_JSON_SCRIPT_TYPE;
54
+ script.textContent = JSON.stringify(payload);
55
+ root.appendChild(script);
56
+ }
57
+
18
58
  describe("createMarimoFile", () => {
19
59
  it("should return a string", () => {
20
60
  const app = {
@@ -90,6 +130,27 @@ describe("createMarimoFile", () => {
90
130
  `);
91
131
  });
92
132
 
133
+ it("should create disabled marimo cells", () => {
134
+ const app = {
135
+ cells: [{ code: "x = 0", disabled: true }, { code: "x = 1" }],
136
+ };
137
+
138
+ const result = createMarimoFile(app);
139
+
140
+ expect(result).toMatchInlineSnapshot(`
141
+ "import marimo
142
+ app = marimo.App()
143
+ @app.cell(disabled=True)
144
+ def __():
145
+ pass
146
+ return
147
+ @app.cell
148
+ def __():
149
+ x = 1
150
+ return"
151
+ `);
152
+ });
153
+
93
154
  it("should properly indent multi-line code", () => {
94
155
  const app = {
95
156
  cells: [{ code: "if True:\n print('hello')\n print('world')" }],
@@ -459,6 +520,7 @@ describe("parseMarimoIslandApps", () => {
459
520
 
460
521
  afterEach(() => {
461
522
  document.body.removeChild(container);
523
+ vi.restoreAllMocks();
462
524
  });
463
525
 
464
526
  it("should parse islands from document", () => {
@@ -528,4 +590,526 @@ describe("parseMarimoIslandApps", () => {
528
590
  ]
529
591
  `);
530
592
  });
593
+
594
+ it("should use supported JSON payload data for matched islands", () => {
595
+ const island = createMockIslandElement({
596
+ appId: "app1",
597
+ cellId: "cell-1",
598
+ code: 'print("dom")',
599
+ innerHTML: "<div>dom</div>",
600
+ });
601
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
602
+ container.appendChild(island);
603
+ appendPayload(container, {
604
+ schemaVersion: 1,
605
+ appId: "app1",
606
+ cells: [createPayloadCell()],
607
+ });
608
+
609
+ const result = parseMarimoIslandApps(container);
610
+
611
+ expect(result).toEqual([
612
+ {
613
+ id: "app1",
614
+ payloadBacked: true,
615
+ cells: [
616
+ {
617
+ cellId: "cell-1",
618
+ code: 'print("payload")',
619
+ idx: 0,
620
+ output: "<div>payload</div>",
621
+ },
622
+ ],
623
+ },
624
+ ]);
625
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
626
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull();
627
+ expect(extractIslandCodeFromEmbed(island)).toBe('print("payload")');
628
+ expect(island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)?.innerHTML).toBe(
629
+ "<div>payload</div>",
630
+ );
631
+ });
632
+
633
+ it("should parse Python-generated island payload snapshots", () => {
634
+ const html = readFileSync(
635
+ new URL(
636
+ "../../../../../tests/_islands/snapshots/html-payload.txt",
637
+ import.meta.url,
638
+ ).pathname.replace(/^\/@fs/, ""),
639
+ "utf8",
640
+ );
641
+ container.innerHTML = html;
642
+
643
+ const result = parseMarimoIslandApps(container);
644
+ const islands = container.querySelectorAll<HTMLElement>(
645
+ ISLAND_TAG_NAMES.ISLAND,
646
+ );
647
+
648
+ expect(result).toEqual([
649
+ {
650
+ id: "main",
651
+ payloadBacked: true,
652
+ cells: [
653
+ {
654
+ cellId: "Hbol",
655
+ code: "import marimo as mo",
656
+ idx: 0,
657
+ output: "",
658
+ },
659
+ {
660
+ cellId: "MJUe",
661
+ code: "mo.md('Hello, HTML!')",
662
+ idx: 1,
663
+ output: "",
664
+ },
665
+ ],
666
+ },
667
+ ]);
668
+ expect(islands[0].getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBeNull();
669
+ expect(islands[1].getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
670
+ expect(islands[2].getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1");
671
+ });
672
+
673
+ it("should fall back to DOM islands for unsupported payload versions", () => {
674
+ const island = createMockIslandElement({
675
+ appId: "app1",
676
+ cellId: "cell-1",
677
+ code: 'print("dom")',
678
+ innerHTML: "<div>dom</div>",
679
+ });
680
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
681
+ container.appendChild(island);
682
+ appendPayload(container, {
683
+ schemaVersion: 2,
684
+ appId: "app1",
685
+ cells: [createPayloadCell()],
686
+ });
687
+
688
+ const result = parseMarimoIslandApps(container);
689
+
690
+ expect(result).toEqual([
691
+ {
692
+ id: "app1",
693
+ cells: [
694
+ {
695
+ code: 'print("dom")',
696
+ idx: 0,
697
+ output: "<div>dom</div>",
698
+ },
699
+ ],
700
+ },
701
+ ]);
702
+ });
703
+
704
+ it("should ignore payload scripts rendered inside island output", () => {
705
+ const nestedPayload = {
706
+ schemaVersion: 1,
707
+ appId: "app1",
708
+ cells: [
709
+ createPayloadCell({
710
+ code: "payload_code_should_not_run = True",
711
+ outputHtml: "<div>payload</div>",
712
+ }),
713
+ ],
714
+ };
715
+ const island = createMockIslandElement({
716
+ appId: "app1",
717
+ cellId: "cell-1",
718
+ code: 'print("dom")',
719
+ innerHTML: `<div>dom</div><script type="${ISLANDS_JSON_SCRIPT_TYPE}">${JSON.stringify(nestedPayload)}</script>`,
720
+ });
721
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
722
+ container.appendChild(island);
723
+
724
+ const result = parseMarimoIslandApps(container);
725
+
726
+ expect(result).toEqual([
727
+ {
728
+ id: "app1",
729
+ cells: [
730
+ {
731
+ code: 'print("dom")',
732
+ idx: 0,
733
+ output: island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)
734
+ ?.innerHTML,
735
+ },
736
+ ],
737
+ },
738
+ ]);
739
+ });
740
+
741
+ it("should use payload order for runtime cell indices", () => {
742
+ const second = createMockIslandElement({
743
+ appId: "app1",
744
+ cellId: "cell-2",
745
+ code: "second_dom = True",
746
+ innerHTML: "<div>second dom</div>",
747
+ });
748
+ const first = createMockIslandElement({
749
+ appId: "app1",
750
+ cellId: "cell-1",
751
+ code: "first_dom = True",
752
+ innerHTML: "<div>first dom</div>",
753
+ });
754
+ for (const island of [second, first]) {
755
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
756
+ container.appendChild(island);
757
+ }
758
+ appendPayload(container, {
759
+ schemaVersion: 1,
760
+ appId: "app1",
761
+ cells: [
762
+ createPayloadCell({
763
+ cellId: "cell-1",
764
+ code: "first_payload = True",
765
+ outputHtml: "<div>first payload</div>",
766
+ }),
767
+ createPayloadCell({
768
+ cellId: "cell-2",
769
+ code: "second_payload = True",
770
+ outputHtml: "<div>second payload</div>",
771
+ }),
772
+ ],
773
+ });
774
+
775
+ const result = parseMarimoIslandApps(container);
776
+
777
+ expect(result[0].cells.map((cell) => cell.code)).toEqual([
778
+ "first_payload = True",
779
+ "second_payload = True",
780
+ ]);
781
+ expect(first.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
782
+ expect(second.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1");
783
+ });
784
+
785
+ it("should include payload-only runtime cells", () => {
786
+ const island = createMockIslandElement({
787
+ appId: "app1",
788
+ cellId: "cell-2",
789
+ code: "value",
790
+ innerHTML: "<div>value</div>",
791
+ });
792
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
793
+ container.appendChild(island);
794
+ appendPayload(container, {
795
+ schemaVersion: 1,
796
+ appId: "app1",
797
+ cells: [
798
+ createPayloadCell({
799
+ cellId: "cell-1",
800
+ code: "import marimo as mo",
801
+ outputHtml: "",
802
+ }),
803
+ createPayloadCell({
804
+ cellId: "cell-2",
805
+ code: "mo.md('visible')",
806
+ outputHtml: "<div>visible</div>",
807
+ }),
808
+ ],
809
+ });
810
+
811
+ const result = parseMarimoIslandApps(container);
812
+
813
+ expect(result[0].cells.map((cell) => cell.code)).toEqual([
814
+ "import marimo as mo",
815
+ "mo.md('visible')",
816
+ ]);
817
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1");
818
+ });
819
+
820
+ it("should bind payload-backed islands by runtime index after static cells", () => {
821
+ const island = createMockIslandElement({
822
+ appId: "app1",
823
+ cellId: "cell-2",
824
+ code: "visible_dom = True",
825
+ innerHTML: "<div>visible dom</div>",
826
+ });
827
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
828
+ container.appendChild(island);
829
+ appendPayload(container, {
830
+ schemaVersion: 1,
831
+ appId: "app1",
832
+ cells: [
833
+ createPayloadCell({
834
+ cellId: "cell-1",
835
+ code: "",
836
+ outputHtml: "<div>static</div>",
837
+ reactive: false,
838
+ }),
839
+ createPayloadCell({
840
+ cellId: "cell-2",
841
+ code: "visible_payload = True",
842
+ outputHtml: "<div>visible payload</div>",
843
+ }),
844
+ ],
845
+ });
846
+
847
+ const result = parseMarimoIslandApps(container);
848
+
849
+ expect(result[0].cells).toEqual([
850
+ {
851
+ cellId: "cell-1",
852
+ code: "",
853
+ disabled: true,
854
+ idx: 0,
855
+ output: "<div>static</div>",
856
+ },
857
+ {
858
+ cellId: "cell-2",
859
+ code: "visible_payload = True",
860
+ idx: 1,
861
+ output: "<div>visible payload</div>",
862
+ },
863
+ ]);
864
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1");
865
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull();
866
+ });
867
+
868
+ it("should fall back to DOM when no payload cell matches an island", () => {
869
+ const island = createMockIslandElement({
870
+ appId: "app1",
871
+ cellId: "dom-only",
872
+ code: "dom_only = True",
873
+ innerHTML: "<div>dom only</div>",
874
+ });
875
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
876
+ island.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
877
+ container.appendChild(island);
878
+ appendPayload(container, {
879
+ schemaVersion: 1,
880
+ appId: "app1",
881
+ cells: [
882
+ createPayloadCell({
883
+ cellId: "payload-only",
884
+ code: "payload_only = True",
885
+ outputHtml: "<div>payload only</div>",
886
+ }),
887
+ ],
888
+ });
889
+
890
+ const result = parseMarimoIslandApps(container);
891
+
892
+ expect(result).toEqual([
893
+ {
894
+ id: "app1",
895
+ cells: [
896
+ {
897
+ code: "dom_only = True",
898
+ idx: 0,
899
+ output: "<div>dom only</div>",
900
+ },
901
+ ],
902
+ },
903
+ ]);
904
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
905
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBe(
906
+ "dom-only",
907
+ );
908
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE)).toBe("true");
909
+ });
910
+
911
+ it("should ignore payloads without matching islands", () => {
912
+ const warn = vi.spyOn(Logger, "warn").mockImplementation(() => undefined);
913
+ appendPayload(container, {
914
+ schemaVersion: 1,
915
+ appId: "app1",
916
+ cells: [createPayloadCell()],
917
+ });
918
+
919
+ const result = parseMarimoIslandApps(container);
920
+
921
+ expect(result).toEqual([]);
922
+ expect(warn).toHaveBeenCalledWith("No embedded marimo apps found.");
923
+ });
924
+
925
+ it("should still parse DOM-only apps when another app has payload", () => {
926
+ const payloadIsland = createMockIslandElement({
927
+ appId: "app1",
928
+ cellId: "cell-1",
929
+ code: "payload_dom = True",
930
+ innerHTML: "<div>payload dom</div>",
931
+ });
932
+ const domIsland = createMockIslandElement({
933
+ appId: "app2",
934
+ code: "dom_app = True",
935
+ innerHTML: "<div>dom app</div>",
936
+ });
937
+ for (const island of [payloadIsland, domIsland]) {
938
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
939
+ container.appendChild(island);
940
+ }
941
+ appendPayload(container, {
942
+ schemaVersion: 1,
943
+ appId: "app1",
944
+ cells: [createPayloadCell()],
945
+ });
946
+
947
+ const result = parseMarimoIslandApps(container);
948
+
949
+ expect(result.map((app) => app.id)).toEqual(["app1", "app2"]);
950
+ expect(result[0].cells[0].code).toBe('print("payload")');
951
+ expect(result[1].cells[0].code).toBe("dom_app = True");
952
+ });
953
+
954
+ it("should materialize non-reactive payload islands without runtime cells", () => {
955
+ const island = createMockIslandElement({
956
+ appId: "app1",
957
+ cellId: "cell-1",
958
+ code: "static_dom = True",
959
+ innerHTML: "<div>static dom</div>",
960
+ });
961
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
962
+ island.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
963
+ container.appendChild(island);
964
+ appendPayload(container, {
965
+ schemaVersion: 1,
966
+ appId: "app1",
967
+ cells: [
968
+ createPayloadCell({
969
+ code: "",
970
+ outputHtml: "<div>static payload</div>",
971
+ reactive: false,
972
+ }),
973
+ ],
974
+ });
975
+
976
+ const result = parseMarimoIslandApps(container);
977
+
978
+ expect(result).toEqual([]);
979
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBeNull();
980
+ expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull();
981
+ expect(island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)?.innerHTML).toBe(
982
+ "<div>static payload</div>",
983
+ );
984
+ });
985
+
986
+ it("should keep non-reactive display code out of runtime cells", () => {
987
+ const staticIsland = createMockIslandElement({
988
+ appId: "app1",
989
+ cellId: "cell-1",
990
+ code: "x = 0",
991
+ innerHTML: "<div>static</div>",
992
+ });
993
+ const reactiveIsland = createMockIslandElement({
994
+ appId: "app1",
995
+ cellId: "cell-2",
996
+ code: "y = 1",
997
+ innerHTML: "<div>reactive</div>",
998
+ });
999
+ staticIsland.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
1000
+ reactiveIsland.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
1001
+ container.append(staticIsland, reactiveIsland);
1002
+ appendPayload(container, {
1003
+ schemaVersion: 1,
1004
+ appId: "app1",
1005
+ cells: [
1006
+ createPayloadCell({
1007
+ cellId: "cell-1",
1008
+ code: "x = 0",
1009
+ outputHtml: "<div>static payload</div>",
1010
+ reactive: false,
1011
+ displayCode: true,
1012
+ }),
1013
+ createPayloadCell({
1014
+ cellId: "cell-2",
1015
+ code: "y = 1",
1016
+ outputHtml: "<div>reactive payload</div>",
1017
+ }),
1018
+ ],
1019
+ });
1020
+
1021
+ const result = parseMarimoIslandApps(container);
1022
+ const file = createMarimoFile(result[0]);
1023
+
1024
+ expect(result[0].cells).toEqual([
1025
+ {
1026
+ cellId: "cell-1",
1027
+ code: "",
1028
+ disabled: true,
1029
+ idx: 0,
1030
+ output: "<div>static payload</div>",
1031
+ },
1032
+ {
1033
+ cellId: "cell-2",
1034
+ code: "y = 1",
1035
+ idx: 1,
1036
+ output: "<div>reactive payload</div>",
1037
+ },
1038
+ ]);
1039
+ expect(file).not.toContain("x = 0");
1040
+ expect(file).toContain("@app.cell(disabled=True)\ndef __():\n pass");
1041
+ expect(reactiveIsland.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe(
1042
+ "1",
1043
+ );
1044
+ });
1045
+
1046
+ it("should update editor initial values from payload code", () => {
1047
+ const island = createMockIslandElement({
1048
+ appId: "app1",
1049
+ cellId: "cell-1",
1050
+ code: 'print("dom")',
1051
+ innerHTML: "<div>dom</div>",
1052
+ });
1053
+ island.insertAdjacentHTML(
1054
+ "beforeend",
1055
+ '<div data-marimo-element><marimo-code-editor data-initial-value="\\"print(\\\\\\"dom\\\\\\")\\""></marimo-code-editor></div>',
1056
+ );
1057
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
1058
+ container.appendChild(island);
1059
+ appendPayload(container, {
1060
+ schemaVersion: 1,
1061
+ appId: "app1",
1062
+ cells: [createPayloadCell()],
1063
+ });
1064
+
1065
+ parseMarimoIslandApps(container);
1066
+
1067
+ expect(
1068
+ island
1069
+ .querySelector(ISLAND_TAG_NAMES.CODE_EDITOR)
1070
+ ?.getAttribute("data-initial-value"),
1071
+ ).toBe(JSON.stringify('print("payload")'));
1072
+ });
1073
+
1074
+ it("should match duplicate cell ids by occurrence", () => {
1075
+ const first = createMockIslandElement({
1076
+ appId: "app1",
1077
+ cellId: "cell-1",
1078
+ code: "dom_first = True",
1079
+ innerHTML: "<div>dom first</div>",
1080
+ });
1081
+ const second = createMockIslandElement({
1082
+ appId: "app1",
1083
+ cellId: "cell-1",
1084
+ code: "dom_second = True",
1085
+ innerHTML: "<div>dom second</div>",
1086
+ });
1087
+ for (const island of [first, second]) {
1088
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
1089
+ container.appendChild(island);
1090
+ }
1091
+ appendPayload(container, {
1092
+ schemaVersion: 1,
1093
+ appId: "app1",
1094
+ cells: [
1095
+ createPayloadCell({
1096
+ code: "payload_first = True",
1097
+ outputHtml: "<div>payload first</div>",
1098
+ }),
1099
+ createPayloadCell({
1100
+ code: "payload_second = True",
1101
+ outputHtml: "<div>payload second</div>",
1102
+ }),
1103
+ ],
1104
+ });
1105
+
1106
+ const result = parseMarimoIslandApps(container);
1107
+
1108
+ expect(result[0].cells.map((cell) => cell.code)).toEqual([
1109
+ "payload_first = True",
1110
+ "payload_second = True",
1111
+ ]);
1112
+ expect(first.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
1113
+ expect(second.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1");
1114
+ });
531
1115
  });
@@ -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
+ }