@marimo-team/frontend 0.25.3-dev103 → 0.25.3-dev105
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/package.json +1 -1
- package/src/core/document/__tests__/hooks.test.tsx +386 -0
- package/src/core/document/hooks.tsx +118 -0
- package/src/core/document/index.ts +10 -0
- package/src/core/host/__tests__/static.test.ts +167 -0
- package/src/core/host/backend.ts +10 -0
- package/src/core/host/static.ts +70 -0
package/package.json
CHANGED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, render, renderHook } from "@testing-library/react";
|
|
4
|
+
import { atom, createStore, Provider } from "jotai";
|
|
5
|
+
import type { PropsWithChildren } from "react";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { Deferred } from "@/utils/Deferred";
|
|
8
|
+
import type { Document } from "../../host/resources/document";
|
|
9
|
+
import {
|
|
10
|
+
createDocumentModel,
|
|
11
|
+
DocumentProvider,
|
|
12
|
+
useCellCode,
|
|
13
|
+
useCellConfig,
|
|
14
|
+
useCellName,
|
|
15
|
+
useCellOrder,
|
|
16
|
+
useDocumentEdit,
|
|
17
|
+
useDocumentSync,
|
|
18
|
+
type DocumentHost,
|
|
19
|
+
} from "..";
|
|
20
|
+
import { cell, CONFIG, document, withCells } from "./fixtures";
|
|
21
|
+
|
|
22
|
+
type HostEdit = DocumentHost["edit"];
|
|
23
|
+
const lifetimes: AbortController[] = [];
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
act(() => lifetimes.splice(0).forEach((controller) => controller.abort()));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function setup(
|
|
30
|
+
initial: Document | null = document(),
|
|
31
|
+
edit: HostEdit = () => new Promise(() => {}),
|
|
32
|
+
) {
|
|
33
|
+
const store = createStore();
|
|
34
|
+
const source = atom(initial);
|
|
35
|
+
const detached = vi.fn();
|
|
36
|
+
source.onMount = () => detached;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
lifetimes.push(controller);
|
|
39
|
+
const model = createDocumentModel({
|
|
40
|
+
store,
|
|
41
|
+
notebook: { documentAtom: source, edit },
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
const wrapper = ({ children }: PropsWithChildren) => (
|
|
45
|
+
<Provider store={store}>
|
|
46
|
+
<DocumentProvider model={model}>{children}</DocumentProvider>
|
|
47
|
+
</Provider>
|
|
48
|
+
);
|
|
49
|
+
return { store, source, model, controller, detached, wrapper };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("document hooks", () => {
|
|
53
|
+
it("flushes edits through the model and aborts work when the session ends", async () => {
|
|
54
|
+
const accepted = withCells(document(), { c1: cell("c1", "local") });
|
|
55
|
+
const edit = vi
|
|
56
|
+
.fn<HostEdit>()
|
|
57
|
+
.mockResolvedValueOnce({
|
|
58
|
+
ok: true,
|
|
59
|
+
status: 200,
|
|
60
|
+
data: { document: accepted.revision, cells: {} },
|
|
61
|
+
})
|
|
62
|
+
.mockImplementation(
|
|
63
|
+
() => new Deferred<Awaited<ReturnType<HostEdit>>>().promise,
|
|
64
|
+
);
|
|
65
|
+
const { store, source, wrapper, controller } = setup(document(), edit);
|
|
66
|
+
const { result, unmount } = renderHook(
|
|
67
|
+
() => ({
|
|
68
|
+
code: useCellCode("c1"),
|
|
69
|
+
edit: useDocumentEdit(),
|
|
70
|
+
sync: useDocumentSync(),
|
|
71
|
+
}),
|
|
72
|
+
{ wrapper, reactStrictMode: true },
|
|
73
|
+
);
|
|
74
|
+
act(() => result.current.edit((tx) => tx.setCode("c1", "local")));
|
|
75
|
+
expect(result.current.sync.status).toEqual({ status: "pending" });
|
|
76
|
+
const { flush, retry } = result.current.sync;
|
|
77
|
+
await act(async () => {
|
|
78
|
+
const flushed = flush();
|
|
79
|
+
// Allow the response to arrive before publishing its confirmation.
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
store.set(source, accepted);
|
|
82
|
+
await expect(flushed).resolves.toEqual(accepted.revision);
|
|
83
|
+
});
|
|
84
|
+
expect(result.current.sync).toEqual({
|
|
85
|
+
status: { status: "idle" },
|
|
86
|
+
flush,
|
|
87
|
+
retry,
|
|
88
|
+
});
|
|
89
|
+
expect(result.current.code).toBe("local");
|
|
90
|
+
act(() => result.current.edit((tx) => tx.setCode("c1", "next")));
|
|
91
|
+
await act(async () => {
|
|
92
|
+
const rejected = (async () => {
|
|
93
|
+
await expect(flush()).rejects.toThrow("session ended");
|
|
94
|
+
})();
|
|
95
|
+
await Promise.resolve();
|
|
96
|
+
unmount();
|
|
97
|
+
controller.abort(new Error("session ended"));
|
|
98
|
+
await rejected;
|
|
99
|
+
});
|
|
100
|
+
expect(edit).toHaveBeenCalledTimes(2);
|
|
101
|
+
expect(edit.mock.calls[1][2].signal.aborted).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("publishes compound edits atomically without changing host records", () => {
|
|
105
|
+
const initial = document();
|
|
106
|
+
const { store, source, wrapper } = setup(initial);
|
|
107
|
+
const seen = vi.fn();
|
|
108
|
+
const { result } = renderHook(
|
|
109
|
+
() => {
|
|
110
|
+
const value = {
|
|
111
|
+
code: useCellCode("c1"),
|
|
112
|
+
name: useCellName("c1"),
|
|
113
|
+
config: useCellConfig("c1"),
|
|
114
|
+
order: useCellOrder(),
|
|
115
|
+
};
|
|
116
|
+
seen(value);
|
|
117
|
+
return { ...value, edit: useDocumentEdit() };
|
|
118
|
+
},
|
|
119
|
+
{ wrapper },
|
|
120
|
+
);
|
|
121
|
+
const edit = result.current.edit;
|
|
122
|
+
seen.mockClear();
|
|
123
|
+
|
|
124
|
+
act(() =>
|
|
125
|
+
edit((tx) => {
|
|
126
|
+
tx.setCode("c1", "edited");
|
|
127
|
+
tx.setName("c1", "renamed");
|
|
128
|
+
tx.updateConfig("c1", { hide_code: true });
|
|
129
|
+
tx.setOrder(["c2", "c1"]);
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const expected = {
|
|
134
|
+
code: "edited",
|
|
135
|
+
name: "renamed",
|
|
136
|
+
config: { ...CONFIG, hide_code: true },
|
|
137
|
+
order: ["c2", "c1"],
|
|
138
|
+
};
|
|
139
|
+
expect(result.current).toEqual({ ...expected, edit });
|
|
140
|
+
expect(seen.mock.calls.length).toBeGreaterThan(0);
|
|
141
|
+
for (const [value] of seen.mock.calls) {
|
|
142
|
+
expect(value).toEqual(expected);
|
|
143
|
+
}
|
|
144
|
+
expect(store.get(source)).toBe(initial);
|
|
145
|
+
|
|
146
|
+
expect(() =>
|
|
147
|
+
act(() =>
|
|
148
|
+
edit((tx) => {
|
|
149
|
+
tx.setCode("c1", "rolled back");
|
|
150
|
+
tx.deleteCell("missing");
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
).toThrow("does not exist");
|
|
154
|
+
expect(result.current).toEqual({ ...expected, edit });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("subscribes to individual fields and keeps the edit function stable", () => {
|
|
158
|
+
const { store, source, wrapper } = setup();
|
|
159
|
+
const onCode = vi.fn();
|
|
160
|
+
const onName = vi.fn();
|
|
161
|
+
const onConfig = vi.fn();
|
|
162
|
+
const onOrder = vi.fn();
|
|
163
|
+
const onEdit = vi.fn();
|
|
164
|
+
function Code() {
|
|
165
|
+
onCode(useCellCode("c1"));
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function Name() {
|
|
169
|
+
onName(useCellName("c1"));
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function Config() {
|
|
173
|
+
onConfig(useCellConfig("c1"));
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function Order() {
|
|
177
|
+
onOrder(useCellOrder());
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
function Edit() {
|
|
181
|
+
onEdit(useDocumentEdit());
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
render(
|
|
185
|
+
<>
|
|
186
|
+
<Code />
|
|
187
|
+
<Name />
|
|
188
|
+
<Config />
|
|
189
|
+
<Order />
|
|
190
|
+
<Edit />
|
|
191
|
+
</>,
|
|
192
|
+
{ wrapper },
|
|
193
|
+
);
|
|
194
|
+
const edit: ReturnType<typeof useDocumentEdit> = onEdit.mock.calls[0][0];
|
|
195
|
+
const clear = () =>
|
|
196
|
+
[onCode, onName, onConfig, onOrder, onEdit].forEach((fn) =>
|
|
197
|
+
fn.mockClear(),
|
|
198
|
+
);
|
|
199
|
+
clear();
|
|
200
|
+
|
|
201
|
+
act(() => edit((tx) => tx.setCode("c1", "local")));
|
|
202
|
+
expect(onCode).toHaveBeenCalledWith("local");
|
|
203
|
+
expect(onName).not.toHaveBeenCalled();
|
|
204
|
+
expect(onConfig).not.toHaveBeenCalled();
|
|
205
|
+
expect(onOrder).not.toHaveBeenCalled();
|
|
206
|
+
expect(onEdit).not.toHaveBeenCalled();
|
|
207
|
+
clear();
|
|
208
|
+
|
|
209
|
+
act(() => edit((tx) => tx.setCode("c2", "other")));
|
|
210
|
+
expect(
|
|
211
|
+
[onCode, onName, onConfig, onOrder, onEdit].map((fn) => fn.mock.calls),
|
|
212
|
+
).toEqual([[], [], [], [], []]);
|
|
213
|
+
|
|
214
|
+
act(() =>
|
|
215
|
+
store.set(
|
|
216
|
+
source,
|
|
217
|
+
withCells(document(), {
|
|
218
|
+
c1: cell("c1", "remote", {
|
|
219
|
+
name: "host name",
|
|
220
|
+
config: { ...CONFIG },
|
|
221
|
+
}),
|
|
222
|
+
}),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
expect(onName).toHaveBeenCalledWith("host name");
|
|
226
|
+
expect(onCode).not.toHaveBeenCalled();
|
|
227
|
+
expect(onConfig).not.toHaveBeenCalled();
|
|
228
|
+
expect(onOrder).not.toHaveBeenCalled();
|
|
229
|
+
expect(onEdit).not.toHaveBeenCalled();
|
|
230
|
+
clear();
|
|
231
|
+
|
|
232
|
+
act(() =>
|
|
233
|
+
edit((tx) => {
|
|
234
|
+
tx.updateConfig("c1", { disabled: true });
|
|
235
|
+
tx.setOrder(["c2", "c1"]);
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
expect(onConfig).toHaveBeenCalledWith({ ...CONFIG, disabled: true });
|
|
239
|
+
expect(onOrder).toHaveBeenCalledWith(["c2", "c1"]);
|
|
240
|
+
expect(onCode).not.toHaveBeenCalled();
|
|
241
|
+
expect(onName).not.toHaveBeenCalled();
|
|
242
|
+
expect(onEdit).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("switches field subscriptions when the cell id changes", () => {
|
|
246
|
+
const { wrapper } = setup();
|
|
247
|
+
const { result, rerender } = renderHook(
|
|
248
|
+
(id) => ({
|
|
249
|
+
code: useCellCode(id),
|
|
250
|
+
edit: useDocumentEdit(),
|
|
251
|
+
}),
|
|
252
|
+
{ wrapper, initialProps: "c1" },
|
|
253
|
+
);
|
|
254
|
+
const edit = result.current.edit;
|
|
255
|
+
rerender("c2");
|
|
256
|
+
expect(result.current).toEqual({ code: "y = 2", edit });
|
|
257
|
+
act(() => edit((tx) => tx.setCode("c1", "old subscription")));
|
|
258
|
+
expect(result.current).toEqual({ code: "y = 2", edit });
|
|
259
|
+
act(() => edit((tx) => tx.setCode("c2", "new subscription")));
|
|
260
|
+
expect(result.current).toEqual({ code: "new subscription", edit });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("distinguishes unavailable documents, hidden source, empty code, and deleted cells", () => {
|
|
264
|
+
const { store, source, wrapper } = setup(null);
|
|
265
|
+
const { result } = renderHook(
|
|
266
|
+
() => ({
|
|
267
|
+
order: useCellOrder(),
|
|
268
|
+
code: useCellCode("c1"),
|
|
269
|
+
name: useCellName("c1"),
|
|
270
|
+
config: useCellConfig("c1"),
|
|
271
|
+
edit: useDocumentEdit(),
|
|
272
|
+
}),
|
|
273
|
+
{ wrapper },
|
|
274
|
+
);
|
|
275
|
+
const edit = result.current.edit;
|
|
276
|
+
const absent = {
|
|
277
|
+
code: undefined,
|
|
278
|
+
name: undefined,
|
|
279
|
+
config: undefined,
|
|
280
|
+
edit,
|
|
281
|
+
};
|
|
282
|
+
expect(result.current).toEqual({ ...absent, order: null });
|
|
283
|
+
const hidden = withCells(document(), {
|
|
284
|
+
c1: cell("c1", "", { code: null }),
|
|
285
|
+
});
|
|
286
|
+
act(() => store.set(source, hidden));
|
|
287
|
+
expect(result.current).toEqual({
|
|
288
|
+
code: null,
|
|
289
|
+
name: "_",
|
|
290
|
+
config: CONFIG,
|
|
291
|
+
order: ["c1", "c2"],
|
|
292
|
+
edit,
|
|
293
|
+
});
|
|
294
|
+
act(() => edit((tx) => tx.setCode("c1", "")));
|
|
295
|
+
expect(result.current.code).toBe("");
|
|
296
|
+
act(() => store.set(source, null));
|
|
297
|
+
expect(result.current).toEqual({ ...absent, order: null });
|
|
298
|
+
act(() => store.set(source, hidden));
|
|
299
|
+
expect(result.current.code).toBe("");
|
|
300
|
+
act(() =>
|
|
301
|
+
edit((tx) => {
|
|
302
|
+
tx.deleteCell("c1");
|
|
303
|
+
tx.deleteCell("c2");
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
expect(result.current).toEqual({ ...absent, order: [] });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("preserves drafts across Strict Mode remounts until the session ends", () => {
|
|
310
|
+
const { wrapper, controller, detached } = setup();
|
|
311
|
+
const useProbe = () => ({
|
|
312
|
+
code: useCellCode("c1"),
|
|
313
|
+
edit: useDocumentEdit(),
|
|
314
|
+
});
|
|
315
|
+
const first = renderHook(useProbe, { wrapper, reactStrictMode: true });
|
|
316
|
+
const edit = first.result.current.edit;
|
|
317
|
+
act(() => edit((tx) => tx.setCode("c1", "strict mode edit")));
|
|
318
|
+
expect(first.result.current.code).toBe("strict mode edit");
|
|
319
|
+
first.unmount();
|
|
320
|
+
expect(detached).not.toHaveBeenCalled();
|
|
321
|
+
|
|
322
|
+
const second = renderHook(useProbe, { wrapper, reactStrictMode: true });
|
|
323
|
+
expect(second.result.current.code).toBe("strict mode edit");
|
|
324
|
+
act(() => edit((tx) => tx.setCode("c1", "same session")));
|
|
325
|
+
expect(second.result.current.code).toBe("same session");
|
|
326
|
+
second.unmount();
|
|
327
|
+
controller.abort();
|
|
328
|
+
expect(detached).toHaveBeenCalledOnce();
|
|
329
|
+
const callback = vi.fn();
|
|
330
|
+
edit(callback);
|
|
331
|
+
expect(callback).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("switches reads and edits to the supplied model and store", () => {
|
|
335
|
+
const first = setup();
|
|
336
|
+
const second = setup(
|
|
337
|
+
withCells(document("n2"), {
|
|
338
|
+
c1: cell("c1", "second source", { notebook_id: "n2" }),
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
const seen = vi.fn();
|
|
342
|
+
function Probe() {
|
|
343
|
+
seen({ code: useCellCode("c1"), edit: useDocumentEdit() });
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const tree = ({ store, model }: typeof first) => (
|
|
347
|
+
<Provider store={store}>
|
|
348
|
+
<DocumentProvider model={model}>
|
|
349
|
+
<Probe />
|
|
350
|
+
</DocumentProvider>
|
|
351
|
+
</Provider>
|
|
352
|
+
);
|
|
353
|
+
const { rerender } = render(tree(first));
|
|
354
|
+
const oldEdit: ReturnType<typeof useDocumentEdit> =
|
|
355
|
+
seen.mock.calls.at(-1)![0].edit;
|
|
356
|
+
act(() => oldEdit((tx) => tx.setCode("c1", "first draft")));
|
|
357
|
+
rerender(tree(second));
|
|
358
|
+
act(() => first.controller.abort());
|
|
359
|
+
expect(first.detached).toHaveBeenCalledOnce();
|
|
360
|
+
const latest: {
|
|
361
|
+
code: string | null | undefined;
|
|
362
|
+
edit: ReturnType<typeof useDocumentEdit>;
|
|
363
|
+
} = seen.mock.calls.at(-1)![0];
|
|
364
|
+
expect(latest.code).toBe("second source");
|
|
365
|
+
expect(latest.edit).not.toBe(oldEdit);
|
|
366
|
+
act(() => latest.edit((tx) => tx.setCode("c1", "second draft")));
|
|
367
|
+
expect(seen.mock.calls.at(-1)![0].code).toBe("second draft");
|
|
368
|
+
const stale = vi.fn();
|
|
369
|
+
oldEdit(stale);
|
|
370
|
+
expect(stale).not.toHaveBeenCalled();
|
|
371
|
+
seen.mockClear();
|
|
372
|
+
act(() =>
|
|
373
|
+
first.store.set(
|
|
374
|
+
first.source,
|
|
375
|
+
withCells(document(), {
|
|
376
|
+
c1: cell("c1", "stale host"),
|
|
377
|
+
}),
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
expect(seen).not.toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("requires a document provider", () => {
|
|
384
|
+
expect(() => renderHook(useDocumentEdit)).toThrow("DocumentProvider");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { CellId } from "@marimo-team/client/protocol";
|
|
4
|
+
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
|
5
|
+
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
|
6
|
+
import { cellCodeBinding } from "./codemirror";
|
|
7
|
+
import type { EditableCell } from "./draft";
|
|
8
|
+
import type { DocumentModel } from "./model";
|
|
9
|
+
import { selectCellField, selectOrder } from "./select";
|
|
10
|
+
|
|
11
|
+
const DocumentContext = createContext<DocumentModel | null>(null);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Expose a session-owned model to React. Construct the model with the same store
|
|
15
|
+
* as the enclosing Jotai Provider. The session owner controls its AbortSignal;
|
|
16
|
+
* mounting or unmounting this provider does not change the model's lifetime.
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <Provider store={store}>
|
|
20
|
+
* <DocumentProvider model={model}>
|
|
21
|
+
* <Editor />
|
|
22
|
+
* </DocumentProvider>
|
|
23
|
+
* </Provider>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function DocumentProvider({
|
|
27
|
+
model,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
model: DocumentModel;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<DocumentContext.Provider value={model}>
|
|
35
|
+
{children}
|
|
36
|
+
</DocumentContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function useDocumentModel() {
|
|
41
|
+
const model = useContext(DocumentContext);
|
|
42
|
+
if (model === null) {
|
|
43
|
+
throw new Error("Document hooks require a DocumentProvider");
|
|
44
|
+
}
|
|
45
|
+
return model;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Null while the document is unavailable; an empty array for an empty document. */
|
|
49
|
+
export function useCellOrder() {
|
|
50
|
+
const { documentAtom } = useDocumentModel();
|
|
51
|
+
return useAtomValue(useMemo(() => selectOrder(documentAtom), [documentAtom]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function useCellField<K extends keyof EditableCell>(id: CellId, field: K) {
|
|
55
|
+
const { documentAtom } = useDocumentModel();
|
|
56
|
+
return useAtomValue(
|
|
57
|
+
useMemo(
|
|
58
|
+
() => selectCellField(documentAtom, id, field),
|
|
59
|
+
[documentAtom, id, field],
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Undefined for an absent cell; null when the host withholds its source. */
|
|
65
|
+
export function useCellCode(id: CellId) {
|
|
66
|
+
return useCellField(id, "code");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Undefined when the cell or document is unavailable. */
|
|
70
|
+
export function useCellName(id: CellId) {
|
|
71
|
+
return useCellField(id, "name");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Undefined when the cell or document is unavailable. */
|
|
75
|
+
export function useCellConfig(id: CellId) {
|
|
76
|
+
return useCellField(id, "config");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A CodeMirror extension that binds the editor's text to a cell's code.
|
|
81
|
+
* Stable until the cell id or document model changes.
|
|
82
|
+
*
|
|
83
|
+
* ```tsx
|
|
84
|
+
* const extension = useCellCodeExtension(cellId);
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function useCellCodeExtension(id: CellId) {
|
|
88
|
+
const model = useDocumentModel();
|
|
89
|
+
const store = useStore();
|
|
90
|
+
return useMemo(
|
|
91
|
+
() => cellCodeBinding({ store, model, cellId: id }),
|
|
92
|
+
[model, store, id],
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A stable edit function. Synchronous callbacks publish atomically or roll back.
|
|
98
|
+
*
|
|
99
|
+
* ```ts
|
|
100
|
+
* const edit = useDocumentEdit();
|
|
101
|
+
* edit((tx) => tx.setCode(cellId, code));
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function useDocumentEdit() {
|
|
105
|
+
return useSetAtom(useDocumentModel().editAtom);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sync status and explicit recovery. flush waits for edits, including newer
|
|
110
|
+
* typing, to reach the confirmed source and returns the revision for Run/Save.
|
|
111
|
+
* Rejections retain local edits; retry explicitly resubmits them. Requests
|
|
112
|
+
* with an unknown outcome reuse their original key within the retry window.
|
|
113
|
+
*/
|
|
114
|
+
export function useDocumentSync() {
|
|
115
|
+
const model = useDocumentModel();
|
|
116
|
+
const status = useAtomValue(model.syncStatusAtom);
|
|
117
|
+
return { status, flush: model.flush, retry: model.retry };
|
|
118
|
+
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
export {
|
|
4
|
+
DocumentProvider,
|
|
5
|
+
useCellCode,
|
|
6
|
+
useCellCodeExtension,
|
|
7
|
+
useCellConfig,
|
|
8
|
+
useCellName,
|
|
9
|
+
useCellOrder,
|
|
10
|
+
useDocumentEdit,
|
|
11
|
+
useDocumentSync,
|
|
12
|
+
} from "./hooks";
|
|
3
13
|
export { createDocumentModel } from "./model";
|
|
4
14
|
export type {
|
|
5
15
|
DocumentEdit,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { createNotebookReducer, NotebookEvents } from "@marimo-team/client";
|
|
4
|
+
import * as Protocol from "@marimo-team/client/protocol";
|
|
5
|
+
import { afterEach, assert, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { document as documentResource } from "../resources/document";
|
|
7
|
+
import {
|
|
8
|
+
createStaticNotebookBackend,
|
|
9
|
+
type StaticNotebookSnapshot,
|
|
10
|
+
} from "../static";
|
|
11
|
+
import { fixtureEvents } from "./fixtures";
|
|
12
|
+
|
|
13
|
+
function snapshot(): StaticNotebookSnapshot {
|
|
14
|
+
const events = fixtureEvents("notebook-events.sse");
|
|
15
|
+
const notebook = events.find((event) => event.event === "notebook")?.data
|
|
16
|
+
.notebook;
|
|
17
|
+
const document = events.find((event) => event.event === "document")?.data
|
|
18
|
+
.document;
|
|
19
|
+
assert(notebook && document);
|
|
20
|
+
return {
|
|
21
|
+
notebook,
|
|
22
|
+
document,
|
|
23
|
+
cells: events.flatMap((event) =>
|
|
24
|
+
event.event === "cell" ? [event.data.cell] : [],
|
|
25
|
+
),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const streams: NotebookEvents[] = [];
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
const closed = streams.splice(0);
|
|
32
|
+
closed.forEach((stream) => stream.close());
|
|
33
|
+
await Promise.all(closed.map((stream) => stream.closed));
|
|
34
|
+
vi.useRealTimers();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function read(
|
|
38
|
+
backend: ReturnType<typeof createStaticNotebookBackend>,
|
|
39
|
+
cursor?: string,
|
|
40
|
+
) {
|
|
41
|
+
const stream = new NotebookEvents(backend.transport, { cursor });
|
|
42
|
+
streams.push(stream);
|
|
43
|
+
const events: Protocol.EventOutput[] = [];
|
|
44
|
+
await new Promise<void>((resolve, reject) => {
|
|
45
|
+
stream.subscribe((event) => {
|
|
46
|
+
events.push(Protocol.Event.parse(event));
|
|
47
|
+
if (event.event === "ready") {
|
|
48
|
+
resolve();
|
|
49
|
+
}
|
|
50
|
+
}, reject);
|
|
51
|
+
});
|
|
52
|
+
return { stream, events };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("static notebook backend", () => {
|
|
56
|
+
it("supplies a complete document through the normal event reader and reducer", async () => {
|
|
57
|
+
const source = snapshot();
|
|
58
|
+
const { stream, events } = await read(createStaticNotebookBackend(source));
|
|
59
|
+
const reducer = createNotebookReducer({ document: documentResource });
|
|
60
|
+
const state = events.reduce(reducer.reduce, reducer.initial);
|
|
61
|
+
expect(events.map((event) => event.event)).toEqual([
|
|
62
|
+
"notebook",
|
|
63
|
+
"cell",
|
|
64
|
+
"cell",
|
|
65
|
+
"cell",
|
|
66
|
+
"document",
|
|
67
|
+
"ready",
|
|
68
|
+
]);
|
|
69
|
+
expect(state.notebook).toEqual({ ...source.notebook, operations: [] });
|
|
70
|
+
expect(state.resources.document.current).toEqual({
|
|
71
|
+
revision: source.document,
|
|
72
|
+
cells: new Map(source.cells.map((cell) => [cell.id, cell])),
|
|
73
|
+
});
|
|
74
|
+
expect(stream.status).toBe("connected");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("preserves withheld source and its revision metadata", async () => {
|
|
78
|
+
const source = snapshot();
|
|
79
|
+
const cells = source.cells.map((cell) => ({ ...cell, code: null }));
|
|
80
|
+
const { events } = await read(
|
|
81
|
+
createStaticNotebookBackend({ ...source, cells }),
|
|
82
|
+
);
|
|
83
|
+
expect(
|
|
84
|
+
events
|
|
85
|
+
.filter((event) => event.event === "cell")
|
|
86
|
+
.map((event) => event.data.cell),
|
|
87
|
+
).toEqual(cells);
|
|
88
|
+
expect(
|
|
89
|
+
events.find((event) => event.event === "document")?.data.document,
|
|
90
|
+
).toEqual(source.document);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("serves an empty document as available", async () => {
|
|
94
|
+
const source = snapshot();
|
|
95
|
+
const document = { ...source.document, order: [] };
|
|
96
|
+
const { events } = await read(
|
|
97
|
+
createStaticNotebookBackend({ ...source, document, cells: [] }),
|
|
98
|
+
);
|
|
99
|
+
const reducer = createNotebookReducer({ document: documentResource });
|
|
100
|
+
expect(
|
|
101
|
+
events.reduce(reducer.reduce, reducer.initial).resources.document.current,
|
|
102
|
+
).toEqual({
|
|
103
|
+
revision: document,
|
|
104
|
+
cells: new Map(),
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps independent readers open without polling or reconnecting", async () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
const backend = createStaticNotebookBackend(snapshot());
|
|
111
|
+
const first = await read(backend);
|
|
112
|
+
const second = await read(backend);
|
|
113
|
+
first.stream.close();
|
|
114
|
+
await first.stream.closed;
|
|
115
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
116
|
+
expect(second.events).toEqual(first.events);
|
|
117
|
+
expect(second.stream.status).toBe("connected");
|
|
118
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("resets a resume cursor before replaying its snapshot", async () => {
|
|
122
|
+
const { events } = await read(
|
|
123
|
+
createStaticNotebookBackend(snapshot()),
|
|
124
|
+
"previous-10",
|
|
125
|
+
);
|
|
126
|
+
expect(events[0]).toEqual({ event: "reset", data: {} });
|
|
127
|
+
expect(events.at(-1)).toEqual({ event: "ready", id: "static", data: {} });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("advertises no mutations and returns a protocol failure for direct edits", async () => {
|
|
131
|
+
const backend = createStaticNotebookBackend(snapshot());
|
|
132
|
+
expect(await backend.client.edit([])).toEqual({
|
|
133
|
+
ok: false,
|
|
134
|
+
status: 501,
|
|
135
|
+
problem: {
|
|
136
|
+
type: "tag:marimo.io,2026:host/unsupported-operation",
|
|
137
|
+
title: "Unsupported operation",
|
|
138
|
+
status: 501,
|
|
139
|
+
detail: "Static notebooks do not support editing",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("respects cancellation before opening, during delivery, and for commands", async () => {
|
|
145
|
+
const backend = createStaticNotebookBackend(snapshot());
|
|
146
|
+
const before = new AbortController();
|
|
147
|
+
before.abort();
|
|
148
|
+
const open = vi.fn();
|
|
149
|
+
const message = vi.fn();
|
|
150
|
+
await backend.transport(
|
|
151
|
+
{ cursor: null, signal: before.signal },
|
|
152
|
+
{ open, message },
|
|
153
|
+
);
|
|
154
|
+
expect(open).not.toHaveBeenCalled();
|
|
155
|
+
expect(message).not.toHaveBeenCalled();
|
|
156
|
+
await expect(
|
|
157
|
+
backend.client.edit([], { signal: before.signal }),
|
|
158
|
+
).rejects.toBe(before.signal.reason);
|
|
159
|
+
const during = new AbortController();
|
|
160
|
+
message.mockImplementation(() => during.abort());
|
|
161
|
+
await backend.transport(
|
|
162
|
+
{ cursor: null, signal: during.signal },
|
|
163
|
+
{ open, message },
|
|
164
|
+
);
|
|
165
|
+
expect(message).toHaveBeenCalledOnce();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { NotebookClient, NotebookTransport } from "@marimo-team/client";
|
|
4
|
+
|
|
5
|
+
/** Events and commands for the same notebook, independent of React or a replica. */
|
|
6
|
+
export interface NotebookBackend {
|
|
7
|
+
readonly transport: NotebookTransport;
|
|
8
|
+
/** Method presence is not permission; the notebook advertises its operations. */
|
|
9
|
+
readonly client: Pick<NotebookClient, "edit">;
|
|
10
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
4
|
+
import type { NotebookBackend } from "./backend";
|
|
5
|
+
|
|
6
|
+
/** A document snapshot with its own identity and revision metadata. */
|
|
7
|
+
export interface StaticNotebookSnapshot {
|
|
8
|
+
readonly notebook: Omit<Protocol.Notebook, "operations">;
|
|
9
|
+
readonly document: Protocol.Document;
|
|
10
|
+
readonly cells: readonly Protocol.Cell[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Serve a document without a server, runtime, or mutation operations.
|
|
15
|
+
* Callers and readers must treat the snapshot records as immutable.
|
|
16
|
+
*/
|
|
17
|
+
export function createStaticNotebookBackend(
|
|
18
|
+
snapshot: StaticNotebookSnapshot,
|
|
19
|
+
): NotebookBackend {
|
|
20
|
+
const frames: Protocol.Event[] = [
|
|
21
|
+
{
|
|
22
|
+
event: "notebook",
|
|
23
|
+
data: { notebook: { ...snapshot.notebook, operations: [] } },
|
|
24
|
+
},
|
|
25
|
+
...snapshot.cells.map((cell): Protocol.CellEvent => ({
|
|
26
|
+
event: "cell",
|
|
27
|
+
data: { cell },
|
|
28
|
+
})),
|
|
29
|
+
{ event: "document", data: { document: snapshot.document } },
|
|
30
|
+
{ event: "ready", id: "static", data: {} },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
transport: ({ cursor, signal }, { open, message }) =>
|
|
35
|
+
new Promise((resolve) => {
|
|
36
|
+
if (signal.aborted) {
|
|
37
|
+
resolve();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Finishing would ask NotebookEvents to reconnect. A snapshot stays open
|
|
41
|
+
// until its reader closes, even though there are no more updates.
|
|
42
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
43
|
+
open();
|
|
44
|
+
if (cursor !== null && !signal.aborted) {
|
|
45
|
+
message({ event: "reset", data: {} });
|
|
46
|
+
}
|
|
47
|
+
for (const frame of frames) {
|
|
48
|
+
if (signal.aborted) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
message(frame);
|
|
52
|
+
}
|
|
53
|
+
}),
|
|
54
|
+
client: {
|
|
55
|
+
edit: async (_changes, options) => {
|
|
56
|
+
options?.signal?.throwIfAborted();
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
status: 501,
|
|
60
|
+
problem: {
|
|
61
|
+
type: "tag:marimo.io,2026:host/unsupported-operation",
|
|
62
|
+
title: "Unsupported operation",
|
|
63
|
+
status: 501,
|
|
64
|
+
detail: "Static notebooks do not support editing",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|