@marimo-team/islands 0.25.3-dev201 → 0.25.3-dev203

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 (82) hide show
  1. package/dist/{ConnectedDataExplorerComponent-BsWiIPFk.js → ConnectedDataExplorerComponent-DFynurqb.js} +4 -4
  2. package/dist/{ErrorBoundary-JYAf40sB.js → ErrorBoundary-CYw4nBEN.js} +2 -2
  3. package/dist/{abnfDiagram-VCTEODGH-I2x4Yu25.js → abnfDiagram-VCTEODGH-DGkUyNdz.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-1bSQMKzK.js → architectureDiagram-5GKGNRK7-Bav9J7uF.js} +1 -1
  5. package/dist/{chat-ui-DXDoSuS4.js → chat-ui-Dorq26Pp.js} +7 -7
  6. package/dist/{chunk-IMKFNOWR-fsCGAJ9G.js → chunk-IMKFNOWR-1UCTsTqh.js} +1 -1
  7. package/dist/{chunk-SHT3W25Y-D2STW4hj.js → chunk-SHT3W25Y-Bige_rTb.js} +1 -1
  8. package/dist/{chunk-SVP7TREG-CiY7cQG-.js → chunk-SVP7TREG-Cmr0Anfb.js} +1 -1
  9. package/dist/{chunk-TICWLB2K-C_t5RBp9.js → chunk-TICWLB2K-IWykFldb.js} +1 -1
  10. package/dist/{classDiagram-ZZMXUADV-B2JvZAP4.js → classDiagram-ZZMXUADV-BH73ECRQ.js} +2 -2
  11. package/dist/{classDiagram-v2-VYDZK3BY-L8VsZG5A.js → classDiagram-v2-VYDZK3BY-CCuCgq0H.js} +2 -2
  12. package/dist/{code-visibility-BKyYMfr_.js → code-visibility-WinzKDYd.js} +4 -4
  13. package/dist/{common-BCY25vum.js → common-D1st4ghC.js} +9 -9
  14. package/dist/{cynefinDiagram-5FMLGOSQ-DdllC264.js → cynefinDiagram-5FMLGOSQ-CWtL7MIJ.js} +1 -1
  15. package/dist/{dagre-GXQ25YYZ-BaWHw4_f.js → dagre-GXQ25YYZ-DsVO2pVC.js} +1 -1
  16. package/dist/{data-grid-overlay-editor-gQmJxgU-.js → data-grid-overlay-editor-Ct_7pOS8.js} +1 -1
  17. package/dist/{diagram-S7CK7UJ4-pFt-tUDe.js → diagram-S7CK7UJ4-BuAktgZv.js} +1 -1
  18. package/dist/{diagram-UQ7AKVKN-DqGJiBp4.js → diagram-UQ7AKVKN-DlkAXHu5.js} +1 -1
  19. package/dist/{diagram-VX7I27RA-Dr-yzMjf.js → diagram-VX7I27RA-B9U9H0E8.js} +1 -1
  20. package/dist/{diagram-Z3DM3KII-8xzJzv-w.js → diagram-Z3DM3KII-BqmfxzUL.js} +1 -1
  21. package/dist/{dropdown-menu-NlnsmNA6.js → dropdown-menu-BFQYzWmC.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-Br04Wy-E.js → ebnfDiagram-PWID7BFC-CH5t8ATi.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-D5-XVgvv.js → erDiagram-RLTQ6QDP-DdIo2BIb.js} +1 -1
  24. package/dist/{error-entries-C2Ux0Fjt.js → error-entries-B0bcEwXm.js} +4 -4
  25. package/dist/{feedback-button-BKgw9MHY.js → feedback-button-BYqWGSOj.js} +5 -5
  26. package/dist/{flowDiagram-HODETNUW-CVl2kREF.js → flowDiagram-HODETNUW-BmYk_-Na.js} +2 -2
  27. package/dist/{formats-BCYthwyc.js → formats-DyYnckF5.js} +1 -1
  28. package/dist/{glide-data-editor-CzS51bb2.js → glide-data-editor-odIdinCX.js} +5 -5
  29. package/dist/{globals-DQ4tv_a-.js → globals-Pb4d_RfL.js} +2 -2
  30. package/dist/{html-to-image-CkH7vdBZ.js → html-to-image-BOd7ryZJ.js} +5 -5
  31. package/dist/{infoDiagram-27XIBGKW-BJ4vNcZE.js → infoDiagram-27XIBGKW-DvYh-Sbk.js} +1 -1
  32. package/dist/{ishikawaDiagram-5VMMS53U-B_Jy1Dsm.js → ishikawaDiagram-5VMMS53U-BdHnYq8s.js} +1 -1
  33. package/dist/{kanban-definition-UXKFOSKX-C6MkS9vw.js → kanban-definition-UXKFOSKX-Bf8CT_tU.js} +1 -1
  34. package/dist/main.js +48 -70
  35. package/dist/{mermaid-Bh918qrq.js → mermaid-DBpOnw8c.js} +34 -34
  36. package/dist/{mindmap-definition-YA3MSWOX-BP3ZTuir.js → mindmap-definition-YA3MSWOX-DkYG8ltR.js} +1 -1
  37. package/dist/{number-overlay-editor-B3ZE3-6d.js → number-overlay-editor-CaCxGSux.js} +1 -1
  38. package/dist/{pegDiagram-XKGWAZYB-nNGOQtnt.js → pegDiagram-XKGWAZYB-DM7BQ0ip.js} +2 -2
  39. package/dist/{pieDiagram-E7YTZNPT-CpRz5BZz.js → pieDiagram-E7YTZNPT-CPFGg4YS.js} +1 -1
  40. package/dist/{process-output-OujAtFW4.js → process-output-8BbxxGrz.js} +2 -2
  41. package/dist/{railroadDiagram-O6MQD6OU-BqOEN4I0.js → railroadDiagram-O6MQD6OU-4rUJlIpQ.js} +2 -2
  42. package/dist/{requirementDiagram-BXWQKSXE-CEfg6vu9.js → requirementDiagram-BXWQKSXE-F6ffUUQC.js} +1 -1
  43. package/dist/{reveal-component-DXa9FXf6.js → reveal-component-Dlt6nj0h.js} +6 -6
  44. package/dist/{sequenceDiagram-WJ2MYXX4-IOmpQcOi.js → sequenceDiagram-WJ2MYXX4-tNU95HVS.js} +1 -1
  45. package/dist/{spec-Dkp64vVQ.js → spec-Alkla7dB.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-tgU-EY9_.js → stateDiagram-D77RDMKH-BA8Modqv.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-BofhG6q1.js → stateDiagram-v2-MP3YSRHH-CFmickht.js} +2 -2
  48. package/dist/{swimlanes-42K2YHIH-DW4vWDBd.js → swimlanes-42K2YHIH-DBiYZZZc.js} +1 -1
  49. package/dist/{swimlanesDiagram-VR7AAH4N-BJcrqUJr.js → swimlanesDiagram-VR7AAH4N-CsgR851k.js} +2 -2
  50. package/dist/{timeline-definition-24CTP7MA-D_QU3NGs.js → timeline-definition-24CTP7MA-Dfj0CMXY.js} +1 -1
  51. package/dist/{toDate-BNDEXbfV.js → toDate-B0-Y0SEW.js} +1 -1
  52. package/dist/{useAsyncData-ASDni9vH.js → useAsyncData-Phxf6GCd.js} +1 -1
  53. package/dist/{useDeepCompareMemoize-B1Ogyf53.js → useDeepCompareMemoize-BXv7DUcU.js} +1 -1
  54. package/dist/{useLifecycle-Bfpa2m7k.js → useLifecycle-DDDsO4O2.js} +1 -1
  55. package/dist/{useTheme-5TwyOh3B.js → useTheme-DbBkeP6Z.js} +65 -60
  56. package/dist/{vega-component-BHS9UOtC.js → vega-component-C_dhEynE.js} +5 -5
  57. package/dist/{vennDiagram-4TSXK5OY-YFYsNIAh.js → vennDiagram-4TSXK5OY-fJ83Z7FE.js} +1 -1
  58. package/dist/{wardleyDiagram-VM6X3IG4-i_n8mAWC.js → wardleyDiagram-VM6X3IG4-DbO9cD15.js} +1 -1
  59. package/dist/{xychartDiagram-S5SC5T6Z-BYGN95wW.js → xychartDiagram-S5SC5T6Z-DmsdQJG7.js} +1 -1
  60. package/package.json +1 -1
  61. package/src/__mocks__/requests.ts +1 -1
  62. package/src/components/editor/header/__tests__/filename-form.test.tsx +1 -4
  63. package/src/components/editor/header/filename-form.tsx +1 -1
  64. package/src/core/cells/__tests__/document.test.ts +1 -22
  65. package/src/core/cells/document.ts +0 -33
  66. package/src/core/islands/bridge.ts +1 -1
  67. package/src/core/network/requests-lazy.ts +1 -1
  68. package/src/core/network/requests-network.ts +8 -9
  69. package/src/core/network/requests-static.ts +1 -1
  70. package/src/core/network/requests-toasting.tsx +1 -1
  71. package/src/core/network/types.ts +6 -2
  72. package/src/core/notebook/document/model.ts +0 -3
  73. package/src/core/notebook/document/sync.ts +4 -53
  74. package/src/core/saving/__tests__/document.test.tsx +123 -0
  75. package/src/core/saving/document.ts +33 -0
  76. package/src/core/saving/save-component.tsx +24 -37
  77. package/src/core/session/active-notebook.ts +6 -0
  78. package/src/core/wasm/__tests__/bridge.test.ts +2 -126
  79. package/src/core/wasm/bridge.ts +1 -18
  80. package/src/core/wasm/worker/worker.ts +2 -2
  81. package/src/core/cells/__tests__/with-document-save.test.ts +0 -67
  82. package/src/core/notebook/document/__tests__/hold.test.ts +0 -88
