@marimo-team/frontend 0.23.16-dev2 → 0.23.16-dev21

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 (195) hide show
  1. package/dist/assets/{CellStatus-BalYv0pK.js → CellStatus-DwV4gbV3.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-DI7mV-Gw.js → ConnectedDataExplorerComponent-CMaZapRd.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-C7_5jjWi.js → ErrorBoundary-DzTD8LVq.js} +1 -1
  4. package/dist/assets/{ImperativeModal-BIavOiUv.js → ImperativeModal-fY-ZfT0b.js} +1 -1
  5. package/dist/assets/JsonOutput-BoDVlAbV.js +53 -0
  6. package/dist/assets/{LazyAnyLanguageCodeMirror-sVm_hz8A.js → LazyAnyLanguageCodeMirror-B2JvSXP-.js} +2 -2
  7. package/dist/assets/{MarimoErrorOutput-DO2k2fA5.js → MarimoErrorOutput-C2HrF0bD.js} +5 -5
  8. package/dist/assets/{RSPContexts-CgC81f0I.js → RSPContexts-WiCgGm__.js} +1 -1
  9. package/dist/assets/RenderHTML-DoCFSir5.js +1 -0
  10. package/dist/assets/{RunButton-BKRupqr7.js → RunButton-COtmJuup.js} +1 -1
  11. package/dist/assets/{add-cell-with-ai-CEZtaZCK.js → add-cell-with-ai-96GHR2R7.js} +16 -16
  12. package/dist/assets/add-connection-dialog-wLF3on0N.js +170 -0
  13. package/dist/assets/{agent-panel-By4A-Cqr.js → agent-panel-xLPKpIwr.js} +5 -5
  14. package/dist/assets/ai-model-dropdown-CR8HQGYf.js +5 -0
  15. package/dist/assets/{alert-dialog-BXegNC0I.js → alert-dialog-Bb8Lz8ko.js} +1 -1
  16. package/dist/assets/{any-language-editor-DCpZRrpx.js → any-language-editor--tRHs8C_.js} +1 -1
  17. package/dist/assets/{app-config-button-NrM9ywXA.js → app-config-button-Ch-fmU-u.js} +1 -1
  18. package/dist/assets/{button-eIihcbUw.js → button-DDGiD1NW.js} +1 -1
  19. package/dist/assets/{cache-panel-wllSTlbM.js → cache-panel-DcQUeUpg.js} +1 -1
  20. package/dist/assets/{capabilities-B09j1Eor.js → capabilities-DoEg0XwL.js} +1 -1
  21. package/dist/assets/{cell-editor-DdofA9oQ.js → cell-editor-CxP3SFwW.js} +10 -10
  22. package/dist/assets/cell-link-D1nZ5myN.js +1 -0
  23. package/dist/assets/cells-D9tCNe6o.js +231 -0
  24. package/dist/assets/{chat-display-BG08ltQ3.js → chat-display-PstK5lnL.js} +1 -1
  25. package/dist/assets/{chat-panel-P2CzCtXJ.js → chat-panel-XuGXjT-8.js} +3 -3
  26. package/dist/assets/{chat-ui-fAJZAoTD.js → chat-ui-Cme86OAK.js} +1 -1
  27. package/dist/assets/{column-preview-BgZLUEOj.js → column-preview-BYiduGzU.js} +1 -1
  28. package/dist/assets/{command-CAJLPEBq.js → command-Dqxie4gq.js} +1 -1
  29. package/dist/assets/{command-palette-DrI24FoL.js → command-palette-C7NlQPUx.js} +1 -1
  30. package/dist/assets/{common-CD6Mj8n6.js → common-XBd1qRDk.js} +1 -1
  31. package/dist/assets/components-D-aPO6md.js +1 -0
  32. package/dist/assets/config-B-Q7J_6d.js +1 -0
  33. package/dist/assets/{config-COUOIPEq.js → config-BJVx3eBW.js} +1 -1
  34. package/dist/assets/{copy-BtTOqPTS.js → copy-Bxb1_Gtw.js} +1 -1
  35. package/dist/assets/{copy-icon-C2pG0ZNK.js → copy-icon-neDQUo4Q.js} +1 -1
  36. package/dist/assets/{createReducer-C7kZ8zEe.js → createReducer-DGx5Af-C.js} +1 -1
  37. package/dist/assets/{datasource-ySAiC9gr.js → datasource-Pl4KKOUa.js} +2 -2
  38. package/dist/assets/{dates-D-kIKDYA.js → dates-C2LWzsCZ.js} +1 -1
  39. package/dist/assets/{dependency-graph-panel-BF23EwcD.js → dependency-graph-panel-98TlJ5y7.js} +4 -4
  40. package/dist/assets/{dialog-D1DEsRZ2.js → dialog-BCVepWLJ.js} +1 -1
  41. package/dist/assets/{dist-ByVeMWkr.js → dist-BLN8PNJV.js} +1 -1
  42. package/dist/assets/dist-CiVShZKM.js +2 -0
  43. package/dist/assets/documentation-panel-DSOMQtKU.js +1 -0
  44. package/dist/assets/{download-BomYt3nl.js → download-CnYLah74.js} +3 -3
  45. package/dist/assets/{dropdown-menu-BFZLnWeO.js → dropdown-menu-dHmZR8bI.js} +1 -1
  46. package/dist/assets/{edit-page-C37w54dQ.js → edit-page-CchyCkOl.js} +6 -6
  47. package/dist/assets/{error-banner-B6eIlxzg.js → error-banner-BSxEOIn2.js} +1 -1
  48. package/dist/assets/{error-entries-Birw-8VX.js → error-entries-CmfGHGq1.js} +3 -3
  49. package/dist/assets/{error-panel-DdsUL6b7.js → error-panel-BKb_UksO.js} +1 -1
  50. package/dist/assets/{es-1D83cgiu.js → es-gYgfF8y5.js} +1 -1
  51. package/dist/assets/{esm-BL7jQmzn.js → esm-B30N1GS0.js} +1 -1
  52. package/dist/assets/{field-BeZbVzXA.js → field-BsxD35VE.js} +1 -1
  53. package/dist/assets/file-explorer-panel-8_ps5wdU.js +26 -0
  54. package/dist/assets/{file-icons-Cb54mIL8.js → file-icons-Czm7CWoc.js} +1 -1
  55. package/dist/assets/{file-name-input-BWFcK1ey.js → file-name-input-CjWG0FWT.js} +1 -1
  56. package/dist/assets/floating-outline-BWk7h_Uf.js +1 -0
  57. package/dist/assets/focus-DTmSq3hN.js +1 -0
  58. package/dist/assets/{form-CgWNb44_.js → form-B5UJtZJr.js} +1 -1
  59. package/dist/assets/{formats-ClEFGPy-.js → formats-DzmLpc9A.js} +1 -1
  60. package/dist/assets/{formatting-B_41gSF5.js → formatting-CDf6w1Kt.js} +1 -1
  61. package/dist/assets/{fullscreen-dUjPYOmm.js → fullscreen-CTDqNQr6.js} +1 -1
  62. package/dist/assets/{gallery-page-CWQQAYpa.js → gallery-page-CJ3ms1Hn.js} +1 -1
  63. package/dist/assets/{glide-data-editor-lMARFWHS.js → glide-data-editor-j2igs9Y_.js} +1 -1
  64. package/dist/assets/home-page-cBU6sDBU.js +4 -0
  65. package/dist/assets/{html-to-image-CwmX28T9.js → html-to-image-1vflBYah.js} +2 -2
  66. package/dist/assets/index-B8eK-daS.js +38 -0
  67. package/dist/assets/index-xs-zSSrX.css +2 -0
  68. package/dist/assets/{input-CqrUjynb.js → input-JxFwu-3G.js} +1 -1
  69. package/dist/assets/kiosk-mode-BumOFEtY.js +1 -0
  70. package/dist/assets/{label-BElwWzf0.js → label-CqjrpwRC.js} +1 -1
  71. package/dist/assets/{layout-BOwE7yok.js → layout-DkpUSblm.js} +5 -5
  72. package/dist/assets/{links-CToBjIIj.js → links-CZr5bjvo.js} +1 -1
  73. package/dist/assets/{logs-panel-iFV4ZJ9b.js → logs-panel-BojuPimp.js} +1 -1
  74. package/dist/assets/{markdown-renderer-QvXG0CRk.js → markdown-renderer-C9vdWclk.js} +2 -2
  75. package/dist/assets/{menu-items-DbSLBfXR.js → menu-items-CCi6aOPm.js} +1 -1
  76. package/dist/assets/{mermaid-Bh0flEIb.js → mermaid-CYN0wOyG.js} +1 -1
  77. package/dist/assets/name-cell-input-BSFsnuTp.js +1 -0
  78. package/dist/assets/node-sql-parser-Si4oZ2P-.js +68 -0
  79. package/dist/assets/{numbers-CeeaFKqu.js → numbers-rmawRqQP.js} +1 -1
  80. package/dist/assets/outline-panel-B1f5a2yI.js +1 -0
  81. package/dist/assets/{packages-panel-BzQF60rG.js → packages-panel-BKuXx1gi.js} +1 -1
  82. package/dist/assets/{pair-with-agent-modal-CKOYwOYw.js → pair-with-agent-modal-nwd26aKZ.js} +1 -1
  83. package/dist/assets/panels-Cvyb3kny.js +4 -0
  84. package/dist/assets/{popover-DrHQ3KEt.js → popover-Bcz1Zikb.js} +1 -1
  85. package/dist/assets/process-output-Csd6PmBH.js +1 -0
  86. package/dist/assets/{radio-group-CLBjGkt5.js → radio-group-CdR34W0K.js} +1 -1
  87. package/dist/assets/readonly-code-display-XBAHhCYR.js +1 -0
  88. package/dist/assets/{readonly-python-code-CW_G5e4E.js → readonly-python-code-BqcMp63O.js} +1 -1
  89. package/dist/assets/{renderShortcut-BZLr8D7H.js → renderShortcut-Bl8_FVn8.js} +1 -1
  90. package/dist/assets/{reveal-component-DqMRxmUr.js → reveal-component-BvsuzhVi.js} +57 -57
  91. package/dist/assets/{run-page-s6Mf8p78.js → run-page-B9jHpo8w.js} +1 -1
  92. package/dist/assets/{runs-D51Xwrsk.js → runs-DBTXqJHS.js} +1 -1
  93. package/dist/assets/scratchpad-panel-D3slJkhR.js +1 -0
  94. package/dist/assets/{scroll-to-bottom-button-Cgr10kaJ.js → scroll-to-bottom-button-C8yfT4wq.js} +1 -1
  95. package/dist/assets/{secrets-panel-Cc5KDBk6.js → secrets-panel-6J6wcuVR.js} +1 -1
  96. package/dist/assets/{select-B5TFNjmU.js → select-BP75_5jJ.js} +1 -1
  97. package/dist/assets/{session-CEFnN9Cq.js → session-BmukRjSi.js} +1 -1
  98. package/dist/assets/{session-panel-CmHetqAb.js → session-panel-DO-4U6Ry.js} +1 -1
  99. package/dist/assets/{share-CYpcX4N4.js → share-852X_cqy.js} +1 -1
  100. package/dist/assets/{skeleton-B_4CZVEm.js → skeleton-C3fwwBky.js} +1 -1
  101. package/dist/assets/{snippets-panel-CXpGflMO.js → snippets-panel-ACMhA8oL.js} +1 -1
  102. package/dist/assets/{spec-CzqcmYQk.js → spec-C7opZZRp.js} +1 -1
  103. package/dist/assets/{state-BvdK8eiw.js → state-D10U2MwA.js} +1 -1
  104. package/dist/assets/{state-BXbiRXZk.js → state-D2ccy8VQ.js} +3 -3
  105. package/dist/assets/{state-WBgc7MiA.js → state-DDag0B_B.js} +1 -1
  106. package/dist/assets/{state-DBzEQtWz.js → state-N0U-K9F6.js} +1 -1
  107. package/dist/assets/{strings-Djnbwmfl.js → strings--BknHX9C.js} +1 -1
  108. package/dist/assets/{swiper-component-DInvCxhI.js → swiper-component-Che9MNs4.js} +1 -1
  109. package/dist/assets/{switch-CvweaCXq.js → switch-Drt3XC71.js} +1 -1
  110. package/dist/assets/{terminal-D3IQBBYo.js → terminal-217O8hlZ.js} +1 -1
  111. package/dist/assets/{textarea-CpFfEUet.js → textarea-BQE_pb7P.js} +1 -1
  112. package/dist/assets/{tooltip-CFX4fYHU.js → tooltip-4bZlU7jl.js} +1 -1
  113. package/dist/assets/{tooltip-HLb6RxVp.js → tooltip-B7U4xkIN.js} +1 -1
  114. package/dist/assets/{tracing-CKn3gWHw.js → tracing-CuJfR83E.js} +1 -1
  115. package/dist/assets/{tracing-panel-BU-o_LCd.js → tracing-panel-CmMG0R-H.js} +2 -2
  116. package/dist/assets/{tree-actions-CsC2XmGo.js → tree-actions-DJTGQWz0.js} +1 -1
  117. package/dist/assets/{useBoolean-D89u30n-.js → useBoolean-CFj86iac.js} +1 -1
  118. package/dist/assets/useCellActionButton-BhCYWOKS.js +1 -0
  119. package/dist/assets/{useDeleteCell-EPH4kcG7.js → useDeleteCell-DsWH8wmn.js} +1 -1
  120. package/dist/assets/{useDependencyPanelTab-CZnK7N2L.js → useDependencyPanelTab-CAI77Oxj.js} +1 -1
  121. package/dist/assets/useEventListener-C0195JsF.js +1 -0
  122. package/dist/assets/{useHotkey-DIuRH8Wu.js → useHotkey-DLZvrRZT.js} +1 -1
  123. package/dist/assets/{useIframeCapabilities-Ccb4fvKc.js → useIframeCapabilities-j6fR_dBA.js} +1 -1
  124. package/dist/assets/{useInstallPackage-BRbUAUFn.js → useInstallPackage-WiYBwhUO.js} +1 -1
  125. package/dist/assets/{useInterval-CC0vKpWm.js → useInterval-CWBTUy5W.js} +1 -1
  126. package/dist/assets/{useNotebookActions-DJKDd0gM.js → useNotebookActions-9T9wvd8s.js} +4 -4
  127. package/dist/assets/useRunCells-BpMmDGqa.js +1 -0
  128. package/dist/assets/{useSplitCell-9Qz93yPZ.js → useSplitCell-CapBPXO1.js} +1 -1
  129. package/dist/assets/{useTheme-KXqAyDPC.js → useTheme-Bxv9B31g.js} +1 -1
  130. package/dist/assets/{utils-Z80SV4_d.js → utils-BWBBpCkT.js} +1 -1
  131. package/dist/assets/{vega-component-Cie3ag_T.js → vega-component-Db8KjZJG.js} +1 -1
  132. package/dist/assets/{write-secret-modal-DEEn7sZV.js → write-secret-modal-3-v-dHVj.js} +1 -1
  133. package/dist/index.html +68 -68
  134. package/package.json +2 -2
  135. package/src/components/data-table/column-formatting/feature.ts +2 -1
  136. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +50 -0
  137. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +87 -8
  138. package/src/components/editor/connections/storage/as-code.ts +40 -21
  139. package/src/components/editor/connections/storage/schemas.ts +83 -42
  140. package/src/components/editor/file-tree/__tests__/file-render-mode.test.ts +27 -0
  141. package/src/components/editor/file-tree/__tests__/file-viewer.test.tsx +150 -0
  142. package/src/components/editor/file-tree/file-preview-metadata.tsx +51 -0
  143. package/src/components/editor/file-tree/file-render-mode.ts +32 -0
  144. package/src/components/editor/file-tree/file-viewer.tsx +76 -50
  145. package/src/components/slides/__tests__/slide-scroll-hint.test.tsx +198 -0
  146. package/src/components/slides/reveal-component.tsx +3 -2
  147. package/src/components/slides/slide-scroll-hint.tsx +161 -0
  148. package/src/components/ui/input.tsx +1 -1
  149. package/src/core/codemirror/keymaps/__tests__/keymaps.test.ts +31 -2
  150. package/src/core/codemirror/keymaps/keymaps.ts +12 -0
  151. package/src/core/codemirror/language/__tests__/sql.test.ts +443 -61
  152. package/src/core/codemirror/language/languages/sql/completion-store.ts +5 -1
  153. package/src/core/codemirror/language/languages/sql/sql.ts +210 -3
  154. package/src/core/hotkeys/hotkeys.ts +0 -2
  155. package/src/core/i18n/__tests__/locale-provider.test.tsx +54 -118
  156. package/src/core/i18n/__tests__/locale.test.ts +113 -0
  157. package/src/core/i18n/locale-provider.tsx +1 -19
  158. package/src/core/i18n/locale.ts +71 -0
  159. package/src/core/islands/__tests__/bridge.test.ts +107 -0
  160. package/src/core/islands/bridge.ts +15 -0
  161. package/src/core/mode.ts +10 -0
  162. package/src/core/network/__tests__/connection.test.ts +45 -0
  163. package/src/core/network/__tests__/requests-network.test.ts +13 -0
  164. package/src/core/network/connection.ts +15 -0
  165. package/src/core/network/requests-network.ts +6 -3
  166. package/src/core/network/types.ts +3 -1
  167. package/src/plugins/impl/FormPlugin.tsx +1 -1
  168. package/src/plugins/impl/__tests__/FormPlugin.test.tsx +162 -0
  169. package/dist/assets/JsonOutput-DnGSkLeP.js +0 -53
  170. package/dist/assets/RenderHTML-BCvU9dYz.js +0 -1
  171. package/dist/assets/add-connection-dialog-CEp3SJR2.js +0 -170
  172. package/dist/assets/ai-model-dropdown-DXgLphFa.js +0 -5
  173. package/dist/assets/cell-link-DqBkyAFe.js +0 -1
  174. package/dist/assets/cells-Cf7bHHq3.js +0 -229
  175. package/dist/assets/components-BH6gDVv6.js +0 -1
  176. package/dist/assets/config-BbOTTCYY.js +0 -1
  177. package/dist/assets/dist-BCMBu0rO.js +0 -2
  178. package/dist/assets/documentation-panel-e9Fi9R7e.js +0 -1
  179. package/dist/assets/file-explorer-panel-DbdoJbbF.js +0 -26
  180. package/dist/assets/floating-outline-wz-WSRgP.js +0 -1
  181. package/dist/assets/focus-DiEyfiAG.js +0 -1
  182. package/dist/assets/home-page-DJ4jJsZj.js +0 -4
  183. package/dist/assets/index-A71LAt6h.css +0 -2
  184. package/dist/assets/index-Cl0lNhQU.js +0 -38
  185. package/dist/assets/kiosk-mode-ABm3oYgq.js +0 -1
  186. package/dist/assets/name-cell-input-C6cukQ9k.js +0 -1
  187. package/dist/assets/node-sql-parser-BWgUjcfV.js +0 -68
  188. package/dist/assets/outline-panel-BPlg9_CE.js +0 -1
  189. package/dist/assets/panels-CW-PUlv2.js +0 -4
  190. package/dist/assets/process-output-BEhIGfkO.js +0 -1
  191. package/dist/assets/readonly-code-display-D63oRtBP.js +0 -1
  192. package/dist/assets/scratchpad-panel-CXsxdMB-.js +0 -1
  193. package/dist/assets/useCellActionButton-CRfIEVB5.js +0 -1
  194. package/dist/assets/useEventListener-B_w3A283.js +0 -1
  195. package/dist/assets/useRunCells-CSSF_mJ0.js +0 -1
