@marimo-team/islands 0.23.16-dev6 → 0.23.16-dev62

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 (198) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CJaUI-S-.js → ConnectedDataExplorerComponent-f9vLjQiF.js} +13 -13
  2. package/dist/{ErrorBoundary-DXlxsrTF.js → ErrorBoundary-DhLqwSWA.js} +3 -3
  3. package/dist/{any-language-editor-P9tjkj4n.js → any-language-editor-DifE-fl7.js} +5 -5
  4. package/dist/{button-BcSml68y.js → button-BSm8IXRU.js} +2 -4
  5. package/dist/{capabilities-BYLFYTYv.js → capabilities-C6OQ5YBO.js} +1 -1
  6. package/dist/{chat-ui-BdSDiVjk.js → chat-ui-DsZJj75A.js} +3117 -3120
  7. package/dist/{check-Cs-KV5XY.js → check-DP7U0H0G.js} +1 -1
  8. package/dist/{code-visibility-CfBftiVM.js → common-Bpnmu1Yz.js} +1085 -1053
  9. package/dist/{copy-Cs3yt6Vt.js → copy-DNq8q3zw.js} +2 -2
  10. package/dist/{dist-B96tMAst.js → dist-DIlxLFXG.js} +2 -2
  11. package/dist/{error-banner-DR7o3CbX.js → error-banner-B_GQfxxD.js} +2 -2
  12. package/dist/{esm-k8PIvuZW.js → esm-DzZVaNH4.js} +1 -1
  13. package/dist/{extends-Cq2JYHKy.js → extends-w2_S44bV.js} +4 -4
  14. package/dist/{formats-BFhkZJwd.js → formats-Dwq1THhr.js} +3 -3
  15. package/dist/{glide-data-editor--CaN-f27.js → glide-data-editor-B9K6-MJe.js} +9 -9
  16. package/dist/{html-to-image-DDENewl2.js → html-to-image-CZ1kLKkq.js} +7350 -6356
  17. package/dist/{input-DStQIuqD.js → input-C5K4VBvB.js} +7 -7
  18. package/dist/{label-DIk0QoFe.js → label-DjoYwqmj.js} +2 -2
  19. package/dist/{loader-CTHCI-qe.js → loader-DX-IDS7n.js} +1 -1
  20. package/dist/main.js +259 -194
  21. package/dist/{mermaid-CdSvixW2.js → mermaid-hv6IckVw.js} +5 -5
  22. package/dist/{node-sql-parser-DLl_zNw0.js → node-sql-parser-v1Vi-Fft.js} +16867 -16665
  23. package/dist/{process-output-2QPcgBQg.js → process-output-bVfbcx5_.js} +32 -28
  24. package/dist/{reveal-component-BclLkYnV.js → reveal-component-JIt3oSeU.js} +642 -634
  25. package/dist/{spec-CnaxwDqy.js → spec-CQKL_D3i.js} +4 -4
  26. package/dist/{strings-BYAhZnQi.js → strings-DIy9NAmg.js} +4 -4
  27. package/dist/style.css +1 -1
  28. package/dist/{swiper-component-DE94711u.js → swiper-component-CEdAYjb7.js} +2 -2
  29. package/dist/toDate-CqhBWjvO.js +713 -0
  30. package/dist/{tooltip-B-qh7UPB.js → tooltip-f-qR0iUx.js} +3 -3
  31. package/dist/{types-DQl7jVYK.js → types-DicH9Oi0.js} +1 -1
  32. package/dist/{useAsyncData-CvO8WUy3.js → useAsyncData-CYOQb1EW.js} +1 -1
  33. package/dist/{useDateFormatter-BSoGim-G.js → useDateFormatter-Bt2qr_XS.js} +2 -2
  34. package/dist/{useDeepCompareMemoize-J3MKRdA4.js → useDeepCompareMemoize-CSHQeQEI.js} +1 -1
  35. package/dist/{useIframeCapabilities-POcFXHbw.js → useIframeCapabilities-kA09ykNq.js} +1 -1
  36. package/dist/{useLifecycle-DqPxHgAN.js → useLifecycle-CMJGqBaY.js} +3 -3
  37. package/dist/{useTheme-XMq9mNf_.js → useTheme-DYfby65G.js} +2 -2
  38. package/dist/{vega-component-CrFoZT1S.js → vega-component-C7nO5zcf.js} +10 -10
  39. package/dist/{zod-D0yOqoGe.js → zod-BUcyXSdR.js} +1 -1
  40. package/package.json +3 -4
  41. package/src/__mocks__/requests.ts +56 -4
  42. package/src/components/data-table/__tests__/__snapshots__/chart-spec-model.test.ts.snap +174 -21
  43. package/src/components/data-table/__tests__/chart-spec-model.test.ts +102 -1
  44. package/src/components/data-table/__tests__/columns.test.tsx +9 -1
  45. package/src/components/data-table/column-summary/chart-spec-model.tsx +163 -103
  46. package/src/components/data-table/column-summary/legacy-chart-spec.ts +15 -7
  47. package/src/components/data-table/column-summary/utils.ts +26 -7
  48. package/src/components/data-table/columns.tsx +11 -1
  49. package/src/components/data-table/types.ts +1 -1
  50. package/src/components/editor/__tests__/viewer-banner.test.tsx +14 -0
  51. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +99 -18
  52. package/src/components/editor/actions/__tests__/pdf-export.test.ts +32 -13
  53. package/src/components/editor/actions/pair-with-agent-commands.ts +21 -10
  54. package/src/components/editor/actions/pair-with-agent-modal.tsx +2 -2
  55. package/src/components/editor/actions/pdf-export.ts +11 -14
  56. package/src/components/editor/actions/useNotebookActions.tsx +28 -19
  57. package/src/components/editor/cell/code/__tests__/cell-editor-activity-lifecycle.test.ts +250 -0
  58. package/src/components/editor/cell/code/cell-editor.tsx +13 -7
  59. package/src/components/editor/chrome/__tests__/state.test.ts +7 -1
  60. package/src/components/editor/chrome/panels/__tests__/logs-panel.test.tsx +25 -0
  61. package/src/components/editor/chrome/panels/__tests__/panel-accordion-state.test.ts +42 -0
  62. package/src/components/editor/chrome/panels/file-explorer-panel.tsx +6 -17
  63. package/src/components/editor/chrome/panels/logs-panel.tsx +12 -2
  64. package/src/components/editor/chrome/panels/panel-accordion-state.ts +51 -0
  65. package/src/components/editor/chrome/panels/session-panel.tsx +5 -16
  66. package/src/components/editor/chrome/panels/write-secret-modal.tsx +40 -12
  67. package/src/components/editor/chrome/state.ts +10 -0
  68. package/src/components/editor/connections/__tests__/json-credentials.test.ts +114 -0
  69. package/src/components/editor/connections/__tests__/paths.test.ts +39 -0
  70. package/src/components/editor/connections/__tests__/quick-add-data-sources.test.tsx +113 -0
  71. package/src/components/editor/connections/add-connection-dialog.tsx +2 -0
  72. package/src/components/editor/connections/components.tsx +6 -2
  73. package/src/components/editor/connections/database/__tests__/__snapshots__/as-code.test.ts.snap +27 -5
  74. package/src/components/editor/connections/database/__tests__/as-code.test.ts +33 -0
  75. package/src/components/editor/connections/database/as-code.ts +37 -13
  76. package/src/components/editor/connections/database/schemas.ts +6 -5
  77. package/src/components/editor/connections/form-renderers.tsx +99 -1
  78. package/src/components/editor/connections/json-credentials.ts +57 -0
  79. package/src/components/editor/connections/paths.ts +21 -0
  80. package/src/components/editor/connections/quick-add-data-sources.tsx +106 -0
  81. package/src/components/editor/connections/secret-combobox.tsx +41 -20
  82. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +88 -2
  83. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +128 -8
  84. package/src/components/editor/connections/storage/as-code.ts +79 -36
  85. package/src/components/editor/connections/storage/schemas.ts +85 -44
  86. package/src/components/editor/file-tree/__tests__/renderers.test.ts +31 -0
  87. package/src/components/editor/file-tree/file-viewer.tsx +11 -7
  88. package/src/components/editor/file-tree/renderers.tsx +21 -0
  89. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +2 -2
  90. package/src/components/editor/viewer-banner.tsx +6 -1
  91. package/src/components/forms/form.tsx +59 -24
  92. package/src/components/forms/options.ts +2 -1
  93. package/src/components/slides/__tests__/cell-output-block.test.tsx +40 -0
  94. package/src/components/slides/__tests__/slide-cell-view.test.tsx +33 -0
  95. package/src/components/slides/reveal-component.tsx +22 -17
  96. package/src/components/slides/slide-cell-view.tsx +1 -1
  97. package/src/components/slides/slides.css +4 -2
  98. package/src/components/static-html/share-modal.tsx +1 -1
  99. package/src/components/ui/input.tsx +1 -1
  100. package/src/core/ai/context/providers/file.ts +8 -29
  101. package/src/core/cells/__tests__/cells.test.ts +12 -2
  102. package/src/core/cells/__tests__/logs.test.ts +5 -5
  103. package/src/core/cells/cells.ts +17 -4
  104. package/src/core/cells/document-changes.ts +1 -0
  105. package/src/core/cells/logs.ts +14 -4
  106. package/src/core/codemirror/cm.ts +3 -0
  107. package/src/core/codemirror/code-lens/__tests__/actions.test.ts +68 -0
  108. package/src/core/codemirror/code-lens/__tests__/analyzer.test.ts +224 -0
  109. package/src/core/codemirror/code-lens/__tests__/entities.test.ts +175 -0
  110. package/src/core/codemirror/code-lens/__tests__/extension.test.ts +325 -0
  111. package/src/core/codemirror/code-lens/__tests__/perf.test.ts +121 -0
  112. package/src/core/codemirror/code-lens/__tests__/popover.test.ts +93 -0
  113. package/src/core/codemirror/code-lens/actions.ts +33 -0
  114. package/src/core/codemirror/code-lens/analyzer.ts +284 -0
  115. package/src/core/codemirror/code-lens/entities.ts +123 -0
  116. package/src/core/codemirror/code-lens/extension.ts +298 -0
  117. package/src/core/codemirror/code-lens/icons.ts +36 -0
  118. package/src/core/codemirror/code-lens/popover.tsx +240 -0
  119. package/src/core/codemirror/copilot/__tests__/language-server.test.ts +396 -100
  120. package/src/core/codemirror/copilot/__tests__/transport.test.ts +128 -540
  121. package/src/core/codemirror/copilot/language-server.ts +182 -70
  122. package/src/core/codemirror/copilot/transport.ts +15 -207
  123. package/src/core/codemirror/copilot/types.ts +9 -2
  124. package/src/core/codemirror/find-replace/__tests__/navigate.test.ts +42 -30
  125. package/src/core/codemirror/find-replace/navigate.ts +2 -0
  126. package/src/core/codemirror/go-to-definition/__tests__/commands.test.ts +24 -3
  127. package/src/core/codemirror/go-to-definition/commands.ts +93 -90
  128. package/src/core/codemirror/keymaps/__tests__/keymaps.test.ts +31 -2
  129. package/src/core/codemirror/keymaps/keymaps.ts +12 -0
  130. package/src/core/codemirror/language/__tests__/sql.test.ts +627 -61
  131. package/src/core/codemirror/language/languages/python.ts +23 -11
  132. package/src/core/codemirror/language/languages/sql/completion-store.ts +5 -1
  133. package/src/core/codemirror/language/languages/sql/renderers.tsx +3 -3
  134. package/src/core/codemirror/language/languages/sql/sql.ts +251 -16
  135. package/src/core/codemirror/lsp/__tests__/federated-lsp.test.ts +161 -0
  136. package/src/core/codemirror/lsp/__tests__/log-messages.test.ts +53 -0
  137. package/src/core/codemirror/lsp/__tests__/normalize-markdown-math.test.ts +25 -0
  138. package/src/core/codemirror/lsp/__tests__/notebook-lsp.test.ts +598 -156
  139. package/src/core/codemirror/lsp/__tests__/transports.test.ts +112 -0
  140. package/src/core/codemirror/lsp/federated-lsp.ts +76 -23
  141. package/src/core/codemirror/lsp/log-messages.ts +37 -0
  142. package/src/core/codemirror/lsp/normalize-markdown-math.ts +8 -4
  143. package/src/core/codemirror/lsp/notebook-lsp.ts +220 -56
  144. package/src/core/codemirror/lsp/transports.ts +8 -1
  145. package/src/core/codemirror/lsp/types.ts +68 -8
  146. package/src/core/codemirror/lsp/utils.ts +14 -0
  147. package/src/core/codemirror/python-node-names.ts +90 -0
  148. package/src/core/codemirror/reactive-references/analyzer.ts +52 -57
  149. package/src/core/codemirror/utils.ts +28 -0
  150. package/src/core/config/feature-flag.tsx +3 -1
  151. package/src/core/datasets/data-source-discovery.ts +5 -0
  152. package/src/core/datasets/request-registry.ts +11 -0
  153. package/src/core/edit-app.tsx +1 -2
  154. package/src/core/export/__tests__/hooks.test.ts +2 -4
  155. package/src/core/export/hooks.ts +4 -3
  156. package/src/core/hotkeys/hotkeys.ts +0 -2
  157. package/src/core/islands/__tests__/bridge.test.ts +107 -0
  158. package/src/core/islands/bootstrap.ts +1 -0
  159. package/src/core/islands/bridge.ts +18 -0
  160. package/src/core/kernel/messages.ts +2 -0
  161. package/src/core/lsp/__tests__/transport.test.ts +509 -337
  162. package/src/core/lsp/transport.ts +304 -132
  163. package/src/core/mode.ts +10 -0
  164. package/src/core/network/__tests__/api.test.ts +42 -0
  165. package/src/core/network/__tests__/connection.test.ts +45 -0
  166. package/src/core/network/__tests__/requests-lazy.test.ts +47 -1
  167. package/src/core/network/__tests__/requests-network.test.ts +48 -0
  168. package/src/core/network/api.ts +27 -0
  169. package/src/core/network/connection.ts +15 -0
  170. package/src/core/network/requests-lazy.ts +3 -0
  171. package/src/core/network/requests-network.ts +31 -8
  172. package/src/core/network/requests-static.ts +3 -0
  173. package/src/core/network/requests-toasting.tsx +3 -0
  174. package/src/core/network/types.ts +27 -6
  175. package/src/core/rtc/state.ts +9 -0
  176. package/src/core/static/download-html.ts +5 -14
  177. package/src/core/wasm/__tests__/bridge.test.ts +99 -5
  178. package/src/core/wasm/bridge.ts +42 -9
  179. package/src/core/wasm/worker/types.ts +10 -3
  180. package/src/core/wasm/worker/worker.ts +7 -8
  181. package/src/core/websocket/useMarimoKernelConnection.tsx +4 -0
  182. package/src/css/app/codemirror.css +16 -2
  183. package/src/hooks/__tests__/useDataSourceDiscovery.test.ts +80 -0
  184. package/src/hooks/useDataSourceDiscovery.ts +18 -0
  185. package/src/plugins/impl/DataTablePlugin.tsx +4 -1
  186. package/src/plugins/impl/FormPlugin.tsx +1 -1
  187. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +158 -0
  188. package/src/plugins/impl/__tests__/FormPlugin.test.tsx +162 -0
  189. package/src/plugins/impl/anywidget/__tests__/model.test.ts +55 -0
  190. package/src/plugins/impl/anywidget/model.ts +20 -5
  191. package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +33 -21
  192. package/src/plugins/impl/data-frames/forms/renderers.tsx +11 -8
  193. package/src/stories/log-viewer.stories.tsx +12 -6
  194. package/src/utils/__tests__/blob.test.ts +27 -1
  195. package/src/utils/__tests__/download.test.tsx +44 -4
  196. package/src/utils/blob.ts +24 -1
  197. package/src/utils/download.ts +14 -15
  198. package/dist/toDate-B3IRfHhw.js +0 -692
