@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
@@ -1,32 +1,21 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- /* oxlint-disable typescript/no-explicit-any */
3
2
 
4
- import type { AnyWidget } from "@anywidget/types";
5
- import { useEffect, useRef } from "react";
3
+ import { useEffect, useRef, useState } from "react";
6
4
  import { z } from "zod";
7
- import { useAsyncData } from "@/hooks/useAsyncData";
8
- import type { HTMLElementNotDerivedFromRef } from "@/hooks/useEventListener";
9
5
  import { createPlugin } from "@/plugins/core/builder";
10
6
  import type { IPluginProps } from "@/plugins/types";
11
- import { prettyError } from "@/utils/errors";
12
- import { Logger } from "@/utils/Logger";
13
- import { hasFunctionProperty, isRecord } from "@/utils/records";
14
7
  import { ErrorBanner } from "../common/error-banner";
15
- import { getMarimoInternal, MODEL_MANAGER, type Model } from "./model";
16
- import type { ModelState, WidgetModelId } from "./types";
17
- import { BINDING_MANAGER, WIDGET_DEF_REGISTRY } from "./widget-binding";
8
+ import { WIDGET_REGISTRY } from "./registry";
9
+ import { isWidgetModelId, type WidgetModelId } from "./types";
18
10
 
19
11
  /**
20
- * AnyWidget asset data
12
+ * The component's only data attribute; everything else arrives through
13
+ * the model's comm messages, keyed by this id.
21
14
  */
22
15
  interface Data {
23
- jsUrl: string;
24
- jsHash: string;
25
16
  modelId: WidgetModelId;
26
17
  }
27
18
 
28
- type AnyWidgetState = ModelState;
29
-
30
19
  /**
31
20
  * Value payload sent by the frontend on state updates.
32
21
  *
@@ -37,264 +26,54 @@ interface ModelIdRef {
37
26
  model_id?: WidgetModelId;
38
27
  }
39
28
 
40
- export function useAnyWidgetModule(opts: { jsUrl: string; jsHash: string }) {
41
- const { jsUrl, jsHash } = opts;
42
-
43
- // JS is an ESM file with a render function on it
44
- // export function render({ model, el }) {
45
- // ...
46
- const {
47
- data: jsModule,
48
- error,
49
- refetch,
50
- } = useAsyncData(async () => {
51
- return await WIDGET_DEF_REGISTRY.getModule(jsUrl, jsHash);
52
- // Re-render on jsHash change (which is a hash of the contents of the file)
53
- // instead of a jsUrl change because URLs may change without the contents
54
- // actually changing (and we don't want to re-render on every change).
55
- // If there is an error loading the URL (e.g. maybe an invalid or old URL),
56
- // we also want to re-render.
57
- }, [jsHash]);
58
-
59
- // If there is an error and the jsUrl has changed, we want to re-render
60
- // because the URL may have changed to a valid URL.
61
- const hasError = Boolean(error);
62
- useEffect(() => {
63
- if (hasError && jsUrl) {
64
- WIDGET_DEF_REGISTRY.invalidate(jsHash);
65
- refetch();
66
- }
67
- }, [hasError, jsUrl]);
68
-
69
- return {
70
- jsModule,
71
- error,
72
- };
73
- }
74
-
75
- export function useMountCss(css: string | null | undefined, host: HTMLElement) {
76
- // Mount the CSS
77
- useEffect(() => {
78
- const shadowRoot = host.shadowRoot;
79
- if (!css || !shadowRoot) {
80
- return;
81
- }
82
-
83
- // Try constructed stylesheets first
84
- if (
85
- "adoptedStyleSheets" in Document.prototype &&
86
- "replace" in CSSStyleSheet.prototype
87
- ) {
88
- const sheet = new CSSStyleSheet();
89
- try {
90
- sheet.replaceSync(css);
91
- if (shadowRoot) {
92
- shadowRoot.adoptedStyleSheets = [
93
- ...shadowRoot.adoptedStyleSheets,
94
- sheet,
95
- ];
96
- }
97
- return () => {
98
- if (shadowRoot) {
99
- shadowRoot.adoptedStyleSheets =
100
- shadowRoot.adoptedStyleSheets.filter((s) => s !== sheet);
101
- }
102
- };
103
- } catch {
104
- // Fall through to inline styles if constructed sheets fail
105
- }
106
- }
107
-
108
- // Fallback to inline styles
109
- const style = document.createElement("style");
110
- style.innerHTML = css;
111
- shadowRoot.append(style);
112
- return () => {
113
- style.remove();
114
- };
115
- }, [css, host]);
116
- }
117
-
118
29
  export const AnyWidgetPlugin = createPlugin<ModelIdRef>("marimo-anywidget")
119
30
  .withData(
120
31
  z.object({
121
- jsUrl: z.string(),
122
- jsHash: z.string(),
123
- modelId: z.string().transform((v) => v as WidgetModelId),
32
+ modelId: z.custom<WidgetModelId>(isWidgetModelId, {
33
+ message: "Expected a non-empty widget model id",
34
+ }),
124
35
  }),
125
36
  )
126
37
  .withFunctions({})
127
38
  .renderer((props) => <AnyWidgetSlot {...props} />);
128
39
 
129
- const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
130
- const { jsUrl, jsHash, modelId } = props.data;
131
- const host = props.host as HTMLElementNotDerivedFromRef;
132
-
133
- const { jsModule, error } = useAnyWidgetModule({ jsUrl, jsHash });
134
-
135
- if (error) {
136
- return <ErrorBanner error={error} />;
137
- }
138
-
139
- if (!jsModule) {
140
- return null;
141
- }
142
-
143
- if (!isAnyWidgetModule(jsModule)) {
144
- return (
145
- <ErrorBanner error={getInvalidAnyWidgetModuleError(jsModule, jsUrl)} />
146
- );
147
- }
148
-
149
- return (
150
- <LoadedSlot
151
- // Force remount when the widget module or model changes (cell re-run).
152
- key={`${jsHash}:${modelId}`}
153
- widget={jsModule.default}
154
- modelId={modelId}
155
- host={host}
156
- />
157
- );
158
- };
159
-
160
40
  /**
161
- * Run the anywidget module
162
- *
163
- * Per AFM spec (anywidget.dev/en/afm):
164
- * - initialize() is called once per model lifetime
165
- * - render() is called once per view (can be multiple per model)
41
+ * React adapter for a runtime-owned view. React supplies only the render
42
+ * target and its lifetime; the runtime owns models, generations, CSS,
43
+ * composition, and hot reload.
166
44
  */
