@marimo-team/frontend 0.25.3-dev92 → 0.25.3-dev95

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/assets/{CellStatus-DwPjR2_8.js → CellStatus-CplYFNTy.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-D9UK8o9A.js → ConnectedDataExplorerComponent-Dft8R6l5.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-BhXbaVBa.js → ErrorBoundary-CdGuz4a_.js} +2 -2
  4. package/dist/assets/{JsonOutput-Dti6dc1X.js → JsonOutput-Csbov8Fx.js} +1 -1
  5. package/dist/assets/{MarimoErrorOutput-BKvntkfq.js → MarimoErrorOutput-czclLlOn.js} +1 -1
  6. package/dist/assets/{RenderHTML-DUI47UDk.js → RenderHTML-CNDedpS4.js} +1 -1
  7. package/dist/assets/{RunButton-CLhvSMZG.js → RunButton-CRfdOxVd.js} +1 -1
  8. package/dist/assets/{add-cell-with-ai-ev63rhN4.js → add-cell-with-ai-BAIX7r0P.js} +1 -1
  9. package/dist/assets/{add-connection-dialog-2Tgl7Mjn.js → add-connection-dialog-DrDuM-tI.js} +1 -1
  10. package/dist/assets/{agent-panel-D4lNacey.js → agent-panel-CoXq_rlA.js} +1 -1
  11. package/dist/assets/{ai-model-dropdown-BqjVBWQp.js → ai-model-dropdown-COvWn46z.js} +1 -1
  12. package/dist/assets/{app-config-button-BToMQZBN.js → app-config-button-CtxoYG4F.js} +1 -1
  13. package/dist/assets/{blob-DMgg2NZo.js → blob-DCjrjZKW.js} +1 -1
  14. package/dist/assets/{cache-panel-Byolv2ht.js → cache-panel-CbWZcBu0.js} +1 -1
  15. package/dist/assets/{cell-editor-DGRa0o5e.js → cell-editor-BnL0Sorl.js} +1 -1
  16. package/dist/assets/{cell-link-D6rI90gy.js → cell-link-BFbgKiRH.js} +1 -1
  17. package/dist/assets/{cells-Cetiartc.js → cells-yOs72eZm.js} +1 -1
  18. package/dist/assets/{chat-display-CgpJ6XM4.js → chat-display-DwRM41t_.js} +1 -1
  19. package/dist/assets/{chat-panel-Djs36Uy4.js → chat-panel-DeTEdjxF.js} +1 -1
  20. package/dist/assets/{chat-ui-B_JGdp0E.js → chat-ui-D6XucD07.js} +1 -1
  21. package/dist/assets/{code-visibility-ny5n3-GH.js → code-visibility-DYDagVy7.js} +1 -1
  22. package/dist/assets/{column-preview-BIpGfTJf.js → column-preview-DXM8SqGY.js} +1 -1
  23. package/dist/assets/{command-palette-9pxpuKHs.js → command-palette-CA6yfNLb.js} +1 -1
  24. package/dist/assets/{common-D0jeQZnr.js → common-B-ZYnO1x.js} +1 -1
  25. package/dist/assets/{components-CJRBUI2s.js → components-CBE8cOLK.js} +1 -1
  26. package/dist/assets/{components-DrU8dOzy.js → components-DCo7mAWO.js} +1 -1
  27. package/dist/assets/{components-DkQChRNr.js → components-vu2jLG08.js} +1 -1
  28. package/dist/assets/{config-BWKfkUWA.js → config-CD1IuZFJ.js} +1 -1
  29. package/dist/assets/{config-CP8Y8_R0.js → config-DMz7oN0r.js} +1 -1
  30. package/dist/assets/{config-DGX7JYor.js → config-DpY86_Yq.js} +1 -1
  31. package/dist/assets/{createReducer-HKXUbqyz.js → createReducer-CaI_Oa0v.js} +1 -1
  32. package/dist/assets/{datasource-BXFNO5td.js → datasource-BakEOWsf.js} +1 -1
  33. package/dist/assets/{dependency-graph-panel-BFVIEz0s.js → dependency-graph-panel-DCGB3_id.js} +1 -1
  34. package/dist/assets/{documentation-panel-CaUMsgkg.js → documentation-panel-CpGZ2Dfn.js} +1 -1
  35. package/dist/assets/{download-C-q7-OBT.js → download-CV1k4XQ4.js} +1 -1
  36. package/dist/assets/{edit-page-VhYLLklC.js → edit-page-By7n2M84.js} +4 -4
  37. package/dist/assets/{error-entries-p_IDc4iy.js → error-entries-JgrZYMbe.js} +1 -1
  38. package/dist/assets/{error-panel-B0BwSTRv.js → error-panel-DS7HWph-.js} +1 -1
  39. package/dist/assets/feedback-button-Dgs_HOOC.js +1 -0
  40. package/dist/assets/{feedback-button-D0HjY39R.js → feedback-button-eYsTCK-g.js} +1 -1
  41. package/dist/assets/{file-explorer-panel-BLIEpQzW.js → file-explorer-panel-BTLwwwD4.js} +1 -1
  42. package/dist/assets/{file-name-input-CZ_W4QXG.js → file-name-input-BaR4h2Ud.js} +1 -1
  43. package/dist/assets/{floating-outline-D7DFh_29.js → floating-outline-Drd73Quo.js} +1 -1
  44. package/dist/assets/{gallery-page-BLgoybvc.js → gallery-page-DXUD11Rm.js} +1 -1
  45. package/dist/assets/{glide-data-editor-DVpOmYXo.js → glide-data-editor-DqBBoQBl.js} +1 -1
  46. package/dist/assets/{home-page--OLVJPiE.js → home-page-pgFFsmga.js} +1 -1
  47. package/dist/assets/{hooks-D0Pzb8Q4.js → hooks-D25kP4Sr.js} +1 -1
  48. package/dist/assets/{index-DB50a7AR.js → index-DNG4qQ-E.js} +6 -6
  49. package/dist/assets/{input-BZdRe7pb.js → input-DmHMEZGh.js} +1 -1
  50. package/dist/assets/kiosk-mode-DFS6HopF.js +1 -0
  51. package/dist/assets/{layout-HYpXx9Kc.js → layout-DLHIGZTK.js} +3 -3
  52. package/dist/assets/links-DX2w97N9.js +1 -0
  53. package/dist/assets/{logs-panel-Cbx5BJ3w.js → logs-panel-D5AAD2s9.js} +1 -1
  54. package/dist/assets/{markdown-renderer-B1GLwS-C.js → markdown-renderer--CwVm4kJ.js} +1 -1
  55. package/dist/assets/{mermaid-B6mqMm5_.js → mermaid-BKXlWk4F.js} +1 -1
  56. package/dist/assets/{name-cell-input-CgINqxMv.js → name-cell-input-BFKZaNI5.js} +1 -1
  57. package/dist/assets/{outline-panel-DaCt5blP.js → outline-panel-qcwRnl0R.js} +1 -1
  58. package/dist/assets/{package-data-dv4FBHxR.js → package-data-CKPmBDtg.js} +1 -1
  59. package/dist/assets/{packages-panel-DRKwpeJ2.js → packages-panel-DnSryNcz.js} +1 -1
  60. package/dist/assets/{panel-accordion-state-C4fxJULv.js → panel-accordion-state-BwJpDMVt.js} +1 -1
  61. package/dist/assets/{panels-D_TmFEit.js → panels-Aqzwb3UR.js} +1 -1
  62. package/dist/assets/{process-output-D1KxMfN7.js → process-output-DuUfHYSr.js} +1 -1
  63. package/dist/assets/{readonly-code-display-DVn7_uLn.js → readonly-code-display-BY-9I_Yk.js} +1 -1
  64. package/dist/assets/{readonly-python-code-5F8u1711.js → readonly-python-code-DSyr-yE2.js} +1 -1
  65. package/dist/assets/{renderShortcut-H60uiHBp.js → renderShortcut-rUwGqKgC.js} +1 -1
  66. package/dist/assets/{requests-lt0JK8Jt.js → requests-BIFR-h4M.js} +1 -1
  67. package/dist/assets/requests-DuLJN9Tf.js +1 -0
  68. package/dist/assets/{reveal-component-B4rC_fg1.js → reveal-component-C_Lb7Mom.js} +1 -1
  69. package/dist/assets/{run-page-BHdHPiQA.js → run-page-ZTw-kCU-.js} +1 -1
  70. package/dist/assets/{runs-6lJxKV1w.js → runs-C49hleBy.js} +1 -1
  71. package/dist/assets/{runtime-details-state-BLDCeDBF.js → runtime-details-state-B7gpnuXx.js} +1 -1
  72. package/dist/assets/{scratchpad-panel-ChSdhkiu.js → scratchpad-panel-D2IHkKoG.js} +1 -1
  73. package/dist/assets/{secrets-panel-Dh3v7XPE.js → secrets-panel-bcfEKxRx.js} +1 -1
  74. package/dist/assets/{session-B8Y-GJnS.js → session-CMLIzZKw.js} +1 -1
  75. package/dist/assets/session-panel-cOSO3WCX.js +1 -0
  76. package/dist/assets/{snippets-panel-D7PPIwX1.js → snippets-panel-BNadUJLU.js} +1 -1
  77. package/dist/assets/{state-BY4_P6ko.js → state-3mcyTS9q.js} +1 -1
  78. package/dist/assets/{state-C0irhlOZ.js → state-B_MCKzBL.js} +1 -1
  79. package/dist/assets/{state-Bd_wAeZf.js → state-BaYudep3.js} +1 -1
  80. package/dist/assets/{state-DaWgt-rn.js → state-C5WAeuTu.js} +1 -1
  81. package/dist/assets/{state-Ck7FMD5A.js → state-Cv2JW3l9.js} +1 -1
  82. package/dist/assets/{state-pXUWLVXv.js → state-_1LTGJQM.js} +1 -1
  83. package/dist/assets/{switch-IoWE8PbW.js → switch-DYjBfpNJ.js} +1 -1
  84. package/dist/assets/{terminal-BwLC9HS5.js → terminal-Ca-kubKb.js} +1 -1
  85. package/dist/assets/{tracing-WN4XSiAm.js → tracing-DbTgS4u9.js} +1 -1
  86. package/dist/assets/{tracing-panel-BVC4DsH6.js → tracing-panel-CjlrpXPy.js} +2 -2
  87. package/dist/assets/{tree-actions-MaUvbTxA.js → tree-actions-C_KYlaSv.js} +1 -1
  88. package/dist/assets/{useAddCell-D7SKNQll.js → useAddCell-C0J8quGE.js} +1 -1
  89. package/dist/assets/{useAsyncData-DhhdOGGd.js → useAsyncData-qvoPYHtx.js} +1 -1
  90. package/dist/assets/{useBoolean-WABMLM83.js → useBoolean-Dd2nWXFJ.js} +1 -1
  91. package/dist/assets/{useCellActionButton-Byz7LlUo.js → useCellActionButton-CbkOj6BE.js} +1 -1
  92. package/dist/assets/{useDebounce-k9QTpQ9-.js → useDebounce-DwTQ_1Pu.js} +1 -1
  93. package/dist/assets/{useDeepCompareMemoize-DFIVN9dn.js → useDeepCompareMemoize-B2DVK2De.js} +1 -1
  94. package/dist/assets/{useDeleteCell-D-k2fD1X.js → useDeleteCell-DHkF5wgS.js} +1 -1
  95. package/dist/assets/{useDependencyPanelTab-neRJ4T16.js → useDependencyPanelTab--yXJW6X5.js} +1 -1
  96. package/dist/assets/{useEvent-Dfcbgjhg.js → useEvent-BDjgaBtz.js} +1 -1
  97. package/dist/assets/{useInstallPackage-CWcPxSLP.js → useInstallPackage-wcDq9hYT.js} +1 -1
  98. package/dist/assets/{useNotebookActions-Df_jxEKJ.js → useNotebookActions-dJQalORg.js} +1 -1
  99. package/dist/assets/{useRunCells-AZJD5ITo.js → useRunCells-C_IwVJR_.js} +1 -1
  100. package/dist/assets/{useSplitCell-CgiU_w77.js → useSplitCell-Ci4MEUj8.js} +1 -1
  101. package/dist/assets/{useTheme-DLtPc-39.js → useTheme-BAA27lh7.js} +1 -1
  102. package/dist/assets/{vega-component-CY6HmM06.js → vega-component-BeyxAJad.js} +1 -1
  103. package/dist/assets/{write-secret-modal-7DzehGyr.js → write-secret-modal-BEx-EUlh.js} +1 -1
  104. package/dist/index.html +40 -40
  105. package/package.json +1 -1
  106. package/src/components/datasources/__tests__/table-list.test.tsx +118 -0
  107. package/src/components/datasources/datasources.tsx +35 -21
  108. package/src/components/pages/__tests__/home-page.test.tsx +116 -0
  109. package/src/components/pages/home-page.tsx +24 -3
  110. package/src/core/document/__tests__/draft.test.ts +322 -0
  111. package/src/core/document/__tests__/fixtures.ts +92 -0
  112. package/src/core/document/__tests__/model.test.ts +434 -0
  113. package/src/core/document/__tests__/submission.test.ts +334 -0
  114. package/src/core/document/draft.ts +472 -0
  115. package/src/core/document/model.ts +395 -0
  116. package/src/core/state/__tests__/jotai.test.tsx +108 -0
  117. package/src/core/state/jotai.ts +1 -1
  118. package/dist/assets/feedback-button-BFoleTOt.js +0 -1
  119. package/dist/assets/kiosk-mode-BGmMSc7A.js +0 -1
  120. package/dist/assets/links-CXcpNIM0.js +0 -1
  121. package/dist/assets/requests-CgSY1X4a.js +0 -1
  122. package/dist/assets/session-panel-CtQgwbFW.js +0 -1
  123. package/src/core/state/__tests__/jotai.test.ts +0 -25