@@ -12,6 +12,8 @@ import type {
12
12
  EnvironmentInfo,
13
13
  ExportAsHTMLRequest,
14
14
  ExportAsMarkdownRequest,
15
+ ExportAsScriptRequest,
16
+ ExportedFile,
15
17
  FileCopyRequest,
16
18
  FileCopyResponse,
17
19
  FileCreateRequest,
@@ -96,8 +98,11 @@ export interface RawBridge {
96
98
  load_packages(request: string): Promise<string>;
97
99
  read_file(request: string): Promise<string>;
98
100
  set_interrupt_buffer(request: Uint8Array): Promise<string>;
99
- export_html(request: ExportAsHTMLRequest): Promise<string>;
100
- export_markdown(request: ExportAsMarkdownRequest): Promise<string>;
101
+ export_html(request: ExportAsHTMLRequest): Promise<ExportedFile<string>>;
102
+ export_markdown(
103
+ request: ExportAsMarkdownRequest,
104
+ ): Promise<ExportedFile<string>>;
105
+ export_script(request: ExportAsScriptRequest): Promise<ExportedFile<string>>;
101
106
  }
102
107
 
103
108
  export type BridgePayload<T extends keyof RawBridge> = T extends keyof RawBridge
@@ -105,9 +110,11 @@ export type BridgePayload<T extends keyof RawBridge> = T extends keyof RawBridge
105
110
  : undefined;
106
111
 
107
112
  export type SerializedBridge = {
108
- [P in keyof RawBridge]: RawBridge[P] extends (
113
+ [P in Exclude<keyof RawBridge, "save">]: RawBridge[P] extends (
109
114
  payload: string,
110
115
  ) => Promise<unknown>
111
116
  ? (payload: string) => Promise<string>
112
117
  : RawBridge[P];
118
+ } & {
119
+ save(payload: string): Promise<void>;
113
120
  };
@@ -250,13 +250,9 @@ const requestHandler = createRPCRequestHandler({
250
250
  * Save the notebook
251
251
  */
252
252
  saveNotebook: async (opts: SaveNotebookRequest) => {
253
- // Partially duplicated from save-worker.ts
254
253
  await pyodideReadyPromise; // Make sure loading is done
255
- const saveFile = self.pyodide.runPython(`
256
- from marimo._pyodide.bootstrap import save_file
257
- save_file
258
- `);
259
- await saveFile(JSON.stringify(opts), WasmFileSystem.NOTEBOOK_FILENAME);
254
+ const bridge = await bridgeReady.promise;
255
+ await bridge.save(JSON.stringify(opts));
260
256
  },
261
257
 
262
258
  /**
@@ -287,8 +283,11 @@ const requestHandler = createRPCRequestHandler({
287
283
  `);
288
284
  }
289
285
 
290
- // Special case to lazily install PyYAML on export_markdown
291
- if (functionName === "export_markdown") {
286
+ // Script export also parses Markdown frontmatter when present.
287
+ if (
288
+ functionName === "export_markdown" ||
289
+ functionName === "export_script"
290
+ ) {
292
291
  await self.pyodide.runPythonAsync(`
293
292
  import micropip
294
293
 
@@ -42,6 +42,7 @@ import { connectionTransportTypeAtom, useSetAppConfig } from "../config/config";
42
42
  import { useDataSourceActions } from "../datasets/data-source-connections";
43
43
  import type { ConnectionName } from "../datasets/engines";
44
44
  import {
45
+ DiscoverDataSources,
45
46
  PreviewSQLSchemaList,
46
47
  PreviewSQLTable,
47
48
  PreviewSQLTableList,
@@ -394,6 +395,9 @@ export function useMarimoKernelConnection(opts: {
394
395
  case "validate-sql-result":
395
396
  ValidateSQL.resolve(msg.data.request_id as RequestId, msg.data);
396
397
  return;
398
+ case "data-source-discovery-result":
399
+ DiscoverDataSources.resolve(msg.data.request_id as RequestId, msg.data);
400
+ return;
397
401
  case "secret-keys-result":
398
402
  SECRETS_REGISTRY.resolve(msg.data.request_id, msg.data);
399
403
  return;
@@ -90,8 +90,22 @@
90
90
  @apply bg-muted border border-border rounded-md px-1;
91
91
  }
92
92
 
93
- .cm-diagnosticText > div {
94
- display: inline-flex;
93
+ /* The message, the docs link, and related info are siblings; stack them. */
94
+ .cm-lsp-diagnostic-message {
95
+ @apply flex flex-col items-start gap-1;
96
+
97
+ /* Rendered markdown, so the `pre-wrap` on `.cm-diagnostic` would only turn
98
+ the whitespace between block tags into blank lines. */
99
+ white-space: normal;
100
+ }
101
+
102
+ .cm-diagnostic-code-link {
103
+ @apply text-xs;
104
+ }
105
+
106
+ /* The docs link already reads `source(code)`; don't repeat it below. */
107
+ .cm-diagnostic:has(.cm-diagnostic-code-link) .cm-diagnosticSource {
108
+ @apply hidden;
95
109
  }
96
110
  }
97
111
 
@@ -0,0 +1,80 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { DiscoverDataSources } from "@/core/datasets/request-registry";
5
+ import { loadDataSourceDiscovery } from "../useDataSourceDiscovery";
6
+
7
+ describe("loadDataSourceDiscovery", () => {
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("returns the kernel's secret-free discovery model", async () => {
13
+ const sources = [
14
+ {
15
+ id: "pyiceberg-prod",
16
+ integration: "pyiceberg",
17
+ category: "catalog" as const,
18
+ displayName: "PyIceberg (prod)",
19
+ confidence: "high" as const,
20
+ origins: [
21
+ {
22
+ type: "configuration" as const,
23
+ label: "Resolved PyIceberg configuration",
24
+ },
25
+ ],
26
+ configuration: [
27
+ {
28
+ field: "Catalog",
29
+ value: {
30
+ kind: "safe-literal" as const,
31
+ value: "prod",
32
+ },
33
+ },
34
+ ],
35
+ code: 'catalog = load_catalog("prod")',
36
+ },
37
+ ];
38
+ const request = vi.spyOn(DiscoverDataSources, "request").mockResolvedValue({
39
+ request_id: "request-id",
40
+ sources,
41
+ });
42
+
43
+ const detected = await loadDataSourceDiscovery();
44
+
45
+ expect({ detected, requests: request.mock.calls }).toMatchInlineSnapshot(`
46
+ {
47
+ "detected": [
48
+ {
49
+ "category": "catalog",
50
+ "code": "catalog = load_catalog("prod")",
51
+ "confidence": "high",
52
+ "configuration": [
53
+ {
54
+ "field": "Catalog",
55
+ "value": {
56
+ "kind": "safe-literal",
57
+ "value": "prod",
58
+ },
59
+ },
60
+ ],
61
+ "displayName": "PyIceberg (prod)",
62
+ "id": "pyiceberg-prod",
63
+ "integration": "pyiceberg",
64
+ "origins": [
65
+ {
66
+ "label": "Resolved PyIceberg configuration",
67
+ "type": "configuration",
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ "requests": [
73
+ [
74
+ {},
75
+ ],
76
+ ],
77
+ }
78
+ `);
79
+ });
80
+ });
@@ -0,0 +1,18 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { DetectedDataSource } from "@/core/datasets/data-source-discovery";
4
+ import { DiscoverDataSources } from "@/core/datasets/request-registry";
5
+ import { useAsyncData } from "./useAsyncData";
6
+
7
+ export async function loadDataSourceDiscovery(): Promise<DetectedDataSource[]> {
8
+ const result = await DiscoverDataSources.request({});
9
+ return result.sources;
10
+ }
11
+
12
+ /**
13
+ * Reusable UI-facing hook for kernel-managed datasource discovery.
14
+ * Consumers decide how to render, filter, or act on the detected model.
15
+ */
16
+ export function useDataSourceDiscovery() {
17
+ return useAsyncData(loadDataSourceDiscovery, []);
18
+ }
@@ -908,7 +908,10 @@ const DataTableComponent = ({
908
908
  );
909
909
  }, [fieldTypes, columnSummaries]);
910
910
 
911
- const fieldTypesOrInferred = fieldTypes ?? inferFieldTypes(data);
911
+ const rowHeaderNames = new Set(rowHeaders.map(([name]) => name));
912
+ const fieldTypesOrInferred =
913
+ fieldTypes ??
914
+ inferFieldTypes(data).filter(([name]) => !rowHeaderNames.has(name));
912
915
 
913
916
  const memoizedUnclampedFieldTypes =
914
917
  useDeepCompareMemoize(fieldTypesOrInferred);
@@ -148,7 +148,7 @@ export const FormWrapper = <T,>({
148
148
  if (evt.key === "Enter" && (evt.ctrlKey || evt.metaKey)) {
149
149
  evt.preventDefault();
150
150
  evt.stopPropagation();
151
- setValue(newValue);
151
+ formDiv.current?.requestSubmit();
152
152
  }
153
153
  }}
154
154
  >
@@ -30,6 +30,164 @@ beforeAll(() => {
30
30
  });
31
31
 
32
32
  describe("LoadingDataTableComponent", () => {
33
+ it("keeps data columns when inferred types include a row header", async () => {
34
+ const host = document.createElement("div");
35
+ const data = JSON.stringify([{ index: "first", value: 1 }]);
36
+
37
+ render(
38
+ <Provider store={store}>
39
+ <TooltipProvider>
40
+ <LoadingDataTableComponent
41
+ label={null}
42
+ totalRows={1}
43
+ pagination={false}
44
+ pageSize={10}
45
+ selection={null}
46
+ showDownload={false}
47
+ showFilters={false}
48
+ showColumnSummaries={false}
49
+ showDataTypes={false}
50
+ showPageSizeSelector={false}
51
+ showColumnExplorer={false}
52
+ showRowExplorer={false}
53
+ showChartBuilder={false}
54
+ rowHeaders={[["index", ["string", "object"]]]}
55
+ fieldTypes={undefined}
56
+ totalColumns={1}
57
+ maxColumns={1}
58
+ hasStableRowId={false}
59
+ lazy={false}
60
+ host={host}
61
+ showSearch={false}
62
+ value={[]}
63
+ setValue={vi.fn()}
64
+ data={data}
65
+ search={vi.fn().mockResolvedValue({
66
+ data,
67
+ total_rows: 1,
68
+ cell_styles: null,
69
+ cell_hover_texts: null,
70
+ })}
71
+ download_as={vi.fn() as DownloadAsArgs}
72
+ get_column_summaries={vi.fn()}
73
+ get_data_url={vi.fn() as GetDataUrl}
74
+ get_row_ids={vi.fn() as GetRowIds}
75
+ get_size_bytes={vi.fn().mockResolvedValue({ size_bytes: null })}
76
+ />
77
+ </TooltipProvider>
78
+ </Provider>,
79
+ );
80
+
81
+ await waitFor(() => {
82
+ expect(
83
+ screen.getByRole("columnheader", { name: "value" }),
84
+ ).toBeInTheDocument();
85
+ });
86
+ });
87
+
88
+ it("does not log duplicate keys when a pivot becomes empty", async () => {
89
+ const host = document.createElement("div");
90
+ const consoleError = vi
91
+ .spyOn(console, "error")
92
+ .mockImplementation(() => {});
93
+ const populatedData = JSON.stringify([
94
+ { DSGROUP: "a", no: 1, yes: 1 },
95
+ { DSGROUP: "b", no: 0, yes: 1 },
96
+ ]);
97
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
98
+ <Provider store={store}>
99
+ <TooltipProvider>{children}</TooltipProvider>
100
+ </Provider>
101
+ );
102
+ const commonProps = {
103
+ label: null,
104
+ pagination: false,
105
+ pageSize: 10,
106
+ selection: null,
107
+ showDownload: false,
108
+ showFilters: false,
109
+ showColumnSummaries: false as const,
110
+ showDataTypes: true,
111
+ showPageSizeSelector: false,
112
+ showColumnExplorer: false,
113
+ showRowExplorer: false,
114
+ showChartBuilder: false,
115
+ rowHeaders: [
116
+ ["DSGROUP", ["string", "object"]],
117
+ ] as FieldTypesWithExternalType,
118
+ maxColumns: "all" as const,
119
+ hasStableRowId: false,
120
+ lazy: false,
121
+ host,
122
+ showSearch: false,
123
+ value: [] as (number | string | { rowId: string; columnName?: string })[],
124
+ setValue: vi.fn(),
125
+ download_as: vi.fn() as DownloadAsArgs,
126
+ get_column_summaries: vi.fn(),
127
+ get_data_url: vi.fn() as GetDataUrl,
128
+ get_row_ids: vi.fn() as GetRowIds,
129
+ get_size_bytes: vi.fn().mockResolvedValue({ size_bytes: null }),
130
+ };
131
+
132
+ try {
133
+ const { rerender } = render(
134
+ <Wrapper>
135
+ <LoadingDataTableComponent
136
+ {...commonProps}
137
+ totalRows={2}
138
+ totalColumns={2}
139
+ fieldTypes={[
140
+ ["no", ["number", "int64"]],
141
+ ["yes", ["number", "int64"]],
142
+ ]}
143
+ data={populatedData}
144
+ search={vi.fn().mockResolvedValue({
145
+ data: populatedData,
146
+ total_rows: 2,
147
+ cell_styles: null,
148
+ cell_hover_texts: null,
149
+ })}
150
+ />
151
+ </Wrapper>,
152
+ );
153
+
154
+ await waitFor(() => {
155
+ expect(screen.getAllByRole("row")).toHaveLength(3);
156
+ });
157
+
158
+ await act(async () => {
159
+ rerender(
160
+ <Wrapper>
161
+ <LoadingDataTableComponent
162
+ {...commonProps}
163
+ totalRows={0}
164
+ totalColumns={0}
165
+ fieldTypes={undefined}
166
+ data="[]"
167
+ search={vi.fn().mockResolvedValue({
168
+ data: "[]",
169
+ total_rows: 0,
170
+ cell_styles: null,
171
+ cell_hover_texts: null,
172
+ })}
173
+ />
174
+ </Wrapper>,
175
+ );
176
+ });
177
+
178
+ await waitFor(() => {
179
+ expect(screen.getByText("No results.")).toBeInTheDocument();
180
+ });
181
+
182
+ const duplicateKeyErrors = consoleError.mock.calls.filter((args) =>
183
+ args.some((arg) => typeof arg === "string" && arg.includes("same key")),
184
+ );
185
+ expect(duplicateKeyErrors).toHaveLength(0);
186
+ } finally {
187
+ consoleError.mockRestore();
188
+ }
189
+ });
190
+
33
191
  /**
34
192
  * Regression test for https://github.com/marimo-team/marimo/issues/8023
35
193
  *
@@ -0,0 +1,162 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { FormWrapper } from "../FormPlugin";
6
+
7
+ describe("FormPlugin", () => {
8
+ it("submits the form on Ctrl+Enter and calls setValue", async () => {
9
+ const setValue = vi.fn();
10
+ const validate = vi.fn().mockResolvedValue(null);
11
+
12
+ render(
13
+ <FormWrapper
14
+ currentValue="old-val"
15
+ newValue="new-val"
16
+ setValue={setValue}
17
+ validate={validate}
18
+ shouldValidate={false}
19
+ clearOnSubmit={false}
20
+ submitButtonLabel="Submit"
21
+ bordered={true}
22
+ loading={false}
23
+ submitButtonDisabled={false}
24
+ showClearButton={false}
25
+ clearButtonLabel="Clear"
26
+ label={null}
27
+ >
28
+ <input data-testid="input" />
29
+ </FormWrapper>,
30
+ );
31
+
32
+ const input = screen.getByTestId("input");
33
+
34
+ // Simulate Ctrl+Enter on input
35
+ fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
36
+
37
+ await waitFor(() => {
38
+ expect(setValue).toHaveBeenCalledWith("new-val");
39
+ });
40
+ });
41
+
42
+ it("submits the form on Ctrl+Enter and runs validation when shouldValidate is true", async () => {
43
+ const setValue = vi.fn();
44
+ const validate = vi.fn().mockResolvedValue("Validation failed message");
45
+
46
+ render(
47
+ <FormWrapper
48
+ currentValue="old-val"
49
+ newValue="new-val"
50
+ setValue={setValue}
51
+ validate={validate}
52
+ shouldValidate={true}
53
+ clearOnSubmit={false}
54
+ submitButtonLabel="Submit"
55
+ bordered={true}
56
+ loading={false}
57
+ submitButtonDisabled={false}
58
+ showClearButton={false}
59
+ clearButtonLabel="Clear"
60
+ label={null}
61
+ >
62
+ <input data-testid="input" />
63
+ </FormWrapper>,
64
+ );
65
+
66
+ const input = screen.getByTestId("input");
67
+
68
+ // Simulate Ctrl+Enter on input
69
+ fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
70
+
71
+ await waitFor(() => {
72
+ expect(validate).toHaveBeenCalledWith({ value: "new-val" });
73
+ // should not submit (setValue not called) due to validation failure
74
+ expect(setValue).not.toHaveBeenCalled();
75
+ // error message displayed
76
+ expect(screen.getByText("Validation failed message")).toBeInTheDocument();
77
+ });
78
+ });
79
+
80
+ it("clears inputs on submission when clearOnSubmit is true", async () => {
81
+ const setValue = vi.fn();
82
+ const validate = vi.fn().mockResolvedValue(null);
83
+
84
+ let mockUIElement: (HTMLElement & { reset?: () => void }) | null = null;
85
+ const mockReset = vi.fn();
86
+
87
+ render(
88
+ <FormWrapper
89
+ currentValue="old-val"
90
+ newValue="new-val"
91
+ setValue={setValue}
92
+ validate={validate}
93
+ shouldValidate={false}
94
+ clearOnSubmit={true}
95
+ submitButtonLabel="Submit"
96
+ bordered={true}
97
+ loading={false}
98
+ submitButtonDisabled={false}
99
+ showClearButton={false}
100
+ clearButtonLabel="Clear"
101
+ label={null}
102
+ >
103
+ <div
104
+ ref={(el) => {
105
+ if (el && !mockUIElement) {
106
+ mockUIElement = document.createElement("marimo-ui-element");
107
+ mockUIElement.setAttribute("data-testid", "mock-ui-element");
108
+ mockUIElement.reset = mockReset;
109
+ const inputEl = document.createElement("input");
110
+ inputEl.type = "text";
111
+ mockUIElement.appendChild(inputEl);
112
+ el.appendChild(mockUIElement);
113
+ }
114
+ }}
115
+ />
116
+ </FormWrapper>,
117
+ );
118
+
119
+ const input = screen.getByRole("textbox");
120
+
121
+ // Simulate Ctrl+Enter on input
122
+ fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
123
+
124
+ await waitFor(() => {
125
+ expect(setValue).toHaveBeenCalledWith("new-val");
126
+ expect(mockReset).toHaveBeenCalled();
127
+ });
128
+ });
129
+
130
+ it("does not submit on Shift+Enter", async () => {
131
+ const setValue = vi.fn();
132
+ const validate = vi.fn().mockResolvedValue(null);
133
+
134
+ render(
135
+ <FormWrapper
136
+ currentValue="old-val"
137
+ newValue="new-val"
138
+ setValue={setValue}
139
+ validate={validate}
140
+ shouldValidate={false}
141
+ clearOnSubmit={false}
142
+ submitButtonLabel="Submit"
143
+ bordered={true}
144
+ loading={false}
145
+ submitButtonDisabled={false}
146
+ showClearButton={false}
147
+ clearButtonLabel="Clear"
148
+ label={null}
149
+ >
150
+ <input data-testid="input" />
151
+ </FormWrapper>,
152
+ );
153
+
154
+ const input = screen.getByTestId("input");
155
+
156
+ // Simulate Shift+Enter on input
157
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
158
+
159
+ expect(setValue).not.toHaveBeenCalled();
160
+ expect(validate).not.toHaveBeenCalled();
161
+ });
162
+ });
@@ -263,6 +263,61 @@ describe("Model", () => {
263
263
  await TestUtils.nextTick(); // flush
264
264
  expect(callback).toHaveBeenCalledTimes(1);
265
265
  });
266
+
267
+ it("should not mark kernel-pushed state dirty", () => {
268
+ getMarimoInternal(model).updateAndEmitDiffs({ foo: "changed", bar: 456 });
269
+
270
+ model.save_changes();
271
+ expect(mockComm.sendUpdate).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it("should not echo kernel-pushed state alongside a local set", () => {
275
+ // Kernel pushes a large trait; widget ESM then sets one field.
276
+ getMarimoInternal(model).updateAndEmitDiffs({
277
+ foo: "from kernel",
278
+ bar: 123,
279
+ });
280
+ model.set("bar", 456);
281
+
282
+ model.save_changes();
283
+ expect(mockComm.sendUpdate).toHaveBeenCalledExactlyOnceWith({
284
+ bar: 456,
285
+ });
286
+ });
287
+
288
+ it("should drop a pending local write superseded by a kernel push", () => {
289
+ // Local write, not yet saved...
290
+ model.set("foo", "local");
291
+ // ...then the kernel pushes a newer value for the same key.
292
+ getMarimoInternal(model).updateAndEmitDiffs({
293
+ foo: "from kernel",
294
+ bar: 123,
295
+ });
296
+ expect(model.get("foo")).toBe("from kernel");
297
+
298
+ // Saving must not resend the stale local value.
299
+ model.save_changes();
300
+ expect(mockComm.sendUpdate).not.toHaveBeenCalled();
301
+ });
302
+
303
+ it("should drop a pending local write the kernel pushed the same value for", () => {
304
+ const callback = vi.fn();
305
+ model.on("change:foo", callback);
306
+
307
+ // Local write, not yet saved...
308
+ model.set("foo", "agreed");
309
+ expect(callback).toHaveBeenCalledTimes(1);
310
+
311
+ // ...then the kernel independently pushes the same value.
312
+ getMarimoInternal(model).updateAndEmitDiffs({
313
+ foo: "agreed",
314
+ bar: 123,
315
+ });
316
+ expect(callback).toHaveBeenCalledTimes(1); // no spurious re-emit
317
+
318
+ model.save_changes();
319
+ expect(mockComm.sendUpdate).not.toHaveBeenCalled();
320
+ });
266
321
  });
267
322
 
268
323
  describe("emitCustomMessage", () => {
@@ -53,7 +53,8 @@ export function getMarimoInternal<T extends ModelState>(
53
53
 
54
54
  export class Model<T extends ModelState> implements AnyModel<T> {
55
55
  #ANY_CHANGE_EVENT = "change";
56
- #dirtyFields: Map<keyof T, unknown>;
56
+ /** Keys last written by the widget that the kernel hasn't seen yet. */
57
+ #dirtyFields: Set<keyof T>;
57
58
  #data: T;
58
59
  #comm: MarimoComm<T>;
59
60
  #listeners: Record<string, Set<EventHandler> | undefined> = {};
@@ -70,7 +71,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
70
71
  constructor(data: T, comm: MarimoComm<T>, signal?: AbortSignal) {
71
72
  this.#data = data;
72
73
  this.#comm = comm;
73
- this.#dirtyFields = new Map();
74
+ this.#dirtyFields = new Set();
74
75
  if (signal) {
75
76
  signal.addEventListener("abort", () => {
76
77
  Logger.debug("[Model] Signal aborted, clearing all listeners");
@@ -140,7 +141,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
140
141
 
141
142
  set<K extends keyof T>(key: K, value: T[K]): void {
142
143
  this.#data = { ...this.#data, [key]: value };
143
- this.#dirtyFields.set(key, value);
144
+ this.#dirtyFields.add(key);
144
145
  this.#emit(`change:${key as K & string}`, value);
145
146
  this.#emitAnyChange();
146
147
  }
@@ -151,7 +152,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
151
152
  }
152
153
  // Only send the dirty fields, not the entire state.
153
154
  const partialData = Object.fromEntries(
154
- this.#dirtyFields.entries(),
155
+ [...this.#dirtyFields].map((key) => [key, this.#data[key]]),
155
156
  ) as Partial<T>;
156
157
 
157
158
  // Clear the dirty fields to avoid sending again.
@@ -204,6 +205,15 @@ export class Model<T extends ModelState> implements AnyModel<T> {
204
205
  }
205
206
  }
206
207
 
208
+ /**
209
+ * Apply a kernel-originated state update.
210
+ *
211
+ * Unlike `set()`, this never marks fields dirty — the kernel already
212
+ * has these values, so `save_changes()` must not echo them back.
213
+ *
214
+ * The kernel wins conflicts: a pending unsaved widget write to the same
215
+ * key is dropped, not replayed on top.
216
+ */
207
217
  #updateAndEmitDiffs(value: T) {
208
218
  if (value == null) {
209
219
  return;
@@ -211,9 +221,14 @@ export class Model<T extends ModelState> implements AnyModel<T> {
211
221
 
212
222
  Object.keys(value).forEach((key) => {
213
223
  const k = key as keyof T;
224
+ // Unconditional: guarding on inequality would leave a same-valued
225
+ // pending write dirty, and echo it back on save.
226
+ this.#dirtyFields.delete(k);
214
227
  // Shallow equal since these can be large objects
215
228
  if (this.#data[k] !== value[k]) {
216
- this.set(k, value[k]);
229
+ this.#data = { ...this.#data, [k]: value[k] };
230
+ this.#emit(`change:${k as keyof T & string}`, value[k]);
231
+ this.#emitAnyChange();
217
232
  }
218
233
  });
219
234
  }