@marimo-team/islands 0.21.2-dev57 → 0.21.2-dev59

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.
@@ -0,0 +1,376 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /**
4
+ * Round-trip tests: perform actions on a primary notebook (producing changes
5
+ * via the middleware), then apply those changes to a replica notebook via
6
+ * applyTransactionChanges. The two should converge to identical document state.
7
+ *
8
+ * This catches drift between what the middleware emits and what
9
+ * apply-transaction consumes.
10
+ */
11
+
12
+ import { python } from "@codemirror/lang-python";
13
+ import { EditorState } from "@codemirror/state";
14
+ import { EditorView } from "@codemirror/view";
15
+ import {
16
+ afterAll,
17
+ afterEach,
18
+ beforeAll,
19
+ beforeEach,
20
+ describe,
21
+ expect,
22
+ it,
23
+ } from "vitest";
24
+ import { cellId } from "@/__tests__/branded";
25
+ import type { CellHandle } from "@/components/editor/notebook-cell";
26
+ import { adaptiveLanguageConfiguration } from "@/core/codemirror/language/extension";
27
+ import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
28
+ import { MultiColumn } from "@/utils/id-tree";
29
+ import { exportedForTesting, type NotebookState } from "../cells";
30
+ import {
31
+ applyTransactionChanges,
32
+ exportedForTesting as middlewareExports,
33
+ } from "../document-changes";
34
+ import { CellId } from "../ids";
35
+
36
+ const { initialNotebookState, reducer, createActions } = exportedForTesting;
37
+ const { drainChanges } = middlewareExports;
38
+
39
+ function createEditor(code: string) {
40
+ const state = EditorState.create({
41
+ doc: code,
42
+ extensions: [
43
+ python(),
44
+ adaptiveLanguageConfiguration({
45
+ cellId: cellId("cell1"),
46
+ completionConfig: {
47
+ activate_on_typing: true,
48
+ signature_hint_on_typing: false,
49
+ copilot: false,
50
+ codeium_api_key: null,
51
+ },
52
+ hotkeys: new OverridingHotkeyProvider({}),
53
+ placeholderType: "marimo-import",
54
+ lspConfig: {},
55
+ }),
56
+ ],
57
+ });
58
+ return new EditorView({ state, parent: document.body });
59
+ }
60
+
61
+ // --- Primary notebook: performs actions, middleware produces changes ---
62
+
63
+ let primary: NotebookState;
64
+
65
+ const primaryActions = createActions((action) => {
66
+ primary = reducer(primary, action);
67
+ for (const [cellIdString, handle] of Object.entries(primary.cellHandles)) {
68
+ // @ts-expect-error - Object.entries doesn't know keys are CellId
69
+ const cid: CellId = cellIdString;
70
+ if (!handle.current) {
71
+ const view = createEditor(primary.cellData[cid].code);
72
+ const h: CellHandle = { editorView: view, editorViewOrNull: view };
73
+ primary.cellHandles[cid] = { current: h };
74
+ }
75
+ }
76
+ });
77
+
78
+ // --- Replica notebook: receives changes via applyTransactionChanges ---
79
+
80
+ let replica: NotebookState;
81
+
82
+ const replicaActions = createActions((action) => {
83
+ replica = reducer(replica, action);
84
+ for (const [cellIdString, handle] of Object.entries(replica.cellHandles)) {
85
+ // @ts-expect-error - Object.entries doesn't know keys are CellId
86
+ const cid: CellId = cellIdString;
87
+ if (!handle.current) {
88
+ const view = createEditor(replica.cellData[cid].code);
89
+ const h: CellHandle = { editorView: view, editorViewOrNull: view };
90
+ replica.cellHandles[cid] = { current: h };
91
+ }
92
+ }
93
+ });
94
+
95
+ let i = 0;
96
+ const originalCreate = CellId.create.bind(CellId);
97
+
98
+ beforeAll(() => {
99
+ CellId.create = () => cellId(`${i++}`);
100
+ });
101
+
102
+ beforeEach(() => {
103
+ i = 0;
104
+ primary = initialNotebookState();
105
+ primary.cellIds = MultiColumn.from([]);
106
+ drainChanges();
107
+ });
108
+
109
+ afterEach(() => {
110
+ middlewareExports.cancelPendingChanges();
111
+ });
112
+
113
+ afterAll(() => {
114
+ CellId.create = originalCreate;
115
+ });
116
+
117
+ /** Set up both notebooks with the same initial cells. */
118
+ function setup(...codes: string[]) {
119
+ for (const code of codes) {
120
+ primaryActions.createNewCell({
121
+ cellId: "__end__",
122
+ before: false,
123
+ code,
124
+ newCellId: CellId.create(),
125
+ });
126
+ }
127
+
128
+ // Apply the setup changes to the replica so both start identical
129
+ const setupChanges = drainChanges();
130
+ replica = initialNotebookState();
131
+ replica.cellIds = MultiColumn.from([]);
132
+ applyTransactionChanges(
133
+ setupChanges,
134
+ replicaActions,
135
+ () => replica.cellIds.inOrderIds,
136
+ );
137
+ // Drain any changes the replica's middleware produced
138
+ drainChanges();
139
+ }
140
+
141
+ /**
142
+ * Drain changes from the primary's middleware and apply them to the replica.
143
+ */
144
+ function sync() {
145
+ const changes = drainChanges();
146
+ applyTransactionChanges(
147
+ changes,
148
+ replicaActions,
149
+ () => replica.cellIds.inOrderIds,
150
+ );
151
+ // Drain any changes the replica's middleware produced (we don't want those)
152
+ drainChanges();
153
+ }
154
+
155
+ /**
156
+ * Extract the document-relevant state: cell ordering, code, name, config.
157
+ * This is the "NotebookDocument" equivalent — what the Python side tracks.
158
+ *
159
+ * TODO(column-config): config.column is excluded because the column
160
+ * reducers (addColumnBreakpoint, dropOverNewColumn, moveColumn, etc.)
161
+ * update cellIds (MultiColumn structure) but don't sync config.column
162
+ * on affected cells. The middleware correctly emits set-config changes with
163
+ * the new column index, but the primary's config.column stays stale,
164
+ * causing a mismatch with the replica. Fix: have the column reducers
165
+ * update config.column as part of their state transition, then remove
166
+ * the { column: _, ...config } exclusion here.
167
+ */
168
+ function documentSnapshot(state: NotebookState) {
169
+ return state.cellIds.inOrderIds.map((id) => {
170
+ const { column: _, ...config } = state.cellData[id].config;
171
+ return {
172
+ id,
173
+ code: state.cellData[id].code,
174
+ name: state.cellData[id].name,
175
+ config,
176
+ };
177
+ });
178
+ }
179
+
180
+ describe("document round-trip", () => {
181
+ it("initial setup converges", () => {
182
+ setup("a", "b", "c");
183
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
184
+ });
185
+
186
+ it("createNewCell at end", () => {
187
+ setup("a", "b");
188
+ primaryActions.createNewCell({
189
+ cellId: "__end__",
190
+ before: false,
191
+ code: "c",
192
+ newCellId: CellId.create(),
193
+ });
194
+ sync();
195
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
196
+ });
197
+
198
+ it("createNewCell before first cell", () => {
199
+ setup("a", "b");
200
+ const [a] = primary.cellIds.inOrderIds;
201
+ primaryActions.createNewCell({
202
+ cellId: a,
203
+ before: true,
204
+ code: "before-a",
205
+ newCellId: CellId.create(),
206
+ });
207
+ sync();
208
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
209
+ });
210
+
211
+ it("createNewCell between cells", () => {
212
+ setup("a", "b", "c");
213
+ const [a] = primary.cellIds.inOrderIds;
214
+ primaryActions.createNewCell({
215
+ cellId: a,
216
+ before: false,
217
+ code: "between",
218
+ newCellId: CellId.create(),
219
+ });
220
+ sync();
221
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
222
+ });
223
+
224
+ it("createNewCell with hideCode config", () => {
225
+ setup("a");
226
+ primaryActions.createNewCell({
227
+ cellId: "__end__",
228
+ before: false,
229
+ code: "hidden",
230
+ newCellId: CellId.create(),
231
+ hideCode: true,
232
+ });
233
+ sync();
234
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
235
+ });
236
+
237
+ it("deleteCell", () => {
238
+ setup("a", "b", "c");
239
+ const [, b] = primary.cellIds.inOrderIds;
240
+ primaryActions.deleteCell({ cellId: b });
241
+ sync();
242
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
243
+ });
244
+
245
+ it("updateCellCode", () => {
246
+ setup("a", "b");
247
+ const [a] = primary.cellIds.inOrderIds;
248
+ primaryActions.updateCellCode({
249
+ cellId: a,
250
+ code: "updated",
251
+ formattingChange: false,
252
+ });
253
+ sync();
254
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
255
+ });
256
+
257
+ it("updateCellName", () => {
258
+ setup("a");
259
+ const [a] = primary.cellIds.inOrderIds;
260
+ primaryActions.updateCellName({ cellId: a, name: "my_var" });
261
+ sync();
262
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
263
+ });
264
+
265
+ it("updateCellConfig", () => {
266
+ setup("a");
267
+ const [a] = primary.cellIds.inOrderIds;
268
+ primaryActions.updateCellConfig({
269
+ cellId: a,
270
+ config: { hide_code: true, disabled: true },
271
+ });
272
+ sync();
273
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
274
+ });
275
+
276
+ it("moveCell down", () => {
277
+ setup("a", "b", "c");
278
+ const [a] = primary.cellIds.inOrderIds;
279
+ primaryActions.moveCell({ cellId: a, before: false });
280
+ sync();
281
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
282
+ });
283
+
284
+ it("sendToTop", () => {
285
+ setup("a", "b", "c");
286
+ const c = primary.cellIds.inOrderIds[2];
287
+ primaryActions.sendToTop({ cellId: c });
288
+ sync();
289
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
290
+ });
291
+
292
+ it("sendToBottom", () => {
293
+ setup("a", "b", "c");
294
+ const [a] = primary.cellIds.inOrderIds;
295
+ primaryActions.sendToBottom({ cellId: a });
296
+ sync();
297
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
298
+ });
299
+
300
+ it("dropCellOverCell", () => {
301
+ setup("a", "b", "c");
302
+ const [a, , c] = primary.cellIds.inOrderIds;
303
+ primaryActions.dropCellOverCell({ cellId: c, overCellId: a });
304
+ sync();
305
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
306
+ });
307
+
308
+ it("multiple operations in sequence", () => {
309
+ setup("a", "b", "c");
310
+
311
+ // Add a cell
312
+ const [a] = primary.cellIds.inOrderIds;
313
+ primaryActions.createNewCell({
314
+ cellId: a,
315
+ before: false,
316
+ code: "new",
317
+ newCellId: CellId.create(),
318
+ });
319
+ sync();
320
+
321
+ // Rename it
322
+ const newId = primary.cellIds.inOrderIds[1];
323
+ primaryActions.updateCellName({ cellId: newId, name: "inserted" });
324
+ sync();
325
+
326
+ // Move it to top
327
+ primaryActions.sendToTop({ cellId: newId });
328
+ sync();
329
+
330
+ // Update code on another cell
331
+ const last =
332
+ primary.cellIds.inOrderIds[primary.cellIds.inOrderIds.length - 1];
333
+ primaryActions.updateCellCode({
334
+ cellId: last,
335
+ code: "modified",
336
+ formattingChange: false,
337
+ });
338
+ sync();
339
+
340
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
341
+ });
342
+
343
+ it("create then delete", () => {
344
+ setup("a", "b");
345
+ primaryActions.createNewCell({
346
+ cellId: "__end__",
347
+ before: false,
348
+ code: "temporary",
349
+ newCellId: CellId.create(),
350
+ });
351
+ sync();
352
+
353
+ const newId =
354
+ primary.cellIds.inOrderIds[primary.cellIds.inOrderIds.length - 1];
355
+ primaryActions.deleteCell({ cellId: newId });
356
+ sync();
357
+
358
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
359
+ });
360
+
361
+ it("addColumnBreakpoint", () => {
362
+ setup("a", "b", "c");
363
+ const [, b] = primary.cellIds.inOrderIds;
364
+ primaryActions.addColumnBreakpoint({ cellId: b });
365
+ sync();
366
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
367
+ });
368
+
369
+ it("dropOverNewColumn", () => {
370
+ setup("a", "b", "c");
371
+ const [, b] = primary.cellIds.inOrderIds;
372
+ primaryActions.dropOverNewColumn({ cellId: b });
373
+ sync();
374
+ expect(documentSnapshot(primary)).toEqual(documentSnapshot(replica));
375
+ });
376
+ });
@@ -29,6 +29,7 @@ import type { CellConfig } from "../network/types";
29
29
  import { isRtcEnabled } from "../rtc/state";