@@ -43,7 +43,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
43
43
  sendRun: throwNotInEditMode,
44
44
  sendRunScratchpad: throwNotInEditMode,
45
45
  sendRename: throwNotInEditMode,
46
- sendSave: throwNotInEditMode,
46
+ saveLayout: throwNotInEditMode,
47
47
  sendCopy: throwNotInEditMode,
48
48
  sendInterrupt: throwNotInEditMode,
49
49
  sendShutdown: throwNotInEditMode,
@@ -24,7 +24,7 @@ export function createErrorToastingRequests(
24
24
  sendRun: "Failed to run",
25
25
  sendRunScratchpad: "Failed to run scratchpad",
26
26
  sendRename: "Failed to rename",
27
- sendSave: "Failed to save",
27
+ saveLayout: "", // The save reports it
28
28
  sendCopy: "Failed to copy",
29
29
  sendInterrupt: "Failed to interrupt",
30
30
  sendShutdown: "Failed to shutdown",
@@ -95,7 +95,7 @@ export type ExecuteCellsRequest = schemas["ExecuteCellsRequest"];
95
95
  export type ExecuteScratchpadRequest = schemas["ExecuteScratchpadRequest"];
96
96
  export type SaveAppConfigurationRequest =
97
97
  schemas["SaveAppConfigurationRequest"];
98
- export type SaveNotebookRequest = schemas["SaveNotebookRequest"];
98
+ export type SaveLayoutRequest = schemas["SaveLayoutRequest"];
99
99
  export type CopyNotebookRequest = schemas["CopyNotebookRequest"];
100
100
  export type SaveUserConfigurationRequest =
101
101
  schemas["SaveUserConfigurationRequest"];
@@ -158,7 +158,11 @@ export interface RunRequests {
158
158
  */
159
159
  export interface EditRequests {
160
160
  sendRename: (request: RenameNotebookRequest) => Promise<null>;
161
- sendSave: (request: SaveNotebookRequest) => Promise<null>;
161
+ /**
162
+ * Write the notebook's layout beside it. The host protocol carries no
163
+ * layout yet, so this stays a request to the host serving the page.
164
+ */
165
+ saveLayout: (request: SaveLayoutRequest) => Promise<null>;
162
166
  sendCopy: (request: CopyNotebookRequest) => Promise<null>;
163
167
  sendStdin: (request: StdinRequest) => Promise<null>;
164
168
  sendRun: (request: ExecuteCellsRequest) => Promise<null>;
@@ -31,8 +31,6 @@ export interface DocumentModel {
31
31
  flush(): Promise<Protocol.Document>;
32
32
  /** Retry a rejection against fresh state, or repeat an uncertain request exactly. */
33
33
  retry(): Promise<Protocol.Document>;
34
- /** Keep edits local until the returned release is called. A no-op while stopped. */
35
- hold(): () => void;
36
34
  /**
37
35
  * Edit and synchronize until the signal aborts, which discards local drafts.
38
36
  * The model can start again once stopped.
@@ -80,7 +78,6 @@ export function createDocumentModel({
80
78
  }),
81
79
  flush: () => store.get(runningAtom)?.sync.flush() ?? stopped(),
82
80
  retry: () => store.get(runningAtom)?.sync.retry() ?? stopped(),
83
- hold: () => store.get(runningAtom)?.sync.hold() ?? (() => undefined),
84
81
  start({ signal }) {
85
82
  signal.throwIfAborted();
86
83
  if (store.get(runningAtom)) {
@@ -97,13 +97,6 @@ export function createDocumentSync({
97
97
  let failure: Extract<DocumentSyncStatus, { status: "error" }> | null = null;
98
98
  let timer: ReturnType<typeof setTimeout> | undefined;
99
99
  let refreshing = false;
100
- // Holds keep edits local, as a legacy save needs while it writes the
101
- // notebook the flush before it produced.
102
- let holds = 0;
103
- let releaseWaiters: Array<{
104
- resolve: () => void;
105
- reject: (reason: unknown) => void;
106
- }> = [];
107
100
 
108
101
  function clearTimer() {
109
102
  clearTimeout(timer);
@@ -146,11 +139,9 @@ export function createDocumentSync({
146
139
  store.set(statusAtom, { status: "idle" });
147
140
  } else {
148
141
  store.set(statusAtom, { status: "pending" });
149
- if (holds === 0) {
150
- timer = setTimeout(() => {
151
- void flush().catch(() => undefined);
152
- }, DEBOUNCE_MS);
153
- }
142
+ timer = setTimeout(() => {
143
+ void flush().catch(() => undefined);
144
+ }, DEBOUNCE_MS);
154
145
  }
155
146
  } finally {
156
147
  refreshing = false;
@@ -323,11 +314,6 @@ export function createDocumentSync({
323
314
  if (running) {
324
315
  return running;
325
316
  }
326
- if (holds > 0) {
327
- return new Promise<void>((resolve, reject) => {
328
- releaseWaiters.push({ resolve, reject });
329
- }).then(flush);
330
- }
331
317
  const notebookId =
332
318
  active?.submission.notebookId ?? store.get(source)?.revision.notebook_id;
333
319
  if (notebookId === undefined) {
@@ -367,52 +353,17 @@ export function createDocumentSync({
367
353
  return flush();
368
354
  }
369
355
 
370
- /**
371
- * Keep edits local until the returned release is called. Flushes
372
- * requested meanwhile wait for it. Holds nest, and a release is
373
- * idempotent.
374
- *
375
- * TODO(MO-8028): Only the legacy save needs this, since the host takes
376
- * the notebook it writes as the document. Saving through the host
377
- * removes both.
378
- */
379
- function hold(): () => void {
380
- holds += 1;
381
- let held = true;
382
- return () => {
383
- if (!held) {
384
- return;
385
- }
386
- held = false;
387
- holds -= 1;
388
- if (holds > 0) {
389
- return;
390
- }
391
- const waiters = releaseWaiters;
392
- releaseWaiters = [];
393
- for (const waiter of waiters) {
394
- waiter.resolve();
395
- }
396
- refresh();
397
- };
398
- }
399
-
400
356
  const unsubscribe = store.sub(observed, refresh);
401
357
  function stop() {
402
358
  clearTimer();
403
359
  unsubscribe();
404
360
  signal.removeEventListener("abort", stop);
405
361
  active?.controller.abort(signal.reason);
406
- const waiters = releaseWaiters;
407
- releaseWaiters = [];
408
- for (const waiter of waiters) {
409
- waiter.reject(signal.reason);
410
- }
411
362
  }
412
363
  signal.addEventListener("abort", stop, { once: true });
413
364
  if (signal.aborted) {
414
365
  stop();
415
366
  }
416
367
  refresh();
417
- return { statusAtom, flush, retry, hold };
368
+ return { statusAtom, flush, retry };
418
369
  }
@@ -0,0 +1,123 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "@marimo-team/client/protocol";
4
+ import { renderHook } from "@testing-library/react";
5
+ import { atom, createStore } from "jotai";
6
+ import type { PropsWithChildren } from "react";
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import { DocumentWrapper } from "@/__mocks__/document";
9
+ import { partial } from "@/__mocks__/partial";
10
+ import type {
11
+ DocumentModel,
12
+ DocumentSyncStatus,
13
+ } from "@/core/notebook/document";
14
+ import type { NotebookReplica } from "@/core/notebook/replica";
15
+ import {
16
+ type FakeSave,
17
+ fakeBackend,
18
+ } from "@/core/notebook/replica/__tests__/fixtures";
19
+ import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
20
+ import { Deferred } from "@/utils/Deferred";
21
+ import { useNotebookDocumentSave } from "../document";
22
+
23
+ const confirmed: Protocol.Document = {
24
+ notebook_id: "n1",
25
+ order: [],
26
+ app_config: {},
27
+ etag: "e1",
28
+ saved_etag: null,
29
+ order_etag: "o1",
30
+ app_config_etag: "a1",
31
+ version: 1,
32
+ };
33
+
34
+ const saved: FakeSave = async () => ({
35
+ ok: true,
36
+ status: 200,
37
+ data: { document: confirmed, cells: {} },
38
+ });
39
+
40
+ function setup({
41
+ flush = vi.fn(async () => confirmed),
42
+ save = vi.fn(saved),
43
+ connected = true,
44
+ }: {
45
+ flush?: DocumentModel["flush"];
46
+ save?: FakeSave;
47
+ connected?: boolean;
48
+ } = {}) {
49
+ const store = createStore();
50
+ const model = partial<DocumentModel>(
51
+ {
52
+ flush,
53
+ retry: vi.fn(),
54
+ syncStatusAtom: atom<DocumentSyncStatus>({ status: "idle" }),
55
+ },
56
+ "model",
57
+ );
58
+ if (connected) {
59
+ store.set(
60
+ activeNotebookReplicaAtom,
61
+ partial<NotebookReplica>(
62
+ { backendAtom: atom(fakeBackend({ save })) },
63
+ "replica",
64
+ ),
65
+ );
66
+ }
67
+ const wrapper = ({ children }: PropsWithChildren) => (
68
+ <DocumentWrapper store={store} model={model}>
69
+ {children}
70
+ </DocumentWrapper>
71
+ );
72
+ const { result } = renderHook(() => useNotebookDocumentSave(), { wrapper });
73
+ return { saveDocument: result.current, flush, save };
74
+ }
75
+
76
+ describe("useNotebookDocumentSave", () => {
77
+ it("flushes this client's edits, then saves the flushed notebook", async () => {
78
+ const flushed = new Deferred<Protocol.Document>();
79
+ const save = vi.fn(saved);
80
+ const { saveDocument } = setup({ flush: () => flushed.promise, save });
81
+ const saving = saveDocument();
82
+ await Promise.resolve();
83
+ expect(save).not.toHaveBeenCalled();
84
+ flushed.resolve(confirmed);
85
+ await saving;
86
+ expect(save).toHaveBeenCalledWith("n1");
87
+ });
88
+
89
+ it("does not save when flushing fails", async () => {
90
+ const save = vi.fn(saved);
91
+ const { saveDocument } = setup({
92
+ flush: () => Promise.reject(new Error("unsynchronized edit")),
93
+ save,
94
+ });
95
+ await expect(saveDocument()).rejects.toThrow("unsynchronized edit");
96
+ expect(save).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("reports the host's refusal", async () => {
100
+ const { saveDocument } = setup({
101
+ save: async () => ({
102
+ ok: false,
103
+ status: 409,
104
+ problem: {
105
+ type: "about:blank",
106
+ title: "Conflict",
107
+ status: 409,
108
+ detail: "storage full",
109
+ },
110
+ }),
111
+ });
112
+ await expect(saveDocument()).rejects.toThrow("storage full");
113
+ });
114
+
115
+ it("does not save while no notebook is connected", async () => {
116
+ const flush = vi.fn(async () => confirmed);
117
+ const { saveDocument } = setup({ flush, connected: false });
118
+ await expect(saveDocument()).rejects.toThrow(
119
+ "The notebook is not connected",
120
+ );
121
+ expect(flush).not.toHaveBeenCalled();
122
+ });
123
+ });
@@ -0,0 +1,33 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /** Save the host's document from React. */
4
+
5
+ import { useAtomValue } from "jotai";
6
+ import { useCallback } from "react";
7
+ import { useNotebookDocumentSync } from "@/core/notebook/document";
8
+ import { activeNotebookBackendAtom } from "../session/active-notebook";
9
+
10
+ /**
11
+ * Save the notebook's document through its host. This client's edits are
12
+ * flushed first, so the host writes nothing older than what the editor
13
+ * shows, and a failure at either step rejects.
14
+ *
15
+ * ```ts
16
+ * const saveDocument = useNotebookDocumentSave();
17
+ * await saveDocument();
18
+ * ```
19
+ */
20
+ export function useNotebookDocumentSave() {
21
+ const { flush } = useNotebookDocumentSync();
22
+ const backend = useAtomValue(activeNotebookBackendAtom);
23
+ return useCallback(async () => {
24
+ if (backend === null) {
25
+ throw new Error("The notebook is not connected");
26
+ }
27
+ const { notebook_id } = await flush();
28
+ const result = await backend.notebook(notebook_id).save();
29
+ if (!result.ok) {
30
+ throw new Error(result.problem.detail ?? result.problem.title);
31
+ }
32
+ }, [flush, backend]);
33
+ }
@@ -20,8 +20,8 @@ import {
20
20
  import { Label } from "../../components/ui/label";
21
21
  import { useEvent } from "../../hooks/useEvent";
22
22
  import { Logger } from "../../utils/Logger";
23
- import { getCellConfigs, getNotebook } from "../cells/cells";
24
- import { flushDocument, withDocumentSave } from "../cells/document";
23
+ import { toast } from "@/components/ui/use-toast";
24
+ import { getNotebook } from "../cells/cells";
25
25
  import { notebookCells } from "../cells/utils";
26
26
  import { formatAll } from "../codemirror/format";
27
27
  import { autoSaveConfigAtom } from "../config/config";
@@ -32,9 +32,9 @@ import { connectionAtom } from "../network/connection";
32
32
  import { canEditAtom } from "@/core/notebook";
33
33
  import { useRequestClient } from "../network/requests";
34
34
  import { ConnectionState } from "../session/types";
35
- import { isWasm } from "../wasm/utils";
36
35
  import { filenameAtom } from "./file-state";
37
36
  import { useFilename, useUpdateFilename } from "./filename";
37
+ import { useNotebookDocumentSave } from "./document";
38
38
  import { documentVersionAtom, needsSaveAtom, savedLayoutAtom } from "./state";
39
39
  import { useAutoSave } from "./useAutoSave";
40
40
 
@@ -99,13 +99,14 @@ export const SaveComponent = () => {
99
99
  };
100
100
 
101
101
  export function useSaveNotebook() {
102
- const { sendSave } = useRequestClient();
102
+ const { saveLayout } = useRequestClient();
103
+ const saveDocument = useNotebookDocumentSave();
103
104
  const { openModal, closeModal, openAlert } = useImperativeModal();
104
105
  const updateFilename = useUpdateFilename();
105
106
  const store = useStore();
106
107
 
107
- // Save the notebook with the given filename
108
- const saveNotebook = useEvent((filename: string, userInitiated: boolean) =>
108
+ // Save the notebook the host has a file for.
109
+ const saveNotebook = useEvent((userInitiated: boolean) =>
109
110
  enqueueNotebookSave(async () => {
110
111
  const connection = store.get(connectionAtom);
111
112
  const autoSaveConfig = store.get(autoSaveConfigAtom);
@@ -121,8 +122,6 @@ export function useSaveNotebook() {
121
122
  return;
122
123
  }
123
124
 
124
- Logger.log("saving to ", filename);
125
-
126
125
  if (userInitiated && autoSaveConfig.format_on_save) {
127
126
  Logger.log("formatting notebook (onSave)");
128
127
  await formatAll();
@@ -132,34 +131,22 @@ export function useSaveNotebook() {
132
131
  return;
133
132
  }
134
133
 
135
- const save = async () => {
136
- // Grab the latest notebook state after formatting and after every
137
- // earlier document transaction has reached the server.
138
- const notebook = getNotebook();
139
- const cells = notebookCells(notebook);
140
- const cellIds = cells.map((cell) => cell.id);
141
- const codes = cells.map((cell) => cell.code);
142
- const cellNames = cells.map((cell) => cell.name);
143
- const configs = getCellConfigs(notebook);
144
- const layout = store.get(layoutStateAtom);
145
-
146
- await sendSave({
147
- cellIds: cellIds,
148
- codes,
149
- names: cellNames,
150
- filename,
151
- configs,
152
- layout: getSerializedLayout(),
153
- persist: true,
134
+ // The notebook is the host's document plus a layout the editor keeps
135
+ // beside it. TODO: fold the layout into the host document.
136
+ const layout = store.get(layoutStateAtom);
137
+ try {
138
+ await saveLayout({ layout: getSerializedLayout() });
139
+ await saveDocument();
140
+ } catch (error) {
141
+ Logger.error("Failed to save notebook", error);
142
+ toast({
143
+ title: "Failed to save",
144
+ description: error instanceof Error ? error.message : String(error),
145
+ variant: "danger",
154
146
  });
155
- return layout;
156
- };
157
- // TODO(MO-7955): Remove this conditional once all frontend saves use
158
- // the host protocol. Host saves cannot recover edits from an editor snapshot.
159
- const savedLayout = isWasm()
160
- ? await flushDocument().then(save)
161
- : await withDocumentSave(save);
162
- store.set(savedLayoutAtom, savedLayout);
147
+ return;
148
+ }
149
+ store.set(savedLayoutAtom, layout);
163
150
  }),
164
151
  );
165
152
 
@@ -171,14 +158,14 @@ export function useSaveNotebook() {
171
158
  isNamedPersistentFile(filename) &&
172
159
  connection.state === ConnectionState.OPEN
173
160
  ) {
174
- saveNotebook(filename, userInitiated);
161
+ saveNotebook(userInitiated);
175
162
  }
176
163
  });
177
164
 
178
165
  const handleSaveDialog = (pythonFilename: string) => {
179
166
  updateFilename(pythonFilename).then((name) => {
180
167
  if (name !== null) {
181
- saveNotebook(name, true);
168
+ saveNotebook(true);
182
169
  }
183
170
  });
184
171
  };
@@ -10,6 +10,12 @@ export const activeNotebookReplicaAtom = atom<NotebookReplica | null>(null);
10
10
  /** The document model owned by the mounted notebook session. */
11
11
  export const activeDocumentModelAtom = atom<DocumentModel | null>(null);
12
12
 
13
+ /** The backend of the connected notebook, or null while none is. */
14
+ export const activeNotebookBackendAtom = atom((get) => {
15
+ const replica = get(activeNotebookReplicaAtom);
16
+ return replica === null ? null : get(replica.backendAtom);
17
+ });
18
+
13
19
  /**
14
20
  * The document the host last confirmed, or null while no notebook is
15
21
  * connected. Facts the host owns read this one, such as permission, the
@@ -1,7 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
- import { cellId } from "@/__tests__/branded";
5
4
  import { Deferred } from "@/utils/Deferred";
6
5
 
7
6
  const {
@@ -11,7 +10,6 @@ const {
11
10
  mockWriteNotebook,
12
11
  mockNotebookCall,
13
12
  mockSetNotebookLayout,
14
- mockFlush,
15
13
  parentRequests,
16
14
  mockBootstrap,
17
15
  rpcListeners,
@@ -24,7 +22,6 @@ const {
24
22
  mockWriteNotebook: vi.fn(),
25
23
  mockNotebookCall: vi.fn(),
26
24
  mockSetNotebookLayout: vi.fn(),
27
- mockFlush: vi.fn(),
28
25
  parentRequests: {} as {
29
26
  persistNotebook?: (snapshot: {
30
27
  filename: string;
@@ -101,18 +98,9 @@ vi.mock("@/core/wasm/store", () => ({
101
98
  notebookFileStore: { readFile: mockNotebookReadFile, saveFile: vi.fn() },
102
99
  }));
103
100
 
104
- vi.mock("@/core/cells/document", async (importOriginal) => ({
105
- ...(await importOriginal<typeof import("@/core/cells/document")>()),
106
- flushDocument: mockFlush,
107
- }));
108
-
109
101
  // Import after all mocks are set up
110
102
  import { store } from "@/core/state/jotai";
111
103
  import { initialModeAtom } from "@/core/mode";
112
- import { fixtureEvents } from "@/core/notebook/replica/__tests__/fixtures";
113
- import { createNotebookReplica } from "@/core/notebook/replica";
114
- import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
115
- import { createEditorBackend } from "@/core/session/backend";
116
104
  import { fallbackFileStore, notebookFileStore } from "../store";
117
105
  import { getWasmWorkerName, PyodideBridge } from "../bridge";
118
106
 
@@ -200,130 +188,18 @@ describe("PyodideBridge.exportAsScript", () => {
200
188
  });
201
189
  });
202
190
 
203
- describe("PyodideBridge.sendSave", () => {
204
- let disconnect: () => void;
205
- const request = {
206
- cellIds: [cellId("cell-1")],
207
- codes: ['value = "NEW"'],
208
- names: ["_"],
209
- filename: "notebook.py",
210
- configs: [{}],
211
- layout: null,
212
- persist: true,
213
- };
214
-
215
- beforeEach(async () => {
191
+ describe("PyodideBridge notebook persistence", () => {
192
+ beforeEach(() => {
216
193
  vi.clearAllMocks();
217
194
  store.set(initialModeAtom, "edit");
218
195
  rpcListeners.initialized();
219
- mockFlush.mockResolvedValue(undefined);
220
196
  mockWriteNotebook.mockResolvedValue(undefined);
221
- mockNotebookUnsubscribe.mockResolvedValue(undefined);
222
- const document = fixtureEvents("notebook-events.sse").find(
223
- (event) => event.event === "document",
224
- )?.data.document;
225
- mockNotebookCall.mockResolvedValue({
226
- status: 200,
227
- body: { document, cells: {} },
228
- });
229
- mockNotebookSubscribe.mockImplementation(async ({ id }) => {
230
- for (const event of fixtureEvents("notebook-events.sse")) {
231
- rpcListeners.notebookEvent({ id, event: JSON.stringify(event) });
232
- }
233
- });
234
- const replica = createNotebookReplica(store);
235
- store.set(activeNotebookReplicaAtom, replica);
236
- const backend = createEditorBackend({
237
- static: false,
238
- snapshot: null,
239
- transportType: "sse",
240
- url: () => "http://localhost/",
241
- headers: () => ({}),
242
- notebookUrl: (id) => new URL(`http://localhost/notebooks/${id}`),
243
- waitUntilReady: async () => undefined,
244
- checkHealth: async () => true,
245
- });
246
- disconnect = replica.connect(backend, { onNotification: vi.fn() });
247
- await vi.waitFor(() =>
248
- expect(store.get(replica.notebookAtom)?.id).toBe("n1"),
249
- );
250
197
  });
251
198
 
252
199
  afterEach(() => {
253
- disconnect();
254
- store.set(activeNotebookReplicaAtom, null);
255
200
  store.set(initialModeAtom, undefined);
256
201
  });
257
202
 
258
- it("saves through the connected backend using the stream's notebook ID", async () => {
259
- const bridge = PyodideBridge.INSTANCE;
260
- const createBackend = vi.spyOn(bridge, "createNotebookBackend");
261
- await bridge.sendSave(request);
262
- expect(createBackend).not.toHaveBeenCalled();
263
- expect(mockNotebookCall).toHaveBeenCalledWith(
264
- expect.objectContaining({
265
- operation: "notebook.save",
266
- params: { notebook_id: "n1" },
267
- body: {},
268
- }),
269
- );
270
- });
271
-
272
- it.each(["unmounted", "not ready", "disconnected"])(
273
- "does not save when the notebook is %s",
274
- async (state) => {
275
- if (state === "unmounted") {
276
- store.set(activeNotebookReplicaAtom, null);
277
- } else if (state === "not ready") {
278
- store.set(activeNotebookReplicaAtom, createNotebookReplica(store));
279
- } else {
280
- disconnect();
281
- }
282
- await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
283
- "The notebook is not connected",
284
- );
285
- expect(mockNotebookCall).not.toHaveBeenCalled();
286
- },
287
- );
288
-
289
- it("flushes edits before asking the host to save without an editor snapshot", async () => {
290
- const flushed = new Deferred<void>();
291
- mockFlush.mockReturnValueOnce(flushed.promise);
292
- const saving = PyodideBridge.INSTANCE.sendSave(request);
293
- await Promise.resolve();
294
- expect(mockNotebookCall).not.toHaveBeenCalled();
295
- flushed.resolve();
296
- await saving;
297
- expect(mockSetNotebookLayout).toHaveBeenCalledWith(null);
298
- expect(mockNotebookCall).toHaveBeenCalledWith(
299
- expect.objectContaining({ operation: "notebook.save", body: {} }),
300
- );
301
- expect(mockWriteNotebook).not.toHaveBeenCalled();
302
- });
303
-
304
- it("does not save when flushing fails", async () => {
305
- mockFlush.mockRejectedValueOnce(new Error("unsynchronized edit"));
306
- await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
307
- "unsynchronized edit",
308
- );
309
- expect(mockNotebookCall).not.toHaveBeenCalled();
310
- });
311
-
312
- it("reports host persistence failures", async () => {
313
- mockNotebookCall.mockResolvedValueOnce({
314
- status: 409,
315
- body: {
316
- type: "tag:marimo.io,2026:host/conflict",
317
- title: "Conflict",
318
- status: 409,
319
- detail: "storage full",
320
- },
321
- });
322
- await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
323
- "storage full",
324
- );
325
- });
326
-
327
203
  it("persists exactly the host's content before acknowledging storage", async () => {
328
204
  const durable = new Deferred<void>();
329
205
  const snapshot = { filename: "notebook.py", contents: "# host revision" };
@@ -42,7 +42,6 @@ import type {
42
42
  } from "../network/types";
43
43
  import { filenameAtom } from "../saving/file-state";
44
44
  import { store } from "../state/jotai";
45
- import { activeNotebookReplicaAtom } from "../session/active-notebook";
46
45
  import { PyodideRouter } from "./router";
47
46
  import { getWorkerRPC, type PersistNotebookRequest } from "./rpc";
48
47
  import { getWasmRuntimeConfig } from "./runtime-config";
@@ -266,23 +265,8 @@ export class PyodideBridge implements RunRequests, EditRequests {
266
265
  return null;
267
266
  };
268
267
 
269
- private async saveHostNotebook() {
270
- const replica = store.get(activeNotebookReplicaAtom);
271
- const notebook = replica && store.get(replica.notebookAtom);
272
- const backend = replica && store.get(replica.backendAtom);
273
- if (!notebook || !backend) {
274
- throw new Error("The notebook is not connected");
275
- }
276
- const result = await backend.notebook(notebook.id).save();
277
- if (!result.ok) {
278
- throw new Error(result.problem.detail ?? result.problem.title);
279
- }
280
- }
281
-
282
- public sendSave: EditRequests["sendSave"] = async ({ layout }) => {
283
- await flushDocument();
268
+ public saveLayout: EditRequests["saveLayout"] = async ({ layout }) => {
284
269
  await this.rpc.proxy.request.setNotebookLayout(layout ?? null);
285
- await this.saveHostNotebook();
286
270
  return null;
287
271
  };
288
272
 
@@ -391,7 +375,6 @@ export class PyodideBridge implements RunRequests, EditRequests {
391
375
  functionName: "save_app_config",
392
376
  payload: request,
393
377
  });
394
- await this.saveHostNotebook();
395
378
  return null;
396
379
  };
397
380
 
@@ -12,7 +12,7 @@ import type { UserConfig } from "@/core/config/config-schema";
12
12
  import type {
13
13
  ListPackagesResponse,
14
14
  PackageOperationResponse,
15
- SaveNotebookRequest,
15
+ SaveLayoutRequest,
16
16
  } from "@/core/network/types";
17
17
  import { decodeUtf8 } from "@/utils/strings";
18
18
  import { Deferred } from "../../../utils/Deferred";
@@ -301,7 +301,7 @@ const requestHandler = createRPCRequestHandler({
301
301
  };
302
302
  },
303
303
 
304
- setNotebookLayout: async (layout: SaveNotebookRequest["layout"]) => {
304
+ setNotebookLayout: async (layout: SaveLayoutRequest["layout"]) => {
305
305
  const notebook = await getNotebook();
306
306
  try {
307
307
  await notebook.set_layout(JSON.stringify(layout));