@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
@@ -0,0 +1,410 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { python } from "@codemirror/lang-python";
4
+ import { EditorState } from "@codemirror/state";
5
+ import { EditorView } from "@codemirror/view";
6
+ import { atom } from "jotai";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { cellId } from "@/__tests__/branded";
9
+ import type {
10
+ DocumentEdit,
11
+ DocumentEditor,
12
+ DocumentModel,
13
+ DocumentSyncStatus,
14
+ EditableCell,
15
+ EditableDocument,
16
+ } from "@/core/notebook/document";
17
+ import {
18
+ adaptiveLanguageConfiguration,
19
+ languageAdapterState,
20
+ } from "@/core/codemirror/language/extension";
21
+ import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
22
+ import { store } from "@/core/state/jotai";
23
+ import { activeDocumentModelAtom } from "@/core/session/active-notebook";
24
+ import { MultiColumn } from "@/utils/id-tree";
25
+ import { isRtcEnabled } from "../../rtc/state";
26
+ import {
27
+ createNotebookActions,
28
+ exportedForTesting,
29
+ type NotebookState,
30
+ notebookReducer,
31
+ } from "../cells";
32
+ import { applyDocument, flushDocument, withDocumentSave } from "../document";
33
+ import { type CellId, SCRATCH_CELL_ID } from "../ids";
34
+
35
+ vi.mock("../../rtc/state", async (importOriginal) => ({
36
+ ...(await importOriginal<typeof import("../../rtc/state")>()),
37
+ isRtcEnabled: vi.fn(() => false),
38
+ }));
39
+
40
+ const { initialNotebookState } = exportedForTesting;
41
+
42
+ const a = cellId("a");
43
+ const b = cellId("b");
44
+ const c = cellId("c");
45
+ const d = cellId("d");
46
+
47
+ function cell(id: CellId, patch: Partial<EditableCell> = {}): EditableCell {
48
+ return {
49
+ id,
50
+ code: `${id} = 1`,
51
+ name: "_",
52
+ config: {
53
+ column: null,
54
+ disabled: false,
55
+ hide_code: false,
56
+ expand_output: false,
57
+ },
58
+ ...patch,
59
+ };
60
+ }
61
+
62
+ function documentOf(cells: EditableCell[]): EditableDocument {
63
+ return {
64
+ order: cells.map((cell) => cell.id),
65
+ cells: new Map(cells.map((cell) => [cell.id, cell])),
66
+ };
67
+ }
68
+
69
+ /** A mounted cell editor, as the notebook's cells have. */
70
+ function createEditor(content: string) {
71
+ const state = EditorState.create({
72
+ doc: content,
73
+ extensions: [
74
+ python(),
75
+ adaptiveLanguageConfiguration({
76
+ cellId: a,
77
+ completionConfig: {
78
+ activate_on_typing: true,
79
+ signature_hint_on_typing: false,
80
+ copilot: false,
81
+ codeium_api_key: null,
82
+ },
83
+ hotkeys: new OverridingHotkeyProvider({}),
84
+ placeholderType: "marimo-import",
85
+ lspConfig: {},
86
+ }),
87
+ ],
88
+ });
89
+ return new EditorView({ state, parent: globalThis.document.body });
90
+ }
91
+
92
+ /** A notebook that follows `cells`, as the session builds one. */
93
+ function notebookOf(cells: EditableCell[]): NotebookState {
94
+ return applyDocument(initialNotebookState(), documentOf(cells));
95
+ }
96
+
97
+ describe("applyDocument", () => {
98
+ it("adds the document's cells to an empty notebook", () => {
99
+ const state = notebookOf([cell(a), cell(b, { code: "" })]);
100
+
101
+ expect(state.cellIds.inOrderIds).toEqual([a, b]);
102
+ expect(state.cellData[a]).toMatchObject({
103
+ code: "a = 1",
104
+ name: "_",
105
+ edited: false,
106
+ lastCodeRun: null,
107
+ });
108
+ expect(state.cellRuntime[a]).toBeDefined();
109
+ expect(state.cellHandles[a]).toBeDefined();
110
+ expect(state.cellData[SCRATCH_CELL_ID]).toBeDefined();
111
+ });
112
+
113
+ it("returns the same state when the document matches", () => {
114
+ const cells = [cell(a), cell(b)];
115
+ const state = notebookOf(cells);
116
+
117
+ expect(applyDocument(state, documentOf(cells))).toBe(state);
118
+ });
119
+
120
+ it("brings a code change into the cell's editor", () => {
121
+ const state = notebookOf([cell(a), cell(b)]);
122
+ const view = createEditor("b = 1");
123
+ state.cellHandles[b].current = { editorView: view, editorViewOrNull: view };
124
+ state.cellData[b] = { ...state.cellData[b], lastCodeRun: "b = 1" };
125
+
126
+ const next = applyDocument(
127
+ state,
128
+ documentOf([cell(a), cell(b, { code: "b = 2" })]),
129
+ );
130
+
131
+ expect(next.cellData[b]).toMatchObject({ code: "b = 2", edited: true });
132
+ expect(next.cellData[a]).toBe(state.cellData[a]);
133
+ expect(view.state.doc.toString()).toBe("b = 2");
134
+ });
135
+
136
+ it("leaves an editor that already shows the document's code alone", () => {
137
+ const state = notebookOf([cell(a)]);
138
+ // The editor runs ahead of the notebook while this client types.
139
+ const view = createEditor('mo.md("hi")');
140
+ state.cellHandles[a].current = { editorView: view, editorViewOrNull: view };
141
+
142
+ const next = applyDocument(
143
+ state,
144
+ documentOf([cell(a, { code: 'mo.md("hi")' })]),
145
+ );
146
+
147
+ expect(next.cellData[a].code).toBe('mo.md("hi")');
148
+ expect(view.state.doc.toString()).toBe('mo.md("hi")');
149
+ expect(view.state.field(languageAdapterState).type).toBe("python");
150
+ });
151
+
152
+ it("leaves an RTC editor to Loro", () => {
153
+ vi.mocked(isRtcEnabled).mockReturnValueOnce(true);
154
+ const state = notebookOf([cell(a)]);
155
+ const view = createEditor("a = 1");
156
+ state.cellHandles[a].current = { editorView: view, editorViewOrNull: view };
157
+
158
+ const next = applyDocument(state, documentOf([cell(a, { code: "a = 2" })]));
159
+
160
+ expect(next.cellData[a].code).toBe("a = 2");
161
+ expect(view.state.doc.toString()).toBe("a = 1");
162
+ });
163
+
164
+ it("keeps the code this client has when the host withholds source", () => {
165
+ const state = notebookOf([cell(a)]);
166
+
167
+ expect(applyDocument(state, documentOf([cell(a, { code: null })]))).toBe(
168
+ state,
169
+ );
170
+ });
171
+
172
+ it("follows a cell's name and config", () => {
173
+ const state = notebookOf([cell(a)]);
174
+
175
+ const next = applyDocument(
176
+ state,
177
+ documentOf([
178
+ cell(a, {
179
+ name: "renamed",
180
+ config: { ...cell(a).config, hide_code: true },
181
+ }),
182
+ ]),
183
+ );
184
+
185
+ expect(next.cellData[a]).toMatchObject({
186
+ name: "renamed",
187
+ config: { hide_code: true },
188
+ edited: false,
189
+ });
190
+ });
191
+
192
+ it("removes cells the document no longer has", () => {
193
+ const state = notebookOf([cell(a), cell(b), cell(c)]);
194
+
195
+ const next = applyDocument(state, documentOf([cell(a), cell(c)]));
196
+
197
+ expect(next.cellIds.inOrderIds).toEqual([a, c]);
198
+ expect(next.cellData[b]).toBeUndefined();
199
+ expect(next.cellRuntime[b]).toBeUndefined();
200
+ expect(next.cellHandles[b]).toBeUndefined();
201
+ });
202
+
203
+ it("regroups columns from the cells' configs, keeping column ids and collapsed sections", () => {
204
+ const column1 = { ...cell(d).config, column: 1 };
205
+ const state = notebookOf([
206
+ cell(a),
207
+ cell(b),
208
+ cell(c),
209
+ cell(d, { config: column1 }),
210
+ ]);
211
+ const [first, second] = state.cellIds.getColumns();
212
+ const collapsed = {
213
+ ...state,
214
+ cellIds: new MultiColumn([first.collapse(a, b), second]),
215
+ };
216
+
217
+ const next = applyDocument(
218
+ collapsed,
219
+ documentOf([cell(c), cell(a), cell(b), cell(d, { config: column1 })]),
220
+ );
221
+
222
+ expect(next.cellIds.inOrderIds).toEqual([c, a, b, d]);
223
+ expect(next.cellIds.getColumnIds()).toEqual(
224
+ collapsed.cellIds.getColumnIds(),
225
+ );
226
+ expect(next.cellIds.atOrThrow(0).isCollapsed(a)).toBe(true);
227
+ expect(next.cellIds.atOrThrow(1).inOrderIds).toEqual([d]);
228
+ });
229
+
230
+ it("keeps a trailing empty column", () => {
231
+ const state = notebookOf([cell(a)]);
232
+ const withEmpty = {
233
+ ...state,
234
+ cellIds: state.cellIds.addColumn(state.cellIds.atOrThrow(0).id),
235
+ };
236
+
237
+ const next = applyDocument(withEmpty, documentOf([cell(a, { code: "" })]));
238
+
239
+ expect(next.cellIds.colLength).toBe(2);
240
+ expect(next.cellData[a].code).toBe("");
241
+ });
242
+ });
243
+
244
+ describe("documentEditMiddleware", () => {
245
+ type Call = [string, ...unknown[]];
246
+ let calls: Call[];
247
+ let state: NotebookState;
248
+ const editor: DocumentEditor = {
249
+ setCode: (id, code) => calls.push(["setCode", id, code]),
250
+ setName: (id, name) => calls.push(["setName", id, name]),
251
+ updateConfig: (id, patch) => calls.push(["updateConfig", id, patch]),
252
+ createCell: (cell, placement) =>
253
+ calls.push(["createCell", cell, placement]),
254
+ deleteCell: (id) => calls.push(["deleteCell", id]),
255
+ setOrder: (order) => calls.push(["setOrder", order]),
256
+ };
257
+ const model: DocumentModel = {
258
+ documentAtom: atom(null),
259
+ editAtom: atom(null, (_get, _set, edit: DocumentEdit) => edit(editor)),
260
+ syncStatusAtom: atom<DocumentSyncStatus>({ status: "idle" }),
261
+ flush: vi.fn(),
262
+ retry: vi.fn(),
263
+ hold: () => () => undefined,
264
+ start: vi.fn(),
265
+ };
266
+ const actions = createNotebookActions((action) => {
267
+ state = notebookReducer(state, action);
268
+ });
269
+
270
+ beforeEach(() => {
271
+ calls = [];
272
+ state = notebookOf([cell(a), cell(b), cell(c)]);
273
+ store.set(activeDocumentModelAtom, model);
274
+ });
275
+
276
+ afterEach(() => {
277
+ store.set(activeDocumentModelAtom, null);
278
+ });
279
+
280
+ it("creates a cell at the end without setting the order", () => {
281
+ actions.createNewCell({
282
+ cellId: c,
283
+ before: false,
284
+ code: "d = 1",
285
+ newCellId: d,
286
+ });
287
+
288
+ expect(calls.map((call) => call[0])).toEqual(["createCell"]);
289
+ expect(calls[0][1]).toMatchObject({ id: d, code: "d = 1" });
290
+ });
291
+
292
+ it("creates a cell elsewhere and sets the order", () => {
293
+ actions.createNewCell({ cellId: a, before: true, newCellId: d });
294
+
295
+ expect(calls.map((call) => call[0])).toEqual(["createCell", "setOrder"]);
296
+ expect(calls[1][1]).toEqual([d, a, b, c]);
297
+ });
298
+
299
+ it("deletes a cell", () => {
300
+ actions.deleteCell({ cellId: b });
301
+
302
+ expect(calls).toEqual([["deleteCell", b]]);
303
+ });
304
+
305
+ it("writes code, name, and config", () => {
306
+ actions.updateCellCode({
307
+ cellId: a,
308
+ code: "a = 2",
309
+ formattingChange: false,
310
+ });
311
+ actions.updateCellName({ cellId: a, name: "first" });
312
+ actions.updateCellConfig({ cellId: a, config: { hide_code: true } });
313
+
314
+ expect(calls).toEqual([
315
+ ["setCode", a, "a = 2"],
316
+ ["setName", a, "first"],
317
+ ["updateConfig", a, { hide_code: true }],
318
+ ]);
319
+ });
320
+
321
+ it("sets the order when a cell moves", () => {
322
+ actions.moveCell({ cellId: c, before: true });
323
+
324
+ expect(calls).toEqual([["setOrder", [a, c, b]]]);
325
+ });
326
+
327
+ it("sets the column of a cell that changes column", () => {
328
+ state = notebookOf([
329
+ cell(a),
330
+ cell(b),
331
+ cell(c, { config: { ...cell(c).config, column: 1 } }),
332
+ ]);
333
+
334
+ actions.moveCell({ cellId: b, direction: "right" });
335
+
336
+ expect(state.cellIds.atOrThrow(1).inOrderIds).toEqual([b, c]);
337
+ expect(calls).toEqual([["updateConfig", b, { column: 1 }]]);
338
+ });
339
+
340
+ it("clears columns merged into one", () => {
341
+ state = notebookOf([
342
+ cell(a),
343
+ cell(b),
344
+ cell(c, { config: { ...cell(c).config, column: 1 } }),
345
+ ]);
346
+
347
+ actions.mergeAllColumns();
348
+
349
+ expect(calls).toEqual([["updateConfig", c, { column: null }]]);
350
+ });
351
+
352
+ it("ignores the kernel's cells and runtime state", () => {
353
+ actions.prepareForRun({ cellId: a });
354
+ actions.setCells([state.cellData[a]]);
355
+
356
+ expect(calls).toEqual([]);
357
+ });
358
+
359
+ it("leaves the scratchpad alone", () => {
360
+ actions.updateCellCode({
361
+ cellId: SCRATCH_CELL_ID,
362
+ code: "scratch",
363
+ formattingChange: false,
364
+ });
365
+
366
+ expect(calls).toEqual([]);
367
+ });
368
+ });
369
+
370
+ describe("flushing before host commands", () => {
371
+ const model = {
372
+ syncStatusAtom: atom<DocumentSyncStatus>({ status: "idle" }),
373
+ flush: vi.fn<() => Promise<never>>(),
374
+ retry: vi.fn<() => Promise<never>>(),
375
+ hold: () => () => undefined,
376
+ } as unknown as DocumentModel;
377
+
378
+ beforeEach(() => {
379
+ vi.mocked(model.flush).mockReset();
380
+ vi.mocked(model.retry).mockReset();
381
+ store.set(activeDocumentModelAtom, model);
382
+ });
383
+
384
+ afterEach(() => {
385
+ store.set(activeDocumentModelAtom, null);
386
+ });
387
+
388
+ it("flushes the session's model, or nothing without one", async () => {
389
+ vi.mocked(model.flush).mockResolvedValue(undefined as never);
390
+ await flushDocument();
391
+ expect(model.flush).toHaveBeenCalledOnce();
392
+
393
+ store.set(activeDocumentModelAtom, null);
394
+ await expect(flushDocument()).resolves.toBeUndefined();
395
+ });
396
+
397
+ it("saves after a flush and resumes the model when the flush failed", async () => {
398
+ vi.mocked(model.flush).mockRejectedValue(new Error("conflict"));
399
+ vi.mocked(model.retry).mockResolvedValue(undefined as never);
400
+ const save = vi.fn().mockResolvedValue("saved");
401
+
402
+ await expect(withDocumentSave(save)).resolves.toBe("saved");
403
+ expect(save).toHaveBeenCalledOnce();
404
+ expect(model.retry).toHaveBeenCalledOnce();
405
+
406
+ vi.mocked(model.flush).mockResolvedValue(undefined as never);
407
+ await withDocumentSave(save);
408
+ expect(model.retry).toHaveBeenCalledOnce();
409
+ });
410
+ });
@@ -0,0 +1,40 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { atom } from "jotai";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createDocumentModel } from "@/core/notebook/document";
6
+ import {
7
+ type FakeEdit,
8
+ fakeBackend,
9
+ } from "@/core/notebook/replica/__tests__/fixtures";
10
+ import type { Document } from "@/core/notebook/replica/resources/document";
11
+ import { activeDocumentModelAtom } from "@/core/session/active-notebook";
12
+ import { store } from "@/core/state/jotai";
13
+ import { flushDocument } from "../document";
14
+
15
+ const lifetimes: AbortController[] = [];
16
+
17
+ afterEach(() => {
18
+ lifetimes.splice(0).forEach((controller) => controller.abort());
19
+ store.set(activeDocumentModelAtom, null);
20
+ });
21
+
22
+ describe("flushDocument", () => {
23
+ it("sends nothing while the document is unavailable", async () => {
24
+ const edit = vi.fn<FakeEdit>();
25
+ const model = createDocumentModel({
26
+ store,
27
+ documentAtom: atom<Document | null>(null),
28
+ backendAtom: atom(fakeBackend({ edit })),
29
+ });
30
+ const controller = new AbortController();
31
+ lifetimes.push(controller);
32
+ model.start({ signal: controller.signal });
33
+ store.set(activeDocumentModelAtom, model);
34
+
35
+ expect(store.get(model.syncStatusAtom)).toEqual({ status: "unavailable" });
36
+ await expect(model.flush()).rejects.toThrow("unavailable");
37
+ await expect(flushDocument()).resolves.toBeUndefined();
38
+ expect(edit).not.toHaveBeenCalled();
39
+ });
40
+ });
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { createStore } from "jotai";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { describe, expect, it, vi } from "vitest";
5
5
  import { MockNotebook } from "@/__mocks__/notebook";
