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

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,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
+ });
@@ -25,7 +25,6 @@ function setup() {
25
25
  store,
26
26
  source,
27
27
  ...model,
28
- abort: () => controller.abort(),
29
28
  submit() {
30
29
  const submission = store.set(model.submitAtom);
31
30
  if (submission === null) {
@@ -104,106 +103,39 @@ describe("document submissions", () => {
104
103
  );
105
104
 
106
105
  it.each(["response", "stream"] as const)(
107
- "preserves later typing when the %s arrives first",
106
+ "does not notify cell subscribers when the %s acknowledges a superseded draft",
108
107
  (first) => {
109
- const {
110
- store,
111
- source,
112
- editAtom,
113
- cellAtom,
114
- changesAtom,
115
- submitAtom,
116
- submit,
117
- settle,
118
- } = setup();
108
+ const { store, source, editAtom, cellAtom, submit, settle } = setup();
119
109
  store.set(editAtom, (tx) => {
120
110
  tx.setCode("c1", "sent");
121
- tx.updateConfig("c1", { hide_code: true });
111
+ tx.createCell({ id: "c3", code: "new" });
122
112
  });
123
113
  const sent = submit();
124
- store.set(editAtom, (tx) => tx.setCode("c1", "still typing"));
125
- const confirmed = withCells(document(), {
126
- c1: cell("c1", "sent", {
127
- etag: "accepted",
128
- config: { ...cell("c1", "").config, hide_code: true },
129
- }),
114
+ store.set(editAtom, (tx) => {
115
+ tx.setCode("c1", "still typing");
116
+ tx.deleteCell("c3");
130
117
  });
131
- const onCell = vi.fn();
132
- store.sub(cellAtom("c1"), onCell);
133
- const arrivals = {
134
- response: () => settle(sent, confirmed),
135
- stream: () => store.set(source, confirmed),
136
- };
137
- arrivals[first]();
138
- expect(store.set(submitAtom)).toBeNull();
139
- arrivals[first === "response" ? "stream" : "response"]();
140
- expect(onCell).not.toHaveBeenCalled();
141
- expect(store.get(cellAtom("c1"))?.code).toBe("still typing");
142
- expect(store.get(changesAtom)).toEqual([
143
- {
144
- kind: "put_cell",
145
- base_etag: "accepted",
146
- cell: {
147
- id: "c1",
148
- code: "still typing",
149
- name: "_",
150
- config: confirmed.cells.get("c1")?.config,
151
- },
152
- },
153
- ]);
154
- const next = submit();
155
- settle(sent, confirmed);
156
- expect(store.set(submitAtom)).toBeNull();
157
- const updated = withCells(confirmed, {
158
- c1: cell("c1", "still typing", {
159
- config: confirmed.cells.get("c1")!.config,
160
- }),
118
+ const onCode = vi.fn();
119
+ const onCreation = vi.fn();
120
+ const unsubscribeCode = store.sub(cellAtom("c1"), onCode);
121
+ const unsubscribeCreation = store.sub(cellAtom("c3"), onCreation);
122
+ const confirmed = withCells(document(), {
123
+ c1: cell("c1", "sent", { etag: "accepted", version: 2 }),
124
+ c3: cell("c3", "new"),
161
125
  });
162
- settle(next, updated);
163
- store.set(source, updated);
164
- expect(store.get(changesAtom)).toEqual([]);
165
- },
166
- );
167
-
168
- it.each(["response", "stream"] as const)(
169
- "never shows a cancelled creation when the %s arrives first",
170
- (first) => {
171
- const {
172
- store,
173
- source,
174
- editAtom,
175
- cellAtom,
176
- orderAtom,
177
- changesAtom,
178
- submit,
179
- settle,
180
- } = setup();
181
- store.set(editAtom, (tx) => tx.createCell({ id: "c3", code: "new" }));
182
- const sent = submit();
183
- store.set(editAtom, (tx) => tx.deleteCell("c3"));
184
- const onCell = vi.fn();
185
- store.sub(cellAtom("c3"), onCell);
186
- // An unrelated revision before the creation must not forget the deletion.
187
- const intermediate = withCells(document(), { c2: cell("c2", "remote") });
188
- store.set(source, intermediate);
189
- const confirmed = withCells(intermediate, { c3: cell("c3", "new") });
190
126
  const arrivals = {
191
127
  response: () => settle(sent, confirmed),
192
128
  stream: () => store.set(source, confirmed),
193
129
  };
194
- arrivals[first]();
195
- arrivals[first === "response" ? "stream" : "response"]();
196
- expect(onCell).not.toHaveBeenCalled();
197
- expect(store.get(orderAtom)).toEqual(["c1", "c2"]);
198
- expect(store.get(changesAtom)).toEqual([
199
- { kind: "delete_cell", id: "c3", base_etag: "c3-etag" },
200
- ]);
201
- const deletion = submit();
202
- const removed = withCells(confirmed, { c3: null });
203
- store.set(source, removed);
204
- settle(deletion, removed);
205
- settle(sent, confirmed);
206
- expect(store.get(changesAtom)).toEqual([]);
130
+ try {
131
+ arrivals[first]();
132
+ arrivals[first === "response" ? "stream" : "response"]();
133
+ expect(onCode).not.toHaveBeenCalled();
134
+ expect(onCreation).not.toHaveBeenCalled();
135
+ } finally {
136
+ unsubscribeCode();
137
+ unsubscribeCreation();
138
+ }
207
139
  },
208
140
  );
209
141
 
@@ -227,36 +159,6 @@ describe("document submissions", () => {
227
159
  expect(store.set(submitAtom)).toBeNull();
228
160
  });
229
161
 
230
- it.each(["before reset", "during reset", "after snapshot"] as const)(
231
- "confirms a submission across reset when its response arrives %s",
232
- (arrival) => {
233
- const { store, source, editAtom, cellAtom, submit, settle } = setup();
234
- store.set(editAtom, (tx) => tx.setCode("c1", "unsaved"));
235
- const sent = submit();
236
- const old = withCells(document(), { c1: cell("c1", "unsaved") });
237
- store.set(editAtom, (tx) => tx.setCode("c1", "newer typing"));
238
- if (arrival === "before reset") {
239
- settle(sent, old);
240
- }
241
- store.set(source, null);
242
- if (arrival === "during reset") {
243
- settle(sent, old);
244
- }
245
- const fresh = withCells(old, { c2: cell("c2", "unrelated") });
246
- store.set(source, fresh);
247
- if (arrival === "after snapshot") {
248
- settle(sent, old);
249
- }
250
- expect(store.get(cellAtom("c1"))?.code).toBe("newer typing");
251
- const retried = submit();
252
- const accepted = withCells(fresh, { c1: cell("c1", "newer typing") });
253
- settle(retried, accepted);
254
- store.set(source, accepted);
255
- store.set(source, withCells(accepted, { c1: cell("c1", "remote") }));
256
- expect(store.get(cellAtom("c1"))?.code).toBe("remote");
257
- },
258
- );
259
-
260
162
  it("invalidates submissions when switching notebooks, retaining each draft", () => {
261
163
  const { store, source, editAtom, cellAtom, submit, settle } = setup();
262
164
  store.set(editAtom, (tx) => tx.setCode("c1", "first"));
@@ -296,51 +198,4 @@ describe("document submissions", () => {
296
198
  settle(sent, remote);
297
199
  expect(store.set(submitAtom)).toBeNull();
298
200
  });
299
-
300
- it("does not treat a response for another notebook as acceptance", () => {
301
- const { store, editAtom, settleAtom, submitAtom, submit } = setup();
302
- store.set(editAtom, (tx) => tx.setCode("c1", "local"));
303
- const sent = submit();
304
- expect(() => store.set(settleAtom, sent, document("n2").revision)).toThrow(
305
- "another notebook",
306
- );
307
- expect(store.set(submitAtom)).toBeNull();
308
- });
309
-
310
- it("releases an unsent reversal without making a request", () => {
311
- const { store, source, editAtom, cellAtom, submitAtom } = setup();
312
- store.set(editAtom, (tx) => tx.setCode("c1", "local"));
313
- store.set(editAtom, (tx) => tx.setCode("c1", "x = 1"));
314
- expect(store.set(submitAtom)).toBeNull();
315
- store.set(source, withCells(document(), { c1: cell("c1", "remote") }));
316
- expect(store.get(cellAtom("c1"))?.code).toBe("remote");
317
- });
318
-
319
- it("stops observing and accepting work after disposal", () => {
320
- const {
321
- store,
322
- source,
323
- editAtom,
324
- orderAtom,
325
- submitAtom,
326
- submit,
327
- settle,
328
- abort,
329
- } = setup();
330
- store.set(editAtom, (tx) => tx.setCode("c1", "local"));
331
- const sent = submit();
332
- abort();
333
- const onOrder = vi.fn();
334
- store.sub(orderAtom, onOrder);
335
- const confirmed = withCells(document(), { c1: cell("c1", "local") });
336
- store.set(source, confirmed);
337
- settle(sent, confirmed);
338
- const edit = vi.fn();
339
- store.set(editAtom, edit);
340
- abort();
341
- expect(edit).not.toHaveBeenCalled();
342
- expect(onOrder).not.toHaveBeenCalled();
343
- expect(store.get(orderAtom)).toBeNull();
344
- expect(store.set(submitAtom)).toBeNull();
345
- });
346
201
  });
@@ -0,0 +1,92 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import * as Protocol from "@marimo-team/client/protocol";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import { initialDocument, protocolFixture } from "./protocol-fixtures";
6
+ import { createTestHost } from "./test-host";
7
+
8
+ afterEach(() => vi.useRealTimers());
9
+
10
+ it("matches the portable host examples for accepted and conflicting edits", () => {
11
+ const host = createTestHost(initialDocument());
12
+ const accepted = host.process({
13
+ notebookId: "n1",
14
+ idempotencyKey: "accepted",
15
+ changes: Protocol.EditRequest.parse(protocolFixture("edit.json")).changes,
16
+ });
17
+ const expected = Protocol.EditResponse.parse(
18
+ protocolFixture("edit-response.json"),
19
+ );
20
+ expect(accepted.result).toEqual({ ok: true, status: 200, data: expected });
21
+ expect(accepted.update).toEqual(host.document());
22
+
23
+ const rejected = host.process({
24
+ notebookId: "n1",
25
+ idempotencyKey: "rejected",
26
+ changes: Protocol.EditRequest.parse(
27
+ protocolFixture("edit-conflict-request.json"),
28
+ ).changes,
29
+ });
30
+ const problem = Protocol.Problem.parse(
31
+ protocolFixture("problems/edit-conflict.json"),
32
+ );
33
+ expect(rejected).toEqual({
34
+ result: {
35
+ ok: false,
36
+ status: 409,
37
+ problem: { ...problem, detail: expect.any(String) },
38
+ },
39
+ });
40
+ expect(host.document().revision).toEqual(expected.document);
41
+ });
42
+
43
+ it("rejects an entire batch when a later precondition is stale", () => {
44
+ const initial = initialDocument();
45
+ const host = createTestHost(initial);
46
+ const result = host.apply([
47
+ { kind: "put_cell", base_etag: null, cell: { id: "created", code: "new" } },
48
+ { kind: "delete_cell", id: "c3", base_etag: "stale" },
49
+ ]);
50
+ expect(result).toMatchObject({
51
+ result: { ok: false, status: 409, problem: { edit: { change: 1 } } },
52
+ });
53
+ expect(host.document()).toEqual(initial);
54
+ expect(result.update).toBeUndefined();
55
+ });
56
+
57
+ it("keeps successful and rejected receipts tied to their original payload and revision", () => {
58
+ vi.useFakeTimers({ now: 0 });
59
+ const host = createTestHost(initialDocument());
60
+ const request = {
61
+ notebookId: "n1",
62
+ idempotencyKey: "original",
63
+ changes: Protocol.EditRequest.parse(protocolFixture("edit.json")).changes,
64
+ };
65
+ const accepted = host.process(request);
66
+ const stale = {
67
+ notebookId: "n1",
68
+ idempotencyKey: "stale",
69
+ changes: Protocol.EditRequest.parse(
70
+ protocolFixture("edit-conflict-request.json"),
71
+ ).changes,
72
+ };
73
+ const rejected = host.process(stale);
74
+ host.apply([
75
+ { kind: "put_cell", base_etag: "*", cell: { id: "c3", code: "later" } },
76
+ ]);
77
+ const latest = host.document();
78
+ expect(host.process(request)).toEqual({ result: accepted.result });
79
+ expect(host.process(stale)).toEqual({ result: rejected.result });
80
+ expect(host.process({ ...request, changes: [] }).result).toMatchObject({
81
+ ok: false,
82
+ status: 409,
83
+ });
84
+ expect(host.document()).toEqual(latest);
85
+
86
+ vi.advanceTimersByTime(300_000);
87
+ expect(host.process(request).result).toMatchObject({
88
+ ok: false,
89
+ status: 409,
90
+ });
91
+ expect(host.document()).toEqual(latest);
92
+ });