@marimo-team/islands 0.25.3-dev129 → 0.25.3-dev134
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/{ConnectedDataExplorerComponent-q0C1F7Sn.js → ConnectedDataExplorerComponent-BAiSVaHj.js} +3 -1
- package/dist/{ErrorBoundary-BwvkPkyC.js → ErrorBoundary-CImYa86e.js} +1 -1
- package/dist/{chat-ui-C0nJ84Wf.js → chat-ui-DqcauBr6.js} +21 -20
- package/dist/{code-visibility-BI1HUhjs.js → code-visibility-BZoWT5f1.js} +2 -2
- package/dist/{common-CMm0Ot4s.js → common-CGmIoGK3.js} +4383 -4327
- package/dist/{data-grid-overlay-editor-DMLTMI_S.js → data-grid-overlay-editor-CtOna148.js} +1 -1
- package/dist/{error-entries-BL2ZBgVo.js → error-entries-CmFDQ9CA.js} +109 -105
- package/dist/{feedback-button-BrpQxIbi.js → feedback-button-7ZbDdjfz.js} +14 -14
- package/dist/{glide-data-editor-DBsCdnOC.js → glide-data-editor-D72zmCan.js} +3 -3
- package/dist/{globals-BWb8HdpC.js → globals-DE2S2io9.js} +1 -1
- package/dist/{html-to-image-D9gz793p.js → html-to-image-CfgsWObF.js} +19 -19
- package/dist/main.js +5504 -5477
- package/dist/{number-overlay-editor-CUARNHHp.js → number-overlay-editor-Br0lPnFE.js} +1 -1
- package/dist/{process-output-761ZoDH1.js → process-output-B64ZYWuI.js} +3 -3
- package/dist/{reveal-component-CJjh6lrq.js → reveal-component-DnbPU885.js} +12 -12
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/module-entry.test.ts +32 -0
- package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
- package/src/components/ai/ai-model-dropdown.tsx +11 -6
- package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
- package/src/components/chat/acp/blocks.tsx +1 -0
- package/src/components/chat/acp/session-tabs.tsx +8 -3
- package/src/components/data-table/TableTopBar.tsx +2 -0
- package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
- package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
- package/src/components/data-table/data-table.tsx +6 -0
- package/src/components/data-table/range-focus/atoms.ts +4 -6
- package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
- package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
- package/src/components/data-table/renderers.tsx +16 -7
- package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
- package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
- package/src/components/datasources/datasources.tsx +1 -0
- package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
- package/src/components/editor/cell/collapse.tsx +5 -3
- package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
- package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
- package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
- package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
- package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
- package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
- package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
- package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
- package/src/components/editor/file-tree/file-explorer.tsx +35 -3
- package/src/components/editor/file-tree/file-name-input.tsx +1 -0
- package/src/components/editor/header/status.tsx +28 -5
- package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
- package/src/components/editor/links/cell-link.tsx +14 -5
- package/src/components/editor/navigation/focus-utils.ts +4 -4
- package/src/components/editor/notebook-cell.tsx +11 -2
- package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
- package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
- package/src/components/editor/package-alert.tsx +8 -3
- package/src/components/scratchpad/scratchpad.test.tsx +95 -0
- package/src/components/scratchpad/scratchpad.tsx +14 -1
- package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
- package/src/components/tracing/tracing.tsx +43 -39
- package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
- package/src/components/ui/draggable-popover.tsx +24 -2
- package/src/core/attachments/state.ts +2 -2
- package/src/core/document/__tests__/codemirror.test.ts +3 -1
- package/src/core/document/__tests__/hooks.test.tsx +7 -7
- package/src/core/document/__tests__/model.test.ts +44 -11
- package/src/core/document/__tests__/scenario-fixture.ts +2 -1
- package/src/core/document/__tests__/test-notebook.ts +19 -9
- package/src/core/document/draft.ts +1 -1
- package/src/core/document/index.ts +3 -10
- package/src/core/document/model.ts +14 -11
- package/src/core/document/state.ts +1 -1
- package/src/core/document/sync.ts +24 -18
- package/src/core/host/AGENTS.md +3 -7
- package/src/core/host/__tests__/fixtures.ts +30 -0
- package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
- package/src/core/host/__tests__/wasm.test.ts +8 -0
- package/src/core/host/backend.ts +4 -0
- package/src/core/host/index.ts +14 -0
- package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
- package/src/core/host/static.ts +2 -0
- package/src/core/host/wasm.ts +10 -1
- package/src/core/wasm/bridge.ts +1 -1
- package/src/core/websocket/__tests__/backend.test.ts +111 -22
- package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
- package/src/core/websocket/backend.ts +43 -5
- package/src/core/websocket/connection-state.ts +1 -1
- package/src/core/websocket/state.ts +3 -3
- package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
- package/src/core/websocket/types.ts +2 -2
- package/src/core/websocket/useNotebookReplica.ts +69 -0
- package/src/core/websocket/useNotebookSession.tsx +6 -6
- package/src/plugins/impl/chat/chat-ui.tsx +2 -1
- package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
- package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
- package/src/plugins/impl/data-frames/panel.tsx +43 -16
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
- package/src/core/websocket/useNotebookClient.ts +0 -89
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { createStore, Provider } from "jotai";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
|
+
import { type Run, type RunId, runsAtom } from "@/core/cells/runs";
|
|
8
|
+
import { Tracing } from "./tracing";
|
|
9
|
+
|
|
10
|
+
vi.mock("react-vega", () => ({ useVegaEmbed: () => undefined }));
|
|
11
|
+
vi.mock("vega-lite", () => ({ compile: () => ({ spec: {} }) }));
|
|
12
|
+
vi.mock("@/components/editor/chrome/panels/panel-context", () => ({
|
|
13
|
+
usePanelSection: () => "sidebar",
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const run: Run = {
|
|
17
|
+
runId: "run-1" as RunId,
|
|
18
|
+
runStartTime: 1_704_067_200,
|
|
19
|
+
cellRuns: new Map(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("trace disclosure keyboard focus", () => {
|
|
23
|
+
it.each([false, true])(
|
|
24
|
+
"keeps the header focused across toggles (inline=%s)",
|
|
25
|
+
(inline) => {
|
|
26
|
+
const store = createStore();
|
|
27
|
+
store.set(runsAtom, {
|
|
28
|
+
runIds: [run.runId],
|
|
29
|
+
runMap: new Map([[run.runId, run]]),
|
|
30
|
+
});
|
|
31
|
+
render(
|
|
32
|
+
<Provider store={store}>
|
|
33
|
+
<TooltipProvider>
|
|
34
|
+
<Tracing />
|
|
35
|
+
</TooltipProvider>
|
|
36
|
+
</Provider>,
|
|
37
|
+
);
|
|
38
|
+
if (inline) {
|
|
39
|
+
fireEvent.click(screen.getByRole("checkbox", { name: "Inline chart" }));
|
|
40
|
+
}
|
|
41
|
+
const header = screen.getByRole("button", { name: /^Run -/ });
|
|
42
|
+
act(() => header.focus());
|
|
43
|
+
for (const expanded of [false, true, false]) {
|
|
44
|
+
// Native buttons emit a click with detail=0 for keyboard activation.
|
|
45
|
+
fireEvent.click(header, { detail: 0 });
|
|
46
|
+
expect(screen.getByRole("button", { name: /^Run -/ })).toBe(header);
|
|
47
|
+
expect(header).toHaveFocus();
|
|
48
|
+
expect(header).toHaveAttribute("aria-expanded", String(expanded));
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it("does not steal focus when a newer run expands automatically", () => {
|
|
54
|
+
const store = createStore();
|
|
55
|
+
store.set(runsAtom, {
|
|
56
|
+
runIds: [run.runId],
|
|
57
|
+
runMap: new Map([[run.runId, run]]),
|
|
58
|
+
});
|
|
59
|
+
render(
|
|
60
|
+
<Provider store={store}>
|
|
61
|
+
<TooltipProvider>
|
|
62
|
+
<Tracing />
|
|
63
|
+
</TooltipProvider>
|
|
64
|
+
</Provider>,
|
|
65
|
+
);
|
|
66
|
+
const header = screen.getByRole("button", { name: /^Run -/ });
|
|
67
|
+
act(() => header.focus());
|
|
68
|
+
const newer: Run = {
|
|
69
|
+
...run,
|
|
70
|
+
runId: "run-2" as RunId,
|
|
71
|
+
runStartTime: run.runStartTime + 1,
|
|
72
|
+
};
|
|
73
|
+
act(() => {
|
|
74
|
+
store.set(runsAtom, {
|
|
75
|
+
runIds: [newer.runId, run.runId],
|
|
76
|
+
runMap: new Map([
|
|
77
|
+
[run.runId, run],
|
|
78
|
+
[newer.runId, newer],
|
|
79
|
+
]),
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
expect(header).toHaveFocus();
|
|
83
|
+
expect(header).toHaveAttribute("aria-expanded", "false");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -52,6 +52,7 @@ export const Tracing: React.FC = () => {
|
|
|
52
52
|
|
|
53
53
|
const { theme } = useTheme();
|
|
54
54
|
const [chartPosition, setChartPosition] = useState<ChartPosition>("above");
|
|
55
|
+
const chartPositionId = React.useId();
|
|
55
56
|
const panelSection = usePanelSection();
|
|
56
57
|
|
|
57
58
|
const toggleChartPosition = () => {
|
|
@@ -76,11 +77,12 @@ export const Tracing: React.FC = () => {
|
|
|
76
77
|
<div className="py-1 px-2 overflow-y-scroll h-full">
|
|
77
78
|
<div className="flex flex-row justify-start gap-3">
|
|
78
79
|
<div className="flex flex-row gap-1 items-center">
|
|
79
|
-
<label htmlFor=
|
|
80
|
+
<label htmlFor={chartPositionId} className="text-xs">
|
|
80
81
|
Inline chart
|
|
81
82
|
</label>
|
|
82
83
|
<input
|
|
83
84
|
type="checkbox"
|
|
85
|
+
id={chartPositionId}
|
|
84
86
|
name="chartPosition"
|
|
85
87
|
data-testid="chartPosition"
|
|
86
88
|
onClick={toggleChartPosition}
|
|
@@ -163,28 +165,31 @@ const TraceBlock: React.FC<{
|
|
|
163
165
|
const chevron = <Icon height={16} className="inline" />;
|
|
164
166
|
|
|
165
167
|
const traceTitle = (
|
|
166
|
-
<
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
aria-expanded={isExpanded}
|
|
171
|
+
className="text-sm cursor-pointer"
|
|
172
|
+
onClick={onToggleExpanded}
|
|
173
|
+
>
|
|
167
174
|
Run - {formatLogTimestamp(run.runStartTime)}
|
|
168
175
|
{chevron}
|
|
169
|
-
</
|
|
176
|
+
</button>
|
|
170
177
|
);
|
|
171
178
|
|
|
172
|
-
if (!isExpanded) {
|
|
173
|
-
return (
|
|
174
|
-
<div key={run.runId} className="flex flex-col">
|
|
175
|
-
<pre className="font-mono font-semibold">{traceTitle}</pre>
|
|
176
|
-
</div>
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
179
|
return (
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
<div
|
|
181
|
+
className={cn(
|
|
182
|
+
"grid grid-cols-1",
|
|
183
|
+
isExpanded &&
|
|
184
|
+
chartPosition === "sideBySide" &&
|
|
185
|
+
"grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_1fr]",
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
<pre className="font-mono font-semibold">{traceTitle}</pre>
|
|
189
|
+
{isExpanded && (
|
|
190
|
+
<TraceBlockBody run={run} chartPosition={chartPosition} theme={theme} />
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
188
193
|
);
|
|
189
194
|
};
|
|
190
195
|
|
|
@@ -192,8 +197,7 @@ const TraceBlockBody: React.FC<{
|
|
|
192
197
|
run: Run;
|
|
193
198
|
chartPosition: ChartPosition;
|
|
194
199
|
theme: ResolvedTheme;
|
|
195
|
-
|
|
196
|
-
}> = ({ run, chartPosition, theme, title }) => {
|
|
200
|
+
}> = ({ run, chartPosition, theme }) => {
|
|
197
201
|
const [hoveredCellId, setHoveredCellId] = useState<CellId | null>();
|
|
198
202
|
const vegaRef = useRef<HTMLDivElement>(null);
|
|
199
203
|
const { ref, width = 300 } = useResizeObserver<HTMLDivElement>();
|
|
@@ -264,16 +268,22 @@ const TraceBlockBody: React.FC<{
|
|
|
264
268
|
}, [embed]);
|
|
265
269
|
|
|
266
270
|
const traceRows = (
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
<div className="font-mono font-semibold">
|
|
272
|
+
<TraceRows
|
|
273
|
+
run={run}
|
|
274
|
+
hoveredCellId={hoveredCellId}
|
|
275
|
+
hiddenInputElementId={hiddenInputElementId}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
272
278
|
);
|
|
273
279
|
|
|
274
280
|
const chartElement = (
|
|
275
281
|
<div
|
|
276
|
-
className={
|
|
282
|
+
className={
|
|
283
|
+
chartPosition === "sideBySide"
|
|
284
|
+
? "-mt-0.5 col-start-2 row-start-1 row-span-2 min-w-0"
|
|
285
|
+
: "min-w-0"
|
|
286
|
+
}
|
|
277
287
|
ref={ref}
|
|
278
288
|
>
|
|
279
289
|
<Suspense>
|
|
@@ -284,24 +294,18 @@ const TraceBlockBody: React.FC<{
|
|
|
284
294
|
|
|
285
295
|
if (chartPosition === "above") {
|
|
286
296
|
return (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
{traceRows}
|
|
292
|
-
</pre>
|
|
293
|
-
</div>
|
|
297
|
+
<>
|
|
298
|
+
{chartElement}
|
|
299
|
+
{traceRows}
|
|
300
|
+
</>
|
|
294
301
|
);
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
return (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
{title}
|
|
301
|
-
{traceRows}
|
|
302
|
-
</pre>
|
|
305
|
+
<>
|
|
306
|
+
{traceRows}
|
|
303
307
|
{chartElement}
|
|
304
|
-
|
|
308
|
+
</>
|
|
305
309
|
);
|
|
306
310
|
};
|
|
307
311
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { DraggablePopover } from "../draggable-popover";
|
|
6
|
+
|
|
7
|
+
it("moves from the keyboard handle without intercepting child input arrows", () => {
|
|
8
|
+
render(
|
|
9
|
+
<DraggablePopover open={true}>
|
|
10
|
+
<input aria-label="Filter" />
|
|
11
|
+
</DraggablePopover>,
|
|
12
|
+
);
|
|
13
|
+
const handle = screen.getByRole("button", {
|
|
14
|
+
name: "Move popup with arrow keys",
|
|
15
|
+
});
|
|
16
|
+
const popup = screen.getByRole("dialog");
|
|
17
|
+
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
|
18
|
+
fireEvent.keyDown(handle, { key: "ArrowDown" });
|
|
19
|
+
expect(popup).toHaveStyle({ left: "10px", top: "10px" });
|
|
20
|
+
expect(
|
|
21
|
+
fireEvent.keyDown(screen.getByRole("textbox"), { key: "ArrowLeft" }),
|
|
22
|
+
).toBe(true);
|
|
23
|
+
expect(popup).toHaveStyle({ left: "10px", top: "10px" });
|
|
24
|
+
});
|
|
@@ -53,14 +53,36 @@ export const DraggablePopover = ({
|
|
|
53
53
|
top: position.y,
|
|
54
54
|
}}
|
|
55
55
|
>
|
|
56
|
-
<
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
aria-label="Move popup with arrow keys"
|
|
57
59
|
onMouseDown={handleMouseDown}
|
|
60
|
+
onKeyDown={(e) => {
|
|
61
|
+
const directions: Record<
|
|
62
|
+
string,
|
|
63
|
+
{ x: number; y: number } | undefined
|
|
64
|
+
> = {
|
|
65
|
+
ArrowLeft: { x: -10, y: 0 },
|
|
66
|
+
ArrowRight: { x: 10, y: 0 },
|
|
67
|
+
ArrowUp: { x: 0, y: -10 },
|
|
68
|
+
ArrowDown: { x: 0, y: 10 },
|
|
69
|
+
};
|
|
70
|
+
const offset = directions[e.key];
|
|
71
|
+
if (offset) {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
setPosition((previous) => ({
|
|
75
|
+
x: previous.x + offset.x,
|
|
76
|
+
y: previous.y + offset.y,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
58
80
|
className={`flex items-center justify-center absolute top-0 left-1/2 -translate-x-1/2 ${
|
|
59
81
|
isDragging ? "cursor-grabbing" : "cursor-grab"
|
|
60
82
|
}`}
|
|
61
83
|
>
|
|
62
84
|
<GripHorizontalIcon className="h-5 w-5 mt-1 text-muted-foreground/40" />
|
|
63
|
-
</
|
|
85
|
+
</button>
|
|
64
86
|
{children}
|
|
65
87
|
</PopoverContent>
|
|
66
88
|
</Popover>
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { atom } from "jotai";
|
|
4
4
|
import type { Attachment } from "@marimo-team/client/protocol";
|
|
5
|
-
import {
|
|
5
|
+
import { notebookReplicaAtom } from "@/core/websocket/state";
|
|
6
6
|
|
|
7
7
|
export type { Attachment } from "@marimo-team/client/protocol";
|
|
8
8
|
|
|
9
9
|
export const agentAttachmentsAtom = atom((get) => {
|
|
10
|
-
const notebook = get(
|
|
10
|
+
const notebook = get(notebookReplicaAtom);
|
|
11
11
|
return (notebook ? get(notebook.attachmentsAtom) : []).filter(
|
|
12
12
|
(attachment) => attachment.kind === "agent",
|
|
13
13
|
);
|
|
@@ -24,6 +24,7 @@ import { SCHEMA_CACHE } from "../../codemirror/language/languages/sql/completion
|
|
|
24
24
|
import type { Document } from "../../host/resources/document";
|
|
25
25
|
import { cellCodeBinding } from "../codemirror";
|
|
26
26
|
import { createDocumentModel } from "../model";
|
|
27
|
+
import { fakeBackend } from "../../host/__tests__/fixtures";
|
|
27
28
|
import { cell, document, withCells } from "./fixtures";
|
|
28
29
|
|
|
29
30
|
const views: EditorView[] = [];
|
|
@@ -87,7 +88,8 @@ function setup({
|
|
|
87
88
|
lifetimes.push(controller);
|
|
88
89
|
const model = createDocumentModel({
|
|
89
90
|
store,
|
|
90
|
-
|
|
91
|
+
documentAtom: source,
|
|
92
|
+
backendAtom: atom(fakeBackend({ edit: () => new Promise(() => {}) })),
|
|
91
93
|
signal: controller.signal,
|
|
92
94
|
});
|
|
93
95
|
const editor = (text = doc) => {
|
|
@@ -15,11 +15,10 @@ import {
|
|
|
15
15
|
useCellOrder,
|
|
16
16
|
useDocumentEdit,
|
|
17
17
|
useDocumentSync,
|
|
18
|
-
type DocumentHost,
|
|
19
18
|
} from "..";
|
|
20
19
|
import { cell, CONFIG, document, withCells } from "./fixtures";
|
|
20
|
+
import { type FakeEdit, fakeBackend } from "../../host/__tests__/fixtures";
|
|
21
21
|
|
|
22
|
-
type HostEdit = DocumentHost["edit"];
|
|
23
22
|
const lifetimes: AbortController[] = [];
|
|
24
23
|
|
|
25
24
|
afterEach(() => {
|
|
@@ -28,7 +27,7 @@ afterEach(() => {
|
|
|
28
27
|
|
|
29
28
|
function setup(
|
|
30
29
|
initial: Document | null = document(),
|
|
31
|
-
edit:
|
|
30
|
+
edit: FakeEdit = () => new Promise(() => {}),
|
|
32
31
|
) {
|
|
33
32
|
const store = createStore();
|
|
34
33
|
const source = atom(initial);
|
|
@@ -38,7 +37,8 @@ function setup(
|
|
|
38
37
|
lifetimes.push(controller);
|
|
39
38
|
const model = createDocumentModel({
|
|
40
39
|
store,
|
|
41
|
-
|
|
40
|
+
documentAtom: source,
|
|
41
|
+
backendAtom: atom(fakeBackend({ edit })),
|
|
42
42
|
signal: controller.signal,
|
|
43
43
|
});
|
|
44
44
|
const wrapper = ({ children }: PropsWithChildren) => (
|
|
@@ -53,14 +53,14 @@ describe("document hooks", () => {
|
|
|
53
53
|
it("flushes edits through the model and aborts work when the session ends", async () => {
|
|
54
54
|
const accepted = withCells(document(), { c1: cell("c1", "local") });
|
|
55
55
|
const edit = vi
|
|
56
|
-
.fn<
|
|
56
|
+
.fn<FakeEdit>()
|
|
57
57
|
.mockResolvedValueOnce({
|
|
58
58
|
ok: true,
|
|
59
59
|
status: 200,
|
|
60
60
|
data: { document: accepted.revision, cells: {} },
|
|
61
61
|
})
|
|
62
62
|
.mockImplementation(
|
|
63
|
-
() => new Deferred<Awaited<ReturnType<
|
|
63
|
+
() => new Deferred<Awaited<ReturnType<FakeEdit>>>().promise,
|
|
64
64
|
);
|
|
65
65
|
const { store, source, wrapper, controller } = setup(document(), edit);
|
|
66
66
|
const { result, unmount } = renderHook(
|
|
@@ -98,7 +98,7 @@ describe("document hooks", () => {
|
|
|
98
98
|
await rejected;
|
|
99
99
|
});
|
|
100
100
|
expect(edit).toHaveBeenCalledTimes(2);
|
|
101
|
-
expect(edit.mock.calls[1][2]
|
|
101
|
+
expect(edit.mock.calls[1][2]?.signal?.aborted).toBe(true);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it("publishes compound edits atomically without changing host records", () => {
|
|
@@ -5,8 +5,10 @@ import type * as Protocol from "@marimo-team/client/protocol";
|
|
|
5
5
|
import { atom, createStore } from "jotai/vanilla";
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { Deferred } from "@/utils/Deferred";
|
|
8
|
+
import type { NotebookBackend } from "../../host/backend";
|
|
8
9
|
import type { Document } from "../../host/resources/document";
|
|
9
|
-
import { createDocumentModel
|
|
10
|
+
import { createDocumentModel } from "../model";
|
|
11
|
+
import { type FakeEdit, fakeBackend } from "../../host/__tests__/fixtures";
|
|
10
12
|
import { cell, document, withCells } from "./fixtures";
|
|
11
13
|
|
|
12
14
|
function response(confirmed: Document): Result<Protocol.EditResponseOutput> {
|
|
@@ -30,11 +32,11 @@ async function expectAbort(promise: Promise<unknown>, reason: Error) {
|
|
|
30
32
|
interface SentEdit {
|
|
31
33
|
notebookId: Protocol.NotebookId;
|
|
32
34
|
changes: readonly Protocol.Change[];
|
|
33
|
-
idempotencyKey
|
|
34
|
-
signal
|
|
35
|
+
idempotencyKey?: string;
|
|
36
|
+
signal?: AbortSignal;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
function setup() {
|
|
39
|
+
function setup({ connected = true } = {}) {
|
|
38
40
|
const store = createStore();
|
|
39
41
|
const source = atom<Document | null>(document());
|
|
40
42
|
const unmounted = vi.fn();
|
|
@@ -45,21 +47,24 @@ function setup() {
|
|
|
45
47
|
replies.push(reply);
|
|
46
48
|
return reply.promise;
|
|
47
49
|
});
|
|
48
|
-
const
|
|
49
|
-
documentAtom: source,
|
|
50
|
+
const backend = fakeBackend({
|
|
50
51
|
edit: (notebookId, changes, options) =>
|
|
51
52
|
sent({ notebookId, changes, ...options }),
|
|
52
|
-
};
|
|
53
|
+
});
|
|
54
|
+
const backendAtom = atom<NotebookBackend | null>(connected ? backend : null);
|
|
53
55
|
const controller = new AbortController();
|
|
54
56
|
lifetimes.push(controller);
|
|
55
57
|
const model = createDocumentModel({
|
|
56
58
|
store,
|
|
57
|
-
|
|
59
|
+
documentAtom: source,
|
|
60
|
+
backendAtom,
|
|
58
61
|
signal: controller.signal,
|
|
59
62
|
});
|
|
60
63
|
return {
|
|
61
64
|
store,
|
|
62
65
|
source,
|
|
66
|
+
backend,
|
|
67
|
+
backendAtom,
|
|
63
68
|
model,
|
|
64
69
|
sent,
|
|
65
70
|
replies,
|
|
@@ -88,11 +93,12 @@ describe("document synchronization", () => {
|
|
|
88
93
|
const controller = new AbortController();
|
|
89
94
|
const reason = new Error("Session ended");
|
|
90
95
|
controller.abort(reason);
|
|
91
|
-
const edit = vi.fn<
|
|
96
|
+
const edit = vi.fn<FakeEdit>();
|
|
92
97
|
expect(() =>
|
|
93
98
|
createDocumentModel({
|
|
94
99
|
store,
|
|
95
|
-
|
|
100
|
+
documentAtom: source,
|
|
101
|
+
backendAtom: atom(fakeBackend({ edit })),
|
|
96
102
|
signal: controller.signal,
|
|
97
103
|
}),
|
|
98
104
|
).toThrow(reason);
|
|
@@ -152,7 +158,34 @@ describe("document synchronization", () => {
|
|
|
152
158
|
await vi.advanceTimersByTimeAsync(0);
|
|
153
159
|
store.set(source, document("n2"));
|
|
154
160
|
await rejected;
|
|
155
|
-
expect(sent.mock.calls[0][0].signal
|
|
161
|
+
expect(sent.mock.calls[0][0].signal?.aborted).toBe(true);
|
|
156
162
|
expect(status()).toMatchObject({ status: "error", retryable: false });
|
|
157
163
|
});
|
|
164
|
+
|
|
165
|
+
it("aborts an in-flight edit when the backend changes and never sends it to the new one", async () => {
|
|
166
|
+
const { store, backendAtom, sent, edit, model } = setup();
|
|
167
|
+
edit("local");
|
|
168
|
+
const rejected = expectFailure(model.flush(), "notebook changed");
|
|
169
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
170
|
+
const other = vi.fn<FakeEdit>();
|
|
171
|
+
store.set(backendAtom, fakeBackend({ edit: other }));
|
|
172
|
+
await rejected;
|
|
173
|
+
expect(sent).toHaveBeenCalledOnce();
|
|
174
|
+
expect(sent.mock.calls[0][0].signal?.aborted).toBe(true);
|
|
175
|
+
expect(other).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps edits as drafts until a backend is connected", async () => {
|
|
179
|
+
const { store, backend, backendAtom, sent, edit, status, value } = setup({
|
|
180
|
+
connected: false,
|
|
181
|
+
});
|
|
182
|
+
edit("local");
|
|
183
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
184
|
+
expect(status()).toEqual({ status: "unavailable" });
|
|
185
|
+
expect(value()?.code).toBe("local");
|
|
186
|
+
|
|
187
|
+
store.set(backendAtom, backend);
|
|
188
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
189
|
+
expect(sent).toHaveBeenCalledOnce();
|
|
190
|
+
});
|
|
158
191
|
});
|
|
@@ -13,7 +13,8 @@ function createSession(initial: Document, controller: AbortController) {
|
|
|
13
13
|
const { notebook, control } = createTestNotebook(store, initial);
|
|
14
14
|
const model = createDocumentModel({
|
|
15
15
|
store,
|
|
16
|
-
notebook,
|
|
16
|
+
documentAtom: notebook.documentAtom,
|
|
17
|
+
backendAtom: notebook.backendAtom,
|
|
17
18
|
signal: controller.signal,
|
|
18
19
|
});
|
|
19
20
|
return {
|
|
@@ -6,8 +6,8 @@ import { atom, type createStore } from "jotai/vanilla";
|
|
|
6
6
|
import { vi } from "vitest";
|
|
7
7
|
import { Deferred } from "@/utils/Deferred";
|
|
8
8
|
import { invariant } from "@/utils/invariant";
|
|
9
|
+
import { type FakeEdit, fakeBackend } from "../../host/__tests__/fixtures";
|
|
9
10
|
import type { Document } from "../../host/resources/document";
|
|
10
|
-
import type { DocumentHost } from "../model";
|
|
11
11
|
import { createTestHost, type EditRequest } from "./test-host";
|
|
12
12
|
|
|
13
13
|
export interface PendingRequest extends EditRequest {
|
|
@@ -33,19 +33,25 @@ export function createTestNotebook(
|
|
|
33
33
|
documents.push(update);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
const edit = vi.fn<
|
|
37
|
-
options
|
|
36
|
+
const edit = vi.fn<FakeEdit>((notebookId, changes, options) => {
|
|
37
|
+
const signal = options?.signal;
|
|
38
|
+
const idempotencyKey = options?.idempotencyKey;
|
|
39
|
+
invariant(
|
|
40
|
+
signal !== undefined && idempotencyKey !== undefined,
|
|
41
|
+
"The test host answers edits that carry a key and a signal",
|
|
42
|
+
);
|
|
43
|
+
signal.throwIfAborted();
|
|
38
44
|
const reply = new Deferred<Result<Protocol.EditResponseOutput>>();
|
|
39
45
|
let outcome: Result<Protocol.EditResponseOutput> | undefined;
|
|
40
|
-
const abort = () => reply.reject(
|
|
41
|
-
const finish = () =>
|
|
42
|
-
|
|
46
|
+
const abort = () => reply.reject(signal.reason);
|
|
47
|
+
const finish = () => signal.removeEventListener("abort", abort);
|
|
48
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
43
49
|
void reply.promise.then(finish, finish);
|
|
44
50
|
const request: PendingRequest = {
|
|
45
51
|
notebookId,
|
|
46
52
|
changes: structuredClone(changes),
|
|
47
|
-
idempotencyKey
|
|
48
|
-
signal
|
|
53
|
+
idempotencyKey,
|
|
54
|
+
signal,
|
|
49
55
|
process() {
|
|
50
56
|
invariant(outcome === undefined, "Request was already processed");
|
|
51
57
|
// Cancellation of the client's wait does not roll back a server commit.
|
|
@@ -74,7 +80,11 @@ export function createTestNotebook(
|
|
|
74
80
|
}
|
|
75
81
|
return reply.promise;
|
|
76
82
|
});
|
|
77
|
-
const notebook = {
|
|
83
|
+
const notebook = {
|
|
84
|
+
documentAtom: source,
|
|
85
|
+
backendAtom: atom(fakeBackend({ edit })),
|
|
86
|
+
edit,
|
|
87
|
+
};
|
|
78
88
|
return {
|
|
79
89
|
notebook,
|
|
80
90
|
control: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import type { Document } from "../host
|
|
3
|
+
import type { Document } from "../host";
|
|
4
4
|
import * as Protocol from "@marimo-team/client/protocol";
|
|
5
5
|
|
|
6
6
|
/** The fields a client edits. Null code means the host withheld source. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
/** The editable document, its drafts, and its synchronization with the host. */
|
|
4
|
+
|
|
3
5
|
export {
|
|
4
6
|
DocumentProvider,
|
|
5
7
|
useCellCode,
|
|
@@ -10,13 +12,4 @@ export {
|
|
|
10
12
|
useDocumentEdit,
|
|
11
13
|
useDocumentSync,
|
|
12
14
|
} from "./hooks";
|
|
13
|
-
export { createDocumentModel } from "./model";
|
|
14
|
-
export type {
|
|
15
|
-
DocumentEdit,
|
|
16
|
-
DocumentEditor,
|
|
17
|
-
DocumentHost,
|
|
18
|
-
DocumentModel,
|
|
19
|
-
DocumentSyncStatus,
|
|
20
|
-
EditableCell,
|
|
21
|
-
EditableDocument,
|
|
22
|
-
} from "./model";
|
|
15
|
+
export { createDocumentModel, type DocumentModel } from "./model";
|
|
@@ -2,16 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import type * as Protocol from "@marimo-team/client/protocol";
|
|
4
4
|
import type { Atom, createStore } from "jotai/vanilla";
|
|
5
|
+
import type { Document, NotebookBackend } from "../host";
|
|
5
6
|
import { createDocumentState, type DocumentState } from "./state";
|
|
6
|
-
import {
|
|
7
|
-
createDocumentSync,
|
|
8
|
-
type DocumentHost,
|
|
9
|
-
type DocumentSyncStatus,
|
|
10
|
-
} from "./sync";
|
|
7
|
+
import { createDocumentSync, type DocumentSyncStatus } from "./sync";
|
|
11
8
|
|
|
12
9
|
export type { DocumentEdit, DocumentEditor } from "./state";
|
|
13
10
|
export type { EditableCell, EditableDocument } from "./draft";
|
|
14
|
-
export type {
|
|
11
|
+
export type { DocumentSyncStatus } from "./sync";
|
|
15
12
|
|
|
16
13
|
/** The editable document and its synchronization with the host. */
|
|
17
14
|
export interface DocumentModel extends Pick<
|
|
@@ -28,20 +25,26 @@ export interface DocumentModel extends Pick<
|
|
|
28
25
|
/** Create once per editing session; abort to stop editing and synchronization together. */
|
|
29
26
|
export function createDocumentModel({
|
|
30
27
|
store,
|
|
31
|
-
|
|
28
|
+
documentAtom,
|
|
29
|
+
backendAtom,
|
|
32
30
|
signal,
|
|
33
31
|
}: {
|
|
34
32
|
store: ReturnType<typeof createStore>;
|
|
35
|
-
|
|
33
|
+
/** The document the host last confirmed. */
|
|
34
|
+
documentAtom: Atom<Document | null>;
|
|
35
|
+
/** The backend that confirmed it, whose commands reach its host. */
|
|
36
|
+
backendAtom: Atom<NotebookBackend | null>;
|
|
36
37
|
signal: AbortSignal;
|
|
37
38
|
}): DocumentModel {
|
|
38
39
|
signal.throwIfAborted();
|
|
39
|
-
const state = createDocumentState({
|
|
40
|
+
const state = createDocumentState({ store, source: documentAtom, signal });
|
|
41
|
+
const sync = createDocumentSync({
|
|
40
42
|
store,
|
|
41
|
-
|
|
43
|
+
documentAtom,
|
|
44
|
+
backendAtom,
|
|
45
|
+
state,
|
|
42
46
|
signal,
|
|
43
47
|
});
|
|
44
|
-
const sync = createDocumentSync({ store, notebook, state, signal });
|
|
45
48
|
return {
|
|
46
49
|
documentAtom: state.documentAtom,
|
|
47
50
|
editAtom: state.editAtom,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type createStore,
|
|
7
7
|
type WritableAtom,
|
|
8
8
|
} from "jotai/vanilla";
|
|
9
|
-
import type { Document } from "../host
|
|
9
|
+
import type { Document } from "../host";
|
|
10
10
|
import * as Protocol from "@marimo-team/client/protocol";
|
|
11
11
|
import {
|
|
12
12
|
acknowledge,
|