@marimo-team/islands 0.23.14-dev6 → 0.23.14-dev61

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 (171) hide show
  1. package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
  2. package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
  3. package/dist/assets/{worker-DEDLIQQV.js → worker-DAWRHcPq.js} +2 -2
  4. package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DinOvbWu.js} +3248 -3044
  5. package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
  6. package/dist/{code-visibility-C90Am97q.js → code-visibility-EvXXzjfW.js} +1169 -960
  7. package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
  8. package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
  9. package/dist/{formats-d6MhLuQ9.js → formats-Dzx4J_z1.js} +1 -1
  10. package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
  11. package/dist/{html-to-image-CGp_08St.js → html-to-image-_wGfk8V-.js} +2389 -2322
  12. package/dist/{input-CbEz_aj_.js → input-DtsN7xm-.js} +1 -1
  13. package/dist/main.js +3630 -1801
  14. package/dist/{mermaid-CJW9vIyO.js → mermaid-BYqXy_NE.js} +2 -2
  15. package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
  16. package/dist/{process-output-R6JsYrv3.js → process-output-Mh4UrjwM.js} +1 -1
  17. package/dist/{reveal-component-C_u9FY4_.js → reveal-component-CKfV5wlk.js} +600 -596
  18. package/dist/{spec-Bv-XlYiv.js → spec-Cz-Bj1JI.js} +1 -1
  19. package/dist/style.css +1 -1
  20. package/dist/{toDate-D-l5s8nn.js → toDate-CWNNlFEX.js} +15 -7
  21. package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-KfHB8wQR.js} +1 -1
  22. package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
  23. package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-DegSo0lV.js} +1 -1
  24. package/dist/{useTheme-BrYvK-_A.js → useTheme-6eZ3GOTS.js} +31 -26
  25. package/dist/{vega-component-Pk6lyc_a.js → vega-component-DzyyM9fc.js} +5 -5
  26. package/package.json +4 -4
  27. package/src/__mocks__/requests.ts +1 -0
  28. package/src/__tests__/CellStatus.test.tsx +1 -2
  29. package/src/components/app-config/user-config-form.tsx +52 -0
  30. package/src/components/chat/acp/agent-panel.tsx +35 -1
  31. package/src/components/chat/chat-panel.tsx +68 -29
  32. package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
  33. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
  34. package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
  35. package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
  36. package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
  37. package/src/components/data-table/charts/charts.tsx +23 -1
  38. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
  39. package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
  40. package/src/components/debugger/debugger-code.tsx +7 -2
  41. package/src/components/editor/actions/useNotebookActions.tsx +2 -2
  42. package/src/components/editor/cell/CellStatus.tsx +1 -20
  43. package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
  44. package/src/components/editor/cell/cell-context-menu.tsx +2 -2
  45. package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
  46. package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
  47. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
  48. package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
  49. package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
  50. package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
  51. package/src/components/editor/code/readonly-python-code.tsx +60 -33
  52. package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
  53. package/src/components/editor/errors/auto-fix.tsx +88 -34
  54. package/src/components/editor/errors/fix-mode.ts +1 -1
  55. package/src/components/editor/notebook-cell.tsx +7 -0
  56. package/src/components/editor/output/ImageOutput.tsx +20 -6
  57. package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
  58. package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
  59. package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
  60. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
  61. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
  62. package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
  63. package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
  64. package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
  65. package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
  66. package/src/components/slides/__tests__/slide.test.tsx +33 -0
  67. package/src/components/slides/minimap.tsx +7 -1
  68. package/src/components/slides/reveal-component.tsx +3 -0
  69. package/src/components/slides/slide-cell-view.tsx +10 -6
  70. package/src/components/slides/slide.tsx +16 -13
  71. package/src/components/tracing/tracing.tsx +2 -1
  72. package/src/core/ai/config.ts +2 -2
  73. package/src/core/ai/context/__tests__/registry.test.ts +133 -3
  74. package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
  75. package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
  76. package/src/core/ai/context/providers/datasource.ts +27 -2
  77. package/src/core/ai/context/providers/error.ts +226 -36
  78. package/src/core/ai/context/registry.ts +77 -43
  79. package/src/core/ai/state.ts +11 -0
  80. package/src/core/cells/__tests__/cell.test.ts +39 -0
  81. package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
  82. package/src/core/cells/cell.ts +5 -3
  83. package/src/core/cells/readonly-code-display.ts +35 -0
  84. package/src/core/codemirror/__tests__/setup.test.ts +33 -1
  85. package/src/core/codemirror/ai/resources.ts +15 -10
  86. package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
  87. package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
  88. package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
  89. package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
  90. package/src/core/codemirror/cells/debugger-state.ts +133 -0
  91. package/src/core/codemirror/cells/extensions.ts +32 -4
  92. package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
  93. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
  94. package/src/core/codemirror/go-to-definition/extension.ts +3 -3
  95. package/src/core/codemirror/go-to-definition/underline.ts +5 -14
  96. package/src/core/codemirror/go-to-definition/utils.ts +49 -1
  97. package/src/core/codemirror/utils.ts +15 -0
  98. package/src/core/config/__tests__/config-schema.test.ts +17 -0
  99. package/src/core/config/config-schema.ts +1 -0
  100. package/src/core/config/config.ts +10 -0
  101. package/src/core/config/feature-flag.tsx +4 -0
  102. package/src/core/edit-app.tsx +8 -5
  103. package/src/core/errors/errors.ts +2 -1
  104. package/src/core/islands/bootstrap.ts +4 -3
  105. package/src/core/islands/bridge.ts +1 -0
  106. package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
  107. package/src/core/lifecycle/render-policy.ts +125 -0
  108. package/src/core/mime.ts +11 -4
  109. package/src/core/network/__tests__/api.test.ts +17 -0
  110. package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
  111. package/src/core/network/api.ts +17 -6
  112. package/src/core/network/requests-lazy.ts +1 -0
  113. package/src/core/network/requests-network.ts +8 -0
  114. package/src/core/network/requests-static.ts +1 -0
  115. package/src/core/network/requests-toasting.tsx +10 -1
  116. package/src/core/network/types.ts +2 -0
  117. package/src/core/run-app.tsx +27 -23
  118. package/src/core/runtime/__tests__/adapter.test.ts +160 -0
  119. package/src/core/runtime/__tests__/runtime.test.ts +45 -0
  120. package/src/core/runtime/adapter.ts +182 -0
  121. package/src/core/runtime/runtime.ts +30 -16
  122. package/src/core/wasm/PyodideLoader.tsx +20 -62
  123. package/src/core/wasm/bridge.ts +13 -4
  124. package/src/core/wasm/state.ts +8 -19
  125. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
  126. package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
  127. package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
  128. package/src/core/websocket/transports/sse.ts +308 -0
  129. package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
  130. package/src/core/websocket/useWebSocket.tsx +12 -2
  131. package/src/css/app/codemirror.css +14 -0
  132. package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
  133. package/src/hooks/useDelayElapsed.ts +27 -0
  134. package/src/mount.tsx +3 -3
  135. package/src/plugins/core/RenderHTML.tsx +35 -1
  136. package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
  137. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  138. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
  139. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
  140. package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
  141. package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
  142. package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
  143. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
  144. package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
  145. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
  146. package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
  147. package/src/plugins/impl/anywidget/host.ts +54 -0
  148. package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
  149. package/src/plugins/impl/anywidget/model.ts +59 -239
  150. package/src/plugins/impl/anywidget/registry.ts +268 -0
  151. package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
  152. package/src/plugins/impl/anywidget/runtime.ts +422 -0
  153. package/src/plugins/impl/anywidget/types.ts +14 -0
  154. package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
  155. package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
  156. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
  157. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
  158. package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
  159. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
  160. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
  161. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
  162. package/src/utils/errors.ts +15 -0
  163. package/src/utils/time.ts +20 -0
  164. package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
  165. package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
  166. package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
  167. package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
  168. package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
  169. package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
  170. package/src/core/wasm/__tests__/state.test.ts +0 -124
  171. package/src/plugins/impl/anywidget/schemas.ts +0 -32
