@marimo-team/frontend 0.24.1-dev80 → 0.24.1-dev82

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 (128) hide show
  1. package/dist/assets/{CellStatus-DMQfH9wD.js → CellStatus-GcHBZKLm.js} +1 -1
  2. package/dist/assets/{JsonOutput-B7sDhGzR.js → JsonOutput-CUJlEdsN.js} +8 -8
  3. package/dist/assets/{MarimoErrorOutput-C0vk393J.js → MarimoErrorOutput-DCAezqcJ.js} +2 -2
  4. package/dist/assets/{RenderHTML-BnMaGpV8.js → RenderHTML-D9-75ltP.js} +1 -1
  5. package/dist/assets/{RunButton-nocIODpS.js → RunButton-plSsbubG.js} +1 -1
  6. package/dist/assets/{add-cell-with-ai-B_TSaqjJ.js → add-cell-with-ai-Cfby28hZ.js} +14 -14
  7. package/dist/assets/{add-connection-dialog-DOuojDa-.js → add-connection-dialog-By0i0Fq5.js} +42 -42
  8. package/dist/assets/{agent-panel-CkKCGjDp.js → agent-panel-Cf482jaA.js} +4 -4
  9. package/dist/assets/ai-model-dropdown-DMfaGQ8o.js +3 -0
  10. package/dist/assets/app-config-button-93unbEYx.js +1 -0
  11. package/dist/assets/{blob-CYM8xQsK.js → blob-yxfhyyvD.js} +1 -1
  12. package/dist/assets/cell-editor-CZmqG6aB.js +21 -0
  13. package/dist/assets/{cell-link-DCOAqVkE.js → cell-link-hHrWcozB.js} +1 -1
  14. package/dist/assets/{cells-C6lBv8kg.js → cells-yuYDzbAy.js} +51 -51
  15. package/dist/assets/{chat-display-B6GBZDq8.js → chat-display-CnhvADgL.js} +1 -1
  16. package/dist/assets/{chat-panel-wWuBXhXw.js → chat-panel-CdE97AxM.js} +2 -2
  17. package/dist/assets/{chat-ui-CQ7A3qdB.js → chat-ui-BD3rpULw.js} +2 -2
  18. package/dist/assets/{column-preview-CatwdUda.js → column-preview-BEmcYaKl.js} +1 -1
  19. package/dist/assets/{command-palette-DcPM_WiO.js → command-palette-BGRRw0sn.js} +1 -1
  20. package/dist/assets/{common-B4aMxphz.js → common-Bs2kaOs2.js} +1 -1
  21. package/dist/assets/{components-D8-ffQsq.js → components-ByyAzQxP.js} +1 -1
  22. package/dist/assets/{datasource-D-_jFtGs.js → datasource-DeZYq5UY.js} +2 -2
  23. package/dist/assets/{dependency-graph-panel-BQli6zxh.js → dependency-graph-panel-g10osAFc.js} +4 -4
  24. package/dist/assets/{documentation-panel-_QE3nPOg.js → documentation-panel-CZ1mKkDh.js} +1 -1
  25. package/dist/assets/{download-Cqjqrhzo.js → download-D1crMjD8.js} +1 -1
  26. package/dist/assets/edit-page-Bk1sT7Wb.js +9 -0
  27. package/dist/assets/{error-entries-c5sIySli.js → error-entries-B6XTLRyE.js} +1 -1
  28. package/dist/assets/{error-panel-CXKrWK_u.js → error-panel-lexB3L6f.js} +1 -1
  29. package/dist/assets/{file-explorer-panel-BZiAb4x_.js → file-explorer-panel-DHRH2J-3.js} +1 -1
  30. package/dist/assets/{file-icons-uUY_hGYm.js → file-icons-Dx3v6lMu.js} +1 -1
  31. package/dist/assets/{file-name-input-DWUKg4LS.js → file-name-input-DzL8D_7x.js} +1 -1
  32. package/dist/assets/{floating-outline-DhGtHg6T.js → floating-outline-3rQEbxxk.js} +1 -1
  33. package/dist/assets/{home-page-Bka_7H02.js → home-page-Dnz4tfZB.js} +1 -1
  34. package/dist/assets/index-BID9w87A.js +29 -0
  35. package/dist/assets/layout-qHVpnnQT.js +9 -0
  36. package/dist/assets/{logs-panel-ragZa1sf.js → logs-panel-CSbUhhRo.js} +1 -1
  37. package/dist/assets/{markdown-renderer-B-uA_6mP.js → markdown-renderer-BgUTGtM0.js} +1 -1
  38. package/dist/assets/{name-cell-input-gC1flbyT.js → name-cell-input-pEE4FGAL.js} +1 -1
  39. package/dist/assets/{outline-panel-D7H7bhOh.js → outline-panel-BgnjEQWg.js} +1 -1
  40. package/dist/assets/{packages-panel-BZnsUm4S.js → packages-panel-BprfUOzZ.js} +1 -1
  41. package/dist/assets/panel-accordion-state-vpqEc3Kd.js +1 -0
  42. package/dist/assets/panels-3h7Cf8xJ.js +4 -0
  43. package/dist/assets/{process-output-DQQ8T5gv.js → process-output-siRm_lza.js} +1 -1
  44. package/dist/assets/{readonly-code-display-Bc0VX9i6.js → readonly-code-display-CLWV76FT.js} +1 -1
  45. package/dist/assets/readonly-python-code-Bor9b9pV.js +1 -0
  46. package/dist/assets/reveal-component-DNPksvzT.js +1069 -0
  47. package/dist/assets/run-page-C4aONLoP.js +1 -0
  48. package/dist/assets/{scratchpad-panel-BJHlMBZV.js → scratchpad-panel-BQSoRQvQ.js} +1 -1
  49. package/dist/assets/session-panel-DB5qEYqt.js +1 -0
  50. package/dist/assets/skeleton-BSxwEE0h.js +1 -0
  51. package/dist/assets/{snippets-panel-2jq36DdG.js → snippets-panel-B8Yi81tz.js} +1 -1
  52. package/dist/assets/state-D1N9_41P.js +3 -0
  53. package/dist/assets/state-O8w98G11.js +1 -0
  54. package/dist/assets/{tracing-91_akEkv.js → tracing-DuLkr7ts.js} +1 -1
  55. package/dist/assets/{tracing-panel-BvE81RYx.js → tracing-panel-CeLKVG8M.js} +2 -2
  56. package/dist/assets/{tree-actions-CGkbUSfG.js → tree-actions-Bh-Nnaub.js} +1 -1
  57. package/dist/assets/useAddCell-_JRYhAFd.js +1 -0
  58. package/dist/assets/useBoolean-Cu7Eado1.js +1 -0
  59. package/dist/assets/{useCellActionButton-cv6xOsnc.js → useCellActionButton-Bi8Lnv3F.js} +1 -1
  60. package/dist/assets/{useDeleteCell-DB-bhhnO.js → useDeleteCell-CBQiavzs.js} +1 -1
  61. package/dist/assets/{useDependencyPanelTab-BDnVzNV-.js → useDependencyPanelTab-BOn3UJ07.js} +1 -1
  62. package/dist/assets/useNotebookActions--pblpIWF.js +10 -0
  63. package/dist/assets/{useRunCells-GCTS1YFv.js → useRunCells-BFQfLlUl.js} +1 -1
  64. package/dist/assets/{useSplitCell-DhT9BA2_.js → useSplitCell-Ig9P6lAn.js} +1 -1
  65. package/dist/index.html +20 -20
  66. package/package.json +1 -1
  67. package/src/__tests__/mount.test.ts +54 -2
  68. package/src/components/editor/__tests__/viewer-banner.test.tsx +1 -1
  69. package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +9 -0
  70. package/src/components/editor/actions/export-dialog/export-notebook.ts +8 -1
  71. package/src/components/editor/actions/export-dialog/use-export-dialog.ts +2 -0
  72. package/src/components/editor/actions/useNotebookActions.tsx +1 -1
  73. package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +1 -1
  74. package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +1 -1
  75. package/src/components/editor/renderers/cells-renderer.tsx +15 -28
  76. package/src/components/editor/renderers/grid-layout/__tests__/plugin.test.ts +42 -0
  77. package/src/components/editor/renderers/grid-layout/plugin.tsx +20 -10
  78. package/src/components/editor/renderers/layout-select.tsx +1 -1
  79. package/src/components/editor/renderers/plugins.ts +7 -1
  80. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +49 -15
  81. package/src/components/editor/renderers/slides-layout/slides-layout.tsx +16 -2
  82. package/src/components/editor/renderers/slides-layout/types.ts +15 -11
  83. package/src/components/editor/renderers/types.ts +5 -5
  84. package/src/components/editor/viewer-banner.tsx +1 -1
  85. package/src/components/pages/run-page.tsx +16 -7
  86. package/src/components/slides/__tests__/reveal-component.test.ts +33 -8
  87. package/src/components/slides/__tests__/speaker-view.test.ts +27 -0
  88. package/src/components/slides/reveal-component.tsx +110 -45
  89. package/src/components/slides/speaker-view.ts +22 -0
  90. package/src/components/static-html/share-modal.tsx +2 -0
  91. package/src/core/cells/__tests__/document-changes.test.ts +343 -26
  92. package/src/core/cells/document-changes.ts +181 -4
  93. package/src/core/export/hooks.ts +2 -0
  94. package/src/core/export/layout.ts +9 -0
  95. package/src/core/kernel/handlers.ts +7 -16
  96. package/src/core/kernel/state.ts +2 -0
  97. package/src/core/layout/__tests__/layout.test.ts +95 -0
  98. package/src/core/layout/layout.ts +59 -65
  99. package/src/core/layout/state.ts +121 -0
  100. package/src/core/meta/__tests__/code-visibility.test.tsx +28 -0
  101. package/src/core/meta/code-visibility.ts +3 -1
  102. package/src/core/run-app.tsx +6 -2
  103. package/src/core/saving/__tests__/save-component.test.ts +31 -1
  104. package/src/core/saving/save-component.tsx +39 -29
  105. package/src/core/saving/state.ts +1 -1
  106. package/src/core/static/download-html.ts +2 -0
  107. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +47 -14
  108. package/src/core/websocket/useMarimoKernelConnection.tsx +7 -2
  109. package/src/mount.tsx +24 -6
  110. package/dist/assets/ai-model-dropdown-CfWMIzjl.js +0 -3
  111. package/dist/assets/app-config-button-ii-Hj5CZ.js +0 -1
  112. package/dist/assets/cell-editor-BVb6iKbw.js +0 -21
  113. package/dist/assets/edit-page-RuyN164g.js +0 -9
  114. package/dist/assets/index-ChbtcNW2.js +0 -29
  115. package/dist/assets/layout-Uv1ZP5XB.js +0 -9
  116. package/dist/assets/panel-accordion-state-BLU7OiMD.js +0 -1
  117. package/dist/assets/panels-B3Y6fue9.js +0 -4
  118. package/dist/assets/readonly-python-code-D2HXwAfT.js +0 -1
  119. package/dist/assets/reveal-component-BN1C8zFu.js +0 -1069
  120. package/dist/assets/run-page-ByVvsH9G.js +0 -1
  121. package/dist/assets/session-panel-CdCKWAOf.js +0 -1
  122. package/dist/assets/skeleton-DirOIQrj.js +0 -1
  123. package/dist/assets/state-C9O-2hQL.js +0 -3
  124. package/dist/assets/state-DyxPdcEa.js +0 -1
  125. package/dist/assets/types-CoHi_MQq.js +0 -1
  126. package/dist/assets/useAddCell-DqvY2ulx.js +0 -1
  127. package/dist/assets/useBoolean-DWBCpKfT.js +0 -1
  128. package/dist/assets/useNotebookActions-Dxp4gTEa.js +0 -10
