@marimo-team/islands 0.25.3-dev169 → 0.25.3-dev170

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 (123) hide show
  1. package/dist/{ConnectedDataExplorerComponent-BbDrlHvG.js → ConnectedDataExplorerComponent-BbSXV-Xa.js} +8 -8
  2. package/dist/{ErrorBoundary-RaIwGWT3.js → ErrorBoundary-NE86qEWZ.js} +4 -4
  3. package/dist/{abnfDiagram-VCTEODGH-Dr-fo8_z.js → abnfDiagram-VCTEODGH-D7n07nXf.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-ByKIl9GG.js → architectureDiagram-5GKGNRK7-D6CjRib5.js} +1 -1
  5. package/dist/{chat-ui-BsV2WH-4.js → chat-ui-CC7GekpG.js} +34 -34
  6. package/dist/{chunk-IMKFNOWR-IKDP18MQ.js → chunk-IMKFNOWR-6dtPz7uS.js} +1 -1
  7. package/dist/{chunk-SHT3W25Y-LkIgegwh.js → chunk-SHT3W25Y-BwnFNKDA.js} +1 -1
  8. package/dist/{chunk-SVP7TREG-DReBYtnB.js → chunk-SVP7TREG-D2eV4vBJ.js} +1 -1
  9. package/dist/{chunk-TICWLB2K-DQ0A0-1D.js → chunk-TICWLB2K-BDs4-_-r.js} +1 -1
  10. package/dist/{classDiagram-ZZMXUADV-CwvXdCNE.js → classDiagram-ZZMXUADV-DCJnf_8v.js} +2 -2
  11. package/dist/{classDiagram-v2-VYDZK3BY-D0XAxDsc.js → classDiagram-v2-VYDZK3BY-BOmSzPBD.js} +2 -2
  12. package/dist/{code-visibility-BMi0KLAQ.js → code-visibility-Bzvc6ajl.js} +23 -23
  13. package/dist/{common-BMkJrZsD.js → common-Ciw4zkh2.js} +3412 -3412
  14. package/dist/{cynefinDiagram-5FMLGOSQ-BFN68nHr.js → cynefinDiagram-5FMLGOSQ-Bln_Soky.js} +1 -1
  15. package/dist/{dagre-GXQ25YYZ-CZt4rtLq.js → dagre-GXQ25YYZ-D-nf7WzW.js} +1 -1
  16. package/dist/{data-grid-overlay-editor-CCc1P42P.js → data-grid-overlay-editor-AQREmxI7.js} +1 -1
  17. package/dist/{diagram-S7CK7UJ4-D5cOz47_.js → diagram-S7CK7UJ4-PsgSMkY2.js} +1 -1
  18. package/dist/{diagram-UQ7AKVKN-Qgdppb4a.js → diagram-UQ7AKVKN-DfwfUuSw.js} +1 -1
  19. package/dist/{diagram-VX7I27RA-BsDCvily.js → diagram-VX7I27RA-C9_e-oMc.js} +1 -1
  20. package/dist/{diagram-Z3DM3KII-igTkGjKI.js → diagram-Z3DM3KII-CWLa5tuY.js} +1 -1
  21. package/dist/{dropdown-menu-BWnnAstC.js → dropdown-menu-B6ftyzOg.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-BHuZGjIr.js → ebnfDiagram-PWID7BFC-B1QRM58v.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-DKObXTVE.js → erDiagram-RLTQ6QDP-DJK_FqvW.js} +1 -1
  24. package/dist/{error-entries-BpUJSLUZ.js → error-entries-DgK3wUJR.js} +5640 -5591
  25. package/dist/{feedback-button-CMVkEos_.js → feedback-button-8a6SETmT.js} +24 -24
  26. package/dist/{flowDiagram-HODETNUW-DZI_kit0.js → flowDiagram-HODETNUW-Uxc25XSJ.js} +2 -2
  27. package/dist/{formats-CwJHWasD.js → formats-DQCyPd5Z.js} +1 -1
  28. package/dist/{glide-data-editor-BA2pcHSV.js → glide-data-editor-B70oke_v.js} +5 -5
  29. package/dist/{globals-B5jjJ0ms.js → globals-Cxd5hNlI.js} +2 -2
  30. package/dist/{html-to-image-CQr2-IDA.js → html-to-image-D4yG5Ru0.js} +25 -25
  31. package/dist/{infoDiagram-27XIBGKW-DdPl-7-n.js → infoDiagram-27XIBGKW-DmjxqwL0.js} +1 -1
  32. package/dist/{ishikawaDiagram-5VMMS53U-DbyW6l59.js → ishikawaDiagram-5VMMS53U-NgCLHqQi.js} +1 -1
  33. package/dist/{kanban-definition-UXKFOSKX-BMX9SyBS.js → kanban-definition-UXKFOSKX-CZaG-iam.js} +1 -1
  34. package/dist/main.js +187 -188
  35. package/dist/{mermaid-C8W8mTbA.js → mermaid-CSvw7c71.js} +34 -34
  36. package/dist/{mindmap-definition-YA3MSWOX-Zd_tAxWL.js → mindmap-definition-YA3MSWOX-DJYQBDH_.js} +1 -1
  37. package/dist/{number-overlay-editor-BIWiV-TU.js → number-overlay-editor-ChK-SKUk.js} +1 -1
  38. package/dist/{pegDiagram-XKGWAZYB-DjYydPdo.js → pegDiagram-XKGWAZYB-CHKO_dAe.js} +2 -2
  39. package/dist/{pieDiagram-E7YTZNPT-R5LKjO2n.js → pieDiagram-E7YTZNPT-DVevbMAo.js} +1 -1
  40. package/dist/{process-output-Uw-VJE5V.js → process-output-DxvBAeL3.js} +3 -3
  41. package/dist/{railroadDiagram-O6MQD6OU-C7uNKtfm.js → railroadDiagram-O6MQD6OU-Detj1kcB.js} +2 -2
  42. package/dist/{requirementDiagram-BXWQKSXE-CxAIzQfl.js → requirementDiagram-BXWQKSXE-BLFD0T_Z.js} +1 -1
  43. package/dist/{reveal-component-XiecZ3Ua.js → reveal-component-CXMXeDZD.js} +6 -6
  44. package/dist/{sequenceDiagram-WJ2MYXX4-B5tmlPd5.js → sequenceDiagram-WJ2MYXX4-BZBgatQV.js} +1 -1
  45. package/dist/{spec-45FnujvG.js → spec-Mnkn3XEr.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-C1hc5H7Y.js → stateDiagram-D77RDMKH-4BZW9-6_.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-DRWeqQTn.js → stateDiagram-v2-MP3YSRHH-C4sKC8Iq.js} +2 -2
  48. package/dist/style.css +1 -1
  49. package/dist/{swimlanes-42K2YHIH-NTLas--s.js → swimlanes-42K2YHIH-SL5yeGe2.js} +1 -1
  50. package/dist/{swimlanesDiagram-VR7AAH4N-ChPggljD.js → swimlanesDiagram-VR7AAH4N-DqPmAYUv.js} +2 -2
  51. package/dist/{timeline-definition-24CTP7MA-B4CZ9srJ.js → timeline-definition-24CTP7MA-BfXUMWkq.js} +1 -1
  52. package/dist/{toDate-rySyYxqk.js → toDate-TMubKIVL.js} +22 -22
  53. package/dist/{useAsyncData-B7MiltyV.js → useAsyncData-DjMGFzFy.js} +1 -1
  54. package/dist/{useDeepCompareMemoize-DZmHBD_1.js → useDeepCompareMemoize-BQR5TJot.js} +1 -1
  55. package/dist/{useLifecycle-DRbRhzxg.js → useLifecycle-C_GvKWbQ.js} +26 -23
  56. package/dist/{useTheme-TLkqJsvo.js → useTheme-Bns2gJZ6.js} +179 -178
  57. package/dist/{vega-component-DC6P127L.js → vega-component-BsVptuhz.js} +5 -5
  58. package/dist/{vennDiagram-4TSXK5OY-DJIvMt44.js → vennDiagram-4TSXK5OY-CH65znGa.js} +1 -1
  59. package/dist/{wardleyDiagram-VM6X3IG4-45H0zg0u.js → wardleyDiagram-VM6X3IG4-z9pWKb2_.js} +1 -1
  60. package/dist/{xychartDiagram-S5SC5T6Z-ZT-XzcPN.js → xychartDiagram-S5SC5T6Z-MxyaWjjq.js} +1 -1
  61. package/package.json +1 -1
  62. package/src/__mocks__/requests.ts +0 -1
  63. package/src/components/editor/cell/StagedAICell.tsx +10 -2
  64. package/src/components/editor/cell/code/cell-editor.tsx +7 -14
  65. package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +92 -0
  66. package/src/components/editor/chrome/wrapper/footer-items/document-sync-status.tsx +54 -0
  67. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  68. package/src/core/ai/__tests__/staged-cells.test.ts +8 -5
  69. package/src/core/ai/staged-cells.ts +7 -2
  70. package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +3 -15
  71. package/src/core/ai/tools/edit-notebook-tool.ts +14 -3
  72. package/src/core/cells/__tests__/cells.test.ts +0 -297
  73. package/src/core/cells/__tests__/document.test.ts +410 -0
  74. package/src/core/cells/__tests__/flush-document.test.ts +40 -0
  75. package/src/core/cells/__tests__/notebook-outline.test.ts +1 -6
  76. package/src/core/cells/__tests__/with-document-save.test.ts +67 -0
  77. package/src/core/cells/cells.ts +27 -158
  78. package/src/core/cells/columns.ts +38 -0
  79. package/src/core/cells/document.ts +360 -0
  80. package/src/core/cells/types.ts +12 -0
  81. package/src/core/codemirror/cells/__tests__/extensions.test.ts +27 -1
  82. package/src/core/codemirror/cells/extensions.ts +14 -0
  83. package/src/core/codemirror/format.ts +6 -5
  84. package/src/core/codemirror/language/__tests__/utils.test.ts +187 -15
  85. package/src/core/codemirror/language/cell-code.ts +114 -0
  86. package/src/core/codemirror/language/extension.ts +47 -30
  87. package/src/core/codemirror/language/languages/sql/sql.ts +7 -17
  88. package/src/core/codemirror/language/utils.ts +9 -39
  89. package/src/core/codemirror/lsp/notebook-lsp.ts +6 -5
  90. package/src/core/codemirror/rtc/extension.ts +0 -1
  91. package/src/core/edit-app.tsx +4 -3
  92. package/src/core/export/layout.ts +2 -2
  93. package/src/core/islands/bridge.ts +0 -1
  94. package/src/core/network/requests-lazy.ts +0 -1
  95. package/src/core/network/requests-network.ts +0 -9
  96. package/src/core/network/requests-static.ts +0 -1
  97. package/src/core/network/requests-toasting.tsx +0 -1
  98. package/src/core/network/types.ts +0 -5
  99. package/src/core/notebook/document/__tests__/codemirror.test.ts +76 -14
  100. package/src/core/notebook/document/__tests__/hold.test.ts +88 -0
  101. package/src/core/notebook/document/__tests__/hooks.test.tsx +3 -3
  102. package/src/core/notebook/document/__tests__/lifecycle-scenarios.test.ts +3 -3
  103. package/src/core/notebook/document/__tests__/model.test.ts +71 -15
  104. package/src/core/notebook/document/__tests__/scenario-fixture.ts +1 -1
  105. package/src/core/notebook/document/codemirror.ts +27 -122
  106. package/src/core/notebook/document/hooks.tsx +2 -2
  107. package/src/core/notebook/document/index.ts +9 -1
  108. package/src/core/notebook/document/model.ts +74 -24
  109. package/src/core/notebook/document/sync.ts +53 -4
  110. package/src/core/run-app.tsx +44 -41
  111. package/src/core/saving/save-component.tsx +2 -5
  112. package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +63 -3
  113. package/src/core/session/active-notebook.ts +4 -0
  114. package/src/core/session/useNotebookSession.tsx +39 -19
  115. package/src/core/wasm/__tests__/bridge.test.ts +3 -3
  116. package/src/core/wasm/bridge.ts +5 -12
  117. package/src/core/wasm/worker/worker.ts +0 -9
  118. package/src/utils/__tests__/{createReducer.test.ts → createReducer.test.tsx} +28 -0
  119. package/src/utils/createReducer.ts +42 -21
  120. package/src/core/cells/__tests__/apply-transaction.test.ts +0 -991
  121. package/src/core/cells/__tests__/document-changes.test.ts +0 -1140
  122. package/src/core/cells/__tests__/document-roundtrip.test.ts +0 -392
  123. package/src/core/cells/document-changes.ts +0 -944
