@marimo-team/islands 0.25.3-dev140 → 0.25.3-dev142

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 (27) hide show
  1. package/dist/{ErrorBoundary-CTJ1U1fx.js → ErrorBoundary-DxdeETpm.js} +1 -1
  2. package/dist/{code-visibility-C1jZfwPW.js → code-visibility-CO8abjLo.js} +1 -1
  3. package/dist/{common-CNv_7gvI.js → common-CbFJQhaI.js} +1 -1
  4. package/dist/{data-grid-overlay-editor-t0t17DPI.js → data-grid-overlay-editor-DBjVUS2K.js} +1 -1
  5. package/dist/{feedback-button-JzaiyZZm.js → feedback-button-CWQoIUKQ.js} +2 -2
  6. package/dist/{glide-data-editor-B3eq-1SC.js → glide-data-editor-BxT8eGip.js} +3 -3
  7. package/dist/{globals-BW8TOLYQ.js → globals-f0UxWZkr.js} +1 -1
  8. package/dist/main.js +9 -8
  9. package/dist/{number-overlay-editor-DoUnDbPE.js → number-overlay-editor-Dso2H7DV.js} +1 -1
  10. package/dist/{reveal-component-ClNn1CDl.js → reveal-component-DMglhlng.js} +2 -2
  11. package/package.json +1 -1
  12. package/src/core/host/__tests__/fixtures.ts +9 -1
  13. package/src/core/host/__tests__/static.test.ts +18 -12
  14. package/src/core/host/__tests__/wasm.test.ts +63 -60
  15. package/src/core/host/backend.ts +1 -1
  16. package/src/core/host/static.ts +10 -5
  17. package/src/core/host/wasm.ts +2 -12
  18. package/src/core/saving/save-component.tsx +12 -3
  19. package/src/core/wasm/__tests__/bridge.test.ts +177 -55
  20. package/src/core/wasm/__tests__/worker.test.ts +6 -1
  21. package/src/core/wasm/bridge.ts +48 -38
  22. package/src/core/wasm/rpc.ts +15 -2
  23. package/src/core/wasm/worker/save-worker.ts +9 -10
  24. package/src/core/wasm/worker/types.ts +6 -4
  25. package/src/core/wasm/worker/worker.ts +29 -17
  26. package/src/core/websocket/__tests__/backend.test.ts +10 -3
  27. package/src/core/websocket/backend.ts +9 -10
@@ -8,20 +8,34 @@ const {
8
8
  mockBridge,
9
9
  mockNotebookReadFile,
10
10
  mockReadNotebook,
11
- mockSaveNotebook,
11
+ mockWriteNotebook,
12
+ mockNotebookCall,
13
+ mockSetNotebookLayout,
14
+ mockFlush,
15
+ parentRequests,
12
16
  mockBootstrap,
13
17
  rpcListeners,
14
- mockNotebookInfo,
15
18
  mockNotebookSubscribe,
16
19
  mockNotebookUnsubscribe,
17
20
  } = vi.hoisted(() => ({
18
21
  mockBridge: vi.fn(),
19
22
  mockNotebookReadFile: vi.fn(),
20
23
  mockReadNotebook: vi.fn(),
21
- mockSaveNotebook: vi.fn(),
24
+ mockWriteNotebook: vi.fn(),
25
+ mockNotebookCall: vi.fn(),
26
+ mockSetNotebookLayout: vi.fn(),
27
+ mockFlush: vi.fn(),
28
+ parentRequests: {} as {
29
+ persistNotebook?: (snapshot: {
30
+ filename: string;
31
+ contents: string;
32
+ }) => Promise<void>;
33
+ },
22
34
  mockBootstrap: vi.fn(),
23
- rpcListeners: {} as Record<string, () => void>,
24
- mockNotebookInfo: vi.fn(),
35
+ rpcListeners: {} as Record<
36
+ string,
37
+ (message?: { id: string; event: string }) => void
38
+ >,
25
39
  mockNotebookSubscribe: vi.fn(),
26
40
  mockNotebookUnsubscribe: vi.fn(),
27
41
  }));