@@ -33,14 +33,20 @@ import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
33
33
  import { requestClientAtom } from "@/core/network/requests";
34
34
  import type { EditRequests, RunRequests } from "@/core/network/types";
35
35
  import { store } from "@/core/state/jotai";
36
+ import { Deferred } from "@/utils/Deferred";
36
37
  import { MultiColumn } from "@/utils/id-tree";
37
38
  import { exportedForTesting, type NotebookState } from "../cells";
38
39
  import {
39
40
  type CellAction,
41
+ abortDocumentResync,
42
+ beginDocumentResync,
40
43
  coalesceChanges,
44
+ completeDocumentResync,
41
45
  type DocumentChange,
46
+ flushDocumentChanges,
42
47
  exportedForTesting as middlewareExports,
43
48
  toDocumentChanges,
49
+ withDocumentSave,
44
50
  } from "../document-changes";
45
51
  import { CellId } from "../ids";
46
52
 
@@ -698,15 +704,16 @@ describe("coalesceChanges", () => {
698
704
  });
699
705
  });
700
706
 
701
- /**
702
- * End-to-end of the debounced flush path: dispatch through the real middleware
703
- * reducer, let the debounce fire, and inspect the batch that reaches
704
- * sendDocumentTransaction. Coalescing only matters once edits actually share a
705
- * debounce window, which drainChanges-based tests can't observe.
706
- */
707
- describe("flush path coalescing", () => {
707
+ describe("document transaction middleware", () => {
708
708
  let sent: DocumentChange[][];
709
709
 
710
+ const updateCode = (cellId: CellId, code: string) => {
711
+ state = dispatch(state, {
712
+ type: "updateCellCode",
713
+ payload: { cellId, code, formattingChange: false },
714
+ });
715
+ };
716
+
710
717
  beforeEach(() => {
711
718
  vi.useFakeTimers();
712
719
  sent = [];
@@ -732,54 +739,364 @@ describe("flush path coalescing", () => {
732
739
  vi.useRealTimers();
733
740
  });
734
741
 
735
- it("coalesces edit + delete of the same cell within one debounce window", () => {
742
+ it("coalesces edit + delete of the same cell within one debounce window", async () => {
736
743
  setup("x = 1");
737
744
  const [x] = state.cellIds.inOrderIds;
738
745
  // Discard the create-cell from setup; it would flush as its own batch.
739
746
  middlewareExports.cancelPendingChanges();
740
747
 
741
- // Same window: edit the cell, then delete it, with no timer advance.
742
- state = dispatch(state, {
743
- type: "updateCellCode",
744
- payload: { cellId: x, code: "x = 2", formattingChange: false },
745
- });
748
+ updateCode(x, "x = 2");
746
749
  state = dispatch(state, {
747
750
  type: "deleteCell",
748
751
  payload: { cellId: x },
749
752
  });
750
753
 
751
- // Debounced: nothing on the wire yet.
752
754
  expect(sent).toEqual([]);
753
755
 
754
- vi.advanceTimersByTime(400);
756
+ await vi.advanceTimersByTimeAsync(400);
755
757
 
756
- // One transaction, and it is just the delete — the conflicting set-code
757
- // has been dropped, so the server never sees edit+delete of one cell.
758
758
  expect(sent).toHaveLength(1);
759
759
  expect(sent[0]).toEqual([{ type: "delete-cell", cellId: x }]);
760
760
  });
761
761
 
762
- it("sends edit and delete as separate clean transactions across windows", () => {
762
+ it("sends edit and delete as separate clean transactions across windows", async () => {
763
763
  setup("x = 1");
764
764
  const [x] = state.cellIds.inOrderIds;
765
765
  middlewareExports.cancelPendingChanges();
766
766
 
767
- state = dispatch(state, {
768
- type: "updateCellCode",
769
- payload: { cellId: x, code: "x = 2", formattingChange: false },
770
- });
771
- vi.advanceTimersByTime(400);
767
+ updateCode(x, "x = 2");
768
+ await vi.advanceTimersByTimeAsync(400);
772
769
 
773
770
  state = dispatch(state, {
774
771
  type: "deleteCell",
775
772
  payload: { cellId: x },
776
773
  });
777
- vi.advanceTimersByTime(400);
774
+ await vi.advanceTimersByTimeAsync(400);
778
775
 
779
- // Two separate, individually-valid transactions: neither conflicts, which
780
- // is why the bug only ever surfaced when both landed in the same window.
781
776
  expect(sent).toHaveLength(2);
782
777
  expect(sent[0]).toEqual([{ type: "set-code", cellId: x, code: "x = 2" }]);
783
778
  expect(sent[1]).toEqual([{ type: "delete-cell", cellId: x }]);
784
779
  });
780
+
781
+ it("flushes pending changes before a dependent operation", async () => {
782
+ setup("x = 1");
783
+ const [x] = state.cellIds.inOrderIds;
784
+ middlewareExports.cancelPendingChanges();
785
+ updateCode(x, "x = 2");
786
+
787
+ await flushDocumentChanges();
788
+
789
+ expect(sent).toEqual([[{ type: "set-code", cellId: x, code: "x = 2" }]]);
790
+ });
791
+
792
+ it("keeps a failed transaction sticky before a dependent operation", async () => {
793
+ const failingClient: Pick<
794
+ EditRequests & RunRequests,
795
+ "sendDocumentTransaction"
796
+ > = {
797
+ sendDocumentTransaction: async ({ changes }) => {
798
+ sent.push(changes);
799
+ throw new Error("connection lost");
800
+ },
801
+ };
802
+ store.set(
803
+ requestClientAtom,
804
+ failingClient as unknown as EditRequests & RunRequests,
805
+ );
806
+
807
+ setup("x = 1");
808
+ const [x] = state.cellIds.inOrderIds;
809
+ middlewareExports.cancelPendingChanges();
810
+ updateCode(x, "x = 2");
811
+ await vi.advanceTimersByTimeAsync(400);
812
+
813
+ updateCode(x, "x = 3");
814
+ await expect(flushDocumentChanges()).rejects.toThrow("connection lost");
815
+
816
+ expect(sent).toEqual([[{ type: "set-code", cellId: x, code: "x = 2" }]]);
817
+ });
818
+
819
+ it("resumes after a full document save establishes a fresh baseline", async () => {
820
+ let attempt = 0;
821
+ const recoveringClient: Pick<
822
+ EditRequests & RunRequests,
823
+ "sendDocumentTransaction"
824
+ > = {
825
+ sendDocumentTransaction: async ({ changes }) => {
826
+ sent.push(changes);
827
+ attempt += 1;
828
+ if (attempt === 1) {
829
+ throw new Error("connection lost");
830
+ }
831
+ return null;
832
+ },
833
+ };
834
+ store.set(
835
+ requestClientAtom,
836
+ recoveringClient as unknown as EditRequests & RunRequests,
837
+ );
838
+
839
+ setup("x = 1");
840
+ const [x] = state.cellIds.inOrderIds;
841
+ middlewareExports.cancelPendingChanges();
842
+ updateCode(x, "x = 2");
843
+ await vi.advanceTimersByTimeAsync(400);
844
+
845
+ updateCode(x, "x = 3");
846
+ await withDocumentSave(async () => {
847
+ updateCode(x, "x = 4");
848
+ });
849
+
850
+ await vi.advanceTimersByTimeAsync(400);
851
+ expect(sent).toEqual([
852
+ [{ type: "set-code", cellId: x, code: "x = 2" }],
853
+ [{ type: "set-code", cellId: x, code: "x = 4" }],
854
+ ]);
855
+ });
856
+
857
+ it("restores unsent edits when a full document save fails", async () => {
858
+ const firstStarted = new Deferred<void>();
859
+ const firstRelease = new Deferred<void>();
860
+ const failingClient: Pick<
861
+ EditRequests & RunRequests,
862
+ "sendDocumentTransaction"
863
+ > = {
864
+ sendDocumentTransaction: async ({ changes }) => {
865
+ sent.push(changes);
866
+ if (sent.length === 1) {
867
+ firstStarted.resolve();
868
+ await firstRelease.promise;
869
+ throw new Error("connection lost");
870
+ }
871
+ return null;
872
+ },
873
+ };
874
+ store.set(
875
+ requestClientAtom,
876
+ failingClient as unknown as EditRequests & RunRequests,
877
+ );
878
+
879
+ setup("x = 1");
880
+ const [x] = state.cellIds.inOrderIds;
881
+ middlewareExports.cancelPendingChanges();
882
+ updateCode(x, "x = 2");
883
+ vi.advanceTimersByTime(400);
884
+ await firstStarted.promise;
885
+
886
+ updateCode(x, "x = 3");
887
+ await vi.advanceTimersByTimeAsync(400);
888
+ firstRelease.resolve();
889
+ await expect(flushDocumentChanges()).rejects.toThrow("connection lost");
890
+
891
+ updateCode(x, "x = 4");
892
+ const firstResync = beginDocumentResync();
893
+ abortDocumentResync(firstResync);
894
+ const secondResync = beginDocumentResync();
895
+ expect(secondResync?.includedTransactions).toEqual([
896
+ [{ type: "set-code", cellId: x, code: "x = 3" }],
897
+ ]);
898
+ expect(secondResync?.includedChanges).toEqual([
899
+ { type: "set-code", cellId: x, code: "x = 4" },
900
+ ]);
901
+ await completeDocumentResync(secondResync);
902
+
903
+ updateCode(x, "x = 5");
904
+ await vi.advanceTimersByTimeAsync(400);
905
+ expect(sent.at(-1)).toEqual([
906
+ { type: "set-code", cellId: x, code: "x = 5" },
907
+ ]);
908
+ });
909
+
910
+ it("keeps edits debounced while an earlier transaction is in flight", async () => {
911
+ const firstRelease = new Deferred<void>();
912
+ const firstStarted = new Deferred<void>();
913
+ const delayedClient: Pick<
914
+ EditRequests & RunRequests,
915
+ "sendDocumentTransaction"
916
+ > = {
917
+ sendDocumentTransaction: async ({ changes }) => {
918
+ sent.push(changes);
919
+ if (sent.length === 1) {
920
+ firstStarted.resolve();
921
+ await firstRelease.promise;
922
+ }
923
+ return null;
924
+ },
925
+ };
926
+ store.set(
927
+ requestClientAtom,
928
+ delayedClient as unknown as EditRequests & RunRequests,
929
+ );
930
+
931
+ setup("x = 1");
932
+ const [x] = state.cellIds.inOrderIds;
933
+ middlewareExports.cancelPendingChanges();
934
+ updateCode(x, "x = 2");
935
+ vi.advanceTimersByTime(400);
936
+ await firstStarted.promise;
937
+
938
+ updateCode(x, "x = 3");
939
+ firstRelease.resolve();
940
+ await vi.advanceTimersByTimeAsync(399);
941
+ expect(sent).toHaveLength(1);
942
+
943
+ await vi.advanceTimersByTimeAsync(1);
944
+ expect(sent).toEqual([
945
+ [{ type: "set-code", cellId: x, code: "x = 2" }],
946
+ [{ type: "set-code", cellId: x, code: "x = 3" }],
947
+ ]);
948
+ });
949
+
950
+ it("waits for every transaction queued while flushing", async () => {
951
+ const releases = Array.from({ length: 3 }, () => new Deferred<void>());
952
+ const starts = Array.from({ length: 3 }, () => new Deferred<void>());
953
+ const orderedClient: Pick<
954
+ EditRequests & RunRequests,
955
+ "sendDocumentTransaction"
956
+ > = {
957
+ sendDocumentTransaction: async ({ changes }) => {
958
+ const index = sent.length;
959
+ sent.push(changes);
960
+ starts[index].resolve();
961
+ await releases[index].promise;
962
+ return null;
963
+ },
964
+ };
965
+ store.set(
966
+ requestClientAtom,
967
+ orderedClient as unknown as EditRequests & RunRequests,
968
+ );
969
+
970
+ setup("x = 1");
971
+ const [x] = state.cellIds.inOrderIds;
972
+ middlewareExports.cancelPendingChanges();
973
+ updateCode(x, "x = 2");
974
+ vi.advanceTimersByTime(400);
975
+ await starts[0].promise;
976
+
977
+ const flushed = flushDocumentChanges();
978
+
979
+ updateCode(x, "x = 3");
980
+ await vi.advanceTimersByTimeAsync(400);
981
+ expect(sent).toHaveLength(1);
982
+
983
+ releases[0].resolve();
984
+ await starts[1].promise;
985
+
986
+ updateCode(x, "x = 4");
987
+ await vi.advanceTimersByTimeAsync(400);
988
+ expect(sent).toHaveLength(2);
989
+
990
+ let flushResolved = false;
991
+ void flushed.then(() => {
992
+ flushResolved = true;
993
+ });
994
+ releases[1].resolve();
995
+ await starts[2].promise;
996
+ expect(flushResolved).toBe(false);
997
+
998
+ releases[2].resolve();
999
+ await flushed;
1000
+ expect(sent).toEqual([
1001
+ [{ type: "set-code", cellId: x, code: "x = 2" }],
1002
+ [{ type: "set-code", cellId: x, code: "x = 3" }],
1003
+ [{ type: "set-code", cellId: x, code: "x = 4" }],
1004
+ ]);
1005
+ });
1006
+
1007
+ it("orders a full save between earlier and later transactions", async () => {
1008
+ const firstStarted = new Deferred<void>();
1009
+ const firstRelease = new Deferred<void>();
1010
+ const saveStarted = new Deferred<void>();
1011
+ const saveRelease = new Deferred<void>();
1012
+ const events: string[] = [];
1013
+ const delayedClient: Pick<
1014
+ EditRequests & RunRequests,
1015
+ "sendDocumentTransaction"
1016
+ > = {
1017
+ sendDocumentTransaction: async ({ changes }) => {
1018
+ const change = changes[0];
1019
+ events.push("code" in change ? change.code : change.type);
1020
+ if (events.length === 1) {
1021
+ firstStarted.resolve();
1022
+ await firstRelease.promise;
1023
+ }
1024
+ return null;
1025
+ },
1026
+ };
1027
+ store.set(
1028
+ requestClientAtom,
1029
+ delayedClient as unknown as EditRequests & RunRequests,
1030
+ );
1031
+
1032
+ setup("x = 1");
1033
+ const [x] = state.cellIds.inOrderIds;
1034
+ middlewareExports.cancelPendingChanges();
1035
+ updateCode(x, "x = 2");
1036
+ vi.advanceTimersByTime(400);
1037
+ await firstStarted.promise;
1038
+
1039
+ const saving = withDocumentSave(async () => {
1040
+ events.push("save");
1041
+ saveStarted.resolve();
1042
+ await saveRelease.promise;
1043
+ });
1044
+ expect(events).toEqual(["x = 2"]);
1045
+
1046
+ firstRelease.resolve();
1047
+ await saveStarted.promise;
1048
+ updateCode(x, "x = 3");
1049
+ await vi.advanceTimersByTimeAsync(400);
1050
+ expect(events).toEqual(["x = 2", "save"]);
1051
+
1052
+ saveRelease.resolve();
1053
+ await saving;
1054
+ await flushDocumentChanges();
1055
+ expect(events).toEqual(["x = 2", "save", "x = 3"]);
1056
+ });
1057
+
1058
+ it("ignores a failure from a transaction reset while in flight", async () => {
1059
+ const firstStarted = new Deferred<void>();
1060
+ const firstRelease = new Deferred<void>();
1061
+ const firstFinished = new Deferred<void>();
1062
+ const delayedClient: Pick<
1063
+ EditRequests & RunRequests,
1064
+ "sendDocumentTransaction"
1065
+ > = {
1066
+ sendDocumentTransaction: async ({ changes }) => {
1067
+ sent.push(changes);
1068
+ if (sent.length === 1) {
1069
+ firstStarted.resolve();
1070
+ try {
1071
+ await firstRelease.promise;
1072
+ } finally {
1073
+ firstFinished.resolve();
1074
+ }
1075
+ }
1076
+ return null;
1077
+ },
1078
+ };
1079
+ store.set(
1080
+ requestClientAtom,
1081
+ delayedClient as unknown as EditRequests & RunRequests,
1082
+ );
1083
+
1084
+ setup("x = 1");
1085
+ const [x] = state.cellIds.inOrderIds;
1086
+ middlewareExports.cancelPendingChanges();
1087
+ updateCode(x, "x = 2");
1088
+ vi.advanceTimersByTime(400);
1089
+ await firstStarted.promise;
1090
+
1091
+ middlewareExports.cancelPendingChanges();
1092
+ firstRelease.reject(new Error("stale connection failure"));
1093
+ await firstFinished.promise;
1094
+ await Promise.resolve();
1095
+
1096
+ updateCode(x, "x = 3");
1097
+ await flushDocumentChanges();
1098
+ expect(sent.at(-1)).toEqual([
1099
+ { type: "set-code", cellId: x, code: "x = 3" },
1100
+ ]);
1101
+ });
785
1102
  });
@@ -17,6 +17,7 @@
17
17
  import { debounce } from "lodash-es";
18
18
  import { assertNever } from "@/utils/assertNever";
19
19
  import type { DispatchedActionOf } from "@/utils/createReducer";
20
+ import { Deferred } from "@/utils/Deferred";
20
21
  import { Logger } from "@/utils/Logger";
21
22
  import type { NotificationMessageData } from "../kernel/messages";
22
23
  import { kioskModeAtom } from "../mode";
@@ -632,16 +633,184 @@ export function coalesceChanges(changes: DocumentChange[]): DocumentChange[] {
632
633
  // ---------------------------------------------------------------------------
633
634
 
634
635
  let pendingChanges: DocumentChange[] = [];
636
+ let stagedTransactions: DocumentChange[][] = [];
637
+ let transactionQueue: Promise<void> = Promise.resolve();
638
+ let transactionGeneration = 0;
639
+ let activeDocumentSave: Deferred<void> | null = null;
640
+
641
+ type TransactionSyncState =
642
+ | { status: "synchronized" }
643
+ | { status: "failed"; error: unknown };
644
+
645
+ interface DocumentResync {
646
+ failure: Extract<TransactionSyncState, { status: "failed" }>;
647
+ includedChanges: DocumentChange[];
648
+ includedTransactions: DocumentChange[][];
649
+ }
635
650
 
636
- const flushChanges = debounce(() => {
651
+ let transactionSyncState: TransactionSyncState = { status: "synchronized" };
652
+
653
+ function stagePendingTransaction(): void {
654
+ if (transactionSyncState.status === "failed") {
655
+ return;
656
+ }
637
657
  const changes = coalesceChanges(pendingChanges);
638
658
  pendingChanges = [];
639
- if (changes.length === 0) {
659
+ if (changes.length > 0) {
660
+ stagedTransactions.push(changes);
661
+ }
662
+ }
663
+
664
+ async function sendStagedTransactions(generation: number): Promise<void> {
665
+ if (generation !== transactionGeneration) {
640
666
  return;
641
667
  }
642
- void getRequestClient().sendDocumentTransaction({ changes });
668
+ if (transactionSyncState.status === "failed") {
669
+ throw transactionSyncState.error;
670
+ }
671
+
672
+ while (stagedTransactions.length > 0) {
673
+ const changes = stagedTransactions[0];
674
+
675
+ try {
676
+ await getRequestClient().sendDocumentTransaction({ changes });
677
+ } catch (error) {
678
+ if (generation !== transactionGeneration) {
679
+ return;
680
+ }
681
+ // A transport failure is ambiguous: the server may have applied the
682
+ // transaction before the response was lost. Keep the failure sticky so
683
+ // an export cannot claim synchronization without a fresh document.
684
+ transactionSyncState = { status: "failed", error };
685
+ stagedTransactions = stagedTransactions.slice(1);
686
+ throw error;
687
+ }
688
+ if (generation !== transactionGeneration) {
689
+ return;
690
+ }
691
+ stagedTransactions.shift();
692
+ }
693
+ }
694
+
695
+ function queuePendingTransactions(): Promise<void> {
696
+ const generation = transactionGeneration;
697
+ const saveBarrier = activeDocumentSave?.promise ?? Promise.resolve();
698
+ const queued = Promise.all([
699
+ transactionQueue.catch(() => undefined),
700
+ saveBarrier,
701
+ ]).then(() => sendStagedTransactions(generation));
702
+ transactionQueue = queued;
703
+ return queued;
704
+ }
705
+
706
+ const flushChanges = debounce(() => {
707
+ stagePendingTransaction();
708
+ void queuePendingTransactions().catch(() => undefined);
643
709
  }, 400);
644
710
 
711
+ export async function flushDocumentChanges(): Promise<void> {
712
+ if (transactionSyncState.status === "failed") {
713
+ throw transactionSyncState.error;
714
+ }
715
+ flushChanges.cancel();
716
+ stagePendingTransaction();
717
+ let awaitedQueue = queuePendingTransactions();
718
+ while (true) {
719
+ await awaitedQueue;
720
+ flushChanges.cancel();
721
+ stagePendingTransaction();
722
+ if (
723
+ pendingChanges.length === 0 &&
724
+ stagedTransactions.length === 0 &&
725
+ transactionQueue === awaitedQueue
726
+ ) {
727
+ return;
728
+ }
729
+ awaitedQueue =
730
+ transactionQueue === awaitedQueue
731
+ ? queuePendingTransactions()
732
+ : transactionQueue;
733
+ }
734
+ }
735
+
736
+ export function beginDocumentResync(): DocumentResync | null {
737
+ if (transactionSyncState.status !== "failed") {
738
+ return null;
739
+ }
740
+ flushChanges.cancel();
741
+ const includedChanges = pendingChanges;
742
+ const includedTransactions = stagedTransactions;
743
+ pendingChanges = [];
744
+ stagedTransactions = [];
745
+ return {
746
+ failure: transactionSyncState,
747
+ includedChanges,
748
+ includedTransactions,
749
+ };
750
+ }
751
+
752
+ export function abortDocumentResync(resync: DocumentResync | null): void {
753
+ if (resync === null || transactionSyncState !== resync.failure) {
754
+ return;
755
+ }
756
+ pendingChanges = [...resync.includedChanges, ...pendingChanges];
757
+ stagedTransactions = [...resync.includedTransactions, ...stagedTransactions];
758
+ }
759
+
760
+ export async function completeDocumentResync(
761
+ resync: DocumentResync | null,
762
+ ): Promise<void> {
763
+ if (resync === null) {
764
+ return;
765
+ }
766
+ await transactionQueue.catch(() => undefined);
767
+ if (transactionSyncState !== resync.failure) {
768
+ return;
769
+ }
770
+ transactionSyncState = { status: "synchronized" };
771
+ transactionQueue = Promise.resolve();
772
+ if (pendingChanges.length > 0) {
773
+ flushChanges();
774
+ }
775
+ }
776
+
777
+ function releaseDocumentSave(barrier: Deferred<void>): void {
778
+ if (activeDocumentSave === barrier) {
779
+ activeDocumentSave = null;
780
+ }
781
+ barrier.resolve();
782
+ }
783
+
784
+ /**
785
+ * Runs a full save after earlier document transactions and holds later
786
+ * transactions until the save settles. A failed transaction makes the save
787
+ * the authoritative document resync boundary.
788
+ */
789
+ export async function withDocumentSave<T>(save: () => Promise<T>): Promise<T> {
790
+ let resync: DocumentResync | null = null;
791
+ try {
792
+ await flushDocumentChanges();
793
+ } catch (error) {
794
+ resync = beginDocumentResync();
795
+ if (resync === null) {
796
+ throw error;
797
+ }
798
+ }
799
+
800
+ const barrier = new Deferred<void>();
801
+ activeDocumentSave = barrier;
802
+ try {
803
+ const result = await save();
804
+ releaseDocumentSave(barrier);
805
+ await completeDocumentResync(resync);
806
+ return result;
807
+ } catch (error) {
808
+ abortDocumentResync(resync);
809
+ releaseDocumentSave(barrier);
810
+ throw error;
811
+ }
812
+ }
813
+
645
814
  function isScratchChange(change: DocumentChange): boolean {
646
815
  if ("cellId" in change && change.cellId === SCRATCH_CELL_ID) {
647
816
  return true;
@@ -658,7 +827,9 @@ function enqueue(change: DocumentChange) {
658
827
  return;
659
828
  }
660
829
  pendingChanges.push(change);
661
- flushChanges();
830
+ if (transactionSyncState.status === "synchronized") {
831
+ flushChanges();
832
+ }
662
833
  }
663
834
 
664
835
  /**
@@ -746,7 +917,13 @@ export function applyTransactionChanges(
746
917
  export const exportedForTesting = {
747
918
  cancelPendingChanges: () => {
748
919
  flushChanges.cancel();
920
+ activeDocumentSave?.resolve();
921
+ activeDocumentSave = null;
922
+ transactionGeneration += 1;
749
923
  pendingChanges = [];
924
+ stagedTransactions = [];
925
+ transactionSyncState = { status: "synchronized" };
926
+ transactionQueue = Promise.resolve();
750
927
  },
751
928
  drainChanges: (): DocumentChange[] => {
752
929
  flushChanges.cancel();
@@ -11,6 +11,7 @@ import { Objects } from "@/utils/objects";
11
11
  import { ProgressState } from "@/utils/progress";
12
12
  import { cellsRuntimeAtom } from "../cells/cells";
13
13
  import type { CellId } from "../cells/ids";
14
+ import { getExportLayout } from "./layout";
14
15
  import { connectionAtom } from "../network/connection";
15
16
  import { useRequestClient } from "../network/requests";
16
17
  import type { UpdateCellOutputsRequest } from "../network/types";
@@ -56,6 +57,7 @@ export function useAutoExport() {
56
57
  download: false,
57
58
  includeCode: true,
58
59
  files: VirtualFileTracker.INSTANCE.filenames(),
60
+ layout: await getExportLayout(),
59
61
  });
60
62
  },
61
63
  // Run every 5 seconds, or when the document becomes visible
@@ -0,0 +1,9 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { flushDocumentChanges } from "@/core/cells/document-changes";
4
+ import { getSerializedLayout } from "@/core/layout/layout";
5
+
6
+ export async function getExportLayout() {
7
+ await flushDocumentChanges();
8
+ return getSerializedLayout();
9
+ }