@@ -0,0 +1,182 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { type Atom, atom } from "jotai";
4
+ import { kernelStateAtom } from "../kernel/state";
5
+ import { islandsInitializedAtom } from "../islands/state";
6
+ import { isIslands } from "../islands/utils";
7
+ import { connectionAtom } from "../network/connection";
8
+ import { isStaticNotebook } from "../static/static-state";
9
+ import { wasmInitStateAtom } from "../wasm/state";
10
+ import { isWasm } from "../wasm/utils";
11
+ import { WebSocketState } from "../websocket/types";
12
+
13
+ export type RuntimeKind = "wasm" | "remote" | "static" | "islands";
14
+
15
+ export type AdapterState =
16
+ | { kind: "connecting"; progress?: { label: string; percent?: number } }
17
+ | { kind: "ready" }
18
+ | {
19
+ kind: "failed";
20
+ error: { message: string; errorKind: "init" | "runtime" };
21
+ };
22
+
23
+ export interface RuntimeCapabilities {
24
+ canHealthCheck: boolean;
25
+ canShutdown: boolean;
26
+ canRestart: boolean;
27
+ supportsLsp: boolean;
28
+ }
29
+
30
+ /** Runtimes with no server-side kernel to manage (wasm, static, islands). */
31
+ const NO_CAPABILITIES: RuntimeCapabilities = {
32
+ canHealthCheck: false,
33
+ canShutdown: false,
34
+ canRestart: false,
35
+ supportsLsp: false,
36
+ };
37
+
38
+ /**
39
+ * One adapter per runtime so consumers don't branch on `isWasm()` /
40
+ * `isStaticNotebook()` / `isIslands()` at the call site.
41
+ */
42
+ export interface RuntimeAdapter {
43
+ readonly kind: RuntimeKind;
44
+ /** Short label suitable for status pills, e.g. "Kernel", "Pyodide". */
45
+ readonly label: string;
46
+ readonly capabilities: RuntimeCapabilities;
47
+ state: Atom<AdapterState>;
48
+ }
49
+
50
+ // --- WASM ------------------------------------------------------------------
51
+
52
+ const wasmStateAtom = atom<AdapterState>((get) => {
53
+ const s = get(wasmInitStateAtom);
54
+ switch (s.kind) {
55
+ case "loading":
56
+ return { kind: "connecting", progress: { label: s.message } };
57
+ case "ready":
58
+ return { kind: "ready" };
59
+ case "error":
60
+ return {
61
+ kind: "failed",
62
+ error: { message: s.message, errorKind: "init" },
63
+ };
64
+ }
65
+ });
66
+
67
+ export const wasmAdapter: RuntimeAdapter = {
68
+ kind: "wasm",
69
+ label: "Pyodide",
70
+ capabilities: NO_CAPABILITIES,
71
+ state: wasmStateAtom,
72
+ };
73
+
74
+ // --- Remote ----------------------------------------------------------------
75
+
76
+ const remoteStateAtom = atom<AdapterState>((get) => {
77
+ const conn = get(connectionAtom);
78
+ switch (conn.state) {
79
+ case WebSocketState.OPEN:
80
+ return { kind: "ready" };
81
+ case WebSocketState.CONNECTING:
82
+ return { kind: "connecting", progress: { label: "Connecting…" } };
83
+ case WebSocketState.NOT_STARTED:
84
+ return { kind: "connecting", progress: { label: "Not connected" } };
85
+ case WebSocketState.CLOSING:
86
+ case WebSocketState.CLOSED:
87
+ return {
88
+ kind: "failed",
89
+ error: {
90
+ message:
91
+ conn.state === WebSocketState.CLOSED
92
+ ? conn.reason
93
+ : "Disconnecting",
94
+ errorKind: "runtime",
95
+ },
96
+ };
97
+ }
98
+ });
99
+
100
+ export const remoteAdapter: RuntimeAdapter = {
101
+ kind: "remote",
102
+ label: "Kernel",
103
+ capabilities: {
104
+ canHealthCheck: true,
105
+ canShutdown: true,
106
+ canRestart: true,
107
+ supportsLsp: true,
108
+ },
109
+ state: remoteStateAtom,
110
+ };
111
+
112
+ // --- Static ----------------------------------------------------------------
113
+
114
+ const staticStateAtom = atom<AdapterState>({ kind: "ready" });
115
+
116
+ export const staticAdapter: RuntimeAdapter = {
117
+ kind: "static",
118
+ label: "Static",
119
+ capabilities: NO_CAPABILITIES,
120
+ state: staticStateAtom,
121
+ };
122
+
123
+ // --- Islands ---------------------------------------------------------------
124
+
125
+ const islandsStateAtom = atom<AdapterState>((get) => {
126
+ const status = get(islandsInitializedAtom);
127
+ if (status === true) {
128
+ return { kind: "ready" };
129
+ }
130
+ if (typeof status === "string") {
131
+ return {
132
+ kind: "failed",
133
+ error: { message: status, errorKind: "init" },
134
+ };
135
+ }
136
+ return {
137
+ kind: "connecting",
138
+ progress: { label: "Initializing islands…" },
139
+ };
140
+ });
141
+
142
+ export const islandsAdapter: RuntimeAdapter = {
143
+ kind: "islands",
144
+ label: "Islands",
145
+ capabilities: NO_CAPABILITIES,
146
+ state: islandsStateAtom,
147
+ };
148
+
149
+ function selectAdapter(): RuntimeAdapter {
150
+ if (isStaticNotebook()) {
151
+ return staticAdapter;
152
+ }
153
+ if (isIslands()) {
154
+ return islandsAdapter;
155
+ }
156
+ if (isWasm()) {
157
+ return wasmAdapter;
158
+ }
159
+ return remoteAdapter;
160
+ }
161
+
162
+ /** Picked once at module load; the choice doesn't change after page load. */
163
+ export const runtimeAdapterAtom = atom<RuntimeAdapter>(selectAdapter());
164
+
165
+ /**
166
+ * Remote runtimes require both the WS to be open *and* the kernel to have
167
+ * ack'd instantiation. WASM/islands have no separate kernel handshake.
168
+ * Static notebooks never run cells.
169
+ */
170
+ export const canRunCellsAtom = atom<boolean>((get) => {
171
+ const adapter = get(runtimeAdapterAtom);
172
+ if (get(adapter.state).kind !== "ready") {
173
+ return false;
174
+ }
175
+ if (adapter.kind === "static") {
176
+ return false;
177
+ }
178
+ if (adapter.kind === "remote") {
179
+ return get(kernelStateAtom).isInstantiated;
180
+ }
181
+ return true;
182
+ });
@@ -107,9 +107,10 @@ export class RuntimeManager {
107
107
  }