167
- async function runAnyWidgetModule<T extends AnyWidgetState>(
168
- widgetDef: AnyWidget<T>,
169
- model: Model<T>,
170
- modelId: WidgetModelId,
171
- el: HTMLElement,
172
- signal: AbortSignal,
173
- ): Promise<void> {
174
- // Clear the element, in case the widget is re-rendering
175
- el.innerHTML = "";
176
-
177
- try {
178
- const binding = BINDING_MANAGER.getOrCreate(modelId);
179
- const render = await binding.bind(widgetDef, model);
180
- await render(el, signal);
181
- // Replay current model values so render listeners observe hydrated state
182
- // even if backend updates arrived before listeners were attached.
183
- getMarimoInternal(model).reemitState();
184
- } catch (error) {
185
- Logger.error("Error rendering anywidget", error);
186
- el.classList.add("text-error");
187
- el.innerHTML = `Error rendering anywidget: ${prettyError(error)}`;
188
- }
189
- }
190
-
191
- function isAnyWidgetModule(mod: any): mod is { default: AnyWidget } {
192
- if (!mod.default) {
193
- return false;
194
- }
195
-
196
- return (
197
- typeof mod.default === "function" ||
198
- typeof mod.default?.render === "function" ||
199
- typeof mod.default?.initialize === "function"
200
- );
201
- }
202
-
203
- function getInvalidAnyWidgetModuleError(mod: unknown, jsUrl: string): Error {
204
- const afmDocs = "https://anywidget.dev/en/afm/";
205
- const hasNamedRender = isRecord(mod) && hasFunctionProperty(mod, "render");
206
- const hasNamedInitialize =
207
- isRecord(mod) && hasFunctionProperty(mod, "initialize");
208
-
209
- if (hasNamedRender || hasNamedInitialize) {
210
- const namedExports = [
211
- hasNamedRender ? "`render`" : null,
212
- hasNamedInitialize ? "`initialize`" : null,
213
- ]
214
- .filter(Boolean)
215
- .join(" and ");
216
- const lifecycleHooks = [
217
- hasNamedRender ? "render" : null,
218
- hasNamedInitialize ? "initialize" : null,
219
- ].filter((hook): hook is string => hook !== null);
220
- const defaultExportExample = `export default { ${lifecycleHooks.join(", ")} }`;
221
- const namedExportExample =
222
- lifecycleHooks.length === 1
223
- ? `export function ${lifecycleHooks[0]}`
224
- : "named export function ...";
225
- return new Error(
226
- `Anywidget module at ${jsUrl} uses named exports (${namedExports}). ` +
227
- "Per the AFM spec, use a default export instead: " +
228
- `\`${defaultExportExample}\` (not \`${namedExportExample}\`). ` +
229
- `See ${afmDocs}`,
230
- );
231
- }
232
-
233
- if (!isRecord(mod) || mod.default === undefined) {
234
- return new Error(
235
- `Anywidget module at ${jsUrl} is missing a default export. ` +
236
- "Per the AFM spec, use `export default { render }` or " +
237
- "`export default async () => ({ render })`. " +
238
- `See ${afmDocs}`,
239
- );
240
- }
241
-
242
- return new Error(
243
- `Anywidget module at ${jsUrl} has an invalid default export. ` +
244
- "Expected a factory function or an object with `render` or `initialize`. " +
245
- `See ${afmDocs}`,
246
- );
247
- }
248
-
249
- interface Props<T extends AnyWidgetState> {
250
- widget: AnyWidget<T>;
251
- modelId: WidgetModelId;
252
- host: HTMLElementNotDerivedFromRef;
253
- }
254
-
255
- const LoadedSlot = <T extends AnyWidgetState>({
256
- widget,
257
- modelId,
258
- host,
259
- }: Props<T> & { widget: AnyWidget<T> }) => {
45
+ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
46
+ const { modelId } = props.data;
260
47
  const htmlRef = useRef<HTMLDivElement>(null);
261
-
262
- // value is already decoded from wire format, may be null if waiting for open message
263
- const model = MODEL_MANAGER.getSync(modelId);
264
-
265
- if (!model) {
266
- Logger.error("Model not found for modelId", modelId);
267
- }
268
-
269
- const css = model?.get("_css");
270
- useMountCss(css, host);
48
+ const [error, setError] = useState<Error>();
271
49
 
272
50
  useEffect(() => {
273
- if (!htmlRef.current || !model) {
51
+ const el = htmlRef.current;
52
+ if (!el) {
274
53
  return;
275
54
  }
276
55
  const controller = new AbortController();
277
- runAnyWidgetModule(
278
- widget,
279
- model,
56
+ setError(undefined);
57
+ WIDGET_REGISTRY.createView({
280
58
  modelId,
281
- htmlRef.current,
282
- controller.signal,
283
- );
59
+ el,
60
+ signal: controller.signal,
61
+ }).catch((error) => {
62
+ if (!controller.signal.aborted) {
63
+ setError(error instanceof Error ? error : new Error(String(error)));
64
+ }
65
+ });
284
66
  return () => controller.abort();
285
- // We re-run the widget when the modelId changes, which means the cell
286
- // that created the Widget has been re-run.
287
- // We need to re-run the widget because it may contain initialization code
288
- // that could be reset by the new widget.
289
- // See example: https://github.com/marimo-team/marimo/issues/3962#issuecomment-2703184123
290
- }, [widget, modelId, model]);
67
+ }, [modelId]);
291
68
 
