@marimo-team/islands 0.23.16-dev9 → 0.23.17-dev0

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 (164) hide show
  1. package/dist/{chat-ui-JeekqoMH.js → chat-ui-DsZJj75A.js} +3104 -3107
  2. package/dist/{code-visibility-DveLKNtj.js → common-CotPLtrU.js} +1068 -1036
  3. package/dist/{formats-CO8cn5_K.js → formats-Dwq1THhr.js} +1 -1
  4. package/dist/{html-to-image-Bif4utQZ.js → html-to-image-CZ1kLKkq.js} +7336 -6342
  5. package/dist/main.js +211 -147
  6. package/dist/{node-sql-parser-DLl_zNw0.js → node-sql-parser-v1Vi-Fft.js} +16867 -16665
  7. package/dist/{process-output-TAzy-GUk.js → process-output-bVfbcx5_.js} +30 -26
  8. package/dist/{reveal-component-DxV4Ppa2.js → reveal-component-IoHipy82.js} +634 -626
  9. package/dist/style.css +1 -1
  10. package/dist/toDate-CqhBWjvO.js +713 -0
  11. package/dist/{vega-component-DphdxaJ_.js → vega-component-C7nO5zcf.js} +2 -2
  12. package/package.json +3 -4
  13. package/src/__mocks__/requests.ts +56 -4
  14. package/src/components/data-table/__tests__/__snapshots__/chart-spec-model.test.ts.snap +174 -21
  15. package/src/components/data-table/__tests__/chart-spec-model.test.ts +102 -1
  16. package/src/components/data-table/__tests__/columns.test.tsx +9 -1
  17. package/src/components/data-table/column-summary/chart-spec-model.tsx +163 -103
  18. package/src/components/data-table/column-summary/legacy-chart-spec.ts +15 -7
  19. package/src/components/data-table/column-summary/utils.ts +26 -7
  20. package/src/components/data-table/columns.tsx +11 -1
  21. package/src/components/data-table/types.ts +1 -1
  22. package/src/components/editor/__tests__/viewer-banner.test.tsx +14 -0
  23. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +99 -18
  24. package/src/components/editor/actions/__tests__/pdf-export.test.ts +32 -13
  25. package/src/components/editor/actions/pair-with-agent-commands.ts +21 -10
  26. package/src/components/editor/actions/pair-with-agent-modal.tsx +2 -2
  27. package/src/components/editor/actions/pdf-export.ts +11 -14
  28. package/src/components/editor/actions/useNotebookActions.tsx +28 -19
  29. package/src/components/editor/cell/code/__tests__/cell-editor-activity-lifecycle.test.ts +250 -0
  30. package/src/components/editor/cell/code/cell-editor.tsx +13 -7
  31. package/src/components/editor/chrome/__tests__/state.test.ts +7 -1
  32. package/src/components/editor/chrome/panels/__tests__/logs-panel.test.tsx +25 -0
  33. package/src/components/editor/chrome/panels/__tests__/panel-accordion-state.test.ts +42 -0
  34. package/src/components/editor/chrome/panels/file-explorer-panel.tsx +6 -17
  35. package/src/components/editor/chrome/panels/logs-panel.tsx +12 -2
  36. package/src/components/editor/chrome/panels/panel-accordion-state.ts +51 -0
  37. package/src/components/editor/chrome/panels/session-panel.tsx +5 -16
  38. package/src/components/editor/chrome/panels/write-secret-modal.tsx +40 -12
  39. package/src/components/editor/chrome/state.ts +10 -0
  40. package/src/components/editor/connections/__tests__/json-credentials.test.ts +114 -0
  41. package/src/components/editor/connections/__tests__/paths.test.ts +39 -0
  42. package/src/components/editor/connections/__tests__/quick-add-data-sources.test.tsx +113 -0
  43. package/src/components/editor/connections/add-connection-dialog.tsx +2 -0
  44. package/src/components/editor/connections/components.tsx +6 -2
  45. package/src/components/editor/connections/database/__tests__/__snapshots__/as-code.test.ts.snap +27 -5
  46. package/src/components/editor/connections/database/__tests__/as-code.test.ts +33 -0
  47. package/src/components/editor/connections/database/as-code.ts +37 -13
  48. package/src/components/editor/connections/database/schemas.ts +6 -5
  49. package/src/components/editor/connections/form-renderers.tsx +99 -1
  50. package/src/components/editor/connections/json-credentials.ts +57 -0
  51. package/src/components/editor/connections/paths.ts +21 -0
  52. package/src/components/editor/connections/quick-add-data-sources.tsx +106 -0
  53. package/src/components/editor/connections/secret-combobox.tsx +41 -20
  54. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +88 -2
  55. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +128 -8
  56. package/src/components/editor/connections/storage/as-code.ts +79 -36
  57. package/src/components/editor/connections/storage/schemas.ts +85 -44
  58. package/src/components/editor/file-tree/__tests__/renderers.test.ts +31 -0
  59. package/src/components/editor/file-tree/file-viewer.tsx +11 -7
  60. package/src/components/editor/file-tree/renderers.tsx +21 -0
  61. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +2 -2
  62. package/src/components/editor/viewer-banner.tsx +6 -1
  63. package/src/components/forms/form.tsx +59 -24
  64. package/src/components/forms/options.ts +2 -1
  65. package/src/components/slides/__tests__/cell-output-block.test.tsx +40 -0
  66. package/src/components/slides/__tests__/slide-cell-view.test.tsx +33 -0
  67. package/src/components/slides/reveal-component.tsx +22 -17
  68. package/src/components/slides/slide-cell-view.tsx +1 -1
  69. package/src/components/slides/slides.css +4 -2
  70. package/src/components/static-html/share-modal.tsx +1 -1
  71. package/src/core/ai/context/providers/file.ts +8 -29
  72. package/src/core/cells/__tests__/cells.test.ts +12 -2
  73. package/src/core/cells/__tests__/logs.test.ts +5 -5
  74. package/src/core/cells/cells.ts +17 -4
  75. package/src/core/cells/document-changes.ts +1 -0
  76. package/src/core/cells/logs.ts +14 -4
  77. package/src/core/codemirror/cm.ts +3 -0
  78. package/src/core/codemirror/code-lens/__tests__/actions.test.ts +68 -0
  79. package/src/core/codemirror/code-lens/__tests__/analyzer.test.ts +224 -0
  80. package/src/core/codemirror/code-lens/__tests__/entities.test.ts +175 -0
  81. package/src/core/codemirror/code-lens/__tests__/extension.test.ts +325 -0
  82. package/src/core/codemirror/code-lens/__tests__/perf.test.ts +121 -0
  83. package/src/core/codemirror/code-lens/__tests__/popover.test.ts +93 -0
  84. package/src/core/codemirror/code-lens/actions.ts +33 -0
  85. package/src/core/codemirror/code-lens/analyzer.ts +284 -0
  86. package/src/core/codemirror/code-lens/entities.ts +123 -0
  87. package/src/core/codemirror/code-lens/extension.ts +298 -0
  88. package/src/core/codemirror/code-lens/icons.ts +36 -0
  89. package/src/core/codemirror/code-lens/popover.tsx +240 -0
  90. package/src/core/codemirror/copilot/__tests__/language-server.test.ts +396 -100
  91. package/src/core/codemirror/copilot/__tests__/transport.test.ts +128 -540
  92. package/src/core/codemirror/copilot/language-server.ts +182 -70
  93. package/src/core/codemirror/copilot/transport.ts +15 -207
  94. package/src/core/codemirror/copilot/types.ts +9 -2
  95. package/src/core/codemirror/find-replace/__tests__/navigate.test.ts +42 -30
  96. package/src/core/codemirror/find-replace/navigate.ts +2 -0
  97. package/src/core/codemirror/go-to-definition/__tests__/commands.test.ts +24 -3
  98. package/src/core/codemirror/go-to-definition/commands.ts +93 -90
  99. package/src/core/codemirror/language/__tests__/sql.test.ts +627 -61
  100. package/src/core/codemirror/language/languages/python.ts +23 -11
  101. package/src/core/codemirror/language/languages/sql/completion-store.ts +5 -1
  102. package/src/core/codemirror/language/languages/sql/renderers.tsx +3 -3
  103. package/src/core/codemirror/language/languages/sql/sql.ts +251 -16
  104. package/src/core/codemirror/lsp/__tests__/federated-lsp.test.ts +161 -0
  105. package/src/core/codemirror/lsp/__tests__/log-messages.test.ts +53 -0
  106. package/src/core/codemirror/lsp/__tests__/normalize-markdown-math.test.ts +25 -0
  107. package/src/core/codemirror/lsp/__tests__/notebook-lsp.test.ts +598 -156
  108. package/src/core/codemirror/lsp/__tests__/transports.test.ts +112 -0
  109. package/src/core/codemirror/lsp/federated-lsp.ts +76 -23
  110. package/src/core/codemirror/lsp/log-messages.ts +37 -0
  111. package/src/core/codemirror/lsp/normalize-markdown-math.ts +8 -4
  112. package/src/core/codemirror/lsp/notebook-lsp.ts +220 -56
  113. package/src/core/codemirror/lsp/transports.ts +8 -1
  114. package/src/core/codemirror/lsp/types.ts +68 -8
  115. package/src/core/codemirror/lsp/utils.ts +14 -0
  116. package/src/core/codemirror/python-node-names.ts +90 -0
  117. package/src/core/codemirror/reactive-references/analyzer.ts +52 -57
  118. package/src/core/codemirror/utils.ts +28 -0
  119. package/src/core/config/feature-flag.tsx +3 -1
  120. package/src/core/datasets/data-source-discovery.ts +5 -0
  121. package/src/core/datasets/request-registry.ts +11 -0
  122. package/src/core/edit-app.tsx +1 -2
  123. package/src/core/export/__tests__/hooks.test.ts +2 -4
  124. package/src/core/export/hooks.ts +4 -3
  125. package/src/core/islands/__tests__/bridge.test.ts +107 -0
  126. package/src/core/islands/bootstrap.ts +1 -0
  127. package/src/core/islands/bridge.ts +18 -0
  128. package/src/core/kernel/messages.ts +2 -0
  129. package/src/core/lsp/__tests__/transport.test.ts +509 -337
  130. package/src/core/lsp/transport.ts +304 -132
  131. package/src/core/mode.ts +10 -0
  132. package/src/core/network/__tests__/api.test.ts +42 -0
  133. package/src/core/network/__tests__/connection.test.ts +45 -0
  134. package/src/core/network/__tests__/requests-lazy.test.ts +47 -1
  135. package/src/core/network/__tests__/requests-network.test.ts +48 -0
  136. package/src/core/network/api.ts +27 -0
  137. package/src/core/network/connection.ts +15 -0
  138. package/src/core/network/requests-lazy.ts +3 -0
  139. package/src/core/network/requests-network.ts +31 -8
  140. package/src/core/network/requests-static.ts +3 -0
  141. package/src/core/network/requests-toasting.tsx +3 -0
  142. package/src/core/network/types.ts +27 -6
  143. package/src/core/rtc/state.ts +9 -0
  144. package/src/core/static/download-html.ts +5 -14
  145. package/src/core/wasm/__tests__/bridge.test.ts +99 -5
  146. package/src/core/wasm/bridge.ts +42 -9
  147. package/src/core/wasm/worker/types.ts +10 -3
  148. package/src/core/wasm/worker/worker.ts +7 -8
  149. package/src/core/websocket/useMarimoKernelConnection.tsx +4 -0
  150. package/src/css/app/codemirror.css +16 -2
  151. package/src/hooks/__tests__/useDataSourceDiscovery.test.ts +80 -0
  152. package/src/hooks/useDataSourceDiscovery.ts +18 -0
  153. package/src/plugins/impl/DataTablePlugin.tsx +4 -1
  154. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +158 -0
  155. package/src/plugins/impl/anywidget/__tests__/model.test.ts +55 -0
  156. package/src/plugins/impl/anywidget/model.ts +20 -5
  157. package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +33 -21
  158. package/src/plugins/impl/data-frames/forms/renderers.tsx +11 -8
  159. package/src/stories/log-viewer.stories.tsx +12 -6
  160. package/src/utils/__tests__/blob.test.ts +27 -1
  161. package/src/utils/__tests__/download.test.tsx +44 -4
  162. package/src/utils/blob.ts +24 -1
  163. package/src/utils/download.ts +14 -15
  164. package/dist/toDate-CmR_xR3P.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);
