@marimo-team/islands 0.25.3-dev92 → 0.25.3-dev94

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 (71) hide show
  1. package/dist/{ConnectedDataExplorerComponent-Da5OoEUL.js → ConnectedDataExplorerComponent-Dhf5EGs5.js} +4 -4
  2. package/dist/{ErrorBoundary-Bp6Cm6jU.js → ErrorBoundary-4VHhuyZ5.js} +2 -2
  3. package/dist/{abnfDiagram-VCTEODGH-Bz3mrprX.js → abnfDiagram-VCTEODGH-Di400kYu.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-Cs5ujb9K.js → architectureDiagram-5GKGNRK7-BdTJcsNJ.js} +1 -1
  5. package/dist/{chat-ui-DvyHgEfl.js → chat-ui-D0jT6WV3.js} +7 -7
  6. package/dist/{chunk-IMKFNOWR-CcTSaxZt.js → chunk-IMKFNOWR-mtD7aJBw.js} +1 -1
  7. package/dist/{chunk-SHT3W25Y-DcJcjOaO.js → chunk-SHT3W25Y-DybB1ShH.js} +1 -1
  8. package/dist/{chunk-SVP7TREG-BVtDTjaF.js → chunk-SVP7TREG-DNhhdCex.js} +1 -1
  9. package/dist/{chunk-TICWLB2K-BkDFkh8q.js → chunk-TICWLB2K-NhAQhlDf.js} +1 -1
  10. package/dist/{classDiagram-ZZMXUADV-CfAt7HT5.js → classDiagram-ZZMXUADV-GTL0l5xS.js} +2 -2
  11. package/dist/{classDiagram-v2-VYDZK3BY-CGEjNYa2.js → classDiagram-v2-VYDZK3BY-BWrM2ruI.js} +2 -2
  12. package/dist/{code-visibility-BS6srmOi.js → code-visibility-C791KvaY.js} +4 -4
  13. package/dist/{common-Ci4xUkZe.js → common-CLwhauF1.js} +9 -9
  14. package/dist/{cynefinDiagram-5FMLGOSQ-BW6NmfJi.js → cynefinDiagram-5FMLGOSQ-CX22wa7c.js} +1 -1
  15. package/dist/{dagre-GXQ25YYZ-CWg3CTuF.js → dagre-GXQ25YYZ-Df6Pp-cl.js} +1 -1
  16. package/dist/{data-grid-overlay-editor-3Xwrzd6B.js → data-grid-overlay-editor-BjOR7Wwc.js} +1 -1
  17. package/dist/{diagram-S7CK7UJ4-BPxtoS-z.js → diagram-S7CK7UJ4-CctU9kVx.js} +1 -1
  18. package/dist/{diagram-UQ7AKVKN-DA-vJdFL.js → diagram-UQ7AKVKN-FVHjCF9j.js} +1 -1
  19. package/dist/{diagram-VX7I27RA-D6bkR84S.js → diagram-VX7I27RA-BSDbwFFv.js} +1 -1
  20. package/dist/{diagram-Z3DM3KII-m60g4cAT.js → diagram-Z3DM3KII-BX8kW7Nc.js} +1 -1
  21. package/dist/{dropdown-menu-B3eUiVJB.js → dropdown-menu-B_7xD2X4.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-CK99sho2.js → ebnfDiagram-PWID7BFC-DtapdLet.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-Bryq0tdE.js → erDiagram-RLTQ6QDP-BAru9jHt.js} +1 -1
  24. package/dist/{error-entries-5MYC7UoK.js → error-entries-BZiemZfD.js} +4 -4
  25. package/dist/{feedback-button-Btjd18-4.js → feedback-button--whPjjUB.js} +5 -5
  26. package/dist/{flowDiagram-HODETNUW-D-3aRpfS.js → flowDiagram-HODETNUW-CjaeFeea.js} +2 -2
  27. package/dist/{formats-ZmooA-MD.js → formats-V8ifA0Bk.js} +1 -1
  28. package/dist/{glide-data-editor-CfaicMD0.js → glide-data-editor-DKzHt0kS.js} +5 -5
  29. package/dist/{globals-C2faijKH.js → globals-BwLJnxj2.js} +2 -2
  30. package/dist/{html-to-image-DvvwId4p.js → html-to-image-DBOTlHF9.js} +5 -5
  31. package/dist/{infoDiagram-27XIBGKW-s1C1kgfU.js → infoDiagram-27XIBGKW-B1hmUggC.js} +1 -1
  32. package/dist/{ishikawaDiagram-5VMMS53U-CCsRgolY.js → ishikawaDiagram-5VMMS53U-DUeT3t-d.js} +1 -1
  33. package/dist/{kanban-definition-UXKFOSKX-BrmxDlcc.js → kanban-definition-UXKFOSKX-DEUnNNgc.js} +1 -1
  34. package/dist/main.js +21 -21
  35. package/dist/{mermaid-DKI0869I.js → mermaid-B0fXpsNP.js} +34 -34
  36. package/dist/{mindmap-definition-YA3MSWOX-641xJysF.js → mindmap-definition-YA3MSWOX-CPexlhDQ.js} +1 -1
  37. package/dist/{number-overlay-editor-wlCw5SbP.js → number-overlay-editor-Df9kpoTU.js} +1 -1
  38. package/dist/{pegDiagram-XKGWAZYB-keVUAUeS.js → pegDiagram-XKGWAZYB-CMpp1OOQ.js} +2 -2
  39. package/dist/{pieDiagram-E7YTZNPT-DHktXPO_.js → pieDiagram-E7YTZNPT-DlMWg5t1.js} +1 -1
  40. package/dist/{process-output-BfUEswoe.js → process-output-BKLVbFnU.js} +2 -2
  41. package/dist/{railroadDiagram-O6MQD6OU-BVEF4OV_.js → railroadDiagram-O6MQD6OU-DBJfseeO.js} +2 -2
  42. package/dist/{requirementDiagram-BXWQKSXE-CWo-UAKV.js → requirementDiagram-BXWQKSXE-BC7V639r.js} +1 -1
  43. package/dist/{reveal-component-COKTwTRj.js → reveal-component-Di7acYGi.js} +6 -6
  44. package/dist/{sequenceDiagram-WJ2MYXX4-CHQvLnq4.js → sequenceDiagram-WJ2MYXX4-CgesU6F-.js} +1 -1
  45. package/dist/{spec-C-LRKGtT.js → spec-CcT0slVc.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-BOJZgbUJ.js → stateDiagram-D77RDMKH-lH_BUhs3.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-C53XXdYH.js → stateDiagram-v2-MP3YSRHH-8LL6FrBe.js} +2 -2
  48. package/dist/{swimlanes-42K2YHIH-D2o1gOtw.js → swimlanes-42K2YHIH-Cuf9B9kE.js} +1 -1
  49. package/dist/{swimlanesDiagram-VR7AAH4N-BHFzN1Wk.js → swimlanesDiagram-VR7AAH4N-BoMMtOPh.js} +2 -2
  50. package/dist/{timeline-definition-24CTP7MA-rv7QUa46.js → timeline-definition-24CTP7MA-yzzw4TF4.js} +1 -1
  51. package/dist/{toDate-C6BRw8i5.js → toDate-DKH0_kMo.js} +1 -1
  52. package/dist/{useAsyncData-ChgNtdmE.js → useAsyncData-D0mLJstx.js} +1 -1
  53. package/dist/{useDeepCompareMemoize-Bet7kSsI.js → useDeepCompareMemoize-BpPWi3G3.js} +1 -1
  54. package/dist/{useLifecycle-VVmz_24o.js → useLifecycle-Dum8WSXq.js} +1 -1
  55. package/dist/{useTheme-DympCsQg.js → useTheme-CfeaVw5D.js} +1 -1
  56. package/dist/{vega-component-B8IjMdad.js → vega-component-DgytCKrL.js} +5 -5
  57. package/dist/{vennDiagram-4TSXK5OY-D36Zk7t-.js → vennDiagram-4TSXK5OY-eJOrTqqC.js} +1 -1
  58. package/dist/{wardleyDiagram-VM6X3IG4-BLBolvyQ.js → wardleyDiagram-VM6X3IG4-TILPvee6.js} +1 -1
  59. package/dist/{xychartDiagram-S5SC5T6Z-CfH57l0v.js → xychartDiagram-S5SC5T6Z-DUPvRwwJ.js} +1 -1
  60. package/package.json +1 -1
  61. package/src/components/datasources/__tests__/table-list.test.tsx +118 -0
  62. package/src/components/datasources/datasources.tsx +35 -21
  63. package/src/core/document/__tests__/draft.test.ts +322 -0
  64. package/src/core/document/__tests__/fixtures.ts +92 -0
  65. package/src/core/document/__tests__/model.test.ts +434 -0
  66. package/src/core/document/__tests__/submission.test.ts +334 -0
  67. package/src/core/document/draft.ts +472 -0
  68. package/src/core/document/model.ts +395 -0
  69. package/src/core/state/__tests__/jotai.test.tsx +108 -0
  70. package/src/core/state/jotai.ts +1 -1
  71. package/src/core/state/__tests__/jotai.test.ts +0 -25