292
- return <div ref={htmlRef} />;
69
+ return (
70
+ <>
71
+ {error ? <ErrorBanner error={error} /> : null}
72
+ <div ref={htmlRef} />
73
+ </>
74
+ );
293
75
  };
294
76
 
295
77
  export const visibleForTesting = {
296
- LoadedSlot,
297
- runAnyWidgetModule,
298
- isAnyWidgetModule,
299
- getInvalidAnyWidgetModuleError,
78
+ AnyWidgetSlot,
300
79
  };
@@ -1,221 +1,163 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { render, waitFor } from "@testing-library/react";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { TestUtils } from "@/__tests__/test-helpers";
6
- import type { HTMLElementNotDerivedFromRef } from "@/hooks/useEventListener";
4
+ import {
5
+ afterEach,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ it,
10
+ type MockInstance,
11
+ vi,
12
+ } from "vitest";
13
+ import type { IPluginProps } from "@/plugins/types";
7
14
  import { visibleForTesting } from "../AnyWidgetPlugin";
8
- import { MODEL_MANAGER, Model } from "../model";
9
- import type { WidgetModelId } from "../types";
10
- import { BINDING_MANAGER } from "../widget-binding";
15
+ import { Model } from "../model";
16
+ import { WIDGET_REGISTRY } from "../registry";
17
+ import type { EsmSpec, ModelState, WidgetModelId } from "../types";
18
+ import { WIDGET_DEF_REGISTRY } from "../widget-binding";
11
19
 