108
108
 
109
109
  /**
110
- * The WebSocket URL of the runtime.
110
+ * Search params for a session connection: the base URL's params, merged
111
+ * with the current page's, plus the session id.
111
112
  */
112
- getWsURL(sessionId: SessionId): URL {
113
+ private getSessionSearchParams(sessionId: SessionId): URLSearchParams {
113
114
  const baseUrl = new URL(this.config.url);
114
115
  const searchParams = new URLSearchParams(baseUrl.search);
115
116
 
@@ -123,27 +124,40 @@ export class RuntimeManager {
123
124
  });
124
125
 
125
126
  searchParams.set(KnownQueryParams.sessionId, sessionId);
126
- return this.formatWsURL("/ws", searchParams);
127
+ return searchParams;
127
128
  }
128
129
 
129
130
  /**
130
- * The WebSocket Sync URL of the runtime, for real-time updates.
131
+ * The WebSocket URL of the runtime.
131
132
  */
132
- getWsSyncURL(sessionId: SessionId): URL {
133
- const baseUrl = new URL(this.config.url);
134
- const searchParams = new URLSearchParams(baseUrl.search);
133
+ getWsURL(sessionId: SessionId): URL {
134
+ return this.formatWsURL("/ws", this.getSessionSearchParams(sessionId));
135
+ }
135
136
 
136
- // Merge in current page's query parameters
137
- const currentParams = new URLSearchParams(window.location.search);
138
- currentParams.forEach((value, key) => {
139
- // Don't override base URL params
140
- if (!searchParams.has(key)) {
141
- searchParams.set(key, value);
142
- }
137
+ /**
138
+ * The SSE URL of the runtime; the http(s) alternative to `getWsURL`
139
+ * when `server.transport` is `"sse"`. Unlike WebSockets, SSE requests
140
+ * can send headers, so auth travels in the Authorization header
141
+ * (see `headers()`) instead of the URL.
142
+ */
143
+ getSseURL(sessionId: SessionId): URL {
144
+ const url = this.formatHttpURL({
145
+ path: "/sse",
146
+ searchParams: this.getSessionSearchParams(sessionId),
147
+ restrictToKnownQueryParams: false,
143
148
  });
149
+ // The page URL can still carry access_token (merged in from the
150
+ // current page's params, before auth cleanup strips it); never
151
+ // forward it — a token in the URL leaks into proxy/server logs.
152
+ url.searchParams.delete(KnownQueryParams.accessToken);
153
+ return url;
154
+ }
144
155
 
145
- searchParams.set(KnownQueryParams.sessionId, sessionId);
146
- return this.formatWsURL("/ws_sync", searchParams);
156
+ /**
157
+ * The WebSocket Sync URL of the runtime, for real-time updates.
158
+ */
159
+ getWsSyncURL(sessionId: SessionId): URL {
160
+ return this.formatWsURL("/ws_sync", this.getSessionSearchParams(sessionId));
147
161
  }
148
162
 
149
163
  /**
@@ -5,54 +5,41 @@ import type React from "react";
5
5
  import type { PropsWithChildren } from "react";
6
6
  import { useEffect, useRef } from "react";
7
7
  import { LargeSpinner } from "@/components/icons/large-spinner";
8
+ import { ProgressiveBoundary } from "@/components/lifecycle/ProgressiveBoundary";
8
9
  import { toast } from "@/components/ui/use-toast";
9
- import { hasCellsAtom } from "@/core/cells/cells";
10
- import { showCodeInRunModeAtom } from "@/core/meta/state";
11
- import { store } from "@/core/state/jotai";
10
+ import { canPaintAtom } from "@/core/lifecycle/render-policy";
11
+ import { wasmInitStateAtom } from "@/core/wasm/state";
12
12
  import { useAsyncData } from "@/hooks/useAsyncData";
13
13
  import { prettyError } from "@/utils/errors";
14
14
  import { Logger } from "@/utils/Logger";
15
- import { hasQueryParam } from "@/utils/urls";
16
- import { KnownQueryParams } from "../constants";
17
- import { type AppMode, getInitialAppMode } from "../mode";
18
15
  import { PyodideBridge } from "./bridge";
19
- import { hasAnyOutputAtom, wasmInitializationAtom } from "./state";
20
16
  import { isWasm } from "./utils";
21
17
 
22
18
  /**
23
- * HOC to load Pyodide before rendering children, if necessary.
19
+ * Paints the snapshot (or hydrated cells) immediately and lets Pyodide
20
+ * finish downloading in the background. UI elements aren't interactive
21
+ * until Pyodide is ready.
24
22
  */
25
23
  export const PyodideLoader: React.FC<PropsWithChildren> = ({ children }) => {
26
24
  if (!isWasm()) {
27
25
  return children;
28
26
  }
29
-
30
27
  return <PyodideLoaderInner>{children}</PyodideLoaderInner>;
31
28
  };
32
29
 
33
30
  const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
34
- // Don't block render on Pyodide: a hydrated snapshot can paint immediately
35
- // while Pyodide downloads in the background.
36
31
  const { error } = useAsyncData(async () => {
37
32
  await PyodideBridge.INSTANCE.initialized.promise;
38
33
  return true;
39
34
  }, []);
40
35
 
41
- const hasCells = useAtomValue(hasCellsAtom);
42
- const hasOutput = useAtomValue(hasAnyOutputAtom);
43
- const nothingToShow = shouldShowSpinner({
44
- hasCells,
45
- hasOutput,
46
- mode: getInitialAppMode(),
47
- codeHidden: isCodeHidden(),
48
- });
36
+ const canPaint = useAtomValue(canPaintAtom);
49
37
 
38
+ // If the snapshot is already on screen, toast instead of throwing so we
39
+ // don't tear it down. With nothing painted, throw to surface the error UI.
50
40
  const didToastErrorRef = useRef(false);
51
41
  useEffect(() => {
52
- // With snapshot content on-screen, toast instead of throwing so the
53
- // snapshot stays readable. The ref ensures we only toast once even if
54
- // nothingToShow toggles later.
55
- if (error && !nothingToShow && !didToastErrorRef.current) {
42
+ if (error && canPaint && !didToastErrorRef.current) {
56
43
  didToastErrorRef.current = true;
57
44
  Logger.error("Pyodide failed to initialize", error);
58
45
  toast({
@@ -61,50 +48,21 @@ const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
61
48
  variant: "danger",
62
49
  });
63
50
  }
64
- }, [error, nothingToShow]);
51
+ }, [error, canPaint]);
65
52
 
66
- if (error && nothingToShow) {
53
+ if (error && !canPaint) {
67
54
  throw error;
68
55
  }
69
56
 
70
- if (nothingToShow) {
71
- return <WasmSpinner />;
72
- }
73
-
74
- return children;
75
- };
76
-
77
- function isCodeHidden() {
78
- // Code is hidden if ANY are true:
79
- // - the query param is set to false
80
- // - the view.showAppCode is false
81
57
  return (
82
- hasQueryParam(KnownQueryParams.showCode, "false") ||
83
- !store.get(showCodeInRunModeAtom)
58
+ <ProgressiveBoundary requires={canPaintAtom} fallback={<WasmSpinner />}>
59
+ {children}
60
+ </ProgressiveBoundary>
84
61
  );
85
- }
86
-
87
- /**
88
- * Pure predicate: should the WASM loader render a spinner instead of its
89
- * children? We block render only when nothing user-visible would appear:
90
- * - no cells have been hydrated (Pyodide hasn't parsed the notebook), or
91
- * - we are in headless run mode (code hidden) with no outputs to display.
92
- */
93
- export function shouldShowSpinner(input: {
94
- hasCells: boolean;
95
- hasOutput: boolean;
96
- mode: AppMode;
97
- codeHidden: boolean;
98
- }): boolean {
99
- const { hasCells, hasOutput, mode, codeHidden } = input;
100
- if (!hasCells) {
101
- return true;
102
- }
103
- return !hasOutput && mode === "read" && codeHidden;
104
- }
105
-
106
- export const WasmSpinner: React.FC<PropsWithChildren> = ({ children }) => {
107
- const wasmInitialization = useAtomValue(wasmInitializationAtom);
62
+ };
108
63
 
109
- return <LargeSpinner title={wasmInitialization} />;
64
+ const WasmSpinner: React.FC = () => {
65
+ const state = useAtomValue(wasmInitStateAtom);
66
+ const title = state.kind === "loading" ? state.message : "Loading…";
67
+ return <LargeSpinner title={title} />;
110
68
  };
@@ -38,7 +38,7 @@ import type { IConnectionTransport } from "../websocket/transports/transport";
38
38
  import { PyodideRouter } from "./router";
39
39
  import { getWorkerRPC } from "./rpc";
40
40
  import { createShareableLink } from "./share";
41
- import { wasmInitializationAtom, wasmInitStatusAtom } from "./state";
41
+ import { wasmInitStateAtom } from "./state";
42
42
  import { fallbackFileStore, notebookFileStore } from "./store";
43
43
  import { isWasm } from "./utils";
44
44
  import type { SaveWorkerSchema } from "./worker/save-worker";
@@ -120,11 +120,16 @@ export class PyodideBridge implements RunRequests, EditRequests {
120
120
  // By initializing after, we get hits on cached network requests
121
121
  this.saveRpc = this.getSaveWorker();
122
122
  this.setInterruptBuffer();
123
- store.set(wasmInitStatusAtom, "ready");
123
+ store.set(wasmInitStateAtom, { kind: "ready" });
124
124
  this.initialized.resolve();
125
125
  });
126
126
  this.rpc.addMessageListener("initializingMessage", ({ message }) => {
127
- store.set(wasmInitializationAtom, message);
127
+ // Only bump the progress label while still loading — if we've already
128
+ // reached "ready" or "error", a late message shouldn't roll us back.
129
+ const current = store.get(wasmInitStateAtom);
130
+ if (current.kind === "loading") {
131
+ store.set(wasmInitStateAtom, { kind: "loading", message });
132
+ }
128
133
  });
129
134
  this.rpc.addMessageListener("initializedError", ({ error }) => {
130
135
  // If already initialized, surface as a toast and leave the deferred /
@@ -138,7 +143,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
138
143
  });
139
144
  return;
140
145
  }