@@ -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
  *
@@ -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
  }
@@ -749,14 +749,8 @@ exports[`renderZodSchema > should render a form group_by 1`] = `
749
749
  class="flex flex-col gap-1"
750
750
  >
751
751
  <div
752
- class="flex flex-row items-start space-x-2"
752
+ class="flex flex-row gap-2 items-center"
753
753
  >
754
- <label
755
- class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
756
- for="_r_1u_-form-item"
757
- >
758
- Drop N/A
759
- </label>
760
754
  <button
761
755
  aria-checked="false"
762
756
  aria-describedby="_r_1u_-form-item-description"
@@ -769,6 +763,16 @@ exports[`renderZodSchema > should render a form group_by 1`] = `
769
763
  type="button"
770
764
  value="on"
771
765
  />
766
+ <div
767
+ class="flex flex-col gap-1"
768
+ >
769
+ <label
770
+ class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
771
+ for="_r_1u_-form-item"
772
+ >
773
+ Drop N/A
774
+ </label>
775
+ </div>
772
776
  </div>
773
777
  </div>
774
778
  </div>
@@ -1252,14 +1256,8 @@ exports[`renderZodSchema > should render a form sample_rows 1`] = `
1252
1256
  class="flex flex-col gap-1"
1253
1257
  >
1254
1258
  <div
1255
- class="flex flex-row items-start space-x-2"
1259
+ class="flex flex-row gap-2 items-center"
1256
1260
  >
1257
- <label
1258
- class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
1259
- for="_r_2n_-form-item"
1260
- >
1261
- Sample with replacement
1262
- </label>
1263
1261
  <button
1264
1262
  aria-checked="false"
1265
1263
  aria-describedby="_r_2n_-form-item-description"
@@ -1272,6 +1270,16 @@ exports[`renderZodSchema > should render a form sample_rows 1`] = `
1272
1270
  type="button"
