@marimo-team/islands 0.23.14-dev9 → 0.23.15-dev0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/{ConnectedDataExplorerComponent-DXBx_nQg.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-DYBcNEdd.js → chat-ui-DinOvbWu.js} +3256 -3052
  5. package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
  6. package/dist/{code-visibility-BZArWgy6.js → code-visibility-DDwWvOQa.js} +905 -889
  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-WsOgyW_K.js → formats-Dzx4J_z1.js} +1 -1
  10. package/dist/{glide-data-editor-qpmKyAPn.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
  11. package/dist/{html-to-image-MqcD07Bw.js → html-to-image-_wGfk8V-.js} +2389 -2323
  12. package/dist/{input-BSdZp5Ng.js → input-DtsN7xm-.js} +1 -1
  13. package/dist/main.js +3667 -1838
  14. package/dist/{mermaid-D-HYBMEV.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-Dt3icftd.js → process-output-Mh4UrjwM.js} +1 -1
  17. package/dist/{reveal-component-CKomb1B5.js → reveal-component-CHu9H-QX.js} +600 -596
  18. package/dist/{spec-CnTgI25l.js → spec-Cz-Bj1JI.js} +1 -1
  19. package/dist/style.css +1 -1
  20. package/dist/{toDate-D1Z7ZXWh.js → toDate-CWNNlFEX.js} +15 -7
  21. package/dist/{useAsyncData-BMc8itk2.js → useAsyncData-KfHB8wQR.js} +1 -1
  22. package/dist/{useDeepCompareMemoize-ZwmDBRDY.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
  23. package/dist/{useLifecycle-CxffarYV.js → useLifecycle-DegSo0lV.js} +1 -1
  24. package/dist/{useTheme-yGsGEk82.js → useTheme-6eZ3GOTS.js} +5 -2
  25. package/dist/{vega-component-BFJTyykA.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/data-table/__tests__/column-explorer.test.tsx +25 -0
  31. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
  32. package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
  33. package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
  34. package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
  35. package/src/components/data-table/charts/charts.tsx +23 -1
  36. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
  37. package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
  38. package/src/components/debugger/debugger-code.tsx +7 -2
  39. package/src/components/editor/cell/CellStatus.tsx +1 -20
  40. package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
  41. package/src/components/editor/cell/cell-context-menu.tsx +2 -2
  42. package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
  43. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
  44. package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
  45. package/src/components/editor/code/readonly-python-code.tsx +60 -33
  46. package/src/components/editor/errors/__tests__/auto-fix.test.ts +11 -107
  47. package/src/components/editor/errors/auto-fix.tsx +11 -31
  48. package/src/components/editor/notebook-cell.tsx +7 -0
  49. package/src/components/editor/output/ImageOutput.tsx +20 -6
  50. package/src/components/editor/output/MarimoTracebackOutput.tsx +20 -3
  51. package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
  52. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
  53. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
  54. package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
  55. package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
  56. package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
  57. package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
  58. package/src/components/slides/__tests__/slide.test.tsx +33 -0
  59. package/src/components/slides/minimap.tsx +7 -1
  60. package/src/components/slides/reveal-component.tsx +3 -0
  61. package/src/components/slides/slide-cell-view.tsx +10 -6
  62. package/src/components/slides/slide.tsx +16 -13
  63. package/src/components/tracing/tracing.tsx +2 -1
  64. package/src/core/ai/context/__tests__/registry.test.ts +133 -3
  65. package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
  66. package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
  67. package/src/core/ai/context/providers/datasource.ts +27 -2
  68. package/src/core/ai/context/providers/error.ts +226 -36
  69. package/src/core/ai/context/registry.ts +77 -43
  70. package/src/core/cells/__tests__/cell.test.ts +39 -0
  71. package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
  72. package/src/core/cells/cell.ts +5 -3
  73. package/src/core/cells/readonly-code-display.ts +35 -0
  74. package/src/core/codemirror/__tests__/setup.test.ts +33 -1
  75. package/src/core/codemirror/ai/resources.ts +15 -10
  76. package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
  77. package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
  78. package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
  79. package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
  80. package/src/core/codemirror/cells/debugger-state.ts +133 -0
  81. package/src/core/codemirror/cells/extensions.ts +32 -4
  82. package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
  83. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
  84. package/src/core/codemirror/go-to-definition/extension.ts +3 -3
  85. package/src/core/codemirror/go-to-definition/underline.ts +5 -14
  86. package/src/core/codemirror/go-to-definition/utils.ts +49 -1
  87. package/src/core/config/__tests__/config-schema.test.ts +17 -0
  88. package/src/core/config/config-schema.ts +1 -0
  89. package/src/core/config/config.ts +10 -0
  90. package/src/core/config/feature-flag.tsx +4 -0
  91. package/src/core/edit-app.tsx +8 -5
  92. package/src/core/errors/errors.ts +2 -1
  93. package/src/core/islands/bootstrap.ts +4 -3
  94. package/src/core/islands/bridge.ts +1 -0
  95. package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
  96. package/src/core/lifecycle/render-policy.ts +125 -0
  97. package/src/core/mime.ts +11 -4
  98. package/src/core/network/__tests__/api.test.ts +17 -0
  99. package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
  100. package/src/core/network/api.ts +17 -6
  101. package/src/core/network/requests-lazy.ts +1 -0
  102. package/src/core/network/requests-network.ts +8 -0
  103. package/src/core/network/requests-static.ts +1 -0
  104. package/src/core/network/requests-toasting.tsx +10 -1
  105. package/src/core/network/types.ts +2 -0
  106. package/src/core/run-app.tsx +27 -23
  107. package/src/core/runtime/__tests__/adapter.test.ts +160 -0
  108. package/src/core/runtime/__tests__/runtime.test.ts +45 -0
  109. package/src/core/runtime/adapter.ts +182 -0
  110. package/src/core/runtime/runtime.ts +30 -16
  111. package/src/core/wasm/PyodideLoader.tsx +20 -62
  112. package/src/core/wasm/bridge.ts +13 -4
  113. package/src/core/wasm/state.ts +8 -19
  114. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
  115. package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
  116. package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
  117. package/src/core/websocket/transports/sse.ts +308 -0
  118. package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
  119. package/src/core/websocket/useWebSocket.tsx +12 -2
  120. package/src/css/app/codemirror.css +14 -0
  121. package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
  122. package/src/hooks/useDelayElapsed.ts +27 -0
  123. package/src/mount.tsx +3 -3
  124. package/src/plugins/core/RenderHTML.tsx +35 -1
  125. package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
  126. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  127. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
  128. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
  129. package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
  130. package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
  131. package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
  132. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
  133. package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
  134. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
  135. package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
  136. package/src/plugins/impl/anywidget/host.ts +54 -0
  137. package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
  138. package/src/plugins/impl/anywidget/model.ts +59 -239
  139. package/src/plugins/impl/anywidget/registry.ts +268 -0
  140. package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
  141. package/src/plugins/impl/anywidget/runtime.ts +422 -0
  142. package/src/plugins/impl/anywidget/types.ts +14 -0
  143. package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
  144. package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
  145. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
  146. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
  147. package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
  148. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
  149. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
  150. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
  151. package/src/utils/errors.ts +15 -0
  152. package/src/utils/time.ts +20 -0
  153. package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
  154. package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
  155. package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
  156. package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
  157. package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
  158. package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
  159. package/src/core/wasm/__tests__/state.test.ts +0 -124
  160. package/src/plugins/impl/anywidget/schemas.ts +0 -32
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { HTTPError } from "@/utils/errors";
4
5
  import { API } from "../api";
5
6
 
6
7
  // Mock fetch globally
@@ -77,4 +78,20 @@ describe("API", () => {
77
78
  expect.objectContaining({ method: "POST" }),
78
79
  );
79
80
  });
81
+
82
+ it("openapi-client error rejects with HTTPError carrying the status", async () => {
83
+ const body = { detail: "This connection is read-only for this action." };
84
+ await expect(
85
+ API.handleResponseReturnNull({
86
+ error: body,
87
+ response: { status: 403, statusText: "Forbidden" } as Response,
88
+ }),
89
+ ).rejects.toMatchObject({ status: 403, cause: body });
90
+ await expect(
91
+ API.handleResponseReturnNull({
92
+ error: body,
93
+ response: { status: 403, statusText: "Forbidden" } as Response,
94
+ }),
95
+ ).rejects.toBeInstanceOf(HTTPError);
96
+ });
80
97
  });