30
30
  import { createDeepEqualAtom, store } from "../state/jotai";
31
31
  import { prepareCellForExecution, transitionCell } from "./cell";
32
+ import { documentTransactionMiddleware } from "./document-changes";
32
33
  import { CellId, SCRATCH_CELL_ID, SETUP_CELL_ID } from "./ids";
33
34
  import { type CellLog, getCellLogsForMessage } from "./logs";
34
35
  import {
@@ -174,6 +175,7 @@ export interface CreateNewCellAction {
174
175
  */
175
176
  const {
176
177
  reducer,
178
+ addMiddleware,
177
179
  createActions,
178
180
  useActions,
179
181
  valueAtom: notebookAtom,
@@ -756,13 +758,28 @@ const {
756
758
  },
757
759
  handleCellMessage: (state, message: CellMessage) => {
758
760
  const cellId = message.cell_id;
759
- const nextState = updateCellRuntimeState({
761
+ let nextState = updateCellRuntimeState({
760
762
  state,
761
763
  cellId,
762
764
  cellReducer: (cell) => {
763
765
  return transitionCell(cell, message);
764
766
  },
765
767
  });
768
+ // When a cell is queued for execution, snapshot the current code
769
+ // as lastCodeRun. This clears staleness for cells executed by the
770
+ // kernel (e.g. via code_mode). If the user edits during execution,
771
+ // code !== lastCodeRun keeps the cell stale.
772
+ if (message.status === "queued") {
773
+ nextState = updateCellData({
774
+ state: nextState,
775
+ cellId,
776
+ cellReducer: (cell) => ({
777
+ ...cell,
778
+ lastCodeRun: cell.code.trim(),
779
+ edited: false,
780
+ }),
781
+ });
782
+ }
766
783
  return {
767
784
  ...nextState,
768
785
  cellLogs: [...nextState.cellLogs, ...getCellLogsForMessage(message)],
@@ -1405,6 +1422,12 @@ const {
1405
1422
  },
1406
1423
  });
1407
1424
 
1425
+ // We apply the middleware here (rather than inline in createReducerAndAtoms)
1426
+ // so that the document transaction middleware can import CellActions and
1427
+ // strictly type the dispatched actions without creating a circular dependency.
1428
+ // @ts-expect-error - TODO: We should have better types for the middleware that are strict
1429
+ addMiddleware(documentTransactionMiddleware);
1430
+
1408
1431
  function isCellCodeHidden(state: NotebookState, cellId: CellId): boolean {
1409
1432
  return (
1410
1433
  Boolean(state.cellData[cellId].config.hide_code) &&
@@ -1789,8 +1812,10 @@ export function createTracebackInfoAtom(
1789
1812
  * Use this hook to dispatch cell actions. This hook will not cause a re-render
1790
1813
  * when cells change.
1791
1814
  */
1792
- export function useCellActions(): CellActions {
1793
- return useActions();
1815
+ export function useCellActions(
1816
+ options: { skipMiddleware?: boolean } = {},
1817
+ ): CellActions {
1818
+ return useActions(options);
1794
1819
  }
1795
1820
 
1796
1821
  /**