@@ -0,0 +1,434 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { atom, createStore } from "jotai/vanilla";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { Document } from "../../host/resources/document";
6
+ import {
7
+ createDocumentModel,
8
+ type DocumentEdit,
9
+ type DocumentEditor,
10
+ } from "../model";
11
+ import { cell, document, withCells } from "./fixtures";
12
+
13
+ function setup(initial: Document | null = document()) {
14
+ const store = createStore();
15
+ const source = atom(initial);
16
+ const model = createDocumentModel(store, source);
17
+ return {
18
+ store,
19
+ source,
20
+ ...model,
21
+ edit: (callback: DocumentEdit) => store.set(model.editAtom, callback),
22
+ };
23
+ }
24
+
25
+ describe("document model", () => {
26
+ it("distinguishes unavailable changes from an unchanged document", () => {
27
+ const { store, source, changesAtom } = setup(null);
28
+ expect(store.get(changesAtom)).toBeNull();
29
+ store.set(source, document());
30
+ expect(store.get(changesAtom)).toEqual([]);
31
+ store.set(source, null);
32
+ expect(store.get(changesAtom)).toBeNull();
33
+ });
34
+
35
+ it("keeps metadata drafts while source is unavailable and follows its return", () => {
36
+ const initial = document();
37
+ const { store, source, cellAtom, changesAtom, edit } = setup(initial);
38
+ edit((tx) => tx.setName("c1", "local name"));
39
+ const hidden = withCells(initial, {
40
+ c1: cell("c1", "", { code: null }),
41
+ });
42
+ store.set(source, hidden);
43
+ expect(store.get(cellAtom("c1"))).toEqual({
44
+ id: "c1",
45
+ code: null,
46
+ name: "local name",
47
+ config: cell("c1", "").config,
48
+ });
49
+ expect(store.get(changesAtom)).toBeNull();
50
+
51
+ store.set(
52
+ source,
53
+ withCells(hidden, {
54
+ c1: cell("c1", "updated source", { etag: "latest" }),
55
+ }),
56
+ );
57
+ expect(store.get(changesAtom)).toEqual([
58
+ {
59
+ kind: "put_cell",
60
+ base_etag: "latest",
61
+ cell: {
62
+ id: "c1",
63
+ code: "updated source",
64
+ name: "local name",
65
+ config: cell("c1", "").config,
66
+ },
67
+ },
68
+ ]);
69
+ });
70
+
71
+ it("rebases a local order onto the latest host order and its precondition", () => {
72
+ const { store, source, orderAtom, changesAtom, edit } = setup();
73
+ edit((tx) => tx.setOrder(["c2", "c1"]));
74
+ store.set(
75
+ source,
76
+ withCells(document(), { c3: cell("c3", "new") }, ["c1", "c3", "c2"]),
77
+ );
78
+ expect(store.get(orderAtom)).toEqual(["c2", "c1", "c3"]);
79
+ expect(store.get(changesAtom)).toEqual([
80
+ {
81
+ kind: "set_order",
82
+ base_etag: 'order:["c1","c3","c2"]',
83
+ order: ["c2", "c1", "c3"],
84
+ },
85
+ ]);
86
+ });
87
+
88
+ it.each(["before", "after"] as const)(
89
+ "appends a creation when its %s anchor was removed remotely",
90
+ (direction) => {
91
+ const { store, source, orderAtom, changesAtom, edit } = setup();
92
+ edit((tx) =>
93
+ tx.createCell({ id: "c3", code: "" }, { [direction]: "c1" }),
94
+ );
95
+ store.set(source, withCells(document(), { c1: null }));
96
+ expect(store.get(orderAtom)).toEqual(["c2", "c3"]);
97
+ expect(store.get(changesAtom)).toEqual([
98
+ {
99
+ kind: "put_cell",
100
+ base_etag: null,
101
+ cell: {
102
+ id: "c3",
103
+ code: "",
104
+ name: "_",
105
+ config: cell("c3", "").config,
106
+ },
107
+ },
108
+ ]);
109
+ },
110
+ );
111
+
112
+ it("publishes the visible order when an anchor is deleted later in the draft", () => {
113
+ const { store, orderAtom, changesAtom, edit } = setup();
114
+ edit((tx) => {
115
+ tx.setCode("c1", "edited before creation");
116
+ tx.createCell({ id: "c3", code: "" }, { after: "c1" });
117
+ tx.deleteCell("c1");
118
+ });
119
+ expect(store.get(orderAtom)).toEqual(["c3", "c2"]);
120
+ expect(store.get(changesAtom)).toEqual([
121
+ { kind: "delete_cell", id: "c1", base_etag: "c1-etag" },
122
+ {
123
+ kind: "put_cell",
124
+ base_etag: null,
125
+ cell: { id: "c3", code: "", name: "_", config: cell("c3", "").config },
126
+ },
127
+ { kind: "set_order", base_etag: "order-1", order: ["c3", "c2"] },
128
+ ]);
129
+ });
130
+
131
+ it("does not release a draft because the host caught up", () => {
132
+ const initial = document();
133
+ const { store, source, cellAtom, edit } = setup(initial);
134
+ edit((tx) => tx.setCode("c1", "local"));
135
+ for (const code of ["local", "later remote edit"]) {
136
+ store.set(source, withCells(initial, { c1: cell("c1", code) }));
137
+ expect(store.get(cellAtom("c1"))?.code).toBe("local");
138
+ }
139
+ });
140
+
141
+ it("keeps drafts separate when notebooks reuse cell ids", () => {
142
+ const { store, source, cellAtom, edit } = setup();
143
+ edit((tx) => tx.setCode("c1", "first notebook"));
144
+ store.set(source, document("n2"));
145
+ expect(store.get(cellAtom("c1"))?.code).toBe("x = 1");
146
+ edit((tx) => tx.setCode("c1", "second notebook"));
147
+ store.set(source, document("n1"));
148
+ expect(store.get(cellAtom("c1"))?.code).toBe("first notebook");
149
+ store.set(source, document("n2"));
150
+ expect(store.get(cellAtom("c1"))?.code).toBe("second notebook");
151
+ });
152
+
153
+ it("does not repeat a satisfied deletion without mounted readers", () => {
154
+ const { store, source, cellAtom, changesAtom, edit } = setup();
155
+ edit((tx) => {
156
+ tx.deleteCell("c1");
157
+ tx.setCode("c2", "keep this draft");
158
+ });
159
+ const removed = withCells(document(), { c1: null });
160
+ store.set(source, removed);
161
+ // No reads between snapshots: reconciliation must observe the removal.
162
+ store.set(
163
+ source,
164
+ withCells(removed, { c1: cell("c1", "restored") }, ["c1", "c2"]),
165
+ );
166
+ expect(store.get(cellAtom("c1"))?.code).toBe("restored");
167
+ expect(store.get(cellAtom("c2"))?.code).toBe("keep this draft");
168
+ expect(store.get(changesAtom)).toEqual([
169
+ {
170
+ kind: "put_cell",
171
+ base_etag: "c2-etag",
172
+ cell: {
173
+ id: "c2",
174
+ code: "keep this draft",
175
+ name: "_",
176
+ config: cell("c2", "").config,
177
+ },
178
+ },
179
+ ]);
180
+ });
181
+
182
+ it("retains edits when the host removes a cell and blocks partial submissions", () => {
183
+ const { store, source, cellAtom, changesAtom, edit } = setup();
184
+ edit((tx) => {
185
+ tx.setCode("c1", "recover this code");
186
+ tx.setCode("c2", "another edit");
187
+ });
188
+ const removed = withCells(document(), { c1: null });
189
+ store.set(source, removed);
190
+ expect(store.get(changesAtom)).toBeNull();
191
+ store.set(source, withCells(removed, { c1: cell("c1", "restored") }));
192
+ expect(store.get(cellAtom("c1"))?.code).toBe("recover this code");
193
+ expect(store.get(changesAtom)).toHaveLength(2);
194
+ });
195
+
196
+ it("composes creation, field edits, deletion and order in one publication", () => {
197
+ const { store, cellAtom, orderAtom, edit } = setup();
198
+ const onOrder = vi.fn(() => {
199
+ expect(store.get(cellAtom("c3"))).toEqual({
200
+ id: "c3",
201
+ code: "updated",
202
+ name: "example",
203
+ config: { ...cell("c3", "").config, hide_code: true },
204
+ });
205
+ expect(store.get(cellAtom("c1"))).toBeUndefined();
206
+ });
207
+ const unsubscribeOrder = store.sub(orderAtom, onOrder);
208
+ edit((tx) => {
209
+ tx.createCell({ id: "c3", code: "3" }, { after: "c2" });
210
+ tx.setCode("c3", "updated");
211
+ tx.setName("c3", "example");
212
+ tx.updateConfig("c3", { hide_code: true });
213
+ tx.deleteCell("c1");
214
+ tx.setOrder(["c3", "c2"]);
215
+ expect(store.get(orderAtom)).toEqual(["c1", "c2"]);
216
+ expect(store.get(cellAtom("c3"))).toBeUndefined();
217
+ });
218
+ expect(store.get(orderAtom)).toEqual(["c3", "c2"]);
219
+ expect(onOrder).toHaveBeenCalledOnce();
220
+ unsubscribeOrder();
221
+ });
222
+
223
+ it("does not notify subscribers for edits that change nothing", () => {
224
+ const { store, cellAtom, orderAtom, edit } = setup();
225
+ const onCell = vi.fn();
226
+ const onOrder = vi.fn();
227
+ const unsubscribeCell = store.sub(cellAtom("c1"), onCell);
228
+ const unsubscribeOrder = store.sub(orderAtom, onOrder);
229
+ edit((tx) => {
230
+ tx.setCode("c1", "x = 1");
231
+ tx.updateConfig("c1", { column: null });
232
+ });
233
+ expect(onCell).not.toHaveBeenCalled();
234
+ edit((tx) => tx.setCode("c1", "local"));
235
+ edit((tx) => tx.setCode("c1", "local"));
236
+ edit((tx) => tx.setOrder(["c1", "c2"]));
237
+ expect(onCell).toHaveBeenCalledOnce();
238
+ expect(onOrder).not.toHaveBeenCalled();
239
+ unsubscribeCell();
240
+ unsubscribeOrder();
241
+ });
242
+
243
+ it("isolates cell, order and configuration subscriptions across local and host updates", () => {
244
+ const initial = document();
245
+ const { store, source, cellAtom, orderAtom, appConfigAtom, edit } =
246
+ setup(initial);
247
+ edit((tx) => {
248
+ tx.setCode("c1", "local");
249
+ tx.createCell({ id: "c3", code: "new" });
250
+ });
251
+ const c1 = cellAtom("c1");
252
+ const first = store.get(c1);
253
+ const order = store.get(orderAtom);
254
+ const config = store.get(appConfigAtom);
255
+ const onFirst = vi.fn();
256
+ const onSecond = vi.fn();
257
+ const onOrder = vi.fn();
258
+ const onConfig = vi.fn();
259
+ const unsubscribe = [
260
+ store.sub(c1, onFirst),
261
+ store.sub(cellAtom("c2"), onSecond),
262
+ store.sub(orderAtom, onOrder),
263
+ store.sub(appConfigAtom, onConfig),
264
+ ];
265
+
266
+ edit((tx) => tx.setCode("c2", "other local"));
267
+ const remote = withCells(initial, {
268
+ c1: cell("c1", "remote", { version: 2, etag: "new-etag" }),
269
+ });
270
+ store.set(source, {
271
+ ...remote,
272
+ revision: {
273
+ ...remote.revision,
274
+ order: [...initial.revision.order],
275
+ app_config: { ...initial.revision.app_config },
276
+ },
277
+ });
278
+ expect(store.get(c1)).toBe(first);
279
+ expect(store.get(orderAtom)).toBe(order);
280
+ expect(store.get(appConfigAtom)).toBe(config);
281
+ expect(onFirst).not.toHaveBeenCalled();
282
+ expect(onSecond).toHaveBeenCalledOnce();
283
+ expect(onOrder).not.toHaveBeenCalled();
284
+ expect(onConfig).not.toHaveBeenCalled();
285
+
286
+ store.set(
287
+ source,
288
+ withCells(remote, {
289
+ c1: cell("c1", "remote", { name: "renamed" }),
290
+ }),
291
+ );
292
+ expect(store.get(cellAtom("c1"))).toEqual({
293
+ id: "c1",
294
+ code: "local",
295
+ name: "renamed",
296
+ config: cell("c1", "").config,
297
+ });
298
+ expect(onFirst).toHaveBeenCalledOnce();
299
+ edit((tx) => tx.setOrder(["c3", "c2", "c1"]));
300
+ expect(onOrder).toHaveBeenCalledOnce();
301
+ expect(onFirst).toHaveBeenCalledOnce();
302
+ store.set(source, {
303
+ ...remote,
304
+ revision: { ...remote.revision, app_config: { width: "full" } },
305
+ });
306
+ expect(onConfig).toHaveBeenCalledOnce();
307
+ unsubscribe.forEach((fn) => fn());
308
+ });
309
+
310
+ it.each([
311
+ [
312
+ "a missing placement anchor",
313
+ (tx: DocumentEditor) =>
314
+ tx.createCell({ id: "c3", code: "" }, { after: "missing" }),
315
+ "Cell missing does not exist",
316
+ ],
317
+ [
318
+ "a deleted placement anchor",
319
+ (tx: DocumentEditor) => {
320
+ tx.deleteCell("c2");
321
+ tx.createCell({ id: "c3", code: "" }, { before: "c2" });
322
+ },
323
+ "Cell c2 does not exist",
324
+ ],
325
+ [
326
+ "an exception",
327
+ () => {
328
+ throw new Error("failed");
329
+ },
330
+ "failed",
331
+ ],
332
+ [
333
+ "a missing cell",
334
+ (tx: DocumentEditor) => tx.setCode("missing", "code"),
335
+ "Cell missing does not exist",
336
+ ],
337
+ [
338
+ "a deleted cell",
339
+ (tx: DocumentEditor) => {
340
+ tx.deleteCell("c2");
341
+ tx.setName("c2", "missing");
342
+ },
343
+ "Cell c2 does not exist",
344
+ ],
345
+ [
346
+ "a duplicate cell",
347
+ (tx: DocumentEditor) => tx.createCell({ id: "c1", code: "duplicate" }),
348
+ "Cell c1 already exists",
349
+ ],
350
+ [
351
+ "an incomplete order",
352
+ (tx: DocumentEditor) => tx.setOrder(["c1"]),
353
+ "Cell order must contain every cell exactly once",
354
+ ],
355
+ [
356
+ "a duplicate in the order",
357
+ (tx: DocumentEditor) => tx.setOrder(["c1", "c1"]),
358
+ "Cell order must contain every cell exactly once",
359
+ ],
360
+ [
361
+ "an unknown cell in the order",
362
+ (tx: DocumentEditor) => tx.setOrder(["c1", "missing"]),
363
+ "Cell order must contain every cell exactly once",
364
+ ],
365
+ [
366
+ "an invalid config",
367
+ (tx: DocumentEditor) => tx.updateConfig("c2", { column: 0.5 }),
368
+ "Invalid input",
369
+ ],
370
+ ])("rolls back the whole transaction for %s", (_, fail, message) => {
371
+ const { store, changesAtom, cellAtom, edit } = setup();
372
+ const onChange = vi.fn();
373
+ const unsubscribe = store.sub(cellAtom("c1"), onChange);
374
+ expect(() =>
375
+ edit((tx) => {
376
+ tx.setCode("c1", "not committed");
377
+ fail(tx);
378
+ }),
379
+ ).toThrow(message);
380
+ expect(store.get(changesAtom)).toEqual([]);
381
+ expect(onChange).not.toHaveBeenCalled();
382
+ edit((tx) => tx.setCode("c1", "next transaction"));
383
+ expect(store.get(cellAtom("c1"))?.code).toBe("next transaction");
384
+ unsubscribe();
385
+ });
386
+
387
+ it("expires the editor after a transaction", () => {
388
+ const { store, cellAtom, edit } = setup();
389
+ let saved: DocumentEditor | undefined;
390
+ edit((tx) => {
391
+ saved = tx;
392
+ tx.setCode("c1", "committed");
393
+ });
394
+ expect(() => saved?.setCode("c1", "too late")).toThrow("has ended");
395
+ expect(store.get(cellAtom("c1"))?.code).toBe("committed");
396
+ });
397
+
398
+ it("rejects asynchronous transactions without publishing before or after await", async () => {
399
+ const { store, changesAtom, edit } = setup();
400
+ expect(() =>
401
+ edit(async (tx) => {
402
+ tx.setCode("c1", "before await");
403
+ await Promise.resolve();
404
+ tx.setCode("c1", "after await");
405
+ }),
406
+ ).toThrow("must be synchronous");
407
+ await Promise.resolve();
408
+ expect(store.get(changesAtom)).toEqual([]);
409
+ });
410
+
411
+ it("rejects nested transactions without overwriting drafts", () => {
412
+ const { store, changesAtom, edit } = setup();
413
+ expect(() =>
414
+ edit((tx) => {
415
+ tx.setCode("c1", "outer");
416
+ edit((inner) => inner.setCode("c2", "inner"));
417
+ }),
418
+ ).toThrow("cannot be nested");
419
+ expect(store.get(changesAtom)).toEqual([]);
420
+ });
421
+
422
+ it("does not commit against a source replaced during the callback", () => {
423
+ const { store, source, changesAtom, edit } = setup();
424
+ expect(() =>
425
+ edit((tx) => {
426
+ tx.setCode("c1", "old notebook");
427
+ store.set(source, document("n2"));
428
+ }),
429
+ ).toThrow("changed during the transaction");
430
+ expect(store.get(changesAtom)).toEqual([]);
431
+ store.set(source, document());
432
+ expect(store.get(changesAtom)).toEqual([]);
433
+ });
434
+ });