@marimo-team/frontend 0.25.3-dev100 → 0.25.3-dev102

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,230 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { vi } from "vitest";
4
+ import { test } from "./scenario-fixture";
5
+
6
+ test.for(["response", "document"] as const)(
7
+ "keeps a conflicting batch intact and retries explicitly with %s first",
8
+ async (first, { expect, session: { user, host, notebook } }) => {
9
+ // The first change is valid; the second conflicts. Neither may commit.
10
+ user.changeCells({
11
+ c1: { code: "other local edit" },
12
+ c3: { code: "local" },
13
+ });
14
+ const synced = user.sync();
15
+ const request = await host.nextRequest();
16
+ host.changeCell("c3", { code: "remote", name: "remote name" });
17
+ const remote = host.document();
18
+ expect(user.cell("c3")?.code).toBe("local");
19
+ request.process();
20
+ expect(host.document()).toEqual(remote);
21
+ user.changeCode("c3", "updated local");
22
+
23
+ if (first === "document") {
24
+ host.deliverDocument();
25
+ }
26
+ request.deliverResponse();
27
+ await expect(synced).rejects.toThrow("conflict");
28
+ expect(user.status()).toMatchObject({ status: "error", retryable: true });
29
+ if (first === "response") {
30
+ host.deliverDocument();
31
+ }
32
+ await vi.advanceTimersByTimeAsync(2000);
33
+ expect(notebook.edit).toHaveBeenCalledOnce();
34
+ expect(user.cell("c3")?.code).toBe("updated local");
35
+ expect(user.cell("c1")?.code).toBe("other local edit");
36
+
37
+ const retried = user.retry();
38
+ const retry = await host.nextRequest();
39
+ expect(retry.idempotencyKey).not.toBe(request.idempotencyKey);
40
+ retry.process();
41
+ retry.deliverResponse();
42
+ host.deliverDocument();
43
+ await expect(retried).resolves.toEqual(host.document().revision);
44
+ expect(host.document().cells.get("c1")?.code).toBe("other local edit");
45
+ expect(host.document().cells.get("c3")).toMatchObject({
46
+ code: "updated local",
47
+ name: "remote name",
48
+ });
49
+ expect(user.cell("c3")).toMatchObject({
50
+ code: "updated local",
51
+ name: "remote name",
52
+ });
53
+ expect(user.status()).toEqual({ status: "idle" });
54
+ },
55
+ );
56
+
57
+ test("waits for the conflicting revision before allowing a retry", async ({
58
+ expect,
59
+ session: { user, host, notebook },
60
+ }) => {
61
+ user.changeCode("c3", "local");
62
+ const synced = user.sync();
63
+ const request = await host.nextRequest();
64
+ host.changeCell("c3", { code: "remote" });
65
+ request.process();
66
+ request.deliverResponse();
67
+ await expect(synced).rejects.toThrow("conflict");
68
+ await expect(user.retry()).rejects.toThrow("Wait for the host");
69
+ expect(notebook.edit).toHaveBeenCalledOnce();
70
+ expect(user.cell("c3")?.code).toBe("local");
71
+
72
+ host.deliverDocument();
73
+ const retried = user.retry();
74
+ const retry = await host.nextRequest();
75
+ retry.process();
76
+ retry.deliverResponse();
77
+ host.deliverDocument();
78
+ await expect(retried).resolves.toEqual(host.document().revision);
79
+ expect(host.document().cells.get("c3")?.code).toBe("local");
80
+ expect(user.status()).toEqual({ status: "idle" });
81
+ });
82
+
83
+ test("replays a lost response without committing twice or losing newer typing", async ({
84
+ expect,
85
+ session: { user, host },
86
+ }) => {
87
+ user.changeCode("c3", "sent");
88
+ const flushed = user.sync();
89
+ const request = await host.nextRequest();
90
+ request.process();
91
+ const committed = host.document();
92
+ host.deliverDocument();
93
+ user.changeCode("c3", "newer");
94
+ request.loseResponse();
95
+
96
+ await vi.advanceTimersByTimeAsync(500);
97
+ const retry = await host.nextRequest();
98
+ expect(retry.idempotencyKey).toBe(request.idempotencyKey);
99
+ expect(retry.changes).toEqual(request.changes);
100
+ retry.process();
101
+ expect(host.document()).toEqual(committed);
102
+ retry.deliverResponse();
103
+
104
+ const next = await host.nextRequest();
105
+ expect(next.idempotencyKey).not.toBe(request.idempotencyKey);
106
+ next.process();
107
+ next.deliverResponse();
108
+ host.deliverDocument();
109
+ await expect(flushed).resolves.toEqual(host.document().revision);
110
+ expect(host.document().revision.version).toBe(committed.revision.version + 1);
111
+ expect(host.document().cells.get("c3")?.code).toBe("newer");
112
+ expect(user.cell("c3")?.code).toBe("newer");
113
+ });
114
+
115
+ test("ignores a timed-out attempt that completes after its retry", async ({
116
+ expect,
117
+ session: { user, host, notebook },
118
+ }) => {
119
+ user.changeCode("c3", "local");
120
+ const flushed = user.sync();
121
+ const first = await host.nextRequest();
122
+ await vi.advanceTimersByTimeAsync(10_500);
123
+ const retry = await host.nextRequest();
124
+ expect(first.signal.aborted).toBe(true);
125
+ expect(retry.idempotencyKey).toBe(first.idempotencyKey);
126
+ retry.process();
127
+ const committed = host.document();
128
+ retry.deliverResponse();
129
+ host.deliverDocument();
130
+ await expect(flushed).resolves.toEqual(committed.revision);
131
+ first.process();
132
+ first.deliverResponse();
133
+ await vi.advanceTimersByTimeAsync(0);
134
+ expect(host.document()).toEqual(committed);
135
+ expect(user.cell("c3")?.code).toBe("local");
136
+ expect(notebook.edit).toHaveBeenCalledTimes(2);
137
+ });
138
+
139
+ test("a manual retry reuses the committed edit after automatic retries give up", async ({
140
+ expect,
141
+ session: { user, host, notebook },
142
+ }) => {
143
+ user.changeCode("c3", "sent");
144
+ const synced = user.sync();
145
+ const request = await host.nextRequest();
146
+ request.process();
147
+ const committed = host.document();
148
+ request.loseResponse();
149
+ for (const delay of [500, 1000]) {
150
+ await vi.advanceTimersByTimeAsync(delay);
151
+ const retry = await host.nextRequest();
152
+ retry.process();
153
+ retry.loseResponse();
154
+ }
155
+ await expect(synced).rejects.toThrow("Connection lost");
156
+ expect(user.status()).toMatchObject({ status: "error", retryable: true });
157
+ expect(notebook.edit).toHaveBeenCalledTimes(3);
158
+ expect(host.document()).toEqual(committed);
159
+
160
+ const retried = user.retry();
161
+ const retry = await host.nextRequest();
162
+ expect(retry.idempotencyKey).toBe(request.idempotencyKey);
163
+ expect(retry.changes).toEqual(request.changes);
164
+ retry.process();
165
+ retry.deliverResponse();
166
+ host.deliverDocument();
167
+ await expect(retried).resolves.toEqual(committed.revision);
168
+ expect(host.document()).toEqual(committed);
169
+ expect(user.status()).toEqual({ status: "idle" });
170
+ expect(notebook.edit).toHaveBeenCalledTimes(4);
171
+ });
172
+
173
+ test("refuses a manual retry after the safe retry window while retaining the draft", async ({
174
+ expect,
175
+ session: { user, host, notebook },
176
+ }) => {
177
+ user.changeCode("c3", "sent");
178
+ const synced = user.sync();
179
+ const request = await host.nextRequest();
180
+ request.process();
181
+ const committed = host.document();
182
+ request.loseResponse();
183
+ for (const delay of [500, 1000]) {
184
+ await vi.advanceTimersByTimeAsync(delay);
185
+ const retry = await host.nextRequest();
186
+ retry.process();
187
+ retry.loseResponse();
188
+ }
189
+ await expect(synced).rejects.toThrow("Connection lost");
190
+ expect(user.status()).toMatchObject({ status: "error", retryable: true });
191
+
192
+ await vi.advanceTimersByTimeAsync(60_000);
193
+ await expect(user.retry()).rejects.toThrow("retry window expired");
194
+ expect(user.status()).toMatchObject({ status: "error", retryable: false });
195
+ expect(notebook.edit).toHaveBeenCalledTimes(3);
196
+ expect(host.document()).toEqual(committed);
197
+ expect(user.cell("c3")?.code).toBe("sent");
198
+ });
199
+
200
+ test("keeps the whole draft when another writer deletes an edited cell", async ({
201
+ expect,
202
+ session: { user, host, notebook },
203
+ }) => {
204
+ user.changeCode("c3", "keep this");
205
+ const synced = user.sync();
206
+ const request = await host.nextRequest();
207
+ host.deleteCell("c3");
208
+ request.process();
209
+ request.deliverResponse();
210
+ host.deliverDocument();
211
+ await expect(synced).rejects.toThrow("conflict");
212
+
213
+ user.changeCode("c1", "other edit");
214
+ await expect(user.retry()).rejects.toThrow("unavailable");
215
+ await vi.advanceTimersByTimeAsync(1000);
216
+ expect(notebook.edit).toHaveBeenCalledOnce();
217
+ expect(host.document().cells.get("c1")?.code).toBe("import marimo as mo");
218
+
219
+ host.createCell({ id: "c3", code: "restored" });
220
+ host.deliverDocument();
221
+ expect(user.cell("c3")?.code).toBe("keep this");
222
+ const retried = user.retry();
223
+ const retry = await host.nextRequest();
224
+ retry.process();
225
+ retry.deliverResponse();
226
+ host.deliverDocument();
227
+ await expect(retried).resolves.toEqual(host.document().revision);
228
+ expect(host.document().cells.get("c3")?.code).toBe("keep this");
229
+ expect(host.document().cells.get("c1")?.code).toBe("other edit");
230
+ });
@@ -0,0 +1,42 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { createStore } from "jotai/vanilla";
4
+ import { test as baseTest, vi } from "vitest";
5
+ import type { Document } from "../../host/resources/document";
6
+ import { createDocumentModel } from "../model";
7
+ import { initialDocument } from "./protocol-fixtures";
8
+ import { createTestNotebook } from "./test-notebook";
9
+ import { createUser } from "./user";
10
+
11
+ function createSession(initial: Document, controller: AbortController) {
12
+ const store = createStore();
13
+ const { notebook, control } = createTestNotebook(store, initial);
14
+ const model = createDocumentModel({
15
+ store,
16
+ notebook,
17
+ signal: controller.signal,
18
+ });
19
+ return {
20
+ user: createUser(store, model),
21
+ host: control,
22
+ notebook,
23
+ controller,
24
+ };
25
+ }
26
+
27
+ export const test = baseTest.extend<{
28
+ initial: Document;
29
+ session: ReturnType<typeof createSession>;
30
+ }>({
31
+ initial: initialDocument(),
32
+ session: async ({ initial }, provide) => {
33
+ vi.useFakeTimers({ now: 0 });
34
+ const controller = new AbortController();
35
+ try {
36
+ await provide(createSession(initial, controller));
37
+ } finally {
38
+ controller.abort();
39
+ vi.useRealTimers();
40
+ }
41
+ },
42
+ });
@@ -0,0 +1,205 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { vi } from "vitest";
4
+ import { test } from "./scenario-fixture";
5
+
6
+ test("waits for a pause in typing before sending the latest edit", async ({
7
+ expect,
8
+ session: { user, host, notebook },
9
+ }) => {
10
+ user.changeCode("c3", "first");
11
+ await vi.advanceTimersByTimeAsync(300);
12
+ user.changeCode("c3", "latest");
13
+ await vi.advanceTimersByTimeAsync(399);
14
+ expect(notebook.edit).not.toHaveBeenCalled();
15
+ await vi.advanceTimersByTimeAsync(1);
16
+ expect(notebook.edit).toHaveBeenCalledOnce();
17
+ const request = await host.nextRequest();
18
+ request.process();
19
+ request.deliverResponse();
20
+ host.deliverDocument();
21
+ await expect(user.sync()).resolves.toEqual(host.document().revision);
22
+ expect(host.document().cells.get("c3")?.code).toBe("latest");
23
+ });
24
+
25
+ test.for(["response", "document"] as const)(
26
+ "preserves newer typing when the %s arrives first",
27
+ async (first, { expect, session: { user, host, notebook } }) => {
28
+ user.changeCells({ c3: { code: "sent", config: { hide_code: true } } });
29
+ const flushed = user.sync();
30
+ const finished = vi.fn();
31
+ void flushed.then(finished, () => undefined);
32
+ const request = await host.nextRequest();
33
+ request.process();
34
+ user.changeCode("c3", "still typing");
35
+
36
+ const deliver = {
37
+ response: () => request.deliverResponse(),
38
+ document: () => host.deliverDocument(),
39
+ };
40
+ deliver[first]();
41
+ await vi.advanceTimersByTimeAsync(0);
42
+ expect(finished).not.toHaveBeenCalled();
43
+ expect(notebook.edit).toHaveBeenCalledOnce();
44
+ expect(user.cell("c3")?.code).toBe("still typing");
45
+
46
+ deliver[first === "response" ? "document" : "response"]();
47
+ await vi.advanceTimersByTimeAsync(0);
48
+ expect(user.cell("c3")?.code).toBe("still typing");
49
+ const next = await host.nextRequest();
50
+ next.process();
51
+ next.deliverResponse();
52
+ host.deliverDocument();
53
+ await expect(flushed).resolves.toEqual(host.document().revision);
54
+ const expected = {
55
+ id: "c3",
56
+ code: "still typing",
57
+ name: "_",
58
+ config: {
59
+ column: null,
60
+ disabled: false,
61
+ hide_code: true,
62
+ expand_output: false,
63
+ },
64
+ };
65
+ expect(user.cell("c3")).toEqual(expected);
66
+ expect(host.document().cells.get("c3")).toMatchObject(expected);
67
+ expect(user.status()).toEqual({ status: "idle" });
68
+
69
+ host.changeCell("c3", {
70
+ code: "another writer",
71
+ config: { hide_code: false },
72
+ });
73
+ host.deliverDocument();
74
+ expect(user.cell("c3")?.code).toBe("another writer");
75
+ expect(user.cell("c3")?.config.hide_code).toBe(false);
76
+ },
77
+ );
78
+
79
+ test("uses a newer confirmed document for edits that have not been sent", async ({
80
+ expect,
81
+ session: { user, host },
82
+ }) => {
83
+ user.changeCode("c3", "local");
84
+ host.changeCell("c3", { name: "remote name" });
85
+ host.deliverDocument();
86
+ const flushed = user.sync();
87
+ const request = await host.nextRequest();
88
+ request.process();
89
+ request.deliverResponse();
90
+ host.deliverDocument();
91
+ await expect(flushed).resolves.toEqual(host.document().revision);
92
+ expect(host.document().cells.get("c3")).toMatchObject({
93
+ code: "local",
94
+ name: "remote name",
95
+ });
96
+ });
97
+
98
+ test.for(["before reset", "during reset", "after snapshot"] as const)(
99
+ "recovers across reset when the response arrives %s",
100
+ async (arrival, { expect, session: { user, host } }) => {
101
+ user.changeCode("c3", "sent");
102
+ const flushed = user.sync();
103
+ const request = await host.nextRequest();
104
+ request.process();
105
+ user.changeCode("c3", "newer typing");
106
+ if (arrival === "before reset") {
107
+ request.deliverResponse();
108
+ await vi.advanceTimersByTimeAsync(0);
109
+ }
110
+ host.reset();
111
+ if (arrival === "during reset") {
112
+ request.deliverResponse();
113
+ await vi.advanceTimersByTimeAsync(0);
114
+ }
115
+ host.changeCell("c1", { code: "unrelated edit" });
116
+ host.deliverSnapshot();
117
+ if (arrival === "after snapshot") {
118
+ await vi.advanceTimersByTimeAsync(0);
119
+ request.deliverResponse();
120
+ }
121
+ expect(user.cell("c3")?.code).toBe("newer typing");
122
+ const next = await host.nextRequest();
123
+ next.process();
124
+ next.deliverResponse();
125
+ host.deliverDocument();
126
+ await expect(flushed).resolves.toEqual(host.document().revision);
127
+ expect(host.document().cells.get("c3")?.code).toBe("newer typing");
128
+ expect(user.cell("c1")?.code).toBe("unrelated edit");
129
+ expect(user.status()).toEqual({ status: "idle" });
130
+
131
+ host.changeCell("c3", { code: "another writer" });
132
+ host.deliverDocument();
133
+ expect(user.cell("c3")?.code).toBe("another writer");
134
+ },
135
+ );
136
+
137
+ test.for(["response", "document"] as const)(
138
+ "does not resurrect a cancelled creation when the %s arrives first",
139
+ async (first, { expect, session: { user, host } }) => {
140
+ user.createCell({ id: "new", code: "new cell" });
141
+ const flushed = user.sync();
142
+ const creation = await host.nextRequest();
143
+ user.deleteCell("new");
144
+ host.changeCell("c1", { code: "unrelated edit" });
145
+ host.deliverDocument();
146
+ creation.process();
147
+ const deliver = {
148
+ response: () => creation.deliverResponse(),
149
+ document: () => host.deliverDocument(),
150
+ };
151
+ deliver[first]();
152
+ await vi.advanceTimersByTimeAsync(0);
153
+ expect(user.cell("new")).toBeUndefined();
154
+ expect(user.order()).toEqual(["c1", "c3", "k7f2"]);
155
+ deliver[first === "response" ? "document" : "response"]();
156
+ const deletion = await host.nextRequest();
157
+ deletion.process();
158
+ deletion.deliverResponse();
159
+ host.deliverDocument();
160
+ await expect(flushed).resolves.toEqual(host.document().revision);
161
+ expect(host.document().cells.has("new")).toBe(false);
162
+ expect(user.cell("new")).toBeUndefined();
163
+ },
164
+ );
165
+
166
+ test("releases an edit undone before sending and follows subsequent host changes", async ({
167
+ expect,
168
+ session: { user, host, notebook },
169
+ }) => {
170
+ user.changeCode("c3", "temporary");
171
+ user.changeCode("c3", "x = 21");
172
+ expect(user.status()).toEqual({ status: "idle" });
173
+ await expect(user.sync()).resolves.toEqual(host.document().revision);
174
+
175
+ host.changeCell("c3", { code: "another writer" });
176
+ host.deliverDocument();
177
+ expect(user.cell("c3")?.code).toBe("another writer");
178
+ await vi.advanceTimersByTimeAsync(1000);
179
+ expect(notebook.edit).not.toHaveBeenCalled();
180
+ });
181
+
182
+ test("flushes edits made while an earlier flush is finishing", async ({
183
+ expect,
184
+ session: { user, host, notebook },
185
+ }) => {
186
+ const original = host.document().revision;
187
+ const first = user.sync();
188
+ // Let the first flush drain without awaiting its returned promise.
189
+ await Promise.resolve();
190
+ user.changeCode("c3", "new code");
191
+ const second = user.sync();
192
+ const settled = vi.fn();
193
+ void second.then(settled, settled);
194
+
195
+ await vi.advanceTimersByTimeAsync(0);
196
+ expect(notebook.edit).toHaveBeenCalledOnce();
197
+ expect(settled).not.toHaveBeenCalled();
198
+ const request = await host.nextRequest();
199
+ request.process();
200
+ expect(host.document().cells.get("c3")?.code).toBe("new code");
201
+ request.deliverResponse();
202
+ host.deliverDocument();
203
+ await expect(second).resolves.toEqual(host.document().revision);
204
+ await expect(first).resolves.toEqual(original);
205
+ });
@@ -8,6 +8,7 @@ import {
8
8
  type DocumentEdit,
9
9
  type DocumentEditor,
10
10
  } from "../state";