@@ -28,34 +28,24 @@
28
28
  * coarsely instead of blocking the editor.
29
29
  */
30
30
 
31
- import { diff } from "@codemirror/merge";
32
31
  import {
33
- type ChangeSpec,
34
32
  type EditorState,
35
33
  type Extension,
36
- type StateEffect,
37
34
  Transaction,
38
35
  } from "@codemirror/state";
39
36
  import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
40
37
  import type { CellId } from "@marimo-team/client/protocol";
41
- import { dequal } from "dequal";
42
38
  import type { Atom, createStore } from "jotai/vanilla";
43
39
  import {
44
- languageAdapterFromCode,
45
- languageAdapterState,
46
- switchLanguage,
47
- } from "../../codemirror/language/extension";
48
- import { initializeSQLDialect } from "../../codemirror/language/languages/sql/sql";
49
- import {
50
- languageMetadataField,
51
- setLanguageMetadata,
52
- } from "../../codemirror/language/metadata";
40
+ editorShowsCellCode,
41
+ getCellCode,
42
+ prepareCellCodeUpdate,
43
+ } from "../../codemirror/language/cell-code";
44
+ import { languageAdapterState } from "../../codemirror/language/extension";
45
+ import { languageMetadataField } from "../../codemirror/language/metadata";
53
46
  import type { DocumentModel } from "./model";
