@marimo-team/islands 0.25.3-dev273 → 0.25.3-dev279

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 (143) hide show
  1. package/dist/{ConnectedDataExplorerComponent-eLHPh6uu.js → ConnectedDataExplorerComponent-CErm132k.js} +25 -26
  2. package/dist/{ErrorBoundary-DBh-wUT9.js → ErrorBoundary-DmTMuDPR.js} +43 -44
  3. package/dist/{abnfDiagram-VCTEODGH-hqd0IBtX.js → abnfDiagram-VCTEODGH-d-Qn2fwb.js} +2 -2
  4. package/dist/{any-language-editor-DnG26QXh.js → any-language-editor-6k6F12Z3.js} +3 -3
  5. package/dist/{architectureDiagram-5GKGNRK7-BzLAFcc0.js → architectureDiagram-5GKGNRK7-DxragHYv.js} +1 -1
  6. package/dist/{assertNever-DIU3JJbt.js → assertNever-B-YzWzbL.js} +14 -14
  7. package/dist/{button-0-RqY1DW.js → button-DZuNnIZO.js} +220 -223
  8. package/dist/{capabilities-B6yy_CBN.js → capabilities-D9FskwsK.js} +1 -1
  9. package/dist/{chat-ui-BWIkve2R.js → chat-ui-v1Fdh0NA.js} +332 -333
  10. package/dist/{check-gUSZbRsl.js → check-XFzZeH-y.js} +1 -1
  11. package/dist/{chunk-IMKFNOWR-DOeVvuyE.js → chunk-IMKFNOWR-C0x0feFg.js} +1 -1
  12. package/dist/{chunk-SHT3W25Y-DZoFI8wR.js → chunk-SHT3W25Y-D0ZdASqB.js} +1 -1
  13. package/dist/{chunk-SVP7TREG-CWu86PSW.js → chunk-SVP7TREG-BHjL96zx.js} +1 -1
  14. package/dist/{chunk-TICWLB2K-BrM0GoP3.js → chunk-TICWLB2K-CzcrwGfp.js} +1 -1
  15. package/dist/{classDiagram-ZZMXUADV-DYi4V2bB.js → classDiagram-ZZMXUADV-BTqFAZTP.js} +2 -2
  16. package/dist/{classDiagram-v2-VYDZK3BY-tN7FIk-m.js → classDiagram-v2-VYDZK3BY-EPXb0Elt.js} +2 -2
  17. package/dist/{code-visibility-M_dXDGp6.js → code-visibility-Dw4-TXN0.js} +20 -20
  18. package/dist/{common-DV0fvw_I.js → common-BXMFi4lr.js} +5399 -5397
  19. package/dist/{config-BsPruWMR.js → config-C8MFHQ2E.js} +23 -22
  20. package/dist/{copy-icon-C2I8sw4h.js → copy-icon-CtllOdmg.js} +3 -3
  21. package/dist/{createReducer-DdtT_7ji.js → createReducer-DRqwmaTc.js} +2 -2
  22. package/dist/{cynefinDiagram-5FMLGOSQ-D5nCnDc-.js → cynefinDiagram-5FMLGOSQ-CBSD344q.js} +1 -1
  23. package/dist/{dagre-GXQ25YYZ-BHkd7f_i.js → dagre-GXQ25YYZ-JItjh3Ge.js} +1 -1
  24. package/dist/{data-grid-overlay-editor-Cd1zGKBt.js → data-grid-overlay-editor-hZzwX7Ia.js} +1 -1
  25. package/dist/{diagram-S7CK7UJ4-D1ALppT2.js → diagram-S7CK7UJ4-CTD9xTVb.js} +1 -1
  26. package/dist/{diagram-UQ7AKVKN-DLv0b83L.js → diagram-UQ7AKVKN-DTfnFai8.js} +1 -1
  27. package/dist/{diagram-VX7I27RA-7XJ7J6qg.js → diagram-VX7I27RA-BcSnucV-.js} +1 -1
  28. package/dist/{diagram-Z3DM3KII-7M7zbHp_.js → diagram-Z3DM3KII-C2ATIE8A.js} +1 -1
  29. package/dist/{dialog-DzCj1qQv.js → dialog-CaYm5imE.js} +33 -33
  30. package/dist/{dropdown-menu-CdXE15an.js → dropdown-menu-Bb0bFOIk.js} +149 -149
  31. package/dist/{ebnfDiagram-PWID7BFC-DHH1qlmV.js → ebnfDiagram-PWID7BFC-B7B9JPxc.js} +2 -2
  32. package/dist/{erDiagram-RLTQ6QDP-fI_0dGef.js → erDiagram-RLTQ6QDP-v6hDno1O.js} +1 -1
  33. package/dist/{error-banner-DPc9eIw5.js → error-banner-CHCdHVFB.js} +44 -45
  34. package/dist/{error-entries-CxN-Nq3V.js → error-entries-C1S-Rmyt.js} +563 -554
  35. package/dist/{feedback-button-CE2gPi4-.js → feedback-button-6Ui2TDLR.js} +12 -12
  36. package/dist/{flowDiagram-HODETNUW-Brg9MA6i.js → flowDiagram-HODETNUW-aIFlAtOy.js} +2 -2
  37. package/dist/{formats-DbSvAPY-.js → formats-PzBQMUnr.js} +3 -3
  38. package/dist/{glide-data-editor-BycATbgF.js → glide-data-editor-PZNlrSDI.js} +14 -16
  39. package/dist/{glide-utils-BSw3SSHF.js → glide-utils-BSQr4d90.js} +51 -59
  40. package/dist/{globals-CW6OvnI5.js → globals-DX6FPZYq.js} +3 -3
  41. package/dist/{html-to-image-Bc2AvXHG.js → html-to-image-CqGUd58T.js} +14529 -14531
  42. package/dist/{infoDiagram-27XIBGKW-E9e403YT.js → infoDiagram-27XIBGKW-D6YHLeCP.js} +1 -1
  43. package/dist/{ishikawaDiagram-5VMMS53U-C-RlYDRo.js → ishikawaDiagram-5VMMS53U-D1ZvprOX.js} +1 -1
  44. package/dist/{kanban-definition-UXKFOSKX-CLFl_uqK.js → kanban-definition-UXKFOSKX-Bna_3BMr.js} +1 -1
  45. package/dist/{loader-BVEtQ4F3.js → loader-DZvtzQoD.js} +1 -1
  46. package/dist/main.js +7357 -7361
  47. package/dist/{mermaid-rd09e1JL.js → mermaid-A36unKXJ.js} +50 -50
  48. package/dist/{mindmap-definition-YA3MSWOX-BQ8TWtyB.js → mindmap-definition-YA3MSWOX-CyVCs__G.js} +1 -1
  49. package/dist/{number-overlay-editor-B_rR-LdM.js → number-overlay-editor-D0CsL3_R.js} +1 -1
  50. package/dist/{pegDiagram-XKGWAZYB-CN8UHFPi.js → pegDiagram-XKGWAZYB-9hNBD2wZ.js} +2 -2
  51. package/dist/{pencil-DcT8AE0h.js → pencil-DYUlSKxZ.js} +1 -1
  52. package/dist/{pieDiagram-E7YTZNPT-DGuJ0EBd.js → pieDiagram-E7YTZNPT-8tr-NYo7.js} +1 -1
  53. package/dist/{process-output-qecD7Dk8.js → process-output-DskX9jLa.js} +4 -4
  54. package/dist/{railroadDiagram-O6MQD6OU-BPDA8wy8.js → railroadDiagram-O6MQD6OU-Dzg1dyyp.js} +2 -2
  55. package/dist/{requirementDiagram-BXWQKSXE-ChzLGbGW.js → requirementDiagram-BXWQKSXE-B7sNqwGG.js} +1 -1
  56. package/dist/{reveal-component-CngVwOSy.js → reveal-component-D7xeI1QR.js} +1003 -1003
  57. package/dist/{select-OxgozOC8.js → select-DsoP1qe8.js} +290 -290
  58. package/dist/{sequenceDiagram-WJ2MYXX4-DFX-bE6O.js → sequenceDiagram-WJ2MYXX4-oP9shbaG.js} +1 -1
  59. package/dist/{spec-oJn0RhpQ.js → spec-BZMDxAu7.js} +4 -4
  60. package/dist/{stateDiagram-D77RDMKH-BR7cI3wK.js → stateDiagram-D77RDMKH-CjCv4ogv.js} +2 -2
  61. package/dist/{stateDiagram-v2-MP3YSRHH-oGqB5UDI.js → stateDiagram-v2-MP3YSRHH-BW9X162a.js} +2 -2
  62. package/dist/{static-state-CuV6Pc3a.js → static-state-D-Q-Ajaa.js} +8 -8
  63. package/dist/style.css +1 -1
  64. package/dist/{swimlanes-42K2YHIH-DwSZl1yC.js → swimlanes-42K2YHIH-Ckfs9Wrn.js} +1 -1
  65. package/dist/{swimlanesDiagram-VR7AAH4N-BHlcd-Ri.js → swimlanesDiagram-VR7AAH4N-DpTNG9YP.js} +2 -2
  66. package/dist/{swiper-component-Dvj-goaD.js → swiper-component-DQbK_ZWm.js} +7 -7
  67. package/dist/{timeline-definition-24CTP7MA-DMmP-G2O.js → timeline-definition-24CTP7MA-BXaBlV6U.js} +1 -1
  68. package/dist/{toast-DbBHmxmB.js → toast-BhjIy3Tq.js} +158 -158
  69. package/dist/{tooltip-BfcDQd2j.js → tooltip-DMMiOn2f.js} +64 -64
  70. package/dist/use-restore-focus-CosX7-gx.js +7568 -0
  71. package/dist/{useAsyncData-BYIAoMqn.js → useAsyncData-Ci4AvJff.js} +4 -4
  72. package/dist/{useDateFormatter-ButWkdcK.js → useDateFormatter-CPlava52.js} +2 -2
  73. package/dist/{useDeepCompareMemoize-Bz0c5GN6.js → useDeepCompareMemoize-NaaWoFlX.js} +1 -1
  74. package/dist/useEvent-BRH-aCqi.js +19 -0
  75. package/dist/{useIframeCapabilities-BIolGsF1.js → useIframeCapabilities-BXhDDoCG.js} +1 -1
  76. package/dist/{useLifecycle-JjxCC7Vu.js → useLifecycle-SniivYmC.js} +2 -2
  77. package/dist/{vega-component-rxIxAAh9.js → vega-component-DIxaDQNs.js} +40 -40
  78. package/dist/{vennDiagram-4TSXK5OY-Db8GtH1G.js → vennDiagram-4TSXK5OY-C-hQ4iNJ.js} +1 -1
  79. package/dist/{wardleyDiagram-VM6X3IG4-DmIiC3FU.js → wardleyDiagram-VM6X3IG4-C2MQ_Umv.js} +1 -1
  80. package/dist/{xychartDiagram-S5SC5T6Z-DS9OPrNq.js → xychartDiagram-S5SC5T6Z-BtTTBn7Q.js} +1 -1
  81. package/package.json +1 -1
  82. package/src/__mocks__/executions.ts +4 -2
  83. package/src/__mocks__/notebook.ts +0 -2
  84. package/src/__tests__/CellStatus.test.tsx +6 -6
  85. package/src/components/editor/__tests__/data-attributes.test.tsx +0 -2
  86. package/src/components/editor/__tests__/output-persistence.test.tsx +20 -5
  87. package/src/components/editor/cell/CellStatus.tsx +6 -5
  88. package/src/components/editor/cell/__tests__/useRunCells.test.tsx +169 -0
  89. package/src/components/editor/cell/collapse.tsx +3 -4
  90. package/src/components/editor/cell/useRunCells.ts +32 -3
  91. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +2 -3
  92. package/src/components/editor/notebook-cell.tsx +23 -37
  93. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +4 -10
  94. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -2
  95. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +8 -5
  96. package/src/components/slides/minimap.tsx +3 -2
  97. package/src/components/slides/reveal-component.tsx +3 -2
  98. package/src/components/slides/slide-cell-view.tsx +8 -8
  99. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -101
  100. package/src/core/cells/__tests__/actions.test.ts +0 -1
  101. package/src/core/cells/__tests__/cell.test.ts +81 -17
  102. package/src/core/cells/__tests__/cells.test.ts +0 -100
  103. package/src/core/cells/__tests__/document.test.ts +2 -8
  104. package/src/core/cells/__tests__/utils.test.ts +93 -24
  105. package/src/core/cells/actions.ts +0 -1
  106. package/src/core/cells/cell.ts +37 -14
  107. package/src/core/cells/cells.ts +10 -99
  108. package/src/core/cells/document.ts +2 -13
  109. package/src/core/cells/types.ts +0 -23
  110. package/src/core/cells/utils.ts +44 -17
  111. package/src/core/codemirror/cells/extensions.ts +0 -14
  112. package/src/core/errors/__tests__/error-entries.test.ts +40 -1
  113. package/src/core/errors/error-entries.ts +2 -3
  114. package/src/core/executions/__tests__/freshness.test.ts +420 -0
  115. package/src/core/executions/__tests__/hooks.test.tsx +106 -0
  116. package/src/core/executions/atoms.ts +22 -0
  117. package/src/core/executions/freshness.ts +164 -0
  118. package/src/core/executions/hooks.ts +17 -1
  119. package/src/core/executions/index.ts +3 -1
  120. package/src/core/islands/bootstrap.ts +0 -1
  121. package/src/core/islands/components/__tests__/web-components.test.tsx +63 -0
  122. package/src/core/islands/components/web-components.tsx +11 -0
  123. package/src/core/kernel/handlers.ts +0 -4
  124. package/src/core/session/useNotebookSession.tsx +2 -7
  125. package/src/css/globals.css +6 -2
  126. package/src/plugins/impl/DataEditorPlugin.tsx +0 -4
  127. package/src/plugins/impl/data-editor/__tests__/column-types.test.ts +5 -0
  128. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +9 -5
  129. package/src/plugins/impl/data-editor/column-types.ts +3 -0
  130. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -5
  131. package/src/plugins/impl/data-editor/glide-utils.ts +10 -32
  132. package/src/plugins/impl/data-editor/types.ts +0 -2
  133. package/src/stories/cell-status.stories.tsx +12 -14
  134. package/src/stories/cell.stories.tsx +44 -10
  135. package/src/stories/layout/vertical/one-column.stories.tsx +0 -10
  136. package/src/theme/__tests__/islands-theme.test.tsx +276 -0
  137. package/src/theme/namespace.tsx +4 -3
  138. package/src/theme/useTheme.ts +71 -41
  139. package/dist/schemas-CdscWNy_.js +0 -4747
  140. package/dist/use-restore-focus-CmflVLWJ.js +0 -1672
  141. package/dist/useTheme-DPLobSFs.js +0 -339
  142. package/dist/utils-EBklsC5R.js +0 -813
  143. package/src/components/editor/cell/__tests__/useRunCells.test.ts +0 -45