@@ -0,0 +1,46 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { HTTPError } from "@/utils/errors";
5
+
6
+ const toastMock = vi.fn();
7
+ vi.mock("@/components/ui/use-toast", () => ({
8
+ toast: (...args: unknown[]) => toastMock(...args),
9
+ }));
10
+
11
+ import { createErrorToastingRequests } from "../requests-toasting";
12
+ import type { EditRequests, RunRequests } from "../types";
13
+
14
+ function requestsThatReject(error: unknown): EditRequests & RunRequests {
15
+ return createErrorToastingRequests({
16
+ sendRun: vi.fn().mockRejectedValue(error),
17
+ } as unknown as EditRequests & RunRequests);
18
+ }
19
+
20
+ describe("createErrorToastingRequests", () => {
21
+ beforeEach(() => {
22
+ toastMock.mockClear();
23
+ });
24
+
25
+ it("suppresses the error toast for a capability 403", async () => {
26
+ const requests = requestsThatReject(
27
+ new HTTPError(403, "Forbidden", {
28
+ detail: "This connection is read-only for this action.",
29
+ }),
30
+ );
31
+ await expect(requests.sendRun({} as never)).rejects.toBeInstanceOf(
32
+ HTTPError,
33
+ );
34
+ expect(toastMock).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it("shows a danger toast for other request errors", async () => {
38
+ const requests = requestsThatReject(new HTTPError(500, "Server Error"));
39
+ await expect(requests.sendRun({} as never)).rejects.toBeInstanceOf(
40
+ HTTPError,
41
+ );
42
+ expect(toastMock).toHaveBeenCalledWith(
43
+ expect.objectContaining({ variant: "danger" }),
44
+ );
45
+ });
46
+ });
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { createMarimoClient } from "@marimo-team/marimo-api";
4
+ import { HTTPError } from "../../utils/errors";
4
5
  import { Logger } from "../../utils/Logger";
5
6
  import { Strings } from "../../utils/strings";
6
7
  import { getRuntimeManager } from "../runtime/config";
@@ -50,7 +51,7 @@ export const API = {
50
51
  const errorBody = isJson
51
52
  ? await response.json()
52
53
  : await response.text();
53
- throw new Error(response.statusText, { cause: errorBody });
54
+ throw new HTTPError(response.status, response.statusText, errorBody);
54
55
  }
55
56
  if (isJson) {
56
57
  return response.json() as RESP;
@@ -83,7 +84,7 @@ export const API = {
83
84
  })
84
85
  .then((response) => {
85
86
  if (!response.ok) {
86
- throw new Error(response.statusText);
87
+ throw new HTTPError(response.status, response.statusText);
87
88
  }
88
89
  if (
89
90
  response.headers.get("Content-Type")?.startsWith("application/json")
@@ -108,8 +109,13 @@ export const API = {
108
109
  response: Response;
109
110
  }): Promise<T> => {
110
111
  if (response.error) {
111
- // oxlint-disable-next-line typescript/prefer-promise-reject-errors
112
- return Promise.reject(response.error);
112
+ return Promise.reject(
113
+ new HTTPError(
114
+ response.response.status,
115
+ response.response.statusText,
116
+ response.error,
117
+ ),
118
+ );
113
119
  }
114
120
  return Promise.resolve(response.data as T);
115
121
  },
@@ -118,8 +124,13 @@ export const API = {
118
124
  response: Response;
119
125
  }): Promise<null> => {
120
126
  if (response.error) {
121
- // oxlint-disable-next-line typescript/prefer-promise-reject-errors
122
- return Promise.reject(response.error);
127
+ return Promise.reject(
128
+ new HTTPError(
129
+ response.response.status,
130
+ response.response.statusText,
131
+ response.error,
132
+ ),
133
+ );
123
134
  }
124
135
  return Promise.resolve(null);
125
136
  },
@@ -108,6 +108,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
108
108
  sendStdin: "waitForConnectionOpen",
109
109
  sendInterrupt: "waitForConnectionOpen",
110
110
  sendPdb: "waitForConnectionOpen",
111
+ sendSetBreakpoints: "waitForConnectionOpen",
111
112
  sendInstallMissingPackages: "waitForConnectionOpen",
112
113
  readSnippets: "waitForConnectionOpen",
113
114
  previewDatasetColumn: "waitForConnectionOpen",
@@ -293,6 +293,14 @@ export function createNetworkRequests(): EditRequests & RunRequests {
293
293
  })
294
294
  .then(handleResponseReturnNull);
295
295
  },
296
+ sendSetBreakpoints: (request) => {
297
+ return getClient()
298
+ .POST("/api/kernel/pdb/breakpoints", {
299
+ body: request,
300
+ params: getParams(),
301
+ })
302
+ .then(handleResponseReturnNull);
303
+ },
296
304
  sendListFiles: async (request) => {
297
305
  await waitForConnectionOpen();
298
306
  return getClient()
@@ -68,6 +68,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
68
68
  sendListFiles: throwNotInEditMode,
69
69
  sendSearchFiles: throwNotInEditMode,
70
70
  sendPdb: throwNotInEditMode,
71
+ sendSetBreakpoints: throwNotInEditMode,
71
72
  sendCreateFileOrFolder: throwNotInEditMode,
72
73
  sendDeleteFileOrFolder: throwNotInEditMode,
73
74
  sendCopyFileOrFolder: throwNotInEditMode,
@@ -5,7 +5,7 @@ import { useAtomValue } from "jotai";
5
5
  import { Spinner } from "@/components/icons/spinner";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { toast } from "@/components/ui/use-toast";
8
- import { NoKernelConnectedError, prettyError } from "@/utils/errors";
8
+ import { HTTPError, NoKernelConnectedError, prettyError } from "@/utils/errors";
9
9
  import { Logger } from "@/utils/Logger";
10
10
  import { useConnectToRuntime } from "../runtime/config";
11
11
  import { store } from "../state/jotai";
@@ -49,6 +49,7 @@ export function createErrorToastingRequests(
49
49
  sendListFiles: "Failed to list files",
50
50
  sendSearchFiles: "Failed to search files",
51
51
  sendPdb: "Failed to start debug session",
52
+ sendSetBreakpoints: "", // No toast
52
53
  sendCreateFileOrFolder: "Failed to create file or folder",
53
54
  sendDeleteFileOrFolder: "Failed to delete file or folder",
54
55
  sendCopyFileOrFolder: "Failed to duplicate file or folder",
@@ -109,6 +110,14 @@ export function createErrorToastingRequests(
109
110
  throw error;
110
111
  }
111
112
 
113
+ // A capability 403: the connection is read-only for this action. The
114
+ // live capability notification already moved the UI to viewer state,
115
+ // so don't alarm the user with an error toast.
116
+ if (error instanceof HTTPError && error.status === 403) {
117
+ Logger.warn(`Request refused, connection is read-only: ${key}`);
118
+ throw error;
119
+ }
120
+
112
121
  const title = MESSAGES[keyString];
113
122
  const message = prettyError(error);
114
123
  if (title) {
@@ -66,6 +66,7 @@ export type ListDataSourceConnectionRequest =
66
66
  schemas["ListDataSourceConnectionRequest"];
67
67
  export type ValidateSQLRequest = schemas["ValidateSQLRequest"];
68
68
  export type DebugCellRequest = schemas["DebugCellRequest"];
69
+ export type SetBreakpointsRequest = schemas["SetBreakpointsRequest"];
69
70
  export type ReadCodeResponse = schemas["ReadCodeResponse"];
70
71
  export type RecentFilesResponse = schemas["RecentFilesResponse"];
71
72
  export type RenameNotebookRequest = schemas["RenameNotebookRequest"];
@@ -172,6 +173,7 @@ export interface EditRequests {
172
173
  getUsageStats: () => Promise<UsageResponse>;
173
174
  // Debugger
174
175
  sendPdb: (request: DebugCellRequest) => Promise<null>;
176
+ sendSetBreakpoints: (request: SetBreakpointsRequest) => Promise<null>;
175
177
  // File explorer requests
176
178
  sendListFiles: (request: FileListRequest) => Promise<FileListResponse>;
177
179
  sendSearchFiles: (request: FileSearchRequest) => Promise<FileSearchResponse>;
@@ -1,13 +1,13 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useAtomValue } from "jotai";
3
+ import { atom, useAtomValue } from "jotai";
4
4
  import { ArrowLeftIcon } from "lucide-react";
5
5
  import { useEffect } from "react";
6
6
  import { AppContainer } from "@/components/editor/app-container";
7
7
  import { AppHeader } from "@/components/editor/header/app-header";
8
+ import { ProgressiveBoundary } from "@/components/lifecycle/ProgressiveBoundary";
8
9
  import { Spinner } from "@/components/icons/spinner";
9
10
  import { buttonVariants } from "@/components/ui/button";
10
- import { DelayMount } from "@/components/utils/delay-mount";
11
11
  import { cn } from "@/utils/cn";
12
12
  import { CellsRenderer } from "../components/editor/renderers/cells-renderer";
13
13
  import {
@@ -18,6 +18,7 @@ import {
18
18
  import type { AppConfig } from "./config/config-schema";
19
19
  import { RuntimeState } from "./kernel/RuntimeState";
20
20
  import { getSessionId } from "./kernel/session";
21
+ import { connectionAtom } from "./network/connection";
21
22
  import { useRequestClient } from "./network/requests";
22
23
  import { isAppConnecting } from "./websocket/connection-utils";
23
24
  import { useMarimoKernelConnection } from "./websocket/useMarimoKernelConnection";
@@ -26,6 +27,16 @@ interface AppProps {
26
27
  appConfig: AppConfig;
27
28
  }
28
29
 
30
+ /**
31
+ * Paint the (possibly empty) app once we have cells, or as soon as we are no
32
+ * longer actively connecting. Without the connection check, a connected or
33
+ * disconnected notebook with no cells would sit on a misleading "Connecting…"
34
+ * spinner instead of rendering the empty app.
35
+ */
36
+ const canPaintRunAppAtom = atom(
37
+ (get) => get(hasCellsAtom) || !isAppConnecting(get(connectionAtom).state),
38
+ );
39
+
29
40
  export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
30
41
  const { setCells } = useCellActions();
31
42
  const { sendComponentValues } = useRequestClient();
@@ -45,26 +56,6 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
45
56
  });
46
57
 
47
58
  const isRunning = useAtomValue(notebookIsRunningAtom);
48
- const isConnecting = isAppConnecting(connection.state);
49
- // Skip the "Connecting..." gate when we already have cells to show — from
50
- // an embedded snapshot or a prior connection.
51
- const hasExistingCells = useAtomValue(hasCellsAtom);
52
-
53
- const renderCells = () => {
54
- // If we are connecting for more than 2 seconds, show a spinner
55
- if (isConnecting && !hasExistingCells) {
56
- return (
57
- <DelayMount milliseconds={2000} fallback={null}>
58
- <Spinner className="mx-auto" />
59
- <p className="text-center text-sm text-muted-foreground mt-2">
60
- Connecting...
61
- </p>
62
- </DelayMount>
63
- );
64
- }
65
-
66
- return <CellsRenderer appConfig={appConfig} mode="read" />;
67
- };
68
59
 
69
60
  const galleryHref = (() => {
70
61
  if (typeof window === "undefined") {
@@ -103,7 +94,20 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
103
94
  </div>
104
95
  )}
105
96
  </AppHeader>
106
- {renderCells()}
97
+ <ProgressiveBoundary
98
+ requires={canPaintRunAppAtom}
99
+ delay={2000}
100
+ fallback={
101
+ <>
102
+ <Spinner className="mx-auto" />
103
+ <p className="text-center text-sm text-muted-foreground mt-2">
104
+ Connecting...
105
+ </p>
106
+ </>
107
+ }
108
+ >
109
+ <CellsRenderer appConfig={appConfig} mode="read" />
110
+ </ProgressiveBoundary>
107
111
  </AppContainer>
108
112
  );
109
113
  };
@@ -0,0 +1,160 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { createStore } from "jotai";
4
+ import { describe, expect, it } from "vitest";
5
+ import { islandsInitializedAtom } from "../../islands/state";
6
+ import { kernelStateAtom } from "../../kernel/state";
7
+ import { connectionAtom } from "../../network/connection";
8
+ import { wasmInitStateAtom } from "../../wasm/state";
9
+ import { WebSocketState } from "../../websocket/types";
10
+ import {
11
+ canRunCellsAtom,
12
+ islandsAdapter,
13
+ remoteAdapter,
14
+ runtimeAdapterAtom,
15
+ staticAdapter,
16
+ wasmAdapter,
17
+ } from "../adapter";
18
+
19
+ describe("wasmAdapter", () => {
20
+ it("reports connecting with the progress label while loading", () => {
21
+ const store = createStore();
22
+ store.set(wasmInitStateAtom, {
23
+ kind: "loading",
24
+ message: "Loading Pyodide…",
25
+ });
26
+ expect(store.get(wasmAdapter.state)).toEqual({
27
+ kind: "connecting",
28
+ progress: { label: "Loading Pyodide…" },
29
+ });
30
+ });
31
+
32
+ it("reports ready when initialized", () => {
33
+ const store = createStore();
34
+ store.set(wasmInitStateAtom, { kind: "ready" });
35
+ expect(store.get(wasmAdapter.state)).toEqual({ kind: "ready" });
36
+ });
37
+
38
+ it("exposes the real error message on failure", () => {
39
+ const store = createStore();
40
+ store.set(wasmInitStateAtom, {
41
+ kind: "error",
42
+ message: "ImportError: numpy",
43
+ });
44
+ expect(store.get(wasmAdapter.state)).toEqual({
45
+ kind: "failed",
46
+ error: { message: "ImportError: numpy", errorKind: "init" },
47
+ });
48
+ });
49
+ });
50
+
51
+ describe("remoteAdapter", () => {
52
+ it.each([
53
+ [{ state: WebSocketState.OPEN }, { kind: "ready" }],
54
+ [
55
+ { state: WebSocketState.CONNECTING },
56
+ { kind: "connecting", progress: { label: "Connecting…" } },
57
+ ],
58
+ [
59
+ { state: WebSocketState.NOT_STARTED },
60
+ { kind: "connecting", progress: { label: "Not connected" } },
61
+ ],
62
+ ] as const)("maps %j to %j", (conn, expected) => {
63
+ const store = createStore();
64
+ store.set(connectionAtom, conn as never);
65
+ expect(store.get(remoteAdapter.state)).toEqual(expected);
66
+ });
67
+
68
+ it("surfaces the close reason when closed", () => {
69
+ const store = createStore();
70
+ store.set(connectionAtom, {
71
+ state: WebSocketState.CLOSED,
72
+ code: "KERNEL_DISCONNECTED",
73
+ reason: "Kernel went away",
74
+ });
75
+ expect(store.get(remoteAdapter.state)).toEqual({
76
+ kind: "failed",
77
+ error: { message: "Kernel went away", errorKind: "runtime" },
78
+ });
79
+ });
80
+ });
81
+
82
+ describe("staticAdapter", () => {
83
+ it("is always ready", () => {
84
+ const store = createStore();
85
+ expect(store.get(staticAdapter.state)).toEqual({ kind: "ready" });
86
+ });
87
+
88
+ it("advertises no capabilities", () => {
89
+ expect(staticAdapter.capabilities).toEqual({
90
+ canHealthCheck: false,
91
+ canShutdown: false,
92
+ canRestart: false,
93
+ supportsLsp: false,
94
+ });
95
+ });
96
+ });
97
+
98
+ describe("islandsAdapter", () => {
99
+ it("is connecting before islands report initialization", () => {
100
+ const store = createStore();
101
+ store.set(islandsInitializedAtom, false);
102
+ expect(store.get(islandsAdapter.state)).toEqual({
103
+ kind: "connecting",
104
+ progress: { label: "Initializing islands…" },
105
+ });
106
+ });
107
+
108
+ it("is ready once islands initialize", () => {
109
+ const store = createStore();
110
+ store.set(islandsInitializedAtom, true);
111
+ expect(store.get(islandsAdapter.state)).toEqual({ kind: "ready" });
112
+ });
113
+
114
+ it("surfaces the error string when islands fail", () => {
115
+ const store = createStore();
116
+ store.set(islandsInitializedAtom, "wheel download failed");
117
+ expect(store.get(islandsAdapter.state)).toEqual({
118
+ kind: "failed",
119
+ error: { message: "wheel download failed", errorKind: "init" },
120
+ });
121
+ });
122
+ });
123
+
124
+ describe("canRunCellsAtom", () => {
125
+ it("is false on a remote adapter until kernel acks", () => {
126
+ const store = createStore();
127
+ // Default adapter in test env is remote.
128
+ store.set(connectionAtom, { state: WebSocketState.OPEN });
129
+ expect(store.get(canRunCellsAtom)).toBe(false);
130
+
131
+ store.set(kernelStateAtom, { isInstantiated: true, error: null });
132
+ expect(store.get(canRunCellsAtom)).toBe(true);
133
+ });
134
+
135
+ it("is true on WASM as soon as the adapter is ready (no separate kernel ack)", () => {
136
+ const store = createStore();
137
+ store.set(runtimeAdapterAtom, wasmAdapter);
138
+ store.set(wasmInitStateAtom, { kind: "loading", message: "…" });
139
+ expect(store.get(canRunCellsAtom)).toBe(false);
140
+
141
+ store.set(wasmInitStateAtom, { kind: "ready" });
142
+ expect(store.get(canRunCellsAtom)).toBe(true);
143
+ });
144
+
145
+ it("is always false on a static notebook", () => {
146
+ const store = createStore();
147
+ store.set(runtimeAdapterAtom, staticAdapter);
148
+ expect(store.get(canRunCellsAtom)).toBe(false);
149
+ });
150
+
151
+ it("is true on islands once initialized", () => {
152
+ const store = createStore();
153
+ store.set(runtimeAdapterAtom, islandsAdapter);
154
+ store.set(islandsInitializedAtom, false);
155
+ expect(store.get(canRunCellsAtom)).toBe(false);
156
+
157
+ store.set(islandsInitializedAtom, true);
158
+ expect(store.get(canRunCellsAtom)).toBe(true);
159
+ });
160
+ });
@@ -154,6 +154,51 @@ describe("RuntimeManager", () => {
154
154
  });
155
155
  });
156
156
 
157
+ describe("getSseURL", () => {
158
+ it("should return an http(s) URL with the session ID", () => {
159
+ const runtime = new RuntimeManager(mockConfig);
160
+ const url = runtime.getSseURL("1234" as SessionId);
161
+
162
+ expect(url.protocol).toBe("https:");
163
+ expect(url.pathname).toBe("/sse");
164
+ expect(url.searchParams.get("session_id")).toBe("1234");
165
+ });
166
+
167
+ it("should never put the auth token in the URL, even cross-origin", () => {
168
+ // SSE requests can send headers, so auth travels in the
169
+ // Authorization header; a token in the URL would leak into
170
+ // proxy and server logs.
171
+ const runtime = new RuntimeManager(
172
+ {
173
+ url: "https://sandbox.example.com",
174
+ lazy: true,
175
+ authToken: "my-secret-token",
176
+ },
177
+ true,
178
+ );
179
+ const url = runtime.getSseURL("s_123" as SessionId);
180
+
181
+ expect(url.searchParams.get("access_token")).toBeNull();
182
+ expect(url.toString()).not.toContain("my-secret-token");
183
+ });
184
+
185
+ it("should strip an access_token forwarded from the page URL", () => {
186
+ // The page URL can briefly carry access_token before auth cleanup
187
+ // removes it; it must not be forwarded to /sse.
188
+ const original = window.location.href;
189
+ window.history.pushState({}, "", "/?access_token=page-token&foo=bar");
190
+ try {
191
+ const runtime = new RuntimeManager(mockConfig);
192
+ const url = runtime.getSseURL("s_123" as SessionId);
193
+
194
+ expect(url.searchParams.get("access_token")).toBeNull();
195
+ expect(url.searchParams.get("foo")).toBe("bar");
196
+ } finally {
197
+ window.history.pushState({}, "", original);
198
+ }
199
+ });
200
+ });
201
+
157
202
  describe("getWsSyncURL", () => {
158
203
  it("should return WebSocket Sync URL", () => {
159
204
  const runtime = new RuntimeManager(mockConfig);