54
47
  import { selectCellField } from "./select";
55
48
 
56
- /** The diff scan limit, matching the default of the merge view. */
57
- const SCAN_LIMIT = 500;
58
-
59
49
  interface Binding {
60
50
  readonly store: ReturnType<typeof createStore>;
61
51
  readonly model: DocumentModel;
@@ -94,17 +84,11 @@ export function cellCodeBinding({
94
84
  }
95
85
 
96
86
  class CellCodeSync {
87
+ // Only the initial load infers a mode; subsequent edits preserve it.
88
+ #initialized = false;
97
89
  #scheduled = false;
98
90
  /** A pull arrived during IME composition and runs once it ends. */
99
91
  #deferred = false;
100
- /**
101
- * A pull is dispatching to the editor.
102
- *
103
- * A language switch dispatches transactions of its own before the text, and
104
- * their in-between states represent the wrong code. Until the switch can be
105
- * part of the pull's one remote transaction, this keeps them from pushing.
106
- */
107
- #pulling = false;
108
92
  /**
109
93
  * The code the editor and the model last agreed on.
110
94
  *
@@ -130,7 +114,10 @@ class CellCodeSync {
130
114
  }
131
115
 
132
116
  public update(update: ViewUpdate) {
133
- if (!this.#pulling && changesCode(update)) {
117
+ if (
118
+ changesCode(update) &&
119
+ !update.transactions.every((tr) => tr.annotation(Transaction.remote))
120
+ ) {
134
121
  this.#push(update.state);
135
122
  }
136
123
  if (this.#deferred && !update.view.composing) {
@@ -164,11 +151,12 @@ class CellCodeSync {
164
151
  this.#synced = undefined;
165
152
  return;
166
153
  }
167
- if (shows(state, current)) {
154
+ this.#initialized = true;
155
+ if (editorShowsCellCode(state, current)) {
168
156
  this.#synced = current;
169
157
  return;
170
158
  }
171
- const next = codeOf(state);
159
+ const next = getCellCode(state);
172
160
  this.#synced = next;
173
161
  store.set(model.editAtom, (tx) => tx.setCode(cellId, next));
174
162
  }
@@ -185,15 +173,21 @@ class CellCodeSync {
185
173
  return;
186
174
  }
187
175
  this.#deferred = false;
188
- if (next !== this.#synced && !shows(this.#view.state, next)) {
189
- this.#pulling = true;
190
- try {
191
- replaceCode(this.#view, next);
192
- } finally {
193
- this.#pulling = false;
176
+ if (next !== this.#synced) {
177
+ const update = prepareCellCodeUpdate(this.#view.state, next, {
178
+ initialize: !this.#initialized,
179
+ });
180
+ if (update) {
181
+ this.#view.dispatch(update, {
182
+ annotations: [
183
+ Transaction.remote.of(true),
184
+ Transaction.addToHistory.of(false),
185
+ ],
186
+ });
194
187
  }
195
188
  }
196
189
  this.#synced = next;
190
+ this.#initialized = true;
197
191
  }
198
192
 
199
193
  /** Pull once after the current update, however many changes arrive. */
@@ -205,44 +199,6 @@ class CellCodeSync {
205
199
  }
206
200
  }
207
201
 
208
- /**
209
- * The cell code an editor state represents.
210
- *
211
- * With a language adapter, this is the adapter's Python for the editor text.
212
- */
213
- function codeOf(state: EditorState): string {
214
- const text = state.doc.toString();
215
- const adapter = state.field(languageAdapterState, false);
216
- return adapter
217
- ? adapter.transformOut(
218
- text,
219
- state.field(languageMetadataField, false) ?? {},
220
- )[0]
221
- : text;
222
- }
223
-
224
- /**
225
- * Whether an editor state shows the given cell code.
226
- *
227
- * An adapter can spell the same code more than one way, such as `mo.md("hi")`
228
- * and its canonical form, so a state shows code that reads into its text and
229
- * metadata as well as the code its own spelling produces.
230
- */
231
- function shows(state: EditorState, code: string): boolean {
232
- if (codeOf(state) === code) {
233
- return true;
234
- }
235
- const adapter = state.field(languageAdapterState, false);
236
- if (!adapter) {
237
- return false;
238
- }
239
- const [text, , metadata] = adapter.transformIn(code);
240
- return (
241
- text === state.doc.toString() &&
242
- dequal(metadata, state.field(languageMetadataField, false))
243
- );
244
- }
245
-
246
202
  /**
247
203
  * Whether an update could change the code an editor represents.
248
204
  *
@@ -258,54 +214,3 @@ function changesCode(update: ViewUpdate): boolean {
258
214
  update.startState.field(languageMetadataField, false)
259
215
  );
260
216
  }
261
-
262
- /**
263
- * Show cell code in the language it implies.
264
- *
265
- * The language switch happens first, in its own transactions, and clears undo
266
- * history because its entries edit the previous language's text. The text and
267
- * metadata follow in one remote transaction that undo history leaves out. An
268
- * SQL dialect depends on the engine in the metadata, so it is set again after.
269
- */
270
- function replaceCode(view: EditorView, code: string) {
271
- let text = code;
272
- const effects: StateEffect<unknown>[] = [];
273
- let sql = false;
274
- const adapter = view.state.field(languageAdapterState, false);
275
- if (adapter) {
276
- const target = languageAdapterFromCode(code);
277
- sql = target.type === "sql";
278
- if (target.type !== adapter.type) {
279
- switchLanguage(view, { language: target.type, keepCodeAsIs: true });
280
- }
281
- const [inner, , metadata] = target.transformIn(code);
282
- text = inner;
283
- if (!dequal(metadata, view.state.field(languageMetadataField, false))) {
284
- effects.push(setLanguageMetadata.of(metadata));
285
- }
286
- }
287
- const current = view.state.doc.toString();
288
- if (current === text && effects.length === 0) {
289
- return;
290
- }
291
- view.dispatch({
292
- changes: changesBetween(current, text),
293
- effects,
294
- annotations: [
295
- Transaction.remote.of(true),
296
- Transaction.addToHistory.of(false),
297
- ],
298
- });
299
- if (sql && effects.length > 0) {
300
- initializeSQLDialect(view);
301
- }
302
- }
303
-
304
- /** The changes that turn one text into another, one per edited range. */
305
- function changesBetween(from: string, to: string): ChangeSpec {
306
- return diff(from, to, { scanLimit: SCAN_LIMIT }).map((change) => ({
307
- from: change.fromA,
308
- to: change.toA,
309
- insert: to.slice(change.fromB, change.toB),
310
- }));
311
- }
@@ -12,8 +12,8 @@ const DocumentContext = createContext<DocumentModel | null>(null);
12
12
 
13
13
  /**
14
14
  * Expose a session-owned model to React. Construct the model with the same store
15
- * as the enclosing Jotai Provider. The session owner controls its AbortSignal;
16
- * mounting or unmounting this provider does not change the model's lifetime.
15
+ * as the enclosing Jotai Provider. The session owner starts it; mounting or
16
+ * unmounting this provider does not change the model's lifetime.
17
17
  *
18
18
  * ```tsx
19
19
  * <Provider store={store}>
@@ -12,4 +12,12 @@ export {
12
12
  useNotebookDocumentEdit,
13
13
  useNotebookDocumentSync,
14
14
  } from "./hooks";
15
- export { createDocumentModel, type DocumentModel } from "./model";
15
+ export {
16
+ createDocumentModel,
17
+ type DocumentEdit,
18
+ type DocumentEditor,
19
+ type DocumentModel,
20
+ type DocumentSyncStatus,
21
+ type EditableCell,
22
+ type EditableDocument,
23
+ } from "./model";
@@ -1,9 +1,19 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import type * as Protocol from "@marimo-team/client/protocol";
4
- import type { Atom, createStore } from "jotai/vanilla";
4
+ import {
5
+ type Atom,
6
+ atom,
7
+ type createStore,
8
+ type WritableAtom,
9
+ } from "jotai/vanilla";
5
10
  import type { Document, NotebookBackend } from "../replica";
6
- import { createDocumentState, type DocumentState } from "./state";
11
+ import type { EditableDocument } from "./draft";
12
+ import {
13
+ createDocumentState,
14
+ type DocumentEdit,
15
+ type DocumentState,
16
+ } from "./state";
7
17
  import { createDocumentSync, type DocumentSyncStatus } from "./sync";
8
18
 
9
19
  export type { DocumentEdit, DocumentEditor } from "./state";
@@ -11,45 +21,85 @@ export type { EditableCell, EditableDocument } from "./draft";
11
21
  export type { DocumentSyncStatus } from "./sync";
12
22
 
13
23
  /** The editable document and its synchronization with the host. */
14
- export interface DocumentModel extends Pick<
15
- DocumentState,
16
- "documentAtom" | "editAtom"
17
- > {
24
+ export interface DocumentModel {
25
+ /** Confirmed host state with local drafts applied, null while unavailable or stopped. */
26
+ readonly documentAtom: Atom<EditableDocument | null>;
27
+ /** Ignored while stopped. */
28
+ readonly editAtom: WritableAtom<null, [edit: DocumentEdit], void>;
18
29
  readonly syncStatusAtom: Atom<DocumentSyncStatus>;
19
30
  /** Drain local edits and return the confirmed revision. */
20
31
  flush(): Promise<Protocol.Document>;
21
32
  /** Retry a rejection against fresh state, or repeat an uncertain request exactly. */
22
33
  retry(): Promise<Protocol.Document>;
34
+ /** Keep edits local until the returned release is called. A no-op while stopped. */
35
+ hold(): () => void;
36
+ /**
37
+ * Edit and synchronize until the signal aborts, which discards local drafts.
38
+ * The model can start again once stopped.
39
+ */
40
+ start(options: { signal: AbortSignal }): void;
23
41
  }
24
42
 
25
- /** Create once per editing session; abort to stop editing and synchronization together. */
43
+ interface Running {
44
+ readonly state: DocumentState;
45
+ readonly sync: ReturnType<typeof createDocumentSync>;
46
+ }
47
+
48
+ /** Create without subscribing to anything; editing begins on start. */
26
49
  export function createDocumentModel({
27
50
  store,
28
- documentAtom,
51
+ documentAtom: source,
29
52
  backendAtom,
30
- signal,
31
53
  }: {
32
54
  store: ReturnType<typeof createStore>;
33
55
  /** The document the host last confirmed. */
34
56
  documentAtom: Atom<Document | null>;
35
57
  /** The backend that confirmed it, whose commands reach its host. */
36
58
  backendAtom: Atom<NotebookBackend | null>;
37
- signal: AbortSignal;
38
59
  }): DocumentModel {
39
- signal.throwIfAborted();
40
- const state = createDocumentState({ store, source: documentAtom, signal });
41
- const sync = createDocumentSync({
42
- store,
43
- documentAtom,
44
- backendAtom,
45
- state,
46
- signal,
47
- });
60
+ const runningAtom = atom<Running | null>(null);
61
+
62
+ function stopped(): Promise<never> {
63
+ return Promise.reject(new Error("The document model is not started"));
64
+ }
65
+
48
66
  return {
49
- documentAtom: state.documentAtom,
50
- editAtom: state.editAtom,
51
- syncStatusAtom: sync.statusAtom,
52
- flush: sync.flush,
53
- retry: sync.retry,
67
+ documentAtom: atom((get) => {
68
+ const running = get(runningAtom);
69
+ return running ? get(running.state.documentAtom) : null;
70
+ }),
71
+ editAtom: atom(null, (get, set, edit: DocumentEdit) => {
72
+ const running = get(runningAtom);
73
+ if (running) {
74
+ set(running.state.editAtom, edit);
75
+ }
76
+ }),
77
+ syncStatusAtom: atom((get) => {
78
+ const running = get(runningAtom);
79
+ return running ? get(running.sync.statusAtom) : { status: "unavailable" };
80
+ }),
81
+ flush: () => store.get(runningAtom)?.sync.flush() ?? stopped(),
82
+ retry: () => store.get(runningAtom)?.sync.retry() ?? stopped(),
83
+ hold: () => store.get(runningAtom)?.sync.hold() ?? (() => undefined),
84
+ start({ signal }) {
85
+ signal.throwIfAborted();
86
+ if (store.get(runningAtom)) {
87
+ throw new Error("The document model is already started");
88
+ }
89
+ const state = createDocumentState({ store, source, signal });
90
+ const sync = createDocumentSync({
91
+ store,
92
+ documentAtom: source,
93
+ backendAtom,
94
+ state,
95
+ signal,
96
+ });
97
+ const stop = () => store.set(runningAtom, null);
98
+ signal.addEventListener("abort", stop, { once: true });
99
+ store.set(runningAtom, { state, sync });
100
+ if (signal.aborted) {
101
+ stop();
102
+ }
103
+ },
54
104
  };
55
105
  }
@@ -97,6 +97,13 @@ 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
+ }> = [];
100
107
 
101
108
  function clearTimer() {
102
109
  clearTimeout(timer);
@@ -139,9 +146,11 @@ export function createDocumentSync({
139
146
  store.set(statusAtom, { status: "idle" });
140
147
  } else {
141
148
  store.set(statusAtom, { status: "pending" });
142
- timer = setTimeout(() => {
143
- void flush().catch(() => undefined);
144
- }, DEBOUNCE_MS);
149
+ if (holds === 0) {
150
+ timer = setTimeout(() => {
151
+ void flush().catch(() => undefined);
152
+ }, DEBOUNCE_MS);
153
+ }
145
154
  }
146
155
  } finally {
147
156
  refreshing = false;
@@ -314,6 +323,11 @@ export function createDocumentSync({
314
323
  if (running) {
315
324
  return running;
316
325
  }
326
+ if (holds > 0) {
327
+ return new Promise<void>((resolve, reject) => {
328
+ releaseWaiters.push({ resolve, reject });
329
+ }).then(flush);
330
+ }
317
331
  const notebookId =
318
332
  active?.submission.notebookId ?? store.get(source)?.revision.notebook_id;
319
333
  if (notebookId === undefined) {
@@ -353,17 +367,52 @@ export function createDocumentSync({
353
367
  return flush();
354
368
  }
355
369
 
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
+
356
400
  const unsubscribe = store.sub(observed, refresh);
357
401
  function stop() {
358
402
  clearTimer();
359
403
  unsubscribe();
360
404
  signal.removeEventListener("abort", stop);
361
405
  active?.controller.abort(signal.reason);
406
+ const waiters = releaseWaiters;
407
+ releaseWaiters = [];
408
+ for (const waiter of waiters) {
409
+ waiter.reject(signal.reason);
410
+ }
362
411
  }
363
412
  signal.addEventListener("abort", stop, { once: true });
364
413
  if (signal.aborted) {
365
414
  stop();
366
415
  }
367
416
  refresh();
368
- return { statusAtom, flush, retry };
417
+ return { statusAtom, flush, retry, hold };
369
418
  }
@@ -17,6 +17,7 @@ import {
17
17
  useCellActions,
18
18
  } from "./cells/cells";
19
19
  import type { AppConfig } from "./config/config-schema";
20
+ import { DocumentProvider } from "./notebook/document";
20
21
  import { RuntimeState } from "./kernel/RuntimeState";
21
22
  import { getSessionId } from "./kernel/session";
22
23
  import { connectionAtom } from "./network/connection";
@@ -51,7 +52,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig, hideHeader }) => {
51
52
  };
52
53
  }, [sendComponentValues]);
53
54
 
54
- const { connection, reconnect } = useNotebookSession({
55
+ const { connection, reconnect, documentModel } = useNotebookSession({
55
56
  autoInstantiate: true,
56
57
  setCells: setCells,
57
58
  sessionId: getSessionId(),
@@ -73,47 +74,49 @@ export const RunApp: React.FC<AppProps> = ({ appConfig, hideHeader }) => {
73
74
  })();
74
75
 
75
76
  return (
76
- <AppContainer
77
- connection={connection}
78
- isRunning={isRunning}
79
- width={appConfig.width}
80
- onReconnect={reconnect}
81
- >
82
- <KernelStartupErrorModal />
83
- <AppHeader
77
+ <DocumentProvider model={documentModel}>
78
+ <AppContainer
84
79
  connection={connection}
85
- className={hideHeader ? "hidden" : "sm:pt-8"}
80
+ isRunning={isRunning}
81
+ width={appConfig.width}
82
+ onReconnect={reconnect}
86
83
  >
87
- {galleryHref && (
88
- <div className="flex items-center px-6 pt-4 sm:-mt-8">
89
- <a
90
- href={galleryHref}
91
- aria-label="Back to gallery"
92
- className={cn(
93
- buttonVariants({ variant: "text", size: "sm" }),
94
- "gap-2 px-0 text-muted-foreground hover:text-foreground",
95
- )}
96
- >
97
- <ArrowLeftIcon className="size-4" aria-hidden={true} />
98
- <span>Back</span>
99
- </a>
100
- </div>
101
- )}
102
- </AppHeader>
103
- <ProgressiveBoundary
104
- requires={canPaintRunAppAtom}
105
- delay={2000}
106
- fallback={
107
- <>
108
- <Spinner className="mx-auto" />
109
- <p className="text-center text-sm text-muted-foreground mt-2">
110
- Connecting...
111
- </p>
112
- </>
113
- }
114
- >
115
- <CellsRenderer appConfig={appConfig} mode="read" />
116
- </ProgressiveBoundary>
117
- </AppContainer>
84
+ <KernelStartupErrorModal />
85
+ <AppHeader
86
+ connection={connection}
87
+ className={hideHeader ? "hidden" : "sm:pt-8"}
88
+ >
89
+ {galleryHref && (
90
+ <div className="flex items-center px-6 pt-4 sm:-mt-8">
91
+ <a
92
+ href={galleryHref}
93
+ aria-label="Back to gallery"
94
+ className={cn(
95
+ buttonVariants({ variant: "text", size: "sm" }),
96
+ "gap-2 px-0 text-muted-foreground hover:text-foreground",
97
+ )}
98
+ >
99
+ <ArrowLeftIcon className="size-4" aria-hidden={true} />
100
+ <span>Back</span>
101
+ </a>
102
+ </div>
103
+ )}
104
+ </AppHeader>
105
+ <ProgressiveBoundary
106
+ requires={canPaintRunAppAtom}
107
+ delay={2000}
108
+ fallback={
109
+ <>
110
+ <Spinner className="mx-auto" />
111
+ <p className="text-center text-sm text-muted-foreground mt-2">
112
+ Connecting...
113
+ </p>
114
+ </>
115
+ }
116
+ >
117
+ <CellsRenderer appConfig={appConfig} mode="read" />
118
+ </ProgressiveBoundary>
119
+ </AppContainer>
120
+ </DocumentProvider>
118
121
  );
119
122
  };
@@ -21,10 +21,7 @@ import { Label } from "../../components/ui/label";
21
21
  import { useEvent } from "../../hooks/useEvent";
22
22
  import { Logger } from "../../utils/Logger";
23
23
  import { getCellConfigs, getNotebook, useNotebook } from "../cells/cells";
24
- import {
25
- flushDocumentChanges,
26
- withDocumentSave,
27
- } from "../cells/document-changes";
24
+ import { flushDocument, withDocumentSave } from "../cells/document";
28
25
  import { notebookCells } from "../cells/utils";
29
26
  import { formatAll } from "../codemirror/format";
30
27
  import { autoSaveConfigAtom } from "../config/config";
@@ -165,7 +162,7 @@ export function useSaveNotebook() {
165
162
  // TODO(MO-7955): Remove this conditional once all frontend saves use
166
163
  // the host protocol. Host saves cannot recover edits from an editor snapshot.
167
164
  const savedNotebook = isWasm()
168
- ? await flushDocumentChanges().then(save)
165
+ ? await flushDocument().then(save)
169
166
  : await withDocumentSave(save);
170
167
  setLastSavedNotebook(savedNotebook);
171
168
  }),
@@ -52,7 +52,10 @@ import { RuntimeManager } from "../../runtime/runtime";
52
52
  import { WebSocketClosedReason, ConnectionState } from "../types";
53
53
  import { classifyCloseEvent } from "../../transports/connection-state";
54
54
  import { createEditorBackend } from "../backend";
55
- import { fakeBackend } from "../../notebook/replica/__tests__/fixtures";
55
+ import {
56
+ fakeBackend,
57
+ fixtureMessages,
58
+ } from "../../notebook/replica/__tests__/fixtures";
56
59
  import { useNotebookSession } from "../useNotebookSession";
57
60
  import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
58
61
 
@@ -142,7 +145,10 @@ function makeRuntimeManager(
142
145
  return runtime;
143
146
  }
144
147
 
145
- function renderConnectionHook(store: ReturnType<typeof createStore>) {
148
+ function renderConnectionHook(
149
+ store: ReturnType<typeof createStore>,
150
+ { reactStrictMode = false } = {},
151
+ ) {
146
152
  const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
147
153
  <JotaiProvider store={store}>
148
154
  <ErrorBoundary fallback={null}>{children}</ErrorBoundary>
@@ -155,7 +161,7 @@ function renderConnectionHook(store: ReturnType<typeof createStore>) {
155
161
  autoInstantiate: false,
156
162
  setCells: vi.fn(),
157
163
  }),
158
- { wrapper },
164
+ { wrapper, reactStrictMode },
159
165
  );
160
166
  }
161
167
 
@@ -228,6 +234,60 @@ describe("useNotebookSession.reconnect()", () => {
228
234
  });
229
235
  });
230
236
 
237
+ describe("useNotebookSession document model", () => {
238
+ it("keeps drafts while the connection drops and discards them when the session ends", async () => {
239
+ const fake = withTransport();
240
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
241
+ const store = createStore();
242
+ const { result, unmount } = renderConnectionHook(store, {
243
+ reactStrictMode: true,
244
+ });
245
+ await started();
246
+ act(() => {
247
+ connection(fake).open();
248
+ fixtureMessages("notebook-events.sse").forEach(connection(fake).receive);
249
+ });
250
+ const model = result.current.documentModel;
251
+ const code = () => store.get(model.documentAtom)?.cells.get("c1")?.code;
252
+ act(() => store.set(model.editAtom, (tx) => tx.setCode("c1", "local")));
253
+ expect(code()).toBe("local");
254
+
255
+ await act(async () => connection(fake).drop());
256
+ expect(result.current.documentModel).toBe(model);
257
+ expect(code()).toBe("local");
258
+
259
+ unmount();
260
+ expect(code()).toBeUndefined();
261
+ await expect(model.flush()).rejects.toThrow("not started");
262
+ });
263
+
264
+ it("follows the host document into the notebook's cells", async () => {
265
+ const fake = withTransport();
266
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
267
+ const store = createStore();
268
+ const { result, unmount } = renderConnectionHook(store);
269
+ await started();
270
+ act(() => {
271
+ connection(fake).open();
272
+ fixtureMessages("notebook-events.sse").forEach(connection(fake).receive);
273
+ });
274
+
275
+ let notebook = store.get(notebookAtom);
276
+ expect(notebook.cellIds.inOrderIds).toEqual(["c1", "c3", "k7f2"]);
277
+ expect(notebook.cellData[cellId("k7f2")]).toMatchObject({
278
+ code: "y = x * 2",
279
+ config: { hide_code: true },
280
+ });
281
+
282
+ const model = result.current.documentModel;
283
+ act(() => store.set(model.editAtom, (tx) => tx.setCode("c3", "x = 42")));
284
+ notebook = store.get(notebookAtom);
285
+ expect(notebook.cellData[cellId("c3")].code).toBe("x = 42");
286
+
287
+ unmount();
288
+ });
289
+ });
290
+
231
291
  describe("useNotebookSession messages", () => {
232
292
  let store: ReturnType<typeof createStore>;
233
293
  let fake: FakeTransport;