6
6
  import { cellId } from "@/__tests__/branded";
7
7
  import {
@@ -10,7 +10,6 @@ import {
10
10
  notebookOutline,
11
11
  notebookReducer,
12
12
  } from "../cells";
13
- import { exportedForTesting } from "../document-changes";
14
13
  import type { Outline } from "../outline";
15
14
 
16
15
  const first = cellId("first");
@@ -40,10 +39,6 @@ function setup() {
40
39
  return { store, actions };
41
40
  }
42
41
 
43
- afterEach(() => {
44
- exportedForTesting.cancelPendingChanges();
45
- });
46
-
47
42
  describe("notebookOutline", () => {
48
43
  it("does not notify outline subscribers when cell code changes", () => {
49
44
  const { store, actions } = setup();
@@ -0,0 +1,67 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { atom } from "jotai";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createDocumentModel } from "@/core/notebook/document";
6
+ import { document } from "@/core/notebook/document/__tests__/fixtures";
7
+ import type { Document } from "@/core/notebook/replica/resources/document";
8
+ import {
9
+ type FakeEdit,
10
+ fakeBackend,
11
+ } from "@/core/notebook/replica/__tests__/fixtures";
12
+ import { activeDocumentModelAtom } from "@/core/session/active-notebook";
13
+ import { store } from "@/core/state/jotai";
14
+ import { Deferred } from "@/utils/Deferred";
15
+ import { withDocumentSave } from "../document";
16
+
17
+ const lifetimes: AbortController[] = [];
18
+
19
+ afterEach(() => {
20
+ lifetimes.splice(0).forEach((controller) => controller.abort());
21
+ store.set(activeDocumentModelAtom, null);
22
+ });
23
+
24
+ describe("withDocumentSave", () => {
25
+ it("sends an edit made during the save only after the save finishes", async () => {
26
+ const source = atom<Document | null>(document());
27
+ const base = document();
28
+ const accepted: Document = {
29
+ ...base,
30
+ revision: { ...base.revision, version: 2, etag: "document-2" },
31
+ };
32
+ // Answer the edit, then publish the accepted document as the host would.
33
+ const edit = vi.fn<FakeEdit>().mockImplementation(async () => {
34
+ queueMicrotask(() => store.set(source, accepted));
35
+ return {
36
+ ok: true,
37
+ status: 200,
38
+ data: { document: accepted.revision, cells: {} },
39
+ };
40
+ });
41
+ const model = createDocumentModel({
42
+ store,
43
+ documentAtom: source,
44
+ backendAtom: atom(fakeBackend({ edit })),
45
+ });
46
+ const controller = new AbortController();
47
+ lifetimes.push(controller);
48
+ model.start({ signal: controller.signal });
49
+ store.set(activeDocumentModelAtom, model);
50
+
51
+ const saving = new Deferred<void>();
52
+ let flushing: Promise<unknown> | undefined;
53
+ const saved = withDocumentSave(async () => {
54
+ store.set(model.editAtom, (tx) => tx.setCode("c1", "x = 2"));
55
+ flushing = model.flush();
56
+ await Promise.resolve();
57
+ expect(edit).not.toHaveBeenCalled();
58
+ await saving.promise;
59
+ return "saved";
60
+ });
61
+
62
+ saving.resolve();
63
+ await expect(saved).resolves.toBe("saved");
64
+ await flushing;
65
+ expect(edit).toHaveBeenCalledTimes(1);
66
+ });
67
+ });