1273
1271
  value="on"
1274
1272
  />
1273
+ <div
1274
+ class="flex flex-col gap-1"
1275
+ >
1276
+ <label
1277
+ class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
1278
+ for="_r_2n_-form-item"
1279
+ >
1280
+ Sample with replacement
1281
+ </label>
1282
+ </div>
1275
1283
  </div>
1276
1284
  </div>
1277
1285
  </div>
@@ -1468,14 +1476,8 @@ exports[`renderZodSchema > should render a form sort_column 1`] = `
1468
1476
  class="flex flex-col gap-1"
1469
1477
  >
1470
1478
  <div
1471
- class="flex flex-row items-start space-x-2"
1479
+ class="flex flex-row gap-2 items-center"
1472
1480
  >
1473
- <label
1474
- class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
1475
- for="_r_1f_-form-item"
1476
- >
1477
- Ascending
1478
- </label>
1479
1481
  <button
1480
1482
  aria-checked="false"
1481
1483
  aria-describedby="_r_1f_-form-item-description"
@@ -1488,6 +1490,16 @@ exports[`renderZodSchema > should render a form sort_column 1`] = `
1488
1490
  type="button"
1489
1491
  value="on"
1490
1492
  />
1493
+ <div
1494
+ class="flex flex-col gap-1"
1495
+ >
1496
+ <label
1497
+ class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
1498
+ for="_r_1f_-form-item"
1499
+ >
1500
+ Ascending
1501
+ </label>
1502
+ </div>
1491
1503
  </div>