141
- store.set(wasmInitStatusAtom, "error");
146
+ store.set(wasmInitStateAtom, { kind: "error", message: error });
142
147
  this.initialized.reject(new Error(error));
143
148
  });
144
149
  this.rpc.addMessageListener("kernelMessage", ({ message }) => {
@@ -252,6 +257,10 @@ export class PyodideBridge implements RunRequests, EditRequests {
252
257
  throwNotImplemented();
253
258
  };
254
259
 
260
+ sendSetBreakpoints: EditRequests["sendSetBreakpoints"] = async () => {
261
+ throwNotImplemented();
262
+ };
263
+
255
264
  sendRun: EditRequests["sendRun"] = async (request) => {
256
265
  await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
257
266
 
@@ -1,24 +1,13 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { atom } from "jotai";
3
- import { notebookAtom } from "../cells/cells";
4
- import { isOutputEmpty } from "../cells/outputs";
5
3
 
6
- export const wasmInitializationAtom = atom<string>("Initializing...");
4
+ /** Tagged so the progress label and error message can't drift apart. */
5
+ export type WasmInitState =
6
+ | { kind: "loading"; message: string }
7
+ | { kind: "ready" }
8
+ | { kind: "error"; message: string };
7
9
 
8
- export type WasmInitStatus = "loading" | "ready" | "error";
9
- export const wasmInitStatusAtom = atom<WasmInitStatus>("loading");
10
-
11
- export const hasAnyOutputAtom = atom<boolean>((get) => {
12
- const notebook = get(notebookAtom);
13
- const runtimeStates = Object.values(notebook.cellRuntime);
14
- // First check if there is any output
15
- const hasOutput = runtimeStates.some(
16
- (runtime) => !isOutputEmpty(runtime.output),
17
- );
18
- if (hasOutput) {
19
- return true;
20
- }
21
- // If there is no output, check if they have all run to completion
22
- // in case this notebook doesn't contain outputs
23
- return runtimeStates.every((runtime) => runtime.status === "idle");
10
+ export const wasmInitStateAtom = atom<WasmInitState>({
11
+ kind: "loading",
12
+ message: "Initializing...",
24
13
  });
@@ -36,6 +36,10 @@ describe("classifyCloseEvent", () => {
36
36
  "MARIMO_NO_SESSION_ID",
37
37
  "MARIMO_NO_SESSION",
38
38
  "MARIMO_SHUTDOWN",
39
+ // Delivered as in-band close events by the SSE transport; must be
40
+ // terminal or a rejected client would reconnect forever.
41
+ "MARIMO_UNAUTHORIZED",
42
+ "MARIMO_KIOSK_NOT_ALLOWED",
39
43
  ])("%s → terminal with KERNEL_DISCONNECTED, closes transport", (reason) => {
40
44
  const decision = classify(reason);
41
45
  expect(decision.kind).toBe("terminal");
@@ -0,0 +1,49 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { BasicTransport } from "../transports/basic";
5
+ import { SseTransport } from "../transports/sse";
6
+ import { WsTransport } from "../transports/ws";
7
+ import { createConnectionTransport } from "../useWebSocket";
8
+
9
+ vi.mock("../../wasm/utils", () => ({
10
+ isWasm: () => false,
11
+ }));
12
+
13
+ describe("createConnectionTransport", () => {
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ const options = {
19
+ url: () => "http://localhost/ws",
20
+ headers: () => ({}),
21
+ };
22
+
23
+ it("uses BasicTransport for static notebooks", () => {
24
+ const transport = createConnectionTransport({
25
+ ...options,
26
+ static: true,
27
+ transportType: "websocket",
28
+ });
29
+ expect(transport).toBeInstanceOf(BasicTransport);
30
+ });
31
+
32
+ it("uses WsTransport by default", () => {
33
+ const transport = createConnectionTransport({
34
+ ...options,
35
+ static: false,
36
+ transportType: "websocket",
37
+ });
38
+ expect(transport).toBeInstanceOf(WsTransport);
39
+ });
40
+
41
+ it("uses SseTransport when the transport type is sse", () => {
42
+ const transport = createConnectionTransport({
43
+ ...options,
44
+ static: false,
45
+ transportType: "sse",
46
+ });
47
+ expect(transport).toBeInstanceOf(SseTransport);
48
+ });
49
+ });