@marimo-team/islands 0.25.3-dev93 → 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.
- package/dist/{ErrorBoundary-jiRCrm1p.js → ErrorBoundary-Cghv7nv2.js} +1 -1
- package/dist/{code-visibility-B_y_dByX.js → code-visibility-CLLrmROz.js} +1 -1
- package/dist/{common-CmpSkUjV.js → common-BUSxx69_.js} +1 -1
- package/dist/{data-grid-overlay-editor-CJTJtfj-.js → data-grid-overlay-editor-CWCIs2Mn.js} +1 -1
- package/dist/{feedback-button-BpHnBE3A.js → feedback-button-Ct9dNjaH.js} +2 -2
- package/dist/{glide-data-editor-B3xUjh0M.js → glide-data-editor-D30nP9Xm.js} +3 -3
- package/dist/{globals-DI7gqOyy.js → globals-B7SyAmCL.js} +1 -1
- package/dist/main.js +6 -6
- package/dist/{number-overlay-editor-DQ6KAcGy.js → number-overlay-editor-8Y4ehcLw.js} +1 -1
- package/dist/{reveal-component-DsWQWmYM.js → reveal-component-D8VAiQY5.js} +2 -2
- package/package.json +1 -1
- package/src/components/pages/__tests__/home-page.test.tsx +116 -0
- package/src/components/pages/home-page.tsx +24 -3
- package/src/core/document/__tests__/draft.test.ts +322 -0
- package/src/core/document/__tests__/fixtures.ts +92 -0
- package/src/core/document/__tests__/model.test.ts +434 -0
- package/src/core/document/__tests__/submission.test.ts +334 -0
- package/src/core/document/draft.ts +472 -0
- package/src/core/document/model.ts +395 -0
|
@@ -0,0 +1,334 @@
|
|
|
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 { createDocumentModel, type DocumentSubmission } from "../model";
|
|
7
|
+
import { cell, document, withCells } from "./fixtures";
|
|
8
|
+
|
|
9
|
+
function setup() {
|
|
10
|
+
const store = createStore();
|
|
11
|
+
const source = atom<Document | null>(document());
|
|
12
|
+
const model = createDocumentModel(store, source);
|
|
13
|
+
return {
|
|
14
|
+
store,
|
|
15
|
+
source,
|
|
16
|
+
...model,
|
|
17
|
+
submit() {
|
|
18
|
+
const submission = store.set(model.submitAtom);
|
|
19
|
+
if (submission === null) {
|
|
20
|
+
throw new Error("Expected a submission");
|
|
21
|
+
}
|
|
22
|
+
return submission;
|
|
23
|
+
},
|
|
24
|
+
settle(submission: DocumentSubmission, confirmed: Document) {
|
|
25
|
+
store.set(model.settleAtom, submission, confirmed.revision);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("document submissions", () => {
|
|
31
|
+
it.each(["host source", "replacement code"] as const)(
|
|
32
|
+
"retains an unsendable draft until it receives %s",
|
|
33
|
+
(recovery) => {
|
|
34
|
+
const {
|
|
35
|
+
store,
|
|
36
|
+
source,
|
|
37
|
+
editAtom,
|
|
38
|
+
changesAtom,
|
|
39
|
+
submitAtom,
|
|
40
|
+
submit,
|
|
41
|
+
settle,
|
|
42
|
+
} = setup();
|
|
43
|
+
const hidden = withCells(document(), {
|
|
44
|
+
c1: cell("c1", "", { code: null }),
|
|
45
|
+
});
|
|
46
|
+
store.set(source, hidden);
|
|
47
|
+
store.set(editAtom, (tx) => {
|
|
48
|
+
tx.setCode("c2", "other edit");
|
|
49
|
+
tx.setName("c1", "renamed");
|
|
50
|
+
});
|
|
51
|
+
expect(store.get(changesAtom)).toBeNull();
|
|
52
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
53
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
54
|
+
|
|
55
|
+
const code = recovery === "host source" ? "revealed" : "";
|
|
56
|
+
if (recovery === "host source") {
|
|
57
|
+
store.set(source, withCells(hidden, { c1: cell("c1", code) }));
|
|
58
|
+
} else {
|
|
59
|
+
store.set(editAtom, (tx) => tx.setCode("c1", code));
|
|
60
|
+
}
|
|
61
|
+
const sent = submit();
|
|
62
|
+
expect(sent.changes).toEqual([
|
|
63
|
+
{
|
|
64
|
+
kind: "put_cell",
|
|
65
|
+
base_etag: "c2-etag",
|
|
66
|
+
cell: {
|
|
67
|
+
id: "c2",
|
|
68
|
+
code: "other edit",
|
|
69
|
+
name: "_",
|
|
70
|
+
config: cell("c2", "").config,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
kind: "put_cell",
|
|
75
|
+
base_etag: "c1-etag",
|
|
76
|
+
cell: {
|
|
77
|
+
id: "c1",
|
|
78
|
+
code,
|
|
79
|
+
name: "renamed",
|
|
80
|
+
config: cell("c1", "").config,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
const accepted = withCells(store.get(source)!, {
|
|
85
|
+
c1: cell("c1", code, { name: "renamed" }),
|
|
86
|
+
c2: cell("c2", "other edit"),
|
|
87
|
+
});
|
|
88
|
+
settle(sent, accepted);
|
|
89
|
+
store.set(source, accepted);
|
|
90
|
+
expect(store.get(changesAtom)).toEqual([]);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
it.each(["response", "stream"] as const)(
|
|
95
|
+
"preserves later typing when the %s arrives first",
|
|
96
|
+
(first) => {
|
|
97
|
+
const {
|
|
98
|
+
store,
|
|
99
|
+
source,
|
|
100
|
+
editAtom,
|
|
101
|
+
cellAtom,
|
|
102
|
+
changesAtom,
|
|
103
|
+
submitAtom,
|
|
104
|
+
submit,
|
|
105
|
+
settle,
|
|
106
|
+
} = setup();
|
|
107
|
+
store.set(editAtom, (tx) => {
|
|
108
|
+
tx.setCode("c1", "sent");
|
|
109
|
+
tx.updateConfig("c1", { hide_code: true });
|
|
110
|
+
});
|
|
111
|
+
const sent = submit();
|
|
112
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "still typing"));
|
|
113
|
+
const confirmed = withCells(document(), {
|
|
114
|
+
c1: cell("c1", "sent", {
|
|
115
|
+
etag: "accepted",
|
|
116
|
+
config: { ...cell("c1", "").config, hide_code: true },
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
const onCell = vi.fn();
|
|
120
|
+
store.sub(cellAtom("c1"), onCell);
|
|
121
|
+
const arrivals = {
|
|
122
|
+
response: () => settle(sent, confirmed),
|
|
123
|
+
stream: () => store.set(source, confirmed),
|
|
124
|
+
};
|
|
125
|
+
arrivals[first]();
|
|
126
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
127
|
+
arrivals[first === "response" ? "stream" : "response"]();
|
|
128
|
+
expect(onCell).not.toHaveBeenCalled();
|
|
129
|
+
expect(store.get(cellAtom("c1"))?.code).toBe("still typing");
|
|
130
|
+
expect(store.get(changesAtom)).toEqual([
|
|
131
|
+
{
|
|
132
|
+
kind: "put_cell",
|
|
133
|
+
base_etag: "accepted",
|
|
134
|
+
cell: {
|
|
135
|
+
id: "c1",
|
|
136
|
+
code: "still typing",
|
|
137
|
+
name: "_",
|
|
138
|
+
config: confirmed.cells.get("c1")?.config,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
const next = submit();
|
|
143
|
+
settle(sent, confirmed);
|
|
144
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
145
|
+
const updated = withCells(confirmed, {
|
|
146
|
+
c1: cell("c1", "still typing", {
|
|
147
|
+
config: confirmed.cells.get("c1")!.config,
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
settle(next, updated);
|
|
151
|
+
store.set(source, updated);
|
|
152
|
+
expect(store.get(changesAtom)).toEqual([]);
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
it.each(["response", "stream"] as const)(
|
|
157
|
+
"never shows a cancelled creation when the %s arrives first",
|
|
158
|
+
(first) => {
|
|
159
|
+
const {
|
|
160
|
+
store,
|
|
161
|
+
source,
|
|
162
|
+
editAtom,
|
|
163
|
+
cellAtom,
|
|
164
|
+
orderAtom,
|
|
165
|
+
changesAtom,
|
|
166
|
+
submit,
|
|
167
|
+
settle,
|
|
168
|
+
} = setup();
|
|
169
|
+
store.set(editAtom, (tx) => tx.createCell({ id: "c3", code: "new" }));
|
|
170
|
+
const sent = submit();
|
|
171
|
+
store.set(editAtom, (tx) => tx.deleteCell("c3"));
|
|
172
|
+
const onCell = vi.fn();
|
|
173
|
+
store.sub(cellAtom("c3"), onCell);
|
|
174
|
+
// An unrelated revision before the creation must not forget the deletion.
|
|
175
|
+
const intermediate = withCells(document(), { c2: cell("c2", "remote") });
|
|
176
|
+
store.set(source, intermediate);
|
|
177
|
+
const confirmed = withCells(intermediate, { c3: cell("c3", "new") });
|
|
178
|
+
const arrivals = {
|
|
179
|
+
response: () => settle(sent, confirmed),
|
|
180
|
+
stream: () => store.set(source, confirmed),
|
|
181
|
+
};
|
|
182
|
+
arrivals[first]();
|
|
183
|
+
arrivals[first === "response" ? "stream" : "response"]();
|
|
184
|
+
expect(onCell).not.toHaveBeenCalled();
|
|
185
|
+
expect(store.get(orderAtom)).toEqual(["c1", "c2"]);
|
|
186
|
+
expect(store.get(changesAtom)).toEqual([
|
|
187
|
+
{ kind: "delete_cell", id: "c3", base_etag: "c3-etag" },
|
|
188
|
+
]);
|
|
189
|
+
const deletion = submit();
|
|
190
|
+
const removed = withCells(confirmed, { c3: null });
|
|
191
|
+
store.set(source, removed);
|
|
192
|
+
settle(deletion, removed);
|
|
193
|
+
settle(sent, confirmed);
|
|
194
|
+
expect(store.get(changesAtom)).toEqual([]);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
it("waits for the response's later revision and follows subsequent host edits", () => {
|
|
199
|
+
const { store, source, editAtom, cellAtom, submitAtom, submit, settle } =
|
|
200
|
+
setup();
|
|
201
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "sent"));
|
|
202
|
+
const sent = submit();
|
|
203
|
+
const edited = withCells(document(), { c1: cell("c1", "sent") });
|
|
204
|
+
const response = withCells(edited, { c2: cell("c2", "mirrored update") });
|
|
205
|
+
settle(sent, response);
|
|
206
|
+
store.set(source, edited);
|
|
207
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
208
|
+
const later = withCells(response, {
|
|
209
|
+
c1: cell("c1", "another client's edit"),
|
|
210
|
+
});
|
|
211
|
+
const seen: (string | null | undefined)[] = [];
|
|
212
|
+
store.sub(cellAtom("c1"), () => seen.push(store.get(cellAtom("c1"))?.code));
|
|
213
|
+
store.set(source, later);
|
|
214
|
+
expect(seen).toEqual(["another client's edit"]);
|
|
215
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it.each(["before reset", "during reset", "after snapshot"] as const)(
|
|
219
|
+
"confirms a submission across reset when its response arrives %s",
|
|
220
|
+
(arrival) => {
|
|
221
|
+
const { store, source, editAtom, cellAtom, submit, settle } = setup();
|
|
222
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "unsaved"));
|
|
223
|
+
const sent = submit();
|
|
224
|
+
const old = withCells(document(), { c1: cell("c1", "unsaved") });
|
|
225
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "newer typing"));
|
|
226
|
+
if (arrival === "before reset") {
|
|
227
|
+
settle(sent, old);
|
|
228
|
+
}
|
|
229
|
+
store.set(source, null);
|
|
230
|
+
if (arrival === "during reset") {
|
|
231
|
+
settle(sent, old);
|
|
232
|
+
}
|
|
233
|
+
const fresh = withCells(old, { c2: cell("c2", "unrelated") });
|
|
234
|
+
store.set(source, fresh);
|
|
235
|
+
if (arrival === "after snapshot") {
|
|
236
|
+
settle(sent, old);
|
|
237
|
+
}
|
|
238
|
+
expect(store.get(cellAtom("c1"))?.code).toBe("newer typing");
|
|
239
|
+
const retried = submit();
|
|
240
|
+
const accepted = withCells(fresh, { c1: cell("c1", "newer typing") });
|
|
241
|
+
settle(retried, accepted);
|
|
242
|
+
store.set(source, accepted);
|
|
243
|
+
store.set(source, withCells(accepted, { c1: cell("c1", "remote") }));
|
|
244
|
+
expect(store.get(cellAtom("c1"))?.code).toBe("remote");
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
it("invalidates submissions when switching notebooks, retaining each draft", () => {
|
|
249
|
+
const { store, source, editAtom, cellAtom, submit, settle } = setup();
|
|
250
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "first"));
|
|
251
|
+
const sent = submit();
|
|
252
|
+
store.set(source, document("n2"));
|
|
253
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "second"));
|
|
254
|
+
store.set(source, withCells(document(), { c2: cell("c2", "remote") }));
|
|
255
|
+
settle(sent, withCells(document(), { c1: cell("c1", "first") }));
|
|
256
|
+
expect(store.get(cellAtom("c1"))?.code).toBe("first");
|
|
257
|
+
store.set(source, document("n2"));
|
|
258
|
+
expect(store.get(cellAtom("c1"))?.code).toBe("second");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("retries a rejected edit with fresh preconditions and ignores its late reply", () => {
|
|
262
|
+
const { store, source, editAtom, settleAtom, submitAtom, submit, settle } =
|
|
263
|
+
setup();
|
|
264
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "local"));
|
|
265
|
+
const sent = submit();
|
|
266
|
+
store.set(settleAtom, sent, null);
|
|
267
|
+
const remote = withCells(document(), {
|
|
268
|
+
c1: cell("c1", "remote", { name: "renamed", etag: "fresh" }),
|
|
269
|
+
});
|
|
270
|
+
store.set(source, remote);
|
|
271
|
+
const retry = submit();
|
|
272
|
+
expect(retry.changes).toEqual([
|
|
273
|
+
{
|
|
274
|
+
kind: "put_cell",
|
|
275
|
+
base_etag: "fresh",
|
|
276
|
+
cell: {
|
|
277
|
+
id: "c1",
|
|
278
|
+
code: "local",
|
|
279
|
+
name: "renamed",
|
|
280
|
+
config: cell("c1", "").config,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
]);
|
|
284
|
+
settle(sent, remote);
|
|
285
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not treat a response for another notebook as acceptance", () => {
|
|
289
|
+
const { store, editAtom, settleAtom, submitAtom, submit } = setup();
|
|
290
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "local"));
|
|
291
|
+
const sent = submit();
|
|
292
|
+
expect(() => store.set(settleAtom, sent, document("n2").revision)).toThrow(
|
|
293
|
+
"another notebook",
|
|
294
|
+
);
|
|
295
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("releases an unsent reversal without making a request", () => {
|
|
299
|
+
const { store, source, editAtom, cellAtom, submitAtom } = setup();
|
|
300
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "local"));
|
|
301
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "x = 1"));
|
|
302
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
303
|
+
store.set(source, withCells(document(), { c1: cell("c1", "remote") }));
|
|
304
|
+
expect(store.get(cellAtom("c1"))?.code).toBe("remote");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("stops observing and accepting work after disposal", () => {
|
|
308
|
+
const {
|
|
309
|
+
store,
|
|
310
|
+
source,
|
|
311
|
+
editAtom,
|
|
312
|
+
orderAtom,
|
|
313
|
+
submitAtom,
|
|
314
|
+
submit,
|
|
315
|
+
settle,
|
|
316
|
+
dispose,
|
|
317
|
+
} = setup();
|
|
318
|
+
store.set(editAtom, (tx) => tx.setCode("c1", "local"));
|
|
319
|
+
const sent = submit();
|
|
320
|
+
dispose();
|
|
321
|
+
const onOrder = vi.fn();
|
|
322
|
+
store.sub(orderAtom, onOrder);
|
|
323
|
+
const confirmed = withCells(document(), { c1: cell("c1", "local") });
|
|
324
|
+
store.set(source, confirmed);
|
|
325
|
+
settle(sent, confirmed);
|
|
326
|
+
const edit = vi.fn();
|
|
327
|
+
store.set(editAtom, edit);
|
|
328
|
+
dispose();
|
|
329
|
+
expect(edit).not.toHaveBeenCalled();
|
|
330
|
+
expect(onOrder).not.toHaveBeenCalled();
|
|
331
|
+
expect(store.get(orderAtom)).toBeNull();
|
|
332
|
+
expect(store.set(submitAtom)).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
});
|