@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.
- package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
- package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
- package/dist/assets/{worker-DEDLIQQV.js → worker-DAWRHcPq.js} +2 -2
- package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DinOvbWu.js} +3248 -3044
- package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
- package/dist/{code-visibility-C90Am97q.js → code-visibility-EvXXzjfW.js} +1169 -960
- package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
- package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
- package/dist/{formats-d6MhLuQ9.js → formats-Dzx4J_z1.js} +1 -1
- package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
- package/dist/{html-to-image-CGp_08St.js → html-to-image-_wGfk8V-.js} +2389 -2322
- package/dist/{input-CbEz_aj_.js → input-DtsN7xm-.js} +1 -1
- package/dist/main.js +3630 -1801
- package/dist/{mermaid-CJW9vIyO.js → mermaid-BYqXy_NE.js} +2 -2
- package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
- package/dist/{process-output-R6JsYrv3.js → process-output-Mh4UrjwM.js} +1 -1
- package/dist/{reveal-component-C_u9FY4_.js → reveal-component-CKfV5wlk.js} +600 -596
- package/dist/{spec-Bv-XlYiv.js → spec-Cz-Bj1JI.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-D-l5s8nn.js → toDate-CWNNlFEX.js} +15 -7
- package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-KfHB8wQR.js} +1 -1
- package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
- package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-DegSo0lV.js} +1 -1
- package/dist/{useTheme-BrYvK-_A.js → useTheme-6eZ3GOTS.js} +31 -26
- package/dist/{vega-component-Pk6lyc_a.js → vega-component-DzyyM9fc.js} +5 -5
- package/package.json +4 -4
- package/src/__mocks__/requests.ts +1 -0
- package/src/__tests__/CellStatus.test.tsx +1 -2
- package/src/components/app-config/user-config-form.tsx +52 -0
- package/src/components/chat/acp/agent-panel.tsx +35 -1
- package/src/components/chat/chat-panel.tsx +68 -29
- package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
- package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
- package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
- package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
- package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
- package/src/components/data-table/charts/charts.tsx +23 -1
- package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
- package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
- package/src/components/debugger/debugger-code.tsx +7 -2
- package/src/components/editor/actions/useNotebookActions.tsx +2 -2
- package/src/components/editor/cell/CellStatus.tsx +1 -20
- package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
- package/src/components/editor/cell/cell-context-menu.tsx +2 -2
- package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
- package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
- package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
- package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
- package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
- package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
- package/src/components/editor/code/readonly-python-code.tsx +60 -33
- package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
- package/src/components/editor/errors/auto-fix.tsx +88 -34
- package/src/components/editor/errors/fix-mode.ts +1 -1
- package/src/components/editor/notebook-cell.tsx +7 -0
- package/src/components/editor/output/ImageOutput.tsx +20 -6
- package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
- package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
- package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
- package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
- package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
- package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
- package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
- package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
- package/src/components/slides/__tests__/slide.test.tsx +33 -0
- package/src/components/slides/minimap.tsx +7 -1
- package/src/components/slides/reveal-component.tsx +3 -0
- package/src/components/slides/slide-cell-view.tsx +10 -6
- package/src/components/slides/slide.tsx +16 -13
- package/src/components/tracing/tracing.tsx +2 -1
- package/src/core/ai/config.ts +2 -2
- package/src/core/ai/context/__tests__/registry.test.ts +133 -3
- package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
- package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
- package/src/core/ai/context/providers/datasource.ts +27 -2
- package/src/core/ai/context/providers/error.ts +226 -36
- package/src/core/ai/context/registry.ts +77 -43
- package/src/core/ai/state.ts +11 -0
- package/src/core/cells/__tests__/cell.test.ts +39 -0
- package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
- package/src/core/cells/cell.ts +5 -3
- package/src/core/cells/readonly-code-display.ts +35 -0
- package/src/core/codemirror/__tests__/setup.test.ts +33 -1
- package/src/core/codemirror/ai/resources.ts +15 -10
- package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
- package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
- package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
- package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
- package/src/core/codemirror/cells/debugger-state.ts +133 -0
- package/src/core/codemirror/cells/extensions.ts +32 -4
- package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
- package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
- package/src/core/codemirror/go-to-definition/extension.ts +3 -3
- package/src/core/codemirror/go-to-definition/underline.ts +5 -14
- package/src/core/codemirror/go-to-definition/utils.ts +49 -1
- package/src/core/codemirror/utils.ts +15 -0
- package/src/core/config/__tests__/config-schema.test.ts +17 -0
- package/src/core/config/config-schema.ts +1 -0
- package/src/core/config/config.ts +10 -0
- package/src/core/config/feature-flag.tsx +4 -0
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/errors.ts +2 -1
- package/src/core/islands/bootstrap.ts +4 -3
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
- package/src/core/lifecycle/render-policy.ts +125 -0
- package/src/core/mime.ts +11 -4
- package/src/core/network/__tests__/api.test.ts +17 -0
- package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
- package/src/core/network/api.ts +17 -6
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +8 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +10 -1
- package/src/core/network/types.ts +2 -0
- package/src/core/run-app.tsx +27 -23
- package/src/core/runtime/__tests__/adapter.test.ts +160 -0
- package/src/core/runtime/__tests__/runtime.test.ts +45 -0
- package/src/core/runtime/adapter.ts +182 -0
- package/src/core/runtime/runtime.ts +30 -16
- package/src/core/wasm/PyodideLoader.tsx +20 -62
- package/src/core/wasm/bridge.ts +13 -4
- package/src/core/wasm/state.ts +8 -19
- package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
- package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
- package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
- package/src/core/websocket/transports/sse.ts +308 -0
- package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
- package/src/core/websocket/useWebSocket.tsx +12 -2
- package/src/css/app/codemirror.css +14 -0
- package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
- package/src/hooks/useDelayElapsed.ts +27 -0
- package/src/mount.tsx +3 -3
- package/src/plugins/core/RenderHTML.tsx +35 -1
- package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
- package/src/plugins/impl/DataTablePlugin.tsx +1 -0
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
- package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
- package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
- package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
- package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
- package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
- package/src/plugins/impl/anywidget/host.ts +54 -0
- package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
- package/src/plugins/impl/anywidget/model.ts +59 -239
- package/src/plugins/impl/anywidget/registry.ts +268 -0
- package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
- package/src/plugins/impl/anywidget/runtime.ts +422 -0
- package/src/plugins/impl/anywidget/types.ts +14 -0
- package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
- package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
- package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
- package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
- package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
- package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
- package/src/utils/errors.ts +15 -0
- package/src/utils/time.ts +20 -0
- package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
- package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
- package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
- package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
- package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
- package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
- package/src/core/wasm/__tests__/state.test.ts +0 -124
- 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
|
|
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 {
|
|
16
|
-
import
|
|
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
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
51
|
+
const el = htmlRef.current;
|
|
52
|
+
if (!el) {
|
|
274
53
|
return;
|
|
275
54
|
}
|
|
276
55
|
const controller = new AbortController();
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
model,
|
|
56
|
+
setError(undefined);
|
|
57
|
+
WIDGET_REGISTRY.createView({
|
|
280
58
|
modelId,
|
|
281
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
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 {
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
64
|
+
WIDGET_REGISTRY.delete(modelId);
|
|
65
|
+
getModuleSpy.mockRestore();
|
|
56
66
|
});
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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("
|
|
64
|
-
render(<
|
|
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).
|
|
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("
|
|
73
|
-
// Regression:
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
const { container, rerender } = render(
|
|
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
|
-
|
|
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("
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
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).
|
|
128
|
+
expect(mockWidget.render).toHaveBeenCalledTimes(1);
|
|
102
129
|
});
|
|
103
130
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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("
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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("
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
});
|