@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.
Files changed (99) hide show
  1. package/dist/{ConnectedDataExplorerComponent-q0C1F7Sn.js → ConnectedDataExplorerComponent-BAiSVaHj.js} +3 -1
  2. package/dist/{ErrorBoundary-BwvkPkyC.js → ErrorBoundary-CImYa86e.js} +1 -1
  3. package/dist/{chat-ui-C0nJ84Wf.js → chat-ui-DqcauBr6.js} +21 -20
  4. package/dist/{code-visibility-BI1HUhjs.js → code-visibility-BZoWT5f1.js} +2 -2
  5. package/dist/{common-CMm0Ot4s.js → common-CGmIoGK3.js} +4383 -4327
  6. package/dist/{data-grid-overlay-editor-DMLTMI_S.js → data-grid-overlay-editor-CtOna148.js} +1 -1
  7. package/dist/{error-entries-BL2ZBgVo.js → error-entries-CmFDQ9CA.js} +109 -105
  8. package/dist/{feedback-button-BrpQxIbi.js → feedback-button-7ZbDdjfz.js} +14 -14
  9. package/dist/{glide-data-editor-DBsCdnOC.js → glide-data-editor-D72zmCan.js} +3 -3
  10. package/dist/{globals-BWb8HdpC.js → globals-DE2S2io9.js} +1 -1
  11. package/dist/{html-to-image-D9gz793p.js → html-to-image-CfgsWObF.js} +19 -19
  12. package/dist/main.js +5504 -5477
  13. package/dist/{number-overlay-editor-CUARNHHp.js → number-overlay-editor-Br0lPnFE.js} +1 -1
  14. package/dist/{process-output-761ZoDH1.js → process-output-B64ZYWuI.js} +3 -3
  15. package/dist/{reveal-component-CJjh6lrq.js → reveal-component-DnbPU885.js} +12 -12
  16. package/dist/style.css +1 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/module-entry.test.ts +32 -0
  19. package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
  20. package/src/components/ai/ai-model-dropdown.tsx +11 -6
  21. package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
  22. package/src/components/chat/acp/blocks.tsx +1 -0
  23. package/src/components/chat/acp/session-tabs.tsx +8 -3
  24. package/src/components/data-table/TableTopBar.tsx +2 -0
  25. package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
  26. package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
  27. package/src/components/data-table/data-table.tsx +6 -0
  28. package/src/components/data-table/range-focus/atoms.ts +4 -6
  29. package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
  30. package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
  31. package/src/components/data-table/renderers.tsx +16 -7
  32. package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
  33. package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
  34. package/src/components/datasources/datasources.tsx +1 -0
  35. package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
  36. package/src/components/editor/cell/collapse.tsx +5 -3
  37. package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
  38. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
  39. package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
  40. package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
  41. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
  42. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
  43. package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
  44. package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
  45. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
  46. package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
  47. package/src/components/editor/file-tree/file-explorer.tsx +35 -3
  48. package/src/components/editor/file-tree/file-name-input.tsx +1 -0
  49. package/src/components/editor/header/status.tsx +28 -5
  50. package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
  51. package/src/components/editor/links/cell-link.tsx +14 -5
  52. package/src/components/editor/navigation/focus-utils.ts +4 -4
  53. package/src/components/editor/notebook-cell.tsx +11 -2
  54. package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
  55. package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
  56. package/src/components/editor/package-alert.tsx +8 -3
  57. package/src/components/scratchpad/scratchpad.test.tsx +95 -0
  58. package/src/components/scratchpad/scratchpad.tsx +14 -1
  59. package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
  60. package/src/components/tracing/tracing.tsx +43 -39
  61. package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
  62. package/src/components/ui/draggable-popover.tsx +24 -2
  63. package/src/core/attachments/state.ts +2 -2
  64. package/src/core/document/__tests__/codemirror.test.ts +3 -1
  65. package/src/core/document/__tests__/hooks.test.tsx +7 -7
  66. package/src/core/document/__tests__/model.test.ts +44 -11
  67. package/src/core/document/__tests__/scenario-fixture.ts +2 -1
  68. package/src/core/document/__tests__/test-notebook.ts +19 -9
  69. package/src/core/document/draft.ts +1 -1
  70. package/src/core/document/index.ts +3 -10
  71. package/src/core/document/model.ts +14 -11
  72. package/src/core/document/state.ts +1 -1
  73. package/src/core/document/sync.ts +24 -18
  74. package/src/core/host/AGENTS.md +3 -7
  75. package/src/core/host/__tests__/fixtures.ts +30 -0
  76. package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
  77. package/src/core/host/__tests__/wasm.test.ts +8 -0
  78. package/src/core/host/backend.ts +4 -0
  79. package/src/core/host/index.ts +14 -0
  80. package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
  81. package/src/core/host/static.ts +2 -0
  82. package/src/core/host/wasm.ts +10 -1
  83. package/src/core/wasm/bridge.ts +1 -1
  84. package/src/core/websocket/__tests__/backend.test.ts +111 -22
  85. package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
  86. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
  87. package/src/core/websocket/backend.ts +43 -5
  88. package/src/core/websocket/connection-state.ts +1 -1
  89. package/src/core/websocket/state.ts +3 -3
  90. package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
  91. package/src/core/websocket/types.ts +2 -2
  92. package/src/core/websocket/useNotebookReplica.ts +69 -0
  93. package/src/core/websocket/useNotebookSession.tsx +6 -6
  94. package/src/plugins/impl/chat/chat-ui.tsx +2 -1
  95. package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
  96. package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
  97. package/src/plugins/impl/data-frames/panel.tsx +43 -16
  98. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
  99. 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="chartPosition" className="text-xs">
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
- <span className="text-sm cursor-pointer" onClick={onToggleExpanded}>
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
- </span>
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
- <TraceBlockBody
182
- key={run.runId}
183
- run={run}
184
- chartPosition={chartPosition}
185
- theme={theme}
186
- title={traceTitle}
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
- title: React.ReactNode;
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
- <TraceRows
268
- run={run}
269
- hoveredCellId={hoveredCellId}
270
- hiddenInputElementId={hiddenInputElementId}
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={chartPosition === "sideBySide" ? "-mt-0.5 flex-1" : ""}
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
- <div key={run.runId} className="flex flex-col">
288
- <pre className="font-mono font-semibold">
289
- {title}
290
- {chartElement}
291
- {traceRows}
292
- </pre>
293
- </div>
297
+ <>
298
+ {chartElement}
299
+ {traceRows}
300
+ </>
294
301
  );