@@ -0,0 +1,71 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /**
4
+ * Locale used when neither the configured locale nor the browser locale can be
5
+ * resolved to a valid BCP 47 tag.
6
+ */
7
+ export const FALLBACK_LOCALE = "en-US";
8
+
9
+ /**
10
+ * Whether a tag is accepted by `Intl` (and therefore by react-aria's
11
+ * `I18nProvider`, which constructs `Intl.Locale` internally).
12
+ */
13
+ export function isValidLocale(locale: string): boolean {
14
+ if (!locale) {
15
+ return false;
16
+ }
17
+ try {
18
+ new Intl.Locale(locale);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Coerce a browser or config locale tag into a valid
27
+ * [BCP 47](https://www.rfc-editor.org/info/rfc5646) tag, or `null` if nothing
28
+ * usable can be recovered.
29
+ *
30
+ * Some environments report POSIX-style tags that `Intl` rejects with
31
+ * `RangeError: Incorrect locale information provided`, crashing anything that
32
+ * constructs an `Intl.*` formatter (see issue #9938). Known offenders:
33
+ * - POSIX modifiers, e.g. Chromium under Playwright reporting `en-US@posix`
34
+ * - charset suffixes, e.g. `en_US.UTF-8`
35
+ * - underscore separators, e.g. `en_US`
36
+ */
37
+ export function normalizeLocale(tag: string | null | undefined): string | null {
38
+ if (!tag) {
39
+ return null;
40
+ }
41
+
42
+ const candidate = tag.split("@")[0].split(".")[0].trim().replaceAll("_", "-");
43
+
44
+ if (isValidLocale(candidate)) {
45
+ return candidate;
46
+ }
47
+
48
+ // Fall back to the language subtag alone (e.g. `en` from a mangled `en-XX`).
49
+ const language = candidate.split("-")[0];
50
+ if (isValidLocale(language)) {
51
+ return language;
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ /** Resolve the browser's locale to a safe BCP 47 tag.
58
+ * See https://github.com/marimo-team/marimo/issues/9938 */
59
+ export function browserLocale(): string {
60
+ const language =
61
+ typeof navigator === "undefined" ? undefined : navigator.language;
62
+ return normalizeLocale(language) ?? FALLBACK_LOCALE;
63
+ }
64
+
65
+ /**
66
+ * Resolve a configured locale to a safe BCP 47 tag, preferring the config and
67
+ * falling back to the browser locale, then to {@link FALLBACK_LOCALE}.
68
+ */
69
+ export function safeLocale(locale: string | null | undefined): string {
70
+ return normalizeLocale(locale) ?? browserLocale();
71
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from "@/__tests__/branded";
11
11
  import { notebookAtom } from "@/core/cells/cells";
12
12
  import { islandsPendingInitialRunsAtom } from "@/core/islands/state";
13
+ import type { IslandsKernelMessage } from "../worker/worker";
13
14
 
14
15
  type Base64String = components["schemas"]["Base64String"];
15
16
  interface TestIslandApp {
@@ -173,6 +174,19 @@ describe("IslandsPyodideBridge", () => {
173
174
  });
174
175
  }
175
176
 
177
+ function sendKernelMessage(sessionGeneration: number) {
178
+ const listener = mockMessageListeners.get("kernelMessage");
179
+ if (!listener) {
180
+ throw new Error("Missing kernel message listener");
181
+ }
182
+ const message = {
183
+ message: "" as IslandsKernelMessage["message"],
184
+ sessionGeneration,
185
+ };
186
+ listener(message as never);
187
+ return message;
188
+ }
189
+
176
190
  async function initializeSingleApp() {
177
191
  mockSingleApp();
178
192
  await bridge.initializeApps();
@@ -281,6 +295,99 @@ describe("IslandsPyodideBridge", () => {
281
295
  });
282
296
 
283
297
  describe("app lifecycle", () => {
298
+ it("ignores messages after a session yields ownership", async () => {
299
+ const consumeMessage = vi.fn();
300
+ bridge.consumeMessages(consumeMessage);
301
+ mockSingleApp();
302
+ await bridge.initializeApps();
303
+
304
+ const firstMessage = sendKernelMessage(1);
305
+ let finishReplacement!: () => void;
306
+ mockReplaceSessionRequest.mockReturnValueOnce(
307
+ new Promise<void>((resolve) => {
308
+ finishReplacement = resolve;
309
+ }),
310
+ );
311
+ setSingleApp("generated app 2");
312
+ const replacement = bridge.initializeApps();
313
+ await vi.waitFor(() =>
314
+ expect(mockReplaceSessionRequest).toHaveBeenCalledOnce(),
315
+ );
316
+ sendKernelMessage(1);
317
+ const currentMessage = sendKernelMessage(2);
318
+
319
+ expect(consumeMessage.mock.calls).toEqual([
320
+ [firstMessage],
321
+ [currentMessage],
322
+ ]);
323
+
324
+ finishReplacement();
325
+ await replacement;
326
+ });
327
+
328
+ it("restores message ownership when replacement fails", async () => {
329
+ const consumeMessage = vi.fn();
330
+ bridge.consumeMessages(consumeMessage);
331
+ mockSingleApp();
332
+ await bridge.initializeApps();
333
+ mockReplaceSessionRequest.mockRejectedValueOnce(
334
+ new Error("replacement failed"),
335
+ );
336
+ setSingleApp("generated app 2");
337
+
338
+ await expect(bridge.initializeApps()).rejects.toThrow(
339
+ "replacement failed",
340
+ );
341
+ const previousMessage = sendKernelMessage(1);
342
+ sendKernelMessage(2);
343
+
344
+ expect(consumeMessage.mock.calls).toEqual([[previousMessage]]);
345
+ });
346
+
347
+ it("ignores messages while their session is stopping", async () => {
348
+ const consumeMessage = vi.fn();
349
+ bridge.consumeMessages(consumeMessage);
350
+ mockSingleApp();
351
+ await bridge.initializeApps();
352
+ let finishStop!: () => void;
353
+ mockStopSessionRequest.mockReturnValueOnce(
354
+ new Promise<void>((resolve) => {
355
+ finishStop = resolve;
356
+ }),
357
+ );
358
+
359
+ const stop = bridge.stopSession();
360
+ await vi.waitFor(() =>
361
+ expect(mockStopSessionRequest).toHaveBeenCalledOnce(),
362
+ );
363
+ sendKernelMessage(1);
364
+
365
+ expect(consumeMessage).not.toHaveBeenCalled();
366
+
367
+ finishStop();
368
+ await stop;
369
+ });
370
+
371
+ it("forwards messages from every app in a multi-app document", async () => {
372
+ const consumeMessage = vi.fn();
373
+ bridge.consumeMessages(consumeMessage);
374
+ mockParseMarimoIslandApps.mockReturnValue([app(), app("app-2")]);
375
+ mockCreateMarimoFile
376
+ .mockReturnValueOnce("generated app 1")
377
+ .mockReturnValueOnce("generated app 2");
378
+ signalWorkerReady();
379
+
380
+ await bridge.initializeApps();
381
+ const firstMessage = sendKernelMessage(1);
382
+ const secondMessage = sendKernelMessage(2);
383
+ sendKernelMessage(3);
384
+
385
+ expect(consumeMessage.mock.calls).toEqual([
386
+ [firstMessage],
387
+ [secondMessage],
388
+ ]);
389
+ });
390
+
284
391
  it("holds controls until the first app session is ready", async () => {
285
392
  setSingleApp();
286
393
 
@@ -66,6 +66,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
66
66
  private session: AppSession | undefined;
67
67
  private sessionReady = new Deferred<AppSession>();
68
68
  private nextSessionGeneration = 0;
69
+ private readonly activeSessionGenerations = new Set<number>();
69
70
  private appTransition = Promise.resolve();
70
71
  private workerReady = new Deferred<void>();
71
72
 
@@ -110,6 +111,11 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
110
111
  );
111
112
 
112
113
  this.rpc.addMessageListener("kernelMessage", (message) => {
114
+ // A stopped session can still have messages in transit after the next
115
+ // app takes ownership of the shared frontend state.
116
+ if (!this.activeSessionGenerations.has(message.sessionGeneration)) {
117
+ return;
118
+ }
113
119
  this.messageConsumer?.(message);
114
120
  });
115
121
  }
@@ -149,6 +155,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
149
155
  return;
150
156
  }
151
157
  if (apps.length > 0) {
158
+ this.activeSessionGenerations.clear();
152
159
  this.store.set(
153
160
  islandsPendingInitialRunsAtom,
154
161
  new Set(apps.map((_, index) => this.nextSessionGeneration + index + 1)),
@@ -165,6 +172,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
165
172
  };
166
173
  const previousSession = this.session;
167
174
  const replacesSession = managesSingleApp && previousSession !== undefined;
175
+ this.activeSessionGenerations.add(request.sessionGeneration);
168
176
  if (replacesSession) {
169
177
  this.store.set(notebookAtom, initialNotebookState());
170
178
  }
@@ -186,8 +194,14 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
186
194
  this.sessionReady.resolve(this.session);
187
195
  }
188
196
  } catch (error) {
197
+ this.activeSessionGenerations.delete(request.sessionGeneration);
189
198
  if (this.session?.sessionGeneration === request.sessionGeneration) {
190
199
  this.session = previousSession;
200
+ if (previousSession) {
201
+ this.activeSessionGenerations.add(
202
+ previousSession.sessionGeneration,
203
+ );
204
+ }
191
205
  }
192
206
  Logger.error(`Failed to start session for app ${app.id}:`, error);
193
207
  if (managesSingleApp) {
@@ -203,6 +217,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
203
217
  if (session?.code === undefined || (appId && session.appId !== appId)) {
204
218
  return;
205
219
  }
220
+ this.activeSessionGenerations.delete(session.sessionGeneration);
206
221
  await this.rpc.proxy.request.stopSession({
207
222
  appId: session.appId,
208
223
  sessionGeneration: session.sessionGeneration,
package/src/core/mode.ts CHANGED
@@ -78,6 +78,16 @@ export const viewStateAtom = atom<ViewState>({
78
78
 
79
79
  export const initialModeAtom = atom<AppMode | undefined>(undefined);
80
80
 
81
+ /**
82
+ * Whether the current page hosts a notebook, and so connects to a kernel.
83
+ * False for non-notebook pages (home, gallery), which are served without a
84
+ * session.
85
+ */
86
+ export function isNotebookPage(): boolean {
87
+ const mode = store.get(initialModeAtom);
88
+ return mode !== "home" && mode !== "gallery";
89
+ }
90
+
81
91
  export const kioskModeAtom = atom<boolean>(false);
82
92
 
83
93
  /**
@@ -0,0 +1,45 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { initialModeAtom } from "@/core/mode";
5
+ import { store } from "@/core/state/jotai";
6
+ import { WebSocketState } from "../../websocket/types";
7
+ import { connectionAtom, waitForConnectionOpenIfNotebook } from "../connection";
8
+
9
+ const NEVER = Symbol("never");
10
+
11
+ /**
12
+ * Resolves to NEVER if the promise does not settle within a macrotask.
13
+ */
14
+ function settledOrNever(promise: Promise<unknown>) {
15
+ return Promise.race([
16
+ promise,
17
+ new Promise((resolve) => setTimeout(() => resolve(NEVER), 0)),
18
+ ]);
19
+ }
20
+
21
+ describe("waitForConnectionOpenIfNotebook", () => {
22
+ afterEach(() => {
23
+ store.set(connectionAtom, { state: WebSocketState.NOT_STARTED });
24
+ store.set(initialModeAtom, undefined);
25
+ });
26
+
27
+ it.each(["home", "gallery"] as const)(
28
+ "resolves without a connection in %s mode",
29
+ async (mode) => {
30
+ store.set(initialModeAtom, mode);
31
+ await expect(
32
+ settledOrNever(waitForConnectionOpenIfNotebook()),
33
+ ).resolves.not.toBe(NEVER);
34
+ },
35
+ );
36
+
37
+ it("waits for the connection to open in edit mode", async () => {
38
+ store.set(initialModeAtom, "edit");
39
+ const promise = waitForConnectionOpenIfNotebook();
40
+ await expect(settledOrNever(promise)).resolves.toBe(NEVER);
41
+
42
+ store.set(connectionAtom, { state: WebSocketState.OPEN });
43
+ await expect(settledOrNever(promise)).resolves.not.toBe(NEVER);
44
+ });
45
+ });
@@ -32,6 +32,7 @@ vi.mock("../api", async () => {
32
32
  vi.mock("../connection", () => ({
33
33
  isConnectedAtom: { read: vi.fn(() => true) },
34
34
  waitForConnectionOpen: vi.fn().mockResolvedValue(undefined),
35
+ waitForConnectionOpenIfNotebook: vi.fn().mockResolvedValue(undefined),
35
36
  }));
36
37
 
37
38
  describe("createNetworkRequests", () => {
@@ -121,6 +122,18 @@ describe("createNetworkRequests", () => {
121
122
  expect(mockClient.GET).toHaveBeenCalledWith("/api/environment");
122
123
  });
123
124
 
125
+ it("getPackageList should not require a kernel connection", async () => {
126
+ const { waitForConnectionOpen, waitForConnectionOpenIfNotebook } =
127
+ await import("../connection");
128
+
129
+ const requests = createNetworkRequests();
130
+ await requests.getPackageList();
131
+
132
+ expect(mockClient.GET).toHaveBeenCalledWith("/api/packages/list");
133
+ expect(waitForConnectionOpenIfNotebook).toHaveBeenCalled();
134
+ expect(waitForConnectionOpen).not.toHaveBeenCalled();
135
+ });
136
+
124
137
  it("exportAsIPYNB should call the new endpoint as text", async () => {
125
138
  const requests = createNetworkRequests();
126
139
  await requests.exportAsIPYNB({
@@ -1,5 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { atom } from "jotai";
3
+ import { isNotebookPage } from "../mode";
3
4
  import { waitFor } from "../state/jotai";
4
5
  import { type ConnectionStatus, WebSocketState } from "../websocket/types";
5
6
 
@@ -17,6 +18,20 @@ export function waitForConnectionOpen() {
17
18
  });
18
19
  }
19
20
 
21
+ /**
22
+ * Waits for the kernel connection, but only on pages that open one. Non-notebook
23
+ * pages (home, gallery) never connect, so waiting there would hang forever.
24
+ *
25
+ * Use for requests that the marimo server can serve without a session, but that
26
+ * should still wait for the kernel when a notebook is open.
27
+ */
28
+ export function waitForConnectionOpenIfNotebook() {
29
+ if (!isNotebookPage()) {
30
+ return Promise.resolve();
31
+ }
32
+ return waitForConnectionOpen();
33
+ }
34
+
20
35
  export const isConnectingAtom = atom((get) => {
21
36
  const connection = get(connectionAtom);
22
37
  return connection.state === WebSocketState.CONNECTING;
@@ -3,7 +3,10 @@
3
3
  import { once } from "@/utils/once";
4
4
  import { getRuntimeManager } from "../runtime/config";
5
5
  import { API, createClientWithRuntimeManager } from "./api";
6
- import { waitForConnectionOpen } from "./connection";
6
+ import {
7
+ waitForConnectionOpen,
8
+ waitForConnectionOpenIfNotebook,
9
+ } from "./connection";
7
10
  import type { EditRequests, RunRequests } from "./types";
8
11
 
9
12
  /**
@@ -489,12 +492,12 @@ export function createNetworkRequests(): EditRequests & RunRequests {
489
492
  },
490
493
  getPackageList: async () => {
491
494
  // If the sidebar is already open, it may try to load before the session has been initialized
492
- await waitForConnectionOpen();
495
+ await waitForConnectionOpenIfNotebook();
493
496
  return getClient().GET("/api/packages/list").then(handleResponse);
494
497
  },
495
498
  getDependencyTree: async () => {
496
499
  // If the sidebar is already open, it may try to load before the session has been initialized
497
- await waitForConnectionOpen();
500
+ await waitForConnectionOpenIfNotebook();
498
501
  return getClient().GET("/api/packages/tree").then(handleResponse);
499
502
  },
500
503
  listSecretKeys: async (request) => {
@@ -191,7 +191,9 @@ export interface EditRequests {
191
191
  request: FileMoveRequest,
192
192
  ) => Promise<FileMoveResponse>;
193
193
  sendUpdateFile: (request: FileUpdateRequest) => Promise<FileUpdateResponse>;
194
- sendFileDetails: (request: { path: string }) => Promise<FileDetailsResponse>;
194
+ sendFileDetails: (
195
+ request: FileDetailsRequest,
196
+ ) => Promise<FileDetailsResponse>;
195
197
  // Homepage requests
196
198
  openTutorial: (request: OpenTutorialRequest) => Promise<MarimoFile>;
197
199
  getRecentFiles: () => Promise<RecentFilesResponse>;
@@ -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
  >
@@ -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
+ });