@marimo-team/frontend 0.24.1-dev81 → 0.24.1-dev83
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/dist/assets/{CellStatus-DMQfH9wD.js → CellStatus-GcHBZKLm.js} +1 -1
- package/dist/assets/{JsonOutput-B7sDhGzR.js → JsonOutput-CUJlEdsN.js} +8 -8
- package/dist/assets/{MarimoErrorOutput-C0vk393J.js → MarimoErrorOutput-DCAezqcJ.js} +2 -2
- package/dist/assets/{RenderHTML-BnMaGpV8.js → RenderHTML-D9-75ltP.js} +1 -1
- package/dist/assets/{RunButton-nocIODpS.js → RunButton-plSsbubG.js} +1 -1
- package/dist/assets/{add-cell-with-ai-B_TSaqjJ.js → add-cell-with-ai-Cfby28hZ.js} +14 -14
- package/dist/assets/{add-connection-dialog-DOuojDa-.js → add-connection-dialog-By0i0Fq5.js} +42 -42
- package/dist/assets/{agent-panel-CkKCGjDp.js → agent-panel-Cf482jaA.js} +4 -4
- package/dist/assets/ai-model-dropdown-DMfaGQ8o.js +3 -0
- package/dist/assets/app-config-button-93unbEYx.js +1 -0
- package/dist/assets/{blob-CYM8xQsK.js → blob-yxfhyyvD.js} +1 -1
- package/dist/assets/cell-editor-CZmqG6aB.js +21 -0
- package/dist/assets/{cell-link-DCOAqVkE.js → cell-link-hHrWcozB.js} +1 -1
- package/dist/assets/{cells-C6lBv8kg.js → cells-yuYDzbAy.js} +51 -51
- package/dist/assets/{chat-display-B6GBZDq8.js → chat-display-CnhvADgL.js} +1 -1
- package/dist/assets/{chat-panel-wWuBXhXw.js → chat-panel-CdE97AxM.js} +2 -2
- package/dist/assets/{chat-ui-CQ7A3qdB.js → chat-ui-BD3rpULw.js} +2 -2
- package/dist/assets/{column-preview-CatwdUda.js → column-preview-BEmcYaKl.js} +1 -1
- package/dist/assets/{command-palette-DcPM_WiO.js → command-palette-BGRRw0sn.js} +1 -1
- package/dist/assets/{common-B4aMxphz.js → common-Bs2kaOs2.js} +1 -1
- package/dist/assets/{components-D8-ffQsq.js → components-ByyAzQxP.js} +1 -1
- package/dist/assets/{datasource-D-_jFtGs.js → datasource-DeZYq5UY.js} +2 -2
- package/dist/assets/{dependency-graph-panel-BQli6zxh.js → dependency-graph-panel-g10osAFc.js} +4 -4
- package/dist/assets/{documentation-panel-_QE3nPOg.js → documentation-panel-CZ1mKkDh.js} +1 -1
- package/dist/assets/{download-Cqjqrhzo.js → download-D1crMjD8.js} +1 -1
- package/dist/assets/edit-page-Bk1sT7Wb.js +9 -0
- package/dist/assets/{error-entries-c5sIySli.js → error-entries-B6XTLRyE.js} +1 -1
- package/dist/assets/{error-panel-CXKrWK_u.js → error-panel-lexB3L6f.js} +1 -1
- package/dist/assets/{file-explorer-panel-BZiAb4x_.js → file-explorer-panel-DHRH2J-3.js} +1 -1
- package/dist/assets/{file-icons-uUY_hGYm.js → file-icons-Dx3v6lMu.js} +1 -1
- package/dist/assets/{file-name-input-DWUKg4LS.js → file-name-input-DzL8D_7x.js} +1 -1
- package/dist/assets/{floating-outline-DhGtHg6T.js → floating-outline-3rQEbxxk.js} +1 -1
- package/dist/assets/{home-page-Bka_7H02.js → home-page-Dnz4tfZB.js} +1 -1
- package/dist/assets/index-BID9w87A.js +29 -0
- package/dist/assets/layout-qHVpnnQT.js +9 -0
- package/dist/assets/{logs-panel-ragZa1sf.js → logs-panel-CSbUhhRo.js} +1 -1
- package/dist/assets/{markdown-renderer-B-uA_6mP.js → markdown-renderer-BgUTGtM0.js} +1 -1
- package/dist/assets/{name-cell-input-gC1flbyT.js → name-cell-input-pEE4FGAL.js} +1 -1
- package/dist/assets/{outline-panel-D7H7bhOh.js → outline-panel-BgnjEQWg.js} +1 -1
- package/dist/assets/{packages-panel-BZnsUm4S.js → packages-panel-BprfUOzZ.js} +1 -1
- package/dist/assets/panel-accordion-state-vpqEc3Kd.js +1 -0
- package/dist/assets/panels-3h7Cf8xJ.js +4 -0
- package/dist/assets/{process-output-DQQ8T5gv.js → process-output-siRm_lza.js} +1 -1
- package/dist/assets/{readonly-code-display-Bc0VX9i6.js → readonly-code-display-CLWV76FT.js} +1 -1
- package/dist/assets/readonly-python-code-Bor9b9pV.js +1 -0
- package/dist/assets/reveal-component-DNPksvzT.js +1069 -0
- package/dist/assets/run-page-C4aONLoP.js +1 -0
- package/dist/assets/{scratchpad-panel-BJHlMBZV.js → scratchpad-panel-BQSoRQvQ.js} +1 -1
- package/dist/assets/session-panel-DB5qEYqt.js +1 -0
- package/dist/assets/skeleton-BSxwEE0h.js +1 -0
- package/dist/assets/{snippets-panel-2jq36DdG.js → snippets-panel-B8Yi81tz.js} +1 -1
- package/dist/assets/state-D1N9_41P.js +3 -0
- package/dist/assets/state-O8w98G11.js +1 -0
- package/dist/assets/{tracing-91_akEkv.js → tracing-DuLkr7ts.js} +1 -1
- package/dist/assets/{tracing-panel-BvE81RYx.js → tracing-panel-CeLKVG8M.js} +2 -2
- package/dist/assets/{tree-actions-CGkbUSfG.js → tree-actions-Bh-Nnaub.js} +1 -1
- package/dist/assets/useAddCell-_JRYhAFd.js +1 -0
- package/dist/assets/useBoolean-Cu7Eado1.js +1 -0
- package/dist/assets/{useCellActionButton-cv6xOsnc.js → useCellActionButton-Bi8Lnv3F.js} +1 -1
- package/dist/assets/{useDeleteCell-DB-bhhnO.js → useDeleteCell-CBQiavzs.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-BDnVzNV-.js → useDependencyPanelTab-BOn3UJ07.js} +1 -1
- package/dist/assets/useNotebookActions--pblpIWF.js +10 -0
- package/dist/assets/{useRunCells-GCTS1YFv.js → useRunCells-BFQfLlUl.js} +1 -1
- package/dist/assets/{useSplitCell-DhT9BA2_.js → useSplitCell-Ig9P6lAn.js} +1 -1
- package/dist/index.html +20 -20
- package/package.json +1 -1
- package/src/__tests__/mount.test.ts +54 -2
- package/src/components/editor/__tests__/viewer-banner.test.tsx +1 -1
- package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +9 -0
- package/src/components/editor/actions/export-dialog/export-notebook.ts +8 -1
- package/src/components/editor/actions/export-dialog/use-export-dialog.ts +2 -0
- package/src/components/editor/actions/useNotebookActions.tsx +1 -1
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +1 -1
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +1 -1
- package/src/components/editor/renderers/cells-renderer.tsx +15 -28
- package/src/components/editor/renderers/grid-layout/__tests__/plugin.test.ts +42 -0
- package/src/components/editor/renderers/grid-layout/plugin.tsx +20 -10
- package/src/components/editor/renderers/layout-select.tsx +1 -1
- package/src/components/editor/renderers/plugins.ts +7 -1
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +49 -15
- package/src/components/editor/renderers/slides-layout/slides-layout.tsx +16 -2
- package/src/components/editor/renderers/slides-layout/types.ts +15 -11
- package/src/components/editor/renderers/types.ts +5 -5
- package/src/components/editor/viewer-banner.tsx +1 -1
- package/src/components/pages/run-page.tsx +16 -7
- package/src/components/slides/__tests__/reveal-component.test.ts +33 -8
- package/src/components/slides/__tests__/speaker-view.test.ts +27 -0
- package/src/components/slides/reveal-component.tsx +110 -45
- package/src/components/slides/speaker-view.ts +22 -0
- package/src/components/static-html/share-modal.tsx +2 -0
- package/src/core/cells/__tests__/document-changes.test.ts +343 -26
- package/src/core/cells/document-changes.ts +181 -4
- package/src/core/export/hooks.ts +2 -0
- package/src/core/export/layout.ts +9 -0
- package/src/core/kernel/handlers.ts +7 -16
- package/src/core/kernel/state.ts +2 -0
- package/src/core/layout/__tests__/layout.test.ts +95 -0
- package/src/core/layout/layout.ts +59 -65
- package/src/core/layout/state.ts +121 -0
- package/src/core/meta/__tests__/code-visibility.test.tsx +28 -0
- package/src/core/meta/code-visibility.ts +3 -1
- package/src/core/run-app.tsx +6 -2
- package/src/core/saving/__tests__/save-component.test.ts +31 -1
- package/src/core/saving/save-component.tsx +39 -29
- package/src/core/saving/state.ts +1 -1
- package/src/core/static/download-html.ts +2 -0
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +47 -14
- package/src/core/websocket/useMarimoKernelConnection.tsx +7 -2
- package/src/mount.tsx +24 -6
- package/dist/assets/ai-model-dropdown-CfWMIzjl.js +0 -3
- package/dist/assets/app-config-button-ii-Hj5CZ.js +0 -1
- package/dist/assets/cell-editor-BVb6iKbw.js +0 -21
- package/dist/assets/edit-page-RuyN164g.js +0 -9
- package/dist/assets/index-ChbtcNW2.js +0 -29
- package/dist/assets/layout-Uv1ZP5XB.js +0 -9
- package/dist/assets/panel-accordion-state-BLU7OiMD.js +0 -1
- package/dist/assets/panels-B3Y6fue9.js +0 -4
- package/dist/assets/readonly-python-code-D2HXwAfT.js +0 -1
- package/dist/assets/reveal-component-BN1C8zFu.js +0 -1069
- package/dist/assets/run-page-ByVvsH9G.js +0 -1
- package/dist/assets/session-panel-CdCKWAOf.js +0 -1
- package/dist/assets/skeleton-DirOIQrj.js +0 -1
- package/dist/assets/state-C9O-2hQL.js +0 -3
- package/dist/assets/state-DyxPdcEa.js +0 -1
- package/dist/assets/types-CoHi_MQq.js +0 -1
- package/dist/assets/useAddCell-DqvY2ulx.js +0 -1
- package/dist/assets/useBoolean-DWBCpKfT.js +0 -1
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
768
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/core/export/hooks.ts
CHANGED
|
@@ -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
|
+
}
|