11
+ import { selectCell, selectOrder } from "../select";
11
12
  import { cell, document, withCells } from "./fixtures";
12
13
 
13
14
  const lifetimes: AbortController[] = [];
@@ -29,6 +30,8 @@ function setup(initial: Document | null = document()) {
29
30
  store,
30
31
  source,
31
32
  ...model,
33
+ cellAtom: (id: string) => selectCell(model.documentAtom, id),
34
+ orderAtom: selectOrder(model.documentAtom),
32
35
  abort: () => controller.abort(),
33
36
  edit: (callback: DocumentEdit) => store.set(model.editAtom, callback),
34
37
  };
@@ -252,10 +255,9 @@ describe("document model", () => {
252
255
  unsubscribeOrder();
253
256
  });
254
257
 
255
- it("isolates cell, order and configuration subscriptions across local and host updates", () => {
258
+ it("isolates cell and order subscriptions across local and host updates", () => {
256
259
  const initial = document();
257
- const { store, source, cellAtom, orderAtom, appConfigAtom, edit } =
258
- setup(initial);
260
+ const { store, source, cellAtom, orderAtom, edit } = setup(initial);
259
261
  edit((tx) => {
260
262
  tx.setCode("c1", "local");
261
263
  tx.createCell({ id: "c3", code: "new" });
@@ -263,16 +265,13 @@ describe("document model", () => {
263
265
  const c1 = cellAtom("c1");
264
266
  const first = store.get(c1);
265
267
  const order = store.get(orderAtom);
266
- const config = store.get(appConfigAtom);
267
268
  const onFirst = vi.fn();
268
269
  const onSecond = vi.fn();
269
270
  const onOrder = vi.fn();
270
- const onConfig = vi.fn();
271
271
  const unsubscribe = [
272
272
  store.sub(c1, onFirst),
273
273
  store.sub(cellAtom("c2"), onSecond),
274
274
  store.sub(orderAtom, onOrder),
275
- store.sub(appConfigAtom, onConfig),
276
275
  ];
277
276
 
278
277
  edit((tx) => tx.setCode("c2", "other local"));
@@ -289,11 +288,9 @@ describe("document model", () => {
289
288
  });
290
289
  expect(store.get(c1)).toBe(first);
291
290
  expect(store.get(orderAtom)).toBe(order);
292
- expect(store.get(appConfigAtom)).toBe(config);
293
291
  expect(onFirst).not.toHaveBeenCalled();
294
292
  expect(onSecond).toHaveBeenCalledOnce();
295
293
  expect(onOrder).not.toHaveBeenCalled();
296
- expect(onConfig).not.toHaveBeenCalled();
297
294
 
298
295
  store.set(
299
296
  source,
@@ -311,11 +308,6 @@ describe("document model", () => {
311
308
  edit((tx) => tx.setOrder(["c3", "c2", "c1"]));
312
309
  expect(onOrder).toHaveBeenCalledOnce();
313
310
  expect(onFirst).toHaveBeenCalledOnce();
314
- store.set(source, {
315
- ...remote,
316
- revision: { ...remote.revision, app_config: { width: "full" } },
317
- });
318
- expect(onConfig).toHaveBeenCalledOnce();
319
311
  unsubscribe.forEach((fn) => fn());
320
312
  });
321
313