295
302
  }
296
303
 
297
304
  return (
298
- <div key={run.runId} className="flex flex-row">
299
- <pre className="font-mono font-semibold">
300
- {title}
301
- {traceRows}
302
- </pre>
305
+ <>
306
+ {traceRows}
303
307
  {chartElement}
304
- </div>
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
- <div
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
- </div>
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 { notebookClientAtom } from "@/core/websocket/state";
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(notebookClientAtom);
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
- notebook: { documentAtom: source, edit: () => new Promise(() => {}) },
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: HostEdit = () => new Promise(() => {}),
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
- notebook: { documentAtom: source, edit },
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<HostEdit>()
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<HostEdit>>>().promise,
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].signal.aborted).toBe(true);
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, type DocumentHost } from "../model";
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: string;
34
- signal: AbortSignal;
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 host: DocumentHost = {
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
- notebook: host,
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<DocumentHost["edit"]>();
96
+ const edit = vi.fn<FakeEdit>();
92
97
  expect(() =>
93
98
  createDocumentModel({
94
99
  store,
95
- notebook: { documentAtom: source, edit },
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.aborted).toBe(true);
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<DocumentHost["edit"]>((notebookId, changes, options) => {
37
- options.signal.throwIfAborted();
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(options.signal.reason);
41
- const finish = () => options.signal.removeEventListener("abort", abort);
42
- options.signal.addEventListener("abort", abort, { once: true });
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: options.idempotencyKey,
48
- signal: options.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 = { documentAtom: source, edit } satisfies DocumentHost;
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/resources/document";
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 { DocumentHost, DocumentSyncStatus } from "./sync";
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
- notebook,
28
+ documentAtom,
29
+ backendAtom,
32
30
  signal,
33
31
  }: {
34
32
  store: ReturnType<typeof createStore>;
35
- notebook: DocumentHost;
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
- source: notebook.documentAtom,
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/resources/document";
9
+ import type { Document } from "../host";
10
10
  import * as Protocol from "@marimo-team/client/protocol";
11
11
  import {
12
12
  acknowledge,