@@ -0,0 +1,164 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /**
4
+ * Whether each cell's result still reflects the document and the values
5
+ * it read, by the host protocol's rules.
6
+ *
7
+ * A completed execution is current when its code fingerprint equals the
8
+ * cell's, every variable it depends on still holds the recorded revision,
9
+ * every name it depends on that was unbound is still unbound, and the
10
+ * executions that bound those variables are current. See the freshness
11
+ * section of `protocols/host/spec.md`.
12
+ *
13
+ * The protocol also ranks why a result is outdated. The editor asks two
14
+ * questions of its own, so that is what this reports; the ranked reason
15
+ * can come back with a reader. Two editor concerns are folded in: a name
16
+ * bound only by an earlier runtime is unbound in this one, and a queued or
17
+ * running execution for the cell's current source already covers it.
18
+ */
19
+
20
+ import type * as Protocol from "@marimo-team/client/protocol";
21
+ import type { Executions, Results } from "@/core/notebook/replica";
22
+
23
+ export interface CellFreshness {
24
+ /** This cell's source changed since its result. */
25
+ readonly sourceChanged: boolean;
26
+ /**
27
+ * A value the result read changed since, by a rerun or an update, here
28
+ * or upstream. An ancestor that was only edited does not count: its
29
+ * values are still the ones this result read.
30
+ */
31
+ readonly inputsChanged: boolean;
32
+ }
33
+
34
+ /** Current, and also a cell with no completed execution to compare. */
35
+ const CURRENT: CellFreshness = { sourceChanged: false, inputsChanged: false };
36
+
37
+ /** What freshness is derived from. */
38
+ export interface CellFreshnessInputs {
39
+ /** The document's cells, for the fingerprint of each cell's source. */
40
+ readonly cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>;
41
+ /** The stream's executions, for the variables currently bound. */
42
+ readonly executions: Executions;
43
+ /** Each cell's last completed record, the result being compared. */
44
+ readonly results: Results;
45
+ }
46
+
47
+ /** Freshness by cell id, as `freshnessOf` builds it. */
48
+ export type CellFreshnessLookup = (id: Protocol.CellId) => CellFreshness;
49
+
50
+ /** The variables bound in the namespace right now, by name. */
51
+ function boundVariables(
52
+ executions: Executions,
53
+ ): ReadonlyMap<string, Protocol.Variable[]> {
54
+ const byName = new Map<string, Protocol.Variable[]>();
55
+ for (const execution of executions.executions.values()) {
56
+ for (const variable of execution.variables.values()) {
57
+ const bound = byName.get(variable.name);
58
+ if (bound === undefined) {
59
+ byName.set(variable.name, [variable]);
60
+ } else {
61
+ bound.push(variable);
62
+ }
63
+ }
64
+ }
65
+ return byName;
66
+ }
67
+
68
+ /**
69
+ * Compares results against the document and the namespace.
70
+ *
71
+ * Build one per set of inputs and ask it about each cell, since the
72
+ * bound variables are indexed once and a binding execution's freshness is
73
+ * computed once however many results depend on it.
74
+ */
75
+ export function freshnessOf(inputs: CellFreshnessInputs): CellFreshnessLookup {
76
+ const bound = boundVariables(inputs.executions);
77
+ const memo = new Map<Protocol.ExecutionId, CellFreshness>();
78
+ const recordOf = (id: Protocol.ExecutionId) =>
79
+ inputs.executions.executions.get(id)?.record;
80
+
81
+ /** Whether a value the record read has changed, or one upstream has. */
82
+ function inputsChanged(
83
+ record: Protocol.Execution,
84
+ visiting: ReadonlySet<Protocol.ExecutionId>,
85
+ ): boolean {
86
+ const path = new Set(visiting).add(record.id);
87
+ for (const [name, revision] of Object.entries(record.depends_on)) {
88
+ const variables = bound.get(name) ?? [];
89
+ if (revision === null) {
90
+ // A result retained from an earlier runtime binds nothing here.
91
+ if (
92
+ variables.some(
93
+ (v) => recordOf(v.execution_id)?.runtime_id === record.runtime_id,
94
+ )
95
+ ) {
96
+ return true;
97
+ }
98
+ continue;
99
+ }
100
+ const variable = variables.find(
101
+ (candidate) => candidate.revision.execution === revision.execution,
102
+ );
103
+ if (
104
+ variable === undefined ||
105
+ variable.revision.update !== revision.update
106
+ ) {
107
+ return true;
108
+ }
109
+ const binder = recordOf(variable.execution_id);
110
+ if (binder === undefined) {
111
+ return true;
112
+ }
113
+ if (!path.has(binder.id) && ofRecord(binder, path).inputsChanged) {
114
+ return true;
115
+ }
116
+ }
117
+ return false;
118
+ }
119
+
120
+ /** Whether a newer execution of the cell already runs its current source. */
121
+ function coveredByPending(
122
+ cell: Protocol.Cell,
123
+ record: Protocol.Execution,
124
+ ): boolean {
125
+ const latest = inputs.executions.latest.get(cell.id);
126
+ if (latest === undefined || latest === record.id) {
127
+ return false;
128
+ }
129
+ const pending = recordOf(latest);
130
+ return (
131
+ pending !== undefined &&
132
+ pending.status !== "completed" &&
133
+ pending.code_fingerprint === cell.code_fingerprint
134
+ );
135
+ }
136
+
137
+ function ofRecord(
138
+ record: Protocol.Execution,
139
+ visiting: ReadonlySet<Protocol.ExecutionId>,
140
+ ): CellFreshness {
141
+ const known = memo.get(record.id);
142
+ if (known !== undefined) {
143
+ return known;
144
+ }
145
+ const cell =
146
+ record.cell_id === null ? undefined : inputs.cells.get(record.cell_id);
147
+ const freshness: CellFreshness = {
148
+ sourceChanged:
149
+ cell === undefined ||
150
+ (cell.code_fingerprint !== record.code_fingerprint &&
151
+ !coveredByPending(cell, record)),
152
+ inputsChanged: inputsChanged(record, visiting),
153
+ };
154
+ memo.set(record.id, freshness);
155
+ return freshness;
156
+ }
157
+
158
+ return (id) => {
159
+ const result = inputs.results.get(id);
160
+ return result === null || result === undefined
161
+ ? CURRENT
162
+ : ofRecord(result, new Set());
163
+ };
164
+ }
@@ -5,7 +5,8 @@ import { dequal } from "dequal";
5
5
  import { useAtomValue } from "jotai";