@@ -4,7 +4,7 @@ import { t as i } from "./react-sHtZdjeP.js";
4
4
  import { t as a } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { h as o } from "./tooltip-CKO1pHJo.js";
6
6
  import { t as s } from "./jsx-runtime-3BD1kT0s.js";
7
- import { E as c, w as l } from "./useTheme-DympCsQg.js";
7
+ import { E as c, w as l } from "./useTheme-CfeaVw5D.js";
8
8
  var u = o("calendar", [
9
9
  ["path", {
10
10
  d: "M8 2v4",
@@ -562,7 +562,7 @@ function Ie(e, t) {
562
562
  let n = (0, C.c)(5), r = V(), i, a;
563
563
  n[0] !== e || n[1] !== t || n[2] !== r ? (i = () => {
564
564
  let n = r.get(e);
565
- r.sub(e, () => {
565
+ return r.sub(e, () => {
566
566
  let i = r.get(e);
567
567
  t(i, n), n = i;
568
568
  });
@@ -3,19 +3,19 @@ import { g as t, h as n, m as r, o as i } from "./button-BxB3RDjN.js";
3
3
  import { t as a } from "./react-sHtZdjeP.js";
4
4
  import { t as o } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { t as s } from "./tooltip-CKO1pHJo.js";
6
- import { C as c, a as l } from "./toDate-C6BRw8i5.js";
6
+ import { C as c, a as l } from "./toDate-DKH0_kMo.js";
7
7
  import { t as u } from "./jsx-runtime-3BD1kT0s.js";
8
8
  import { n as d } from "./error-banner-Sl2FlSJ4.js";
9
9
  import { t as f } from "./debounce-C3jyATpc.js";
10
- import { A as p, n as ee } from "./useTheme-DympCsQg.js";
10
+ import { A as p, n as ee } from "./useTheme-CfeaVw5D.js";
11
11
  import { c as m, t as h } from "./arrays-R6JsImiT.js";
12
12
  import { t as te } from "./objects-DCmMvZtn.js";
13
- import { t as ne } from "./useAsyncData-ChgNtdmE.js";
14
- import { a as g, i as re, n as ie, t as ae } from "./formats-ZmooA-MD.js";
13
+ import { t as ne } from "./useAsyncData-D0mLJstx.js";
14
+ import { a as g, i as re, n as ie, t as ae } from "./formats-V8ifA0Bk.js";
15
15
  import { D as oe, n as _ } from "./vega-loader.browser-B9Pz0EGP.js";
16
16
  import { t as se } from "./react-vega-C4T9Rb_w.js";
17
17
  import { a as ce, n as le } from "./loader-Bm8cy3e8.js";
18
- import { t as ue } from "./useDeepCompareMemoize-Bet7kSsI.js";
18
+ import { t as ue } from "./useDeepCompareMemoize-BpPWi3G3.js";
19
19
  import { t as v } from "./semaphore-89jsoHtS.js";
20
20
  import { t as de } from "./use-vega-container-remeasure-Bz4PZMtt.js";
21
21
  //#region src/plugins/impl/vega/fix-relative-url.ts
@@ -3,7 +3,7 @@ import { t } from "./src-CA_m4ONl.js";
3
3
  import { n } from "./chunk-Y2CYZVJY-DKKS1VyV.js";
4
4
  import { $ as r, H as i, K as a, U as o, a as s, b as c, c as l, et as u, f as d, nt as f, tt as p, v as m, w as h, y as g } from "./chunk-DU6HZSFF-BwiyaNpg.js";
5
5
  import { t as _ } from "./rough.esm-CtV9AnV1.js";
6
- import { p as v } from "./mermaid-DKI0869I.js";
6
+ import { p as v } from "./mermaid-B0fXpsNP.js";
7
7
  //#region ../node_modules/.pnpm/khroma@2.1.0/node_modules/khroma/dist/methods/transparentize.js
8
8
  var y = (e, t) => p(e, "a", -t), b = 1e-10;
9
9
  function x(e, t) {
@@ -2,7 +2,7 @@ import { i as e } from "./chunk-75Z2AOVW-D7gxIs2Q.js";
2
2
  import { n as t } from "./src-CA_m4ONl.js";
3
3
  import { n } from "./chunk-Y2CYZVJY-DKKS1VyV.js";
4
4
  import { D as r, H as i, K as a, U as o, a as s, b as c, c as l, v as u, w as d, x as f, y as p } from "./chunk-DU6HZSFF-BwiyaNpg.js";
5
- import { p as m } from "./mermaid-DKI0869I.js";
5
+ import { p as m } from "./mermaid-B0fXpsNP.js";
6
6
  import { n as h } from "./mermaid-parser.core-DxuSfYwJ.js";
7
7
  import { t as g } from "./chunk-JWPE2WC7-DJvv1t8U.js";
8
8
  //#region ../node_modules/.pnpm/mermaid@11.17.2/node_modules/mermaid/dist/chunks/mermaid.core/wardleyDiagram-VM6X3IG4.mjs
@@ -9,7 +9,7 @@ import { n as s } from "./chunk-Y2CYZVJY-DKKS1VyV.js";
9
9
  import { D as c, H as l, K as u, U as d, a as f, b as p, c as m, f as h, v as g, w as _, y as v, z as y } from "./chunk-DU6HZSFF-BwiyaNpg.js";
10
10
  import "./chunk-PWAF6VOD-CQw5qATZ.js";
11
11
  import { t as b } from "./chunk-GMAD6QVW-BhaJx0X3.js";
12
- import { p as x } from "./mermaid-DKI0869I.js";
12
+ import { p as x } from "./mermaid-B0fXpsNP.js";
13
13
  //#region ../node_modules/.pnpm/d3-scale@4.0.2/node_modules/d3-scale/src/band.js
14
14
  function S() {
15
15
  var e = t().unknown(void 0), r = e.domain, i = e.range, o = 0, s = 1, c, l, u = !1, d = 0, f = 0, p = .5;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev92",
3
+ "version": "0.25.3-dev94",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -0,0 +1,118 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import { Provider } from "jotai";
5
+ import type React from "react";
6
+ import { describe, expect, it } from "vitest";
7
+ import { MockRequestClient } from "@/__mocks__/requests";
8
+ import type { SQLTableContext } from "@/core/datasets/data-source-connections";
9
+ import { PreviewSQLTableList } from "@/core/datasets/request-registry";
10
+ import type { SQLTableListPreview } from "@/core/kernel/messages";
11
+ import type { RequestId } from "@/core/network/DeferredRequestRegistry";
12
+ import { requestClientAtom } from "@/core/network/requests";
13
+ import { store } from "@/core/state/jotai";
14
+ import { TableList } from "../datasources";
15
+
16
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
17
+ <Provider store={store}>{children}</Provider>
18
+ );
19
+
20
+ const sqlTableContext: SQLTableContext = {
21
+ engine: "my_engine",
22
+ database: "my_db",
23
+ schema: "public",
24
+ dialect: "postgres",
25
+ schemaPath: ["public"],
26
+ };
27
+
28
+ // The kernel answers table-list requests over the websocket, so the mock
29
+ // settles the deferred request instead of returning the response directly.
30
+ function renderDeferredTableList(settle?: (requestId: RequestId) => void) {
31
+ const client = MockRequestClient.create();
32
+ client.previewSQLTableList.mockImplementation(async ({ requestId }) => {
33
+ settle?.(requestId);
34
+ return null;
35
+ });
36
+ store.set(requestClientAtom, client);
37
+ render(
38
+ <TableList
39
+ tables={[]}
40
+ tablesResolved={false}
41
+ sqlTableContext={sqlTableContext}
42
+ />,
43
+ { wrapper },
44
+ );
45
+ return client;
46
+ }
47
+
48
+ describe("TableList deferred discovery", () => {
49
+ it("shows the error when the request rejects", async () => {
50
+ renderDeferredTableList((requestId) =>
51
+ PreviewSQLTableList.reject(requestId, new Error("engine offline")),
52
+ );
53
+
54
+ expect(screen.getByText("Loading tables...")).toBeInTheDocument();
55
+
56
+ await waitFor(() => {
57
+ expect(screen.getByText("engine offline")).toBeInTheDocument();
58
+ });
59
+ expect(screen.queryByText("Loading tables...")).toBeNull();
60
+ });
61
+
62
+ it("shows the error when the response has no tables field", async () => {
63
+ renderDeferredTableList((requestId) =>
64
+ PreviewSQLTableList.resolve(requestId, {} as SQLTableListPreview),
65
+ );
66
+
67
+ await waitFor(() => {
68
+ expect(screen.getByText("No tables available")).toBeInTheDocument();
69
+ });
70
+ expect(screen.queryByText("Loading tables...")).toBeNull();
71
+ });
72
+
73
+ it("shows the empty state for an empty table list", async () => {
74
+ renderDeferredTableList((requestId) =>
75
+ PreviewSQLTableList.resolve(requestId, {
76
+ tables: [],
77
+ } as unknown as SQLTableListPreview),
78
+ );
79
+
80
+ await waitFor(() => {
81
+ expect(screen.getByText("No tables found")).toBeInTheDocument();
82
+ });
83
+ expect(screen.queryByText("Loading tables...")).toBeNull();
84
+ });
85
+
86
+ it("requests the table list with the table context and waits for the kernel", async () => {
87
+ let pendingRequestId: RequestId | undefined;
88
+ const client = renderDeferredTableList((requestId) => {
89
+ pendingRequestId = requestId;
90
+ });
91
+
92
+ await waitFor(() => {
93
+ expect(client.previewSQLTableList).toHaveBeenCalledTimes(1);
94
+ });
95
+ expect(client.previewSQLTableList).toHaveBeenCalledWith(
96
+ expect.objectContaining({
97
+ engine: "my_engine",
98
+ database: "my_db",
99
+ schema: "public",
100
+ schemaPath: ["public"],
101
+ }),
102
+ );
103
+ expect(screen.getByText("Loading tables...")).toBeInTheDocument();
104
+
105
+ // Settle the request so the process-wide registry does not keep the entry.
106
+ expect(pendingRequestId).toBeDefined();
107
+ PreviewSQLTableList.resolve(
108
+ pendingRequestId as RequestId,
109
+ {
110
+ tables: [],
111
+ } as unknown as SQLTableListPreview,
112
+ );
113
+ await waitFor(() => {
114
+ expect(screen.queryByText("Loading tables...")).toBeNull();
115
+ });
116
+ expect(PreviewSQLTableList.requests.size).toBe(0);
117
+ });
118
+ });
@@ -702,7 +702,40 @@ const SchemaNode: React.FC<SchemaNodeProps> = (props) => {
702
702
  );
703
703
  };
704
704
 
705
- const TableList: React.FC<{
705
+ // Kept outside the component because the React Compiler does not yet
706
+ // support try/finally inside component bodies.
707
+ async function loadTableList(
708
+ sqlTableContext: SQLTableContext,
709
+ addTableList: ReturnType<typeof useDataSourceActions>["addTableList"],
710
+ setLoading: (loading: boolean) => void,
711
+ ) {
712
+ setLoading(true);
713
+ try {
714
+ const { engine, database, schema, schemaPath } = sqlTableContext;
715
+ const previewTableList = await PreviewSQLTableList.request({
716
+ engine: engine,
717
+ database: database,
718
+ schema: schema,
719
+ schemaPath: schemaPath ?? [],
720
+ });
721
+
722
+ if (!previewTableList?.tables) {
723
+ throw new Error("No tables available");
724
+ }
725
+
726
+ addTableList({
727
+ tables: previewTableList.tables,
728
+ sqlTableContext: sqlTableContext,
729
+ });
730
+ } finally {
731
+ setLoading(false);
732
+ }
733
+ }
734
+
735
+ /**
736
+ * This is exported for testing purposes only.
737
+ */
738
+ export const TableList: React.FC<{
706
739
  tables: DataTable[];
707
740
  sqlTableContext?: SQLTableContext;
708
741
  searchValue?: string;
@@ -731,26 +764,7 @@ const TableList: React.FC<{
731
764
  // resets tablesResolved to false).
732
765
  const { isPending, error } = useAsyncData(async () => {
733
766
  if (!tablesResolved && sqlTableContext) {
734
- setTablesLoading(true);
735
-
736
- const { engine, database, schema, schemaPath } = sqlTableContext;
737
- const previewTableList = await PreviewSQLTableList.request({
738
- engine: engine,
739
- database: database,
740
- schema: schema,
741
- schemaPath: schemaPath ?? [],
742
- });
743
-
744
- if (!previewTableList?.tables) {
745
- setTablesLoading(false);
746
- throw new Error("No tables available");
747
- }
748
-
749
- addTableList({
750
- tables: previewTableList.tables,
751
- sqlTableContext: sqlTableContext,
752
- });
753
- setTablesLoading(false);
767
+ await loadTableList(sqlTableContext, addTableList, setTablesLoading);
754
768
  }
755
769
  }, [tablesResolved, sqlTableContext]);
756
770
 
@@ -0,0 +1,322 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ acknowledge,
6
+ createCell,
7
+ deleteCell,
8
+ type Draft,
9
+ type EditableCell,
10
+ editCell,
11
+ EMPTY_DRAFT,
12
+ setOrder,
13
+ toChanges,
14
+ viewDocument,
15
+ } from "../draft";
16
+ import { cell, CONFIG, document, withCells } from "./fixtures";
17
+
18
+ const confirmed = document();
19
+
20
+ function edits(...steps: Parameters<typeof editCell>[3][]): Draft {
21
+ return steps.reduce(
22
+ (draft, edit) => editCell(confirmed, draft, "c1", edit),
23
+ EMPTY_DRAFT,
24
+ );
25
+ }
26
+
27
+ describe("editCell", () => {
28
+ it("lets later fields win and merges config field by field", () => {
29
+ const draft = edits(
30
+ { code: "a", config: { hide_code: true } },
31
+ { name: "n", config: { column: 1, hide_code: undefined } },
32
+ );
33
+ expect(viewDocument(confirmed, draft).cells.get("c1")).toEqual({
34
+ id: "c1",
35
+ code: "a",
36
+ name: "n",
37
+ config: { ...CONFIG, hide_code: true, column: 1 },
38
+ });
39
+ });
40
+
41
+ it("keeps a reversal to the confirmed value as a draft", () => {
42
+ const draft = edits({ code: "local" }, { code: "x = 1" });
43
+ const remote = withCells(confirmed, { c1: cell("c1", "earlier edit") });
44
+ expect(viewDocument(remote, draft).cells.get("c1")?.code).toBe("x = 1");
45
+ });
46
+
47
+ it("accepts empty strings, false and null while ignoring undefined", () => {
48
+ const draft = edits(
49
+ { code: "", config: { column: 1, disabled: true } },
50
+ {
51
+ code: undefined,
52
+ config: { column: null, disabled: false, hide_code: undefined },
53
+ },
54
+ );
55
+ expect(viewDocument(confirmed, draft).cells.get("c1")).toEqual({
56
+ id: "c1",
57
+ code: "",
58
+ name: "_",
59
+ config: CONFIG,
60
+ });
61
+ });
62
+ });
63
+
64
+ describe("createCell and deleteCell", () => {
65
+ it("fills protocol defaults and places the cell in the view", () => {
66
+ const atEnd = createCell(EMPTY_DRAFT, { id: "c3", code: "3" });
67
+ const after = createCell(
68
+ EMPTY_DRAFT,
69
+ { id: "c3", code: "3" },
70
+ { after: "c1" },
71
+ );
72
+ const before = createCell(
73
+ EMPTY_DRAFT,
74
+ { id: "c3", code: "3" },
75
+ { before: "c1" },
76
+ );
77
+ const orphan = createCell(
78
+ EMPTY_DRAFT,
79
+ { id: "c3", code: "3" },
80
+ { after: "gone" },
81
+ );
82
+ expect(viewDocument(confirmed, atEnd).order).toEqual(["c1", "c2", "c3"]);
83
+ expect(viewDocument(confirmed, after).order).toEqual(["c1", "c3", "c2"]);
84
+ expect(viewDocument(confirmed, before).order).toEqual(["c3", "c1", "c2"]);
85
+ expect(viewDocument(confirmed, orphan).order).toEqual(["c1", "c2", "c3"]);
86
+ expect(viewDocument(confirmed, atEnd).cells.get("c3")).toEqual({
87
+ id: "c3",
88
+ code: "3",
89
+ name: "_",
90
+ config: CONFIG,
91
+ });
92
+ });
93
+
94
+ it("forgets a created cell when it is deleted", () => {
95
+ const draft = deleteCell(
96
+ createCell(EMPTY_DRAFT, { id: "c3", code: "" }),
97
+ "c3",
98
+ );
99
+ expect(toChanges(confirmed, draft)).toEqual([]);
100
+ expect(viewDocument(confirmed, draft).order).toEqual(["c1", "c2"]);
101
+ });
102
+ });
103
+
104
+ describe("viewDocument", () => {
105
+ it("preserves withheld source separately from an empty cell", () => {
106
+ const hidden = cell("c1", "", { code: null });
107
+ const empty = cell("c2", "");
108
+ const source = withCells(confirmed, { c1: hidden, c2: empty });
109
+ expect(viewDocument(source, EMPTY_DRAFT)).toEqual({
110
+ order: ["c1", "c2"],
111
+ cells: new Map([
112
+ ["c1", hidden],
113
+ ["c2", empty],
114
+ ]),
115
+ });
116
+ expect(toChanges(source, EMPTY_DRAFT)).toEqual([]);
117
+ });
118
+
119
+ it("follows the host for untouched fields, cells and order", () => {
120
+ const draft = edits({ code: "local", config: { hide_code: true } });
121
+ const remote = withCells(
122
+ confirmed,
123
+ {
124
+ c1: cell("c1", "remote", {
125
+ name: "renamed",
126
+ config: { ...CONFIG, disabled: true },
127
+ version: 2,
128
+ }),
129
+ c2: cell("c2", "remote too"),
130
+ },
131
+ ["c2", "c1"],
132
+ );
133
+ expect(viewDocument(remote, draft)).toEqual({
134
+ order: ["c2", "c1"],
135
+ cells: new Map<string, EditableCell | undefined>([
136
+ [
137
+ "c1",
138
+ {
139
+ id: "c1",
140
+ code: "local",
141
+ name: "renamed",
142
+ config: { ...CONFIG, disabled: true, hide_code: true },
143
+ },
144
+ ],
145
+ ["c2", remote.cells.get("c2")],
146
+ ]),
147
+ });
148
+ });
149
+ });
150
+
151
+ describe("toChanges", () => {
152
+ it.each([{ name: "renamed" }, { config: { hide_code: true } }])(
153
+ "withholds the whole request when an edited cell lacks source: %o",
154
+ (edit) => {
155
+ const hidden = withCells(confirmed, {
156
+ c1: cell("c1", "", { code: null }),
157
+ });
158
+ const created = createCell(EMPTY_DRAFT, { id: "c3", code: "" });
159
+ const draft = editCell(hidden, created, "c1", edit);
160
+ expect(toChanges(hidden, draft)).toBeNull();
161
+ expect(viewDocument(hidden, draft).cells.get("c1")?.code).toBeNull();
162
+ },
163
+ );
164
+
165
+ it.each(["replacement", ""])(
166
+ "uses explicitly supplied source %j when the host withholds it",
167
+ (code) => {
168
+ const hidden = withCells(confirmed, {
169
+ c1: cell("c1", "", { code: null }),
170
+ });
171
+ const draft = editCell(hidden, EMPTY_DRAFT, "c1", {
172
+ name: "renamed",
173
+ code,
174
+ });
175
+ expect(toChanges(hidden, draft)).toEqual([
176
+ {
177
+ kind: "put_cell",
178
+ base_etag: "c1-etag",
179
+ cell: { id: "c1", code, name: "renamed", config: CONFIG },
180
+ },
181
+ ]);
182
+ },
183
+ );
184
+
185
+ it("can delete and reorder cells without their source", () => {
186
+ const hidden = withCells(confirmed, {
187
+ c1: cell("c1", "", { code: null }),
188
+ c2: cell("c2", "", { code: null }),
189
+ });
190
+ expect(toChanges(hidden, deleteCell(EMPTY_DRAFT, "c1"))).toEqual([
191
+ { kind: "delete_cell", id: "c1", base_etag: "c1-etag" },
192
+ ]);
193
+ expect(toChanges(hidden, setOrder(EMPTY_DRAFT, ["c2", "c1"]))).toEqual([
194
+ { kind: "set_order", base_etag: "order-1", order: ["c2", "c1"] },
195
+ ]);
196
+ });
197
+
198
+ it("is empty for an empty draft and omits puts that change nothing", () => {
199
+ expect(toChanges(confirmed, EMPTY_DRAFT)).toEqual([]);
200
+ expect(
201
+ toChanges(confirmed, edits({ code: "local" }, { code: "x = 1" })),
202
+ ).toEqual([]);
203
+ });
204
+
205
+ it("puts the merged cell against the confirmed etag", () => {
206
+ const draft = edits({ code: "local" });
207
+ const remote = withCells(confirmed, {
208
+ c1: cell("c1", "remote", { name: "renamed", etag: "c1-etag-2" }),
209
+ });
210
+ expect(toChanges(remote, draft)).toEqual([
211
+ {
212
+ kind: "put_cell",
213
+ base_etag: "c1-etag-2",
214
+ cell: { id: "c1", code: "local", name: "renamed", config: CONFIG },
215
+ },
216
+ ]);
217
+ });
218
+
219
+ it.each([{ after: "c3" }, { before: "c3" }])(
220
+ "can anchor to a cell created earlier in the request: %o",
221
+ (placement) => {
222
+ let draft = createCell(EMPTY_DRAFT, { id: "c3", code: "3" });
223
+ draft = createCell(draft, { id: "c4", code: "4" }, placement);
224
+ expect(toChanges(confirmed, draft)).toEqual([
225
+ {
226
+ kind: "put_cell",
227
+ base_etag: null,
228
+ cell: { id: "c3", code: "3", name: "_", config: CONFIG },
229
+ },
230
+ {
231
+ kind: "put_cell",
232
+ base_etag: null,
233
+ cell: { id: "c4", code: "4", name: "_", config: CONFIG },
234
+ ...placement,
235
+ },
236
+ ]);
237
+ },
238
+ );
239
+
240
+ it.each([{ after: "c2" }, { before: "c2" }])(
241
+ "omits an anchor deleted earlier in the request: %o",
242
+ (placement) => {
243
+ let draft = deleteCell(EMPTY_DRAFT, "c2");
244
+ draft = createCell(draft, { id: "c3", code: "3" }, placement);
245
+ expect(toChanges(confirmed, draft)).toEqual([
246
+ { kind: "delete_cell", id: "c2", base_etag: "c2-etag" },
247
+ {
248
+ kind: "put_cell",
249
+ base_etag: null,
250
+ cell: { id: "c3", code: "3", name: "_", config: CONFIG },
251
+ },
252
+ ]);
253
+ },
254
+ );
255
+
256
+ it("creates, deletes, then sets the reconciled order", () => {
257
+ let draft = setOrder(EMPTY_DRAFT, ["c2", "c1"]);
258
+ draft = createCell(draft, { id: "c3", code: "3" }, { after: "c2" });
259
+ draft = deleteCell(draft, "c1");
260
+ expect(toChanges(confirmed, draft)).toEqual([
261
+ {
262
+ kind: "put_cell",
263
+ base_etag: null,
264
+ cell: { id: "c3", code: "3", name: "_", config: CONFIG },
265
+ after: "c2",
266
+ },
267
+ { kind: "delete_cell", id: "c1", base_etag: "c1-etag" },
268
+ { kind: "set_order", base_etag: "order-1", order: ["c2", "c3"] },
269
+ ]);
270
+ });
271
+ });
272
+
273
+ describe("acknowledge", () => {
274
+ it("releases accepted fields independently, keeping later code and config edits", () => {
275
+ const sent = edits({
276
+ code: "sent",
277
+ config: { hide_code: true, column: 1 },
278
+ });
279
+ const later = editCell(confirmed, sent, "c1", {
280
+ code: "typed more",
281
+ config: { column: 2 },
282
+ });
283
+ const remote = withCells(confirmed, {
284
+ c1: cell("c1", "remote", { config: { ...CONFIG, column: 1 } }),
285
+ });
286
+ expect(
287
+ viewDocument(remote, acknowledge(later, sent)).cells.get("c1"),
288
+ ).toEqual({
289
+ id: "c1",
290
+ code: "typed more",
291
+ name: "_",
292
+ config: { ...CONFIG, column: 2 },
293
+ });
294
+ });
295
+
296
+ it("turns an accepted creation into an edit of only the fields changed since", () => {
297
+ const sent = createCell(EMPTY_DRAFT, { id: "c3", code: "3" });
298
+ const later = editCell(confirmed, sent, "c3", { name: "named" });
299
+ const remote = withCells(confirmed, { c3: cell("c3", "remote code") });
300
+ expect(toChanges(remote, acknowledge(later, sent))).toEqual([
301
+ {
302
+ kind: "put_cell",
303
+ base_etag: "c3-etag",
304
+ cell: { id: "c3", code: "remote code", name: "named", config: CONFIG },
305
+ },
306
+ ]);
307
+ });
308
+
309
+ it("releases an accepted order but preserves a subsequent reorder", () => {
310
+ const sent = setOrder(EMPTY_DRAFT, ["c2", "c1"]);
311
+ const later = setOrder(sent, ["c1", "c2"]);
312
+ const remote = withCells(confirmed, {}, ["c2", "c1"]);
313
+ expect(toChanges(remote, acknowledge(sent, sent))).toEqual([]);
314
+ expect(toChanges(remote, acknowledge(later, sent))).toEqual([
315
+ {
316
+ kind: "set_order",
317
+ base_etag: remote.revision.order_etag,
318
+ order: ["c1", "c2"],
319
+ },
320
+ ]);
321
+ });
322
+ });
@@ -0,0 +1,92 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { Document } from "../../host/resources/document";
4
+ import type * as Protocol from "@marimo-team/client/protocol";
5
+
6
+ export const CONFIG: Protocol.CellConfig = {
7
+ column: null,
8
+ disabled: false,
9
+ hide_code: false,
10
+ expand_output: false,
11
+ };
12
+
13
+ export function cell(
14
+ id: Protocol.CellId,
15
+ code: string,
16
+ overrides: Partial<Protocol.Cell> = {},
17
+ ): Protocol.Cell {
18
+ return {
19
+ id,
20
+ notebook_id: "n1",
21
+ code,
22
+ name: "_",
23
+ config: CONFIG,
24
+ etag: `${id}-etag`,
25
+ code_hash: `${id}-hash`,
26
+ version: 1,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ /** A two-cell document, `c1` then `c2`. */
32
+ export function document(notebookId: Protocol.NotebookId = "n1"): Document {
33
+ const cells = [
34
+ cell("c1", "x = 1", { notebook_id: notebookId }),
35
+ cell("c2", "y = 2", { notebook_id: notebookId }),
36
+ ];
37
+ return {
38
+ revision: {
39
+ notebook_id: notebookId,
40
+ order: cells.map((c) => c.id),
41
+ app_config: { width: "medium" },
42
+ etag: "document-1",
43
+ saved_etag: "document-1",
44
+ order_etag: "order-1",
45
+ app_config_etag: "config-1",
46
+ version: 1,
47
+ },
48
+ cells: new Map(cells.map((c) => [c.id, c])),
49
+ };
50
+ }
51
+
52
+ /** The document with some cells replaced or removed and the order updated. */
53
+ export function withCells(
54
+ base: Document,
55
+ changes: Record<Protocol.CellId, Protocol.Cell | null>,
56
+ order?: readonly Protocol.CellId[],
57
+ ): Document {
58
+ const cells = new Map(base.cells);
59
+ for (const [id, next] of Object.entries(changes)) {
60
+ if (next === null) {
61
+ cells.delete(id);
62
+ } else {
63
+ cells.set(id, next);
64
+ }
65
+ }
66
+ const nextOrder = order
67
+ ? [...order]
68
+ : [
69
+ ...base.revision.order.filter((id) => cells.has(id)),
70
+ ...[...cells.keys()].filter((id) => !base.cells.has(id)),
71
+ ];
72
+ const orderChanged =
73
+ nextOrder.length !== base.revision.order.length ||
74
+ nextOrder.some((id, index) => id !== base.revision.order[index]);
75
+ const orderEtag = orderChanged
76
+ ? `order:${JSON.stringify(nextOrder)}`
77
+ : base.revision.order_etag;
78
+ return {
79
+ revision: {
80
+ ...base.revision,
81
+ order: nextOrder,
82
+ order_etag: orderEtag,
83
+ version: base.revision.version + 1,
84
+ etag: JSON.stringify([
85
+ orderEtag,
86
+ base.revision.app_config_etag,
87
+ ...nextOrder.map((id) => cells.get(id)?.etag),
88
+ ]),
89
+ },
90
+ cells,
91
+ };
92
+ }