@@ -47,24 +61,31 @@ class MockURL extends URL {
47
61
  vi.stubGlobal("URL", MockURL);
48
62
 
49
63
  vi.mock("@/core/wasm/rpc", () => ({
50
- getWorkerRPC: () => ({
51
- send: { bootstrap: mockBootstrap },
52
- proxy: {
53
- request: {
54
- bridge: mockBridge,
55
- startSession: vi.fn(),
56
- readFile: vi.fn(),
57
- readNotebook: mockReadNotebook,
58
- saveNotebook: mockSaveNotebook,
59
- notebookInfo: mockNotebookInfo,
60
- notebookSubscribe: mockNotebookSubscribe,
61
- notebookUnsubscribe: mockNotebookUnsubscribe,
64
+ getWorkerRPC: (_worker: Worker, handlers?: typeof parentRequests) => {
65
+ Object.assign(parentRequests, handlers);
66
+ return {
67
+ send: { bootstrap: mockBootstrap },
68
+ proxy: {
69
+ request: {
70
+ bridge: mockBridge,
71
+ startSession: vi.fn(),
72
+ readFile: vi.fn(),
73
+ readNotebook: mockReadNotebook,
74
+ writeNotebook: mockWriteNotebook,
75
+ notebookSubscribe: mockNotebookSubscribe,
76
+ notebookUnsubscribe: mockNotebookUnsubscribe,
77
+ notebookCall: mockNotebookCall,
78
+ setNotebookLayout: mockSetNotebookLayout,
79
+ },
62
80
  },
63
- },
64
- addMessageListener: (event: string, cb: () => void) => {
65
- rpcListeners[event] = cb;
66
- },
67
- }),
81
+ addMessageListener: (
82
+ event: string,
83
+ cb: (typeof rpcListeners)[string],
84
+ ) => {
85
+ rpcListeners[event] = cb;
86
+ },
87
+ };
88
+ },
68
89
  }));
69
90
 
70
91
  vi.mock("@/core/meta/globals", () => ({
@@ -80,14 +101,25 @@ vi.mock("@/core/wasm/store", () => ({
80
101
  notebookFileStore: { readFile: mockNotebookReadFile, saveFile: vi.fn() },
81
102
  }));
82
103
 
104
+ vi.mock("@/core/cells/document-changes", () => ({
105
+ flushDocumentChanges: mockFlush,
106
+ documentTransactionMiddleware: vi.fn(),
107
+ }));
108
+
83
109
  // Import after all mocks are set up
84
110
  import { store } from "@/core/state/jotai";
85
111
  import { initialModeAtom } from "@/core/mode";
112
+ import { fixtureEvents } from "@/core/host/__tests__/fixtures";
113
+ import { createNotebookReplica } from "@/core/host";
114
+ import { notebookReplicaAtom } from "@/core/websocket/state";
115
+ import { createEditorBackend } from "@/core/websocket/backend";
116
+ import { fallbackFileStore, notebookFileStore } from "../store";
86
117
  import { getWasmWorkerName, PyodideBridge } from "../bridge";
87
118
 
88
- // Access INSTANCE once at module level so the constructor runs (and
89
- // addMessageListener populates rpcListeners) before any test executes.
90
- void PyodideBridge.INSTANCE;
119
+ beforeEach(() => {
120
+ Reflect.deleteProperty(window, "_marimo_private_PyodideBridge");
121
+ void PyodideBridge.INSTANCE;
122
+ });
91
123
 
92
124
  describe("PyodideBridge.readCode", () => {
93
125
  beforeEach(() => {
@@ -169,6 +201,7 @@ describe("PyodideBridge.exportAsScript", () => {
169
201
  });
170
202
 
171
203
  describe("PyodideBridge.sendSave", () => {
204
+ let disconnect: () => void;
172
205
  const request = {
173
206
  cellIds: [cellId("cell-1")],
174
207
  codes: ['value = "NEW"'],
@@ -179,50 +212,140 @@ describe("PyodideBridge.sendSave", () => {
179
212
  persist: true,
180
213
  };
181
214
 
182
- beforeEach(() => {
215
+ beforeEach(async () => {
183
216
  vi.clearAllMocks();
184
217
  store.set(initialModeAtom, "edit");
185
218
  rpcListeners.initialized();
186
- mockSaveNotebook.mockResolvedValue(null);
187
- mockReadNotebook.mockResolvedValue(
188
- "import marimo\napp = marimo.App()\n# NEW",
219
+ mockFlush.mockResolvedValue(undefined);
220
+ 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(notebookReplicaAtom, replica);
236
+ const backend = createEditorBackend({
237
+ static: false,
238
+ transportType: "sse",
239
+ url: () => "http://localhost/",
240
+ headers: () => ({}),
241
+ notebookUrl: (id) => new URL(`http://localhost/notebooks/${id}`),
242
+ waitUntilReady: async () => undefined,
243
+ checkHealth: async () => true,
244
+ });
245
+ disconnect = replica.connect(backend, { onNotification: vi.fn() });
246
+ await vi.waitFor(() =>
247
+ expect(store.get(replica.notebookAtom)?.id).toBe("n1"),
189
248
  );
190
249
  });
191
250
 
192
251
  afterEach(() => {
252
+ disconnect();
253
+ store.set(notebookReplicaAtom, null);
193
254
  store.set(initialModeAtom, undefined);
194
255
  });
195
256
 
196
- it("waits for the session save before generating exports", async () => {
197
- const mainSave = new Deferred<void>();
198
- mockSaveNotebook
199
- .mockResolvedValueOnce(null)
200
- .mockReturnValueOnce(mainSave.promise);
257
+ it("saves through the connected backend using the stream's notebook ID", async () => {
258
+ const bridge = PyodideBridge.INSTANCE;
259
+ const createBackend = vi.spyOn(bridge, "createNotebookBackend");
260
+ await bridge.sendSave(request);
261
+ expect(createBackend).not.toHaveBeenCalled();
262
+ expect(mockNotebookCall).toHaveBeenCalledWith(
263
+ expect.objectContaining({
264
+ operation: "notebook.save",
265
+ params: { notebook_id: "n1" },
266
+ body: {},
267
+ }),
268
+ );
269
+ });
201
270
 
202
- await PyodideBridge.INSTANCE.sendSave(request);
203
- expect(mockSaveNotebook).toHaveBeenCalledTimes(2);
204
- expect(mockSaveNotebook).toHaveBeenNthCalledWith(1, request);
205
- expect(mockSaveNotebook).toHaveBeenNthCalledWith(2, request);
271
+ it.each(["unmounted", "not ready", "disconnected"])(
272
+ "does not save when the notebook is %s",
273
+ async (state) => {
274
+ if (state === "unmounted") {
275
+ store.set(notebookReplicaAtom, null);
276
+ } else if (state === "not ready") {
277
+ store.set(notebookReplicaAtom, createNotebookReplica(store));
278
+ } else {
279
+ disconnect();
280
+ }
281
+ await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
282
+ "The notebook is not connected",
283
+ );
284
+ expect(mockNotebookCall).not.toHaveBeenCalled();
285
+ },
286
+ );
206
287
 
207
- const markdown = PyodideBridge.INSTANCE.exportAsMarkdown({
208
- download: false,
209
- });
210
- const script = PyodideBridge.INSTANCE.exportAsScript({
211
- download: false,
212
- });
288
+ it("flushes edits before asking the host to save without an editor snapshot", async () => {
289
+ const flushed = new Deferred<void>();
290
+ mockFlush.mockReturnValueOnce(flushed.promise);
291
+ const saving = PyodideBridge.INSTANCE.sendSave(request);
213
292
  await Promise.resolve();
214
- expect(mockBridge).not.toHaveBeenCalled();
293
+ expect(mockNotebookCall).not.toHaveBeenCalled();
294
+ flushed.resolve();
295
+ await saving;
296
+ expect(mockSetNotebookLayout).toHaveBeenCalledWith(null);
297
+ expect(mockNotebookCall).toHaveBeenCalledWith(
298
+ expect.objectContaining({ operation: "notebook.save", body: {} }),
299
+ );
300
+ expect(mockWriteNotebook).not.toHaveBeenCalled();
301
+ });
215
302
 
216
- mainSave.resolve();
217
- await Promise.all([markdown, script]);
218
- expect(mockBridge).toHaveBeenCalledWith({
219
- functionName: "export_markdown",
220
- payload: { download: false },
221
- });
222
- expect(mockBridge).toHaveBeenCalledWith({
223
- functionName: "export_script",
224
- payload: { download: false },
303
+ it("does not save when flushing fails", async () => {
304
+ mockFlush.mockRejectedValueOnce(new Error("unsynchronized edit"));
305
+ await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
306
+ "unsynchronized edit",
307
+ );
308
+ expect(mockNotebookCall).not.toHaveBeenCalled();
309
+ });
310
+
311
+ it("reports host persistence failures", async () => {
312
+ mockNotebookCall.mockResolvedValueOnce({
313
+ status: 409,
314
+ body: {
315
+ type: "tag:marimo.io,2026:host/conflict",
316
+ title: "Conflict",
317
+ status: 409,
318
+ detail: "storage full",
319
+ },
225
320
  });
321
+ await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
322
+ "storage full",
323
+ );
324
+ });
325
+
326
+ it("persists exactly the host's content before acknowledging storage", async () => {
327
+ const durable = new Deferred<void>();
328
+ const snapshot = { filename: "notebook.py", contents: "# host revision" };
329
+ mockWriteNotebook.mockReturnValueOnce(durable.promise);
330
+ const writing = parentRequests.persistNotebook!(snapshot);
331
+ await Promise.resolve();
332
+ expect(mockWriteNotebook).toHaveBeenCalledWith(snapshot);
333
+ expect(notebookFileStore.saveFile).not.toHaveBeenCalled();
334
+ durable.resolve();
335
+ await writing;
336
+ expect(notebookFileStore.saveFile).toHaveBeenCalledWith(snapshot.contents);
337
+ expect(fallbackFileStore.saveFile).toHaveBeenCalledWith(snapshot.contents);
338
+ });
339
+
340
+ it("propagates storage failure without updating the URL", async () => {
341
+ mockWriteNotebook.mockRejectedValueOnce(new Error("IndexedDB failed"));
342
+ await expect(
343
+ parentRequests.persistNotebook!({
344
+ filename: "notebook.py",
345
+ contents: "# host",
346
+ }),
347
+ ).rejects.toThrow("IndexedDB failed");
348
+ expect(notebookFileStore.saveFile).not.toHaveBeenCalled();
226
349
  });
227
350
  });
228
351
 
@@ -231,7 +354,6 @@ it("cancels a pending notebook subscription before its RPC response", async () =
231
354
  store.set(initialModeAtom, "read");
232
355
  rpcListeners.initialized();
233
356
  const pending = new Deferred<void>();
234
- mockNotebookInfo.mockResolvedValue({ id: "n1" });
235
357
  mockNotebookSubscribe.mockReturnValue(pending.promise);
236
358
  mockNotebookUnsubscribe.mockResolvedValue(undefined);
237
359
  const controller = new AbortController();
@@ -77,6 +77,7 @@ async function startWorker(subscribing = Promise.resolve()) {
77
77
  },
78
78
  ),
79
79
  unsubscribe: (id: string) => subscribers.delete(id),
80
+ set_persistence: vi.fn(),
80
81
  destroy: vi.fn(),
81
82
  };
82
83
  const bridge = { notebook, destroy: vi.fn() };
@@ -120,10 +121,13 @@ async function startWorker(subscribing = Promise.resolve()) {
120
121
  }
121
122
 
122
123
  it("delivers startup notifications after a delayed first subscription, without rerunning on resubscribe", async () => {
123
- const { controller, init, event, pyodide } = await startWorker();
124
+ const { controller, init, notebook, event, pyodide } = await startWorker();
124
125
  try {
125
126
  await new Promise((resolve) => setTimeout(resolve, 0));
126
127
  expect(rpc.send.initialized).toHaveBeenCalledOnce();
128
+ expect(notebook.set_persistence).toHaveBeenCalledExactlyOnceWith(
129
+ expect.any(Function),
130
+ );
127
131
  expect(pyodide.loadPackagesFromImports).toHaveResolved();
128
132
  expect(init).not.toHaveBeenCalled();
129
133
 
@@ -137,6 +141,7 @@ it("delivers startup notifications after a delayed first subscription, without r
137
141
  await handlers.requests.notebookSubscribe({ id: "second", cursor: null });
138
142
  await new Promise((resolve) => setTimeout(resolve, 0));
139
143
  expect(init).toHaveBeenCalledOnce();
144
+ expect(notebook.set_persistence).toHaveBeenCalledOnce();
140
145
  } finally {
141
146
  await controller.stopSession();
142
147
  }
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  /* oxlint-disable typescript/no-explicit-any */
3
3
 
4
+ import { createRPCRequestHandler } from "rpc-anywhere";
4
5
  import { toast } from "@/components/ui/use-toast";
5
6
  import { userConfigAtom } from "@/core/config/config";
6
7
  import { serializeBlob } from "@/utils/blob";
@@ -12,6 +13,7 @@ import { once } from "@/utils/once";
12
13
  import { generateUUID } from "@/utils/uuid";
13
14
  import { createModuleWorker } from "@/utils/worker";
14
15
  import { notebookIsRunningAtom } from "../cells/cells";
16
+ import { flushDocumentChanges } from "../cells/document-changes";
15
17
  import { createWasmNotebookBackend } from "../host";
16
18
  import type { ClientCommand } from "../kernel/messages";
17
19
  import { getInitialAppMode } from "../mode";
@@ -40,8 +42,9 @@ import type {
40
42
  } from "../network/types";
41
43
  import { filenameAtom } from "../saving/file-state";
42
44
  import { store } from "../state/jotai";
45
+ import { notebookReplicaAtom } from "../websocket/state";
43
46
  import { PyodideRouter } from "./router";
44
- import { getWorkerRPC } from "./rpc";
47
+ import { getWorkerRPC, type PersistNotebookRequest } from "./rpc";
45
48
  import { getWasmRuntimeConfig } from "./runtime-config";
46
49
  import { createShareableLink } from "./share";
47
50
  import { wasmInitStateAtom } from "./state";
@@ -68,7 +71,6 @@ export class PyodideBridge implements RunRequests, EditRequests {
68
71
 
69
72
  private rpc!: ReturnType<typeof getWorkerRPC<WorkerSchema>>;
70
73
  private saveRpc: SaveWorker | undefined;
71
- private pendingSessionSave: Promise<unknown> = Promise.resolve();
72
74
  private notebookConsumers = new Map<string, (event: unknown) => void>();
73
75
  private interruptBuffer?: Uint8Array;
74
76
 
@@ -80,7 +82,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
80
82
  return {
81
83
  readFile: throwNotImplemented,
82
84
  readNotebook: async () => (await notebookFileStore.readFile()) ?? "",
83
- saveNotebook: throwNotImplemented,
85
+ writeNotebook: throwNotImplemented,
84
86
  };
85
87
  }
86
88
 
@@ -95,7 +97,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
95
97
  },
96
98
  );
97
99
 
98
- const rpc = getWorkerRPC<SaveWorkerSchema>(saveWorker);
100
+ const rpc = getWorkerRPC<SaveWorkerSchema>(saveWorker, undefined, Infinity);
99
101
  rpc.send.bootstrap(runtimeConfig);
100
102
  return rpc.proxy.request;
101
103
  }
@@ -114,7 +116,22 @@ export class PyodideBridge implements RunRequests, EditRequests {
114
116
  });
115
117
 
116
118
  // Create the RPC
117
- this.rpc = getWorkerRPC<WorkerSchema>(worker);
119
+ this.rpc = getWorkerRPC<WorkerSchema>(
120
+ worker,
121
+ createRPCRequestHandler({
122
+ persistNotebook: async (snapshot: PersistNotebookRequest) => {
123
+ if (!this.saveRpc) {
124
+ throw new Error("Notebook storage is not initialized");
125
+ }
126
+ await this.saveRpc.writeNotebook(snapshot);
127
+ notebookFileStore.saveFile(snapshot.contents);
128
+ fallbackFileStore.saveFile(snapshot.contents);
129
+ return null;
130
+ },
131
+ }),
132
+ // Synchronous Python execution can block worker requests indefinitely.
133
+ Infinity,
134
+ );
118
135
  this.rpc.send.bootstrap(runtimeConfig);
119
136
 
120
137
  this.rpc.addMessageListener("notebookEvent", ({ id, event }) => {
@@ -209,11 +226,8 @@ export class PyodideBridge implements RunRequests, EditRequests {
209
226
 
210
227
  public async createNotebookBackend() {
211
228
  await this.initialized.promise;
212
- const { id } = await this.rpc.proxy.request.notebookInfo();
213
229
  return createWasmNotebookBackend({
214
- notebookId: id,
215
230
  subscribe: async (cursor, emit, signal) => {
216
- await this.initialized.promise;
217
231
  signal.throwIfAborted();
218
232
  const id = generateUUID();
219
233
  this.notebookConsumers.set(id, emit);
@@ -233,10 +247,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
233
247
  throw error;
234
248
  }
235
249
  },
236
- call: async (call) => {
237
- await this.initialized.promise;
238
- return this.rpc.proxy.request.notebookCall(call);
239
- },
250
+ call: (call) => this.rpc.proxy.request.notebookCall(call),
240
251
  });
241
252
  }
242
253
 
@@ -255,30 +266,23 @@ export class PyodideBridge implements RunRequests, EditRequests {
255
266
  return null;
256
267
  };
257
268
 
258
- public sendSave: EditRequests["sendSave"] = async (request) => {
259
- if (!this.saveRpc) {
260
- Logger.warn("Save RPC not initialized");
261
- return null;
269
+ private async saveHostNotebook() {
270
+ const replica = store.get(notebookReplicaAtom);
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");
262
275
  }
263
-
264
- const durableSave = this.saveRpc.saveNotebook(request);
265
- // Generated exports read the session-owned app in the main worker.
266
- this.pendingSessionSave = this.pendingSessionSave
267
- .catch(() => undefined)
268
- .then(async () => {
269
- await durableSave;
270
- await this.rpc.proxy.request.saveNotebook(request);
271
- });
272
- void this.pendingSessionSave.catch((error) => {
273
- Logger.error(error);
274
- });
275
-
276
- await durableSave;
277
- const code = await this.readCode();
278
- if (code.contents) {
279
- notebookFileStore.saveFile(code.contents);
280
- fallbackFileStore.saveFile(code.contents);
276
+ const result = await backend.notebook(notebook.id).save();
277
+ if (!result.ok) {
278
+ throw new Error(result.problem.detail ?? result.problem.title);
281
279
  }
280
+ }
281
+
282
+ public sendSave: EditRequests["sendSave"] = async ({ layout }) => {
283
+ await flushDocumentChanges();
284
+ await this.rpc.proxy.request.setNotebookLayout(layout ?? null);
285
+ await this.saveHostNotebook();
282
286
  return null;
283
287
  };
284
288
 
@@ -395,6 +399,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
395
399
  functionName: "save_app_config",
396
400
  payload: request,
397
401
  });
402
+ await this.saveHostNotebook();
398
403
  return null;
399
404
  };
400
405
 
@@ -560,7 +565,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
560
565
  public exportAsHTML: EditRequests["exportAsHTML"] = async (
561
566
  request: ExportAsHTMLRequest,
562
567
  ) => {
563
- await this.pendingSessionSave;
568
+ await flushDocumentChanges();
564
569
  const response = await this.rpc.proxy.request.bridge({
565
570
  functionName: "export_html",
566
571
  payload: withDevAssetUrl(request),
@@ -571,7 +576,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
571
576
  public exportAsMarkdown: EditRequests["exportAsMarkdown"] = async (
572
577
  request: ExportAsMarkdownRequest,
573
578
  ) => {
574
- await this.pendingSessionSave;
579
+ await flushDocumentChanges();
575
580
  const response = await this.rpc.proxy.request.bridge({
576
581
  functionName: "export_markdown",
577
582
  payload: request,
@@ -582,7 +587,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
582
587
  public exportAsScript: EditRequests["exportAsScript"] = async (
583
588
  request: ExportAsScriptRequest,
584
589
  ) => {
585
- await this.pendingSessionSave;
590
+ await flushDocumentChanges();
586
591
  const response = await this.rpc.proxy.request.bridge({
587
592
  functionName: "export_script",
588
593
  payload: request,
@@ -653,7 +658,12 @@ export class PyodideBridge implements RunRequests, EditRequests {
653
658
  return null;
654
659
  };
655
660
 
656
- public sendDocumentTransaction = () => Promise.resolve(null);
661
+ public sendDocumentTransaction: EditRequests["sendDocumentTransaction"] =
662
+ async (request) => {
663
+ await this.initialized.promise;
664
+ await this.rpc.proxy.request.documentTransaction(request);
665
+ return null;
666
+ };
657
667
 
658
668
  public addPackage: EditRequests["addPackage"] = async (request) => {
659
669
  return this.rpc.proxy.request.addPackage(request);
@@ -9,18 +9,31 @@ import { Logger } from "@/utils/Logger";
9
9
  import type { WasmRuntimeConfig } from "./runtime-config";
10
10
  import { TRANSPORT_ID } from "./worker/constants";
11
11
 
12
+ export interface PersistNotebookRequest {
13
+ filename: string;
14
+ contents: string;
15
+ }
16
+
12
17
  export type ParentSchema = RPCSchema<{
18
+ requests: {
19
+ persistNotebook: { params: PersistNotebookRequest; response: null };
20
+ };
13
21
  messages: {
14
22
  bootstrap: WasmRuntimeConfig;
15
23
  };
16
24
  }>;
17
25
 
18
- export function getWorkerRPC<WorkerSchema extends RPCSchema>(worker: Worker) {
26
+ export function getWorkerRPC<WorkerSchema extends RPCSchema>(
27
+ worker: Worker,
28
+ requestHandler?: RPCOptions<ParentSchema, WorkerSchema>["requestHandler"],
29
+ maxRequestTime = 20_000,
30
+ ) {
19
31
  return createRPC<ParentSchema, WorkerSchema>({
20
32
  transport: createWorkerTransport(worker, {
21
33
  transportId: TRANSPORT_ID,
22
34
  }),
23
- maxRequestTime: 20_000, // 20 seconds
35
+ requestHandler,
36
+ maxRequestTime,
24
37
  _debugHooks: {
25
38
  onSend: (message) => {
26
39
  Logger.debug("[rpc] Parent -> Worker", message);
@@ -7,12 +7,11 @@ import {
7
7
  createWorkerParentTransport,
8
8
  type RPCSchema,
9
9
  } from "rpc-anywhere";
10
- import type { SaveNotebookRequest } from "@/core/network/types";
11
10
  import { decodeUtf8 } from "@/utils/strings";
12
11
  import { Deferred } from "../../../utils/Deferred";
13
12
  import { prettyError } from "../../../utils/errors";
14
13
  import { Logger } from "../../../utils/Logger";
15
- import type { ParentSchema } from "../rpc";
14
+ import type { ParentSchema, PersistNotebookRequest } from "../rpc";
16
15
  import type { WasmRuntimeConfig } from "../runtime-config";
17
16
  import { TRANSPORT_ID } from "./constants";
18
17
  import { WasmFileSystem } from "./fs";
@@ -70,14 +69,14 @@ const requestHandler = createRPCRequestHandler({
70
69
  await pyodideReadyPromise; // Make sure loading is done
71
70
  return WasmFileSystem.readNotebook(self.pyodide);
72
71
  },
73
- saveNotebook: async (opts: SaveNotebookRequest) => {
74
- await pyodideReadyPromise; // Make sure loading is done
75
- const saveFile = self.pyodide.runPython(`
76
- from marimo._pyodide.bootstrap import save_file
77
-
78
- save_file
79
- `);
80
- await saveFile(JSON.stringify(opts), WasmFileSystem.NOTEBOOK_FILENAME);
72
+ writeNotebook: async ({ filename, contents }: PersistNotebookRequest) => {
73
+ await pyodideReadyPromise;
74
+ const directory = filename.substring(0, filename.lastIndexOf("/"));
75
+ if (directory) {
76
+ self.pyodide.FS.mkdirTree(directory);
77
+ }
78
+ self.pyodide.FS.writeFile(filename, contents);
79
+ self.pyodide.FS.writeFile(WasmFileSystem.NOTEBOOK_FILENAME, contents);
81
80
  await WasmFileSystem.persistFilesToRemote(self.pyodide);
82
81
  },
83
82
  });
@@ -30,7 +30,6 @@ import type {
30
30
  FormatCellsRequest,
31
31
  FormatResponse,
32
32
  SaveAppConfigurationRequest,
33
- SaveNotebookRequest,
34
33
  SaveUserConfigurationRequest,
35
34
  Snippets,
36
35
  } from "../../network/types";
@@ -79,7 +78,6 @@ export interface RawBridge {
79
78
  read_snippets(): Promise<Snippets>;
80
79
  get_environment_info(): Promise<EnvironmentInfo>;
81
80
  format(request: FormatCellsRequest): Promise<FormatResponse>;
82
- save(request: SaveNotebookRequest): Promise<string>;
83
81
  copy(request: CopyNotebookRequest): Promise<string>;
84
82
  save_app_config(request: SaveAppConfigurationRequest): Promise<string>;
85
83
  save_user_config(request: SaveUserConfigurationRequest): Promise<null>;
@@ -112,13 +110,12 @@ export type BridgePayload<T extends keyof RawBridge> = T extends keyof RawBridge
112
110
  : undefined;
113
111
 
114
112
  export type SerializedBridge = {
115
- [P in Exclude<keyof RawBridge, "save">]: RawBridge[P] extends (
113
+ [P in keyof RawBridge]: RawBridge[P] extends (
116
114
  payload: string,
117
115
  ) => Promise<unknown>
118
116
  ? (payload: string) => Promise<string>
119
117
  : RawBridge[P];
120
118
  } & {
121
- save(payload: string): Promise<void>;
122
119
  notebook: {
123
120
  readonly id: string;
124
121
  subscribe(
@@ -128,6 +125,11 @@ export type SerializedBridge = {
128
125
  ): Promise<void>;
129
126
  unsubscribe(id: string): void;
130
127
  call(call: string): Promise<string>;
128
+ transaction(request: string): Promise<void>;
129
+ set_layout(layout: string): Promise<void>;
130
+ set_persistence(
131
+ persist: (contents: string, filename: string) => Promise<void>,
132
+ ): void;
131
133
  destroy(): void;
132
134
  };
133
135
  };