6
6
  import { selectAtom } from "jotai/utils";
7
7
  import { useMemo } from "react";
8
- import { cellExecutionInputsAtom } from "./atoms";
8
+ import { cellExecutionInputsAtom, cellFreshnessAtom } from "./atoms";
9
+ import type { CellFreshness } from "./freshness";
9
10
  import { type CellExecutionView, cellExecutionView } from "./select";
10
11
 
11
12
  /**
@@ -29,3 +30,18 @@ export function useCellExecution(id: Protocol.CellId): CellExecutionView {
29
30
  );
30
31
  return useAtomValue(cellAtom);
31
32
  }
33
+
34
+ /**
35
+ * Whether a cell's result is current, by the host protocol's rules over
36
+ * the confirmed document and the executions mirror.
37
+ *
38
+ * The confirmed document is what the host has accepted, so an edit reads
39
+ * as such once the model has flushed it, not on the keystroke.
40
+ */
41
+ export function useCellFreshness(id: Protocol.CellId): CellFreshness {
42
+ const cellAtom = useMemo(
43
+ () => selectAtom(cellFreshnessAtom, (freshness) => freshness(id), dequal),
44
+ [id],
45
+ );
46
+ return useAtomValue(cellAtom);
47
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  export {
12
12
  cellExecutionInputsAtom,
13
+ cellFreshnessAtom,
13
14
  notebookIsRunningAtom,
14
15
  notebookQueuedOrRunningCountAtom,
15
16
  observeCellMessage,
@@ -18,7 +19,8 @@ export {
18
19
  waitForRuntimeRunning,
19
20
  withdrawCells,
20
21
  } from "./atoms";
21
- export { useCellExecution } from "./hooks";
22
+ export type { CellFreshness, CellFreshnessLookup } from "./freshness";
23
+ export { useCellExecution, useCellFreshness } from "./hooks";
22
24
  export {
23
25
  type CellExecutionInputs,
24
26
  type CellExecutionView,
@@ -221,7 +221,6 @@ function handleNotification(
221
221
  handleKernelReady(msg, {
222
222
  autoInstantiate: true,
223
223
  cells: notebookCells(store.get(notebookAtom)),
224
- setLastRun: actions.setLastRun,
225
224
  resetCellRuntimes: actions.resetCellRuntimes,
226
225
  setLayoutData: Functions.NOOP,
227
226
  setCapabilities: Functions.NOOP,
@@ -9,6 +9,7 @@ import {
9
9
  describe,
10
10
  expect,
11
11
  it,
12
+ vi,
12
13
  } from "vitest";
13
14
  import { cellId } from "@/__tests__/branded";
14
15
  import { MockNotebook } from "@/__mocks__/notebook";
@@ -22,6 +23,7 @@ import {
22
23
  import { islandsPendingInitialRunsAtom } from "@/core/islands/state";
23
24
  import { requestClientAtom } from "@/core/network/requests";
24
25
  import { store } from "@/core/state/jotai";
26
+ import { buildIslandHTML } from "../../__tests__/test-utils";
25
27
  import { parseMarimoIslandApps } from "../../parse";
26
28
  import { MarimoIslandElement } from "../web-components";
27
29
 
@@ -45,9 +47,55 @@ beforeEach(() => {
45
47
  afterEach(async () => {
46
48
  await actAndFlush(() => document.body.replaceChildren());
47
49
  store.set(requestClientAtom, null);
50
+ document.body.removeAttribute("style");
51
+ vi.unstubAllEnvs();
48
52
  });
49
53
 
50
54
  describe("MarimoIslandElement lifecycle", () => {
55
+ it.each([true, false])(
56
+ "synchronizes the theme of a reactive=%s island across reconnects",
57
+ async (reactive) => {
58
+ vi.stubEnv("VITE_MARIMO_ISLANDS", "true");
59
+ if (reactive) {
60
+ setNotebook("runtime-cell", "runtime output");
61
+ }
62
+ const { container, island } = createThemeIsland(reactive);
63
+ document.body.style.colorScheme = "dark";
64
+ await actAndFlush(() => document.body.append(container));
65
+ expect(island).toHaveClass("marimo", "dark");
66
+ await actAndFlush(() => {
67
+ document.body.style.colorScheme = "light";
68
+ });
69
+ expect(island).toHaveClass("marimo", "light");
70
+ expect(island).not.toHaveClass("dark");
71
+ await actAndFlush(() => island.remove());
72
+ await actAndFlush(() => {
73
+ document.body.style.colorScheme = "dark";
74
+ });
75
+ expect(island).toHaveClass("light");
76
+ await actAndFlush(() => container.append(island));
77
+ expect(island).toHaveClass("dark");
78
+ },
79
+ );
80
+
81
+ it("can disconnect before its scheduled render and reconnect with the current theme", async () => {
82
+ vi.stubEnv("VITE_MARIMO_ISLANDS", "true");
83
+ const { container, island } = createThemeIsland(false);
84
+ document.body.style.colorScheme = "dark";
85
+ await actAndFlush(() => {
86
+ document.body.append(container);
87
+ island.remove();
88
+ });
89
+ expect(island.querySelector("marimo-cell-output")).not.toBeNull();
90
+ await actAndFlush(() => {
91
+ document.body.style.colorScheme = "light";
92
+ });
93
+ expect(island).toHaveClass("dark");
94
+ await actAndFlush(() => container.append(island));
95
+ expect(island).toHaveClass("light");
96
+ expect(island.textContent).toContain("static output");
97
+ });
98
+
51
99
  it("keeps static output until the initial run completes", async () => {
52
100
  store.set(islandsPendingInitialRunsAtom, new Set([1]));
53
101
  setNotebook("runtime-cell", "runtime output");
@@ -182,6 +230,21 @@ describe("MarimoIslandElement lifecycle", () => {
182
230
  });
183
231
  });
184
232
 
233
+ function createThemeIsland(reactive: boolean) {
234
+ const container = document.createElement("div");
235
+ container.innerHTML = buildIslandHTML([
236
+ {
237
+ appId: "app-1",
238
+ cellId: "runtime-cell",
239
+ reactive,
240
+ code: "x = 1",
241
+ output: "<pre>static output</pre>",
242
+ },
243
+ ]);
244
+ const island = container.querySelector<HTMLElement>(ISLAND_TAG_NAMES.ISLAND)!;
245
+ return { container, island };
246
+ }
247
+
185
248
  function setNotebook(id: string, output?: string): void {
186
249
  const runtimeCellId = cellId(id);
187
250
  store.set(
@@ -10,6 +10,7 @@ import { OBJECT_ID_ATTR } from "@/core/dom/ui-element-constants";
10
10
  import { UI_ELEMENT_REGISTRY } from "@/core/dom/uiregistry";
11
11
  import { LocaleProvider } from "@/core/i18n/locale-provider";
12
12
  import { renderHTML } from "@/plugins/core/RenderHTML";
13
+ import { resolvedThemeAtom } from "@/theme/useTheme";
13
14
  import { invariant } from "@/utils/invariant";
14
15
  import type { CellId, UIElementId } from "../../cells/ids";
15
16
  import { store } from "../../state/jotai";
@@ -44,6 +45,7 @@ export class MarimoIslandElement extends HTMLElement {
44
45
  private runtimeAppId?: string;
45
46
  private runtimeCellId?: CellId;
46
47
  private unsubscribeCellIds?: () => void;
48
+ private unsubscribeTheme?: () => void;
47
49
 
48
50
  public static readonly tagName = ISLAND_TAG_NAMES.ISLAND;
49
51
  public static readonly outputTagName = ISLAND_TAG_NAMES.CELL_OUTPUT;
@@ -132,6 +134,13 @@ export class MarimoIslandElement extends HTMLElement {
132
134
  ? this.cellId
133
135
  : undefined;
134
136
  this.syncCellIdsSubscription();
137
+ const syncTheme = () => {
138
+ const theme = store.get(resolvedThemeAtom);
139
+ this.classList.toggle("dark", theme === "dark");
140
+ this.classList.toggle("light", theme === "light");
141
+ };
142
+ this.unsubscribeTheme = store.sub(resolvedThemeAtom, syncTheme);
143
+ syncTheme();
135
144
  queueMicrotask(() => {
136
145
  // Guard against disconnect between connectedCallback and microtask
137
146
  if (
@@ -297,6 +306,8 @@ export class MarimoIslandElement extends HTMLElement {
297
306
  * Cleanup when element is removed from DOM
298
307
  */
299
308
  public disconnectedCallback(): void {
309
+ this.unsubscribeTheme?.();
310
+ this.unsubscribeTheme = undefined;
300
311
  this.removeEventListener(
301
312
  ISLAND_SOURCE_CHANGED_EVENT,
302
313
  this.handleSourceChanged,
@@ -60,7 +60,6 @@ export function handleKernelReady(
60
60
  autoInstantiate: boolean;
61
61
  /** The notebook's cells, as the host document provided them. */
62
62
  cells: CellData[];
63
- setLastRun: ReducerCellActions["setLastRun"];
64
63
  resetCellRuntimes: ReducerCellActions["resetCellRuntimes"];
65
64
  setLayoutData: (payload: {
66
65
  layoutView: LayoutType;
@@ -73,7 +72,6 @@ export function handleKernelReady(
73
72
  const {
74
73
  autoInstantiate,
75
74
  cells,
76
- setLastRun,
77
75
  resetCellRuntimes,
78
76
  setLayoutData,
79
77
  setCapabilities,
@@ -82,8 +80,6 @@ export function handleKernelReady(
82
80
  const { resumed, ui_values, capabilities, auto_instantiated } = data;
83
81
 
84
82
  resetCellRuntimes();
85
- // TODO(MO-8403): Delete with `lastCodeRun`.
86
- setLastRun({ lastCodeRun: data.last_executed_code ?? {} });
87
83
  const layoutState = buildLayoutState(data, cells, setLayoutData);
88
84
 
89
85
  setCapabilities(capabilities);
@@ -92,12 +92,8 @@ export function useNotebookSession(opts: {
92
92
  const { showBoundary } = useErrorBoundary();
93
93
  const store = useStore();
94
94
 
95
- const {
96
- handleCellMessage,
97
- reconcileDocument,
98
- setLastRun,
99
- resetCellRuntimes,
100
- } = useReducerActions();
95
+ const { handleCellMessage, reconcileDocument, resetCellRuntimes } =
96
+ useReducerActions();
101
97
  const { reconcileStagedCells } = useStagedAICellsActions();
102
98
  const { addCellNotification } = useRunsActions();
103
99
  const setInitialRunCompleted = useSetAtom(initialRunCompletedAtom);
@@ -154,7 +150,6 @@ export function useNotebookSession(opts: {
154
150
  const layout = handleKernelReady(msg.data, {
155
151
  autoInstantiate,
156
152
  cells: notebookCells(store.get(notebookAtom)),
157
- setLastRun,
158
153
  resetCellRuntimes,
159
154
  setLayoutData,
160
155
  setCapabilities,
@@ -128,14 +128,18 @@
128
128
  --cm-search-match-selected-background-dark: #6199ff88;
129
129
  }
130
130
 
131
+ .marimo.light {
132
+ color-scheme: light;
133
+ }
134
+
131
135
  :root:has(body.dark),
132
136
  .dark,
133
- .marimo:is(.dark *) {
137
+ .marimo:not(.light):is(.dark *) {
134
138
  color-scheme: dark;
135
139
  }
136
140
 
137
141
  .dark,
138
- .marimo:is(.dark *) {
142
+ .marimo:not(.light):is(.dark *) {
139
143
  --ring: var(--blue-8, #2870bd);
140
144
  --destructive: var(--red-6, #72232d);
141
145
  --destructive-hover: var(--red-7, #8c333a);
@@ -92,13 +92,9 @@ interface Props {
92
92
  const LoadingDataEditor = (props: Props) => {
93
93
  const [editorState, setEditorState] = useState<EditorState | null>(null);
94
94
 
95
- // Load the data
96
95
  const { data: loadedState, error } = useAsyncData(async () => {
97
96
  const withoutExternalTypes = toFieldTypes(props.fieldTypes ?? []);
98
97
 
99
- // If we already have the data, return it
100
- // Otherwise, load the data from the URL. Vega's CSV parser takes a
101
- // plain `Record`; column order doesn't matter for parsing.
102
98
  const localData = Array.isArray(props.data)
103
99
  ? props.data
104
100
  : await vegaLoadData<EditorRow>(
@@ -10,6 +10,11 @@ describe("normalizeConfiguredColumns", () => {
10
10
  vi.restoreAllMocks();
11
11
  });
12
12
 
13
+ it("preserves the data reference when no columns are configured", () => {
14
+ const data = [{ label: "01", done: "yes" }];
15
+ expect(normalizeConfiguredColumns(data, new Map())).toBe(data);
16
+ });
17
+
13
18
  it("normalizes large integer boolean values from the CSV loader", async () => {
14
19
  vi.spyOn(vegaLoader, "load").mockResolvedValueOnce(
15
20
  "done,label\n9007199254740992,01\n-9007199254740992,02\n0,03\n",
@@ -65,7 +65,11 @@ vi.mock("@glideapps/glide-data-grid", async () => {
65
65
  });
66
66
 
67
67
  vi.mock("@glideapps/glide-data-grid-cells", () => ({
68
- DropdownCell: { kind: "dropdown-renderer" },
68
+ DropdownCell: {
69
+ kind: "dropdown-renderer",
70
+ isMatch: (cell: { data: { kind: string } }) =>
71
+ cell.data.kind === "dropdown-cell",
72
+ },
69
73
  }));
70
74
 
71
75
  vi.mock("@/theme/useTheme", () => ({
@@ -74,11 +78,11 @@ vi.mock("@/theme/useTheme", () => ({
74
78
 
75
79
  const editorProps = {
76
80
  data: [{ name: "alice" }],
77
- columnFields: new Map([["name", "string"]]) as Map<string, "string">,
78
- editableColumns: "all" as const,
81
+ columnFields: new Map([["name", "string"]]),
82
+ editableColumns: "all",
79
83
  columnTypes: new Map(),
80
84
  onAddEdits: vi.fn(),
81
- };
85
+ } satisfies React.ComponentProps<typeof GlideDataEditor>;
82
86
 
83
87
  describe("GlideDataEditor portal", () => {
84
88
  let fullscreenElement: Element | null;
@@ -148,7 +152,7 @@ describe("GlideDataEditor portal", () => {
148
152
  },
149
153
  });
150
154
  expect(capturedDataEditor.customRenderers).toEqual([
151
- { kind: "dropdown-renderer" },
155
+ { kind: "dropdown-renderer", isMatch: expect.any(Function) },
152
156
  ]);
153
157
  });
154
158
 
@@ -33,6 +33,9 @@ export function normalizeConfiguredColumns(
33
33
  data: EditorRow[],
34
34
  columnTypes: ColumnTypes,
35
35
  ): EditorRow[] {
36
+ if (columnTypes.size === 0) {
37
+ return data;
38
+ }
36
39
  return data.map((row) => {
37
40
  const normalizedRow = { ...row };
38
41
  for (const [columnName, columnType] of columnTypes) {
@@ -55,11 +55,13 @@ import {
55
55
  } from "./types";
56
56
 
57
57
  function getEditedValue(newValue: EditableGridCell): unknown {
58
- if (newValue.kind !== GridCellKind.Custom) {
59
- return newValue.data;
58
+ if (
59
+ newValue.kind === GridCellKind.Custom &&
60
+ DropdownCellRenderer.isMatch(newValue)
61
+ ) {
62
+ return newValue.data.value;
60
63
  }
61
- const data = newValue.data as { kind?: unknown; value?: unknown };
62
- return data.kind === "dropdown-cell" ? data.value : newValue.data;
64
+ return newValue.data;
63
65
  }
64
66
 
65
67
  interface GlideDataEditorProps {
@@ -190,7 +192,6 @@ export const GlideDataEditor = ({
190
192
  const key = column.title;
191
193
 
192
194
  // Deletes are not handled by validateCell, so we need to handle them here.
193
- // Custom dropdown cells carry their selected value inside their data.
194
195
  let newData = getEditedValue(newValue);
195
196
  if (Array.isArray(column.configuredType)) {
196
197
  if (
@@ -132,7 +132,6 @@ export function pasteCells(options: {
132
132
  const { range } = selection.current;
133
133
  const { x: startCol, y: startRow } = range;
134
134
 
135
- // Read clipboard data
136
135
  navigator.clipboard
137
136
  .readText()
138
137
  .then((clipboardText) => {
@@ -140,43 +139,25 @@ export function pasteCells(options: {
140
139
  return;
141
140
  }
142
141
 
143
- // Parse tab-separated values
144
- const rows = clipboardText.split("\n").filter((row) => row.trim());
145
- const parsedData: string[][] = [];
146
-
147
- for (const row of rows) {
148
- const cells = row.split("\t");
149
- parsedData.push(cells);
150
- }
151
-
152
- if (parsedData.length === 0) {
153
- return;
154
- }
142
+ const parsedData = clipboardText
143
+ .split("\n")
144
+ .filter((row) => row.trim())
145
+ .map((row) => row.split("\t"));
155
146
 
156
147
  const edits: Edits["edits"] = [];
157
148
  const editableColumnSet =
158
149
  editableColumns === "all" ? null : new Set(editableColumns);
159
150
 
160
151
  for (const [rowIndex, dataRow] of parsedData.entries()) {
161
- if (!dataRow) {
162
- continue;
163
- }
164
-
165
152
  const targetRowIdx = startRow + rowIndex;
166
153
 
167
- // Check if we've exceeded the data bounds
168
154
  if (targetRowIdx >= data.length) {
169
155
  break;
170
156
  }
171
157
 
172
158
  for (const [colIndex, cellValue] of dataRow.entries()) {
173
- if (cellValue === undefined) {
174
- continue;
175
- }
176
-
177
159
  const targetColIdx = startCol + colIndex;
178
160
 
179
- // Check if we've exceeded the column bounds
180
161
  if (targetColIdx >= columns.length) {
181
162
  break;
182
163
  }
@@ -191,13 +172,13 @@ export function pasteCells(options: {
191
172
  continue;
192
173
  }
193
174
 
194
- // Convert the value based on the cell type
195
175
  let convertedValue: unknown = cellValue;
196
176
 
197
- if (Array.isArray(columnType)) {
198
- if (!isValidCellValue(dataType, cellValue, columnType)) {
199
- continue;
200
- }
177
+ if (
178
+ Array.isArray(columnType) &&
179
+ !isValidCellValue(dataType, cellValue, columnType)
180
+ ) {
181
+ continue;
201
182
  }
202
183
 
203
184
  switch (dataType) {
@@ -243,12 +224,9 @@ export function pasteCells(options: {
243
224
  continue;
244
225
  }
245
226
 
246
- // Get the column ID from the columns array using the title
247
- const columnId = columns[targetColIdx].title;
248
-
249
227
  edits.push({
250
228
  rowIdx: targetRowIdx,
251
- columnId,
229
+ columnId: column.title,
252
230
  value: convertedValue,
253
231
  });
254
232
  }
@@ -6,8 +6,6 @@ import type { DataType } from "@/core/kernel/messages";
6
6
 
7
7
  export type EditorRow = Record<string, unknown>;
8
8
 
9
- // Shared transport for specialized column editors: dropdown columns use a
10
- // string array, while checkbox columns use the boolean discriminator.
11
9
  export type ColumnType = "boolean" | string[];
12
10
  export type ColumnTypes = Map<string, ColumnType>;
13
11