12
- const { LoadedSlot, isAnyWidgetModule, getInvalidAnyWidgetModuleError } =
13
- visibleForTesting;
20
+ const { AnyWidgetSlot } = visibleForTesting;
14
21
 
15
22
  // Helper to create typed model IDs for tests
16
23
  const asModelId = (id: string): WidgetModelId => id as WidgetModelId;
17
24
 
18
- // Mock a minimal AnyWidget implementation
19
- const mockWidget = {
20
- initialize: vi.fn(),
21
- render: vi.fn(),
22
- };
23
-
24
- describe("LoadedSlot", () => {
25
- const modelId = asModelId("test-model-id");
26
- let mockModel: Model<{ count: number }>;
27
-
28
- const mockProps = {
29
- widget: mockWidget,
30
- data: {
31
- jsUrl: "http://example.com/widget.js",
32
- jsHash: "abc123",
33
- modelId: modelId,
34
- },
35
- host: document.createElement(
36
- "div",
37
- ) as unknown as HTMLElementNotDerivedFromRef,
38
- modelId: modelId,
25
+ function createMockComm() {
26
+ return {
27
+ sendUpdate: vi.fn().mockResolvedValue(undefined),
28
+ sendCustomMessage: vi.fn().mockResolvedValue(undefined),
29
+ };
30
+ }
31
+
32
+ function createMockModel(state?: ModelState) {
33
+ return new Model<ModelState>(state ?? { count: 0 }, createMockComm());
34
+ }
35
+
36
+ const hostEl = () => document.createElement("div");
37
+
38
+ describe("AnyWidgetSlot", () => {
39
+ const SPEC: EsmSpec = { url: "./@file/10-slot.js", hash: "slot-hash" };
40
+ let getModuleSpy: MockInstance<typeof WIDGET_DEF_REGISTRY.getModule>;
41
+ let nextId = 0;
42
+ let modelId: WidgetModelId;
43
+ let mockWidget: {
44
+ initialize: ReturnType<typeof vi.fn>;
45
+ render: ReturnType<typeof vi.fn>;
39
46
  };
40
47
 
41
48
  beforeEach(() => {
42
- vi.clearAllMocks();
43
- // Create and register a mock model before each test
44
- mockModel = new Model(
45
- { count: 0 },
46
- {
47
- sendUpdate: vi.fn().mockResolvedValue(undefined),
48
- sendCustomMessage: vi.fn().mockResolvedValue(undefined),
49
- },
50
- );
51
- MODEL_MANAGER.set(modelId, mockModel);
49
+ nextId += 1;
50
+ modelId = asModelId(`slot-test-${nextId}`);
51
+ mockWidget = { initialize: vi.fn(), render: vi.fn() };
52
+ getModuleSpy = vi
53
+ .spyOn(WIDGET_DEF_REGISTRY, "getModule")
54
+ .mockResolvedValue({ default: mockWidget });
55
+ WIDGET_REGISTRY.setModel(modelId, createMockModel());
56
+ WIDGET_REGISTRY.setSpec(modelId, {
57
+ url: SPEC.url,
58
+ // Unique hash per test so the module-cache spy is always hit.
59
+ hash: `${SPEC.hash}-${nextId}`,
60
+ });
52
61
  });
53
62
 
54
63
  afterEach(() => {
55
- BINDING_MANAGER.destroy(modelId);
64
+ WIDGET_REGISTRY.delete(modelId);
65
+ getModuleSpy.mockRestore();
56
66
  });
57
67
 
58
- it("should render a div with ref", () => {
59
- const { container } = render(<LoadedSlot {...mockProps} />);
60
- expect(container.querySelector("div")).not.toBeNull();
68
+ const props = (
69
+ id: WidgetModelId,
70
+ value: Record<string, unknown> = {},
71
+ ): IPluginProps<{ model_id?: WidgetModelId }, { modelId: WidgetModelId }> =>
72
+ ({
73
+ data: { modelId: id },
74
+ value,
75
+ host: hostEl(),
76
+ setValue: vi.fn(),
77
+ functions: {},
78
+ }) as unknown as IPluginProps<
79
+ { model_id?: WidgetModelId },
80
+ { modelId: WidgetModelId }
81
+ >;
82
+
83
+ it("resolves the widget through the registry and renders a view", async () => {
84
+ render(<AnyWidgetSlot {...props(modelId)} />);
85
+ await waitFor(() => {
86
+ expect(mockWidget.initialize).toHaveBeenCalledTimes(1);
87
+ expect(mockWidget.render).toHaveBeenCalledTimes(1);
88
+ });
61
89
  });
62
90
 
63
- it("should call runAnyWidgetModule on initialization", async () => {
64
- render(<LoadedSlot {...mockProps} />);
65
-
66
- // Wait a render
91
+ it("aborts the runtime-owned view on unmount", async () => {
92
+ const { unmount } = render(<AnyWidgetSlot {...props(modelId)} />);
67
93
  await waitFor(() => {
68
- expect(mockWidget.render).toHaveBeenCalled();
94
+ expect(mockWidget.render).toHaveBeenCalledTimes(1);
69
95
  });
96
+ const signal = mockWidget.render.mock.calls[0][0].signal as AbortSignal;
97
+ expect(signal.aborted).toBe(false);
98
+ unmount();
99
+ expect(signal.aborted).toBe(true);
70
100
  });
71
101
 
72
- it("should not remount when value update drops model_id", async () => {
73
- // Regression: when the frontend sends a state update (e.g. {zoom_level: 0}),
74
- // it overwrites the UIElement value that originally held {model_id: "..."}.
75
- // The key must stay stable because modelId comes from data, not value.
76
- const { container, rerender } = render(<LoadedSlot {...mockProps} />);
77
-
102
+ it("does not remount when only the value changes", async () => {
103
+ // Regression: a state update rewrites the plugin value (dropping
104
+ // model_id), but the key comes from data attributes, so the view
105
+ // must survive.
106
+ const { container, rerender } = render(
107
+ <AnyWidgetSlot {...props(modelId)} />,
108
+ );
78
109
  await waitFor(() => {
79
110
  expect(mockWidget.render).toHaveBeenCalledTimes(1);
80
111
  });
81
-
82
112
  const divBefore = container.querySelector("div");
83
113
 
84
- // Simulate a value update that does NOT include model_id
85
- // (this is what happens when the widget sends trait state)
86
- rerender(<LoadedSlot {...mockProps} />);
114
+ rerender(<AnyWidgetSlot {...props(modelId, { zoom_level: 0 })} />);
87
115
 
88
116
  await waitFor(() => {
89
- // The div should be the same DOM node (no remount)
90
117
  expect(container.querySelector("div")).toBe(divBefore);
91
- // render should not be called again (no remount)
92
118
  expect(mockWidget.render).toHaveBeenCalledTimes(1);
93
119
  });
94
120
  });
95
121
 
96
- it("should re-run widget when widget prop changes", async () => {
97
- const { rerender } = render(<LoadedSlot {...mockProps} />);
98
-
99
- // Wait for initial render
122
+ it("remounts and re-initializes when modelId changes (cell re-run)", async () => {
123
+ // Regression for marimo-team/marimo#3962: a cell re-run reconstructs
124
+ // the widget, opening a new comm with a new model id. The plugin
125
+ // must remount so initialize runs against the fresh model.
126
+ const { rerender } = render(<AnyWidgetSlot {...props(modelId)} />);
100
127
  await waitFor(() => {
101
- expect(mockWidget.render).toHaveBeenCalled();
128
+ expect(mockWidget.render).toHaveBeenCalledTimes(1);
102
129
  });
103
130
 
104
- // Create a new widget mock
105
- const newMockWidget = {
106
- initialize: vi.fn(),
107
- render: vi.fn(),
108
- };
109
-
110
- // Change the widget
111
- rerender(<LoadedSlot {...mockProps} widget={newMockWidget} />);
112
- await TestUtils.nextTick();
113
-
114
- // Wait for re-render with new widget
115
- await waitFor(() => {
116
- expect(newMockWidget.render).toHaveBeenCalled();
131
+ const rerunId = asModelId(`slot-test-${nextId}-rerun`);
132
+ WIDGET_REGISTRY.setModel(rerunId, createMockModel());
133
+ WIDGET_REGISTRY.setSpec(rerunId, {
134
+ url: SPEC.url,
135
+ hash: `${SPEC.hash}-${nextId}-rerun`,
117
136
  });
137
+ try {
138
+ rerender(<AnyWidgetSlot {...props(rerunId)} />);
139
+ await waitFor(() => {
140
+ expect(mockWidget.initialize).toHaveBeenCalledTimes(2);
141
+ expect(mockWidget.render).toHaveBeenCalledTimes(2);
142
+ });
143
+ } finally {
144
+ WIDGET_REGISTRY.delete(rerunId);
145
+ }
118
146
  });
119
147
 
120
- it("should hydrate view state even when listener attaches late", async () => {
121
- mockModel = new Model(
122
- { count: 8 },
123
- {
124
- sendUpdate: vi.fn().mockResolvedValue(undefined),
125
- sendCustomMessage: vi.fn().mockResolvedValue(undefined),
126
- },
127
- );
128
- MODEL_MANAGER.set(modelId, mockModel);
129
-
130
- const lateListenerWidget = {
131
- initialize: vi.fn(),
132
- render: vi.fn(({ model, el }) => {
133
- // Simulate a widget view that starts with a local default and
134
- // relies on change events for hydration.
135
- el.textContent = "count is 5";
136
- const onCount = () => {
137
- el.textContent = `count is ${model.get("count")}`;
138
- };
139
- model.on("change:count", onCount);
140
- return () => model.off("change:count", onCount);
141
- }),
142
- };
143
-
144
- const { container } = render(
145
- <LoadedSlot {...mockProps} widget={lateListenerWidget} />,
146
- );
147
-
148
+ it("shows an error banner when the widget cannot be resolved", async () => {
149
+ getModuleSpy.mockRejectedValue(new Error("import failed"));
150
+ const { container } = render(<AnyWidgetSlot {...props(modelId)} />);
148
151
  await waitFor(() => {
149
- expect(lateListenerWidget.render).toHaveBeenCalled();
150
- expect(container.textContent).toContain("count is 8");
152
+ expect(container.textContent).toContain("import failed");
151
153
  });
152
154
  });
153
- });
154
-
155
- describe("isAnyWidgetModule", () => {
156
- it("should accept a default object with render", () => {
157
- expect(isAnyWidgetModule({ default: { render: () => undefined } })).toBe(
158
- true,
159
- );
160
- });
161
155
 
162
- it("should accept a default factory function", () => {
163
- expect(
164
- isAnyWidgetModule({ default: async () => ({ render: () => {} }) }),
165
- ).toBe(true);
166
- });
167
-
168
- it("should reject legacy named render exports", () => {
169
- expect(isAnyWidgetModule({ render: () => undefined })).toBe(false);
170
- });
171
- });
172
-
173
- describe("getInvalidAnyWidgetModuleError", () => {
174
- const jsUrl = "./@file/widget.js";
175
-
176
- it("should explain legacy named render exports", () => {
177
- const error = getInvalidAnyWidgetModuleError(
178
- { render: () => undefined },
179
- jsUrl,
180
- );
181
- expect(error.message).toContain("named exports (`render`)");
182
- expect(error.message).toContain("`export default { render }`");
183
- expect(error.message).toContain("not `export function render`");
184
- });
185
-
186
- it("should explain legacy named initialize exports", () => {
187
- const error = getInvalidAnyWidgetModuleError(
188
- { initialize: () => undefined },
189
- jsUrl,
190
- );
191
- expect(error.message).toContain("named exports (`initialize`)");
192
- expect(error.message).toContain("`export default { initialize }`");
193
- expect(error.message).toContain("not `export function initialize`");
194
- });
195
-
196
- it("should avoid nested backticks for multi-hook named exports", () => {
197
- const error = getInvalidAnyWidgetModuleError(
198
- { render: () => undefined, initialize: () => undefined },
199
- jsUrl,
200
- );
201
- expect(error.message).toContain(
202
- "`export default { render, initialize }` (not `named export function ...`).",
203
- );
204
- expect(error.message).not.toContain("`named `export");
205
- });
206
-
207
- it("should explain a missing default export", () => {
208
- expect(getInvalidAnyWidgetModuleError({}, jsUrl).message).toContain(
209
- "missing a default export",
210
- );
211
- expect(getInvalidAnyWidgetModuleError(null, jsUrl).message).toContain(
212
- "missing a default export",
213
- );
214
- });
215
-
216
- it("should explain an invalid default export", () => {
217
- const error = getInvalidAnyWidgetModuleError({ default: {} }, jsUrl);
218
- expect(error.message).toContain("invalid default export");
219
- expect(error.message).toContain("https://anywidget.dev/en/afm/");
156
+ it("shows an error banner when render fails", async () => {
157
+ mockWidget.render.mockRejectedValue(new Error("widget exploded"));
158
+ const { container } = render(<AnyWidgetSlot {...props(modelId)} />);
159
+ await waitFor(() => {
160
+ expect(container.textContent).toContain("widget exploded");
161
+ });
220
162
  });
221
163
  });