1492
1504
  </div>
1493
1505
  </div>
@@ -411,9 +411,12 @@ const ColumnFilterForm = <T extends FieldValues>({
411
411
 
412
412
  const columnRenderer = columnIdRenderer<T>();
413
413
  const children = [
414
- renderZodSchema(columnIdSchema, form, `${path}.column_id` as Path<T>, [
415
- columnRenderer,
416
- ]),
414
+ renderZodSchema({
415
+ schema: columnIdSchema,
416
+ form,
417
+ path: `${path}.column_id` as Path<T>,
418
+ renderers: [columnRenderer],
419
+ }),
417
420
  ];
418
421
 
419
422
  // When column ID changes, get the new dtype and reset the operator
@@ -486,12 +489,12 @@ const ColumnFilterForm = <T extends FieldValues>({
486
489
  if (operandSchemas.length === 1) {
487
490
  children.push(
488
491
  <React.Fragment key="value">
489
- {renderZodSchema(
490
- operandSchemas[0],
492
+ {renderZodSchema({
493
+ schema: operandSchemas[0],
491
494
  form,
492
- `${path}.value` as Path<T>,
493
- [],
494
- )}
495
+ path: `${path}.value` as Path<T>,
496
+ renderers: [],
497
+ })}
495
498
  </React.Fragment>,
496
499
  );
497
500
  }