@marimo-team/islands 0.23.14-dev58 → 0.23.14-dev59
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/{code-visibility-Con-26vn.js → code-visibility-ChrxMIRR.js} +28 -28
- package/dist/main.js +1920 -1619
- package/dist/{reveal-component-CfKy-mTN.js → reveal-component-BLijd8xt.js} +1 -1
- package/package.json +2 -2
- package/src/core/islands/bootstrap.ts +3 -3
- package/src/core/websocket/useMarimoKernelConnection.tsx +3 -3
- package/src/mount.tsx +3 -3
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -305
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +121 -229
- 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 +408 -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/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/plugins/impl/anywidget/schemas.ts +0 -32
|
@@ -1,271 +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
|
-
LoadedSlot,
|
|
14
|
-
isAnyWidgetModule,
|
|
15
|
-
resolveAnyWidget,
|
|
16
|
-
getInvalidAnyWidgetModuleError,
|
|
17
|
-
} = visibleForTesting;
|
|
20
|
+
const { AnyWidgetSlot } = visibleForTesting;
|
|
18
21
|
|
|
19
22
|
// Helper to create typed model IDs for tests
|
|
20
23
|
const asModelId = (id: string): WidgetModelId => id as WidgetModelId;
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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>;
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
beforeEach(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
});
|
|
56
61
|
});
|
|
57
62
|
|
|
58
63
|
afterEach(() => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
WIDGET_REGISTRY.delete(modelId);
|
|
65
|
+
getModuleSpy.mockRestore();
|
|
66
|
+
});
|
|
67
|
+
|
|
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)} />);
|
|
71
85
|
await waitFor(() => {
|
|
72
|
-
expect(mockWidget.
|
|
86
|
+
expect(mockWidget.initialize).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(mockWidget.render).toHaveBeenCalledTimes(1);
|
|
73
88
|
});
|
|
74
89
|
});
|
|
75
90
|
|
|
76
|
-
it("
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
it("aborts the runtime-owned view on unmount", async () => {
|
|
92
|
+
const { unmount } = render(<AnyWidgetSlot {...props(modelId)} />);
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(mockWidget.render).toHaveBeenCalledTimes(1);
|
|
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);
|
|
100
|
+
});
|
|
101
|
+
|
|
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
|
+
);
|
|
82
109
|
await waitFor(() => {
|
|
83
110
|
expect(mockWidget.render).toHaveBeenCalledTimes(1);
|
|
84
111
|
});
|
|
85
|
-
|
|
86
112
|
const divBefore = container.querySelector("div");
|
|
87
113
|
|
|
88
|
-
|
|
89
|
-
// (this is what happens when the widget sends trait state)
|
|
90
|
-
rerender(<LoadedSlot {...mockProps} />);
|
|
114
|
+
rerender(<AnyWidgetSlot {...props(modelId, { zoom_level: 0 })} />);
|
|
91
115
|
|
|
92
116
|
await waitFor(() => {
|
|
93
|
-
// The div should be the same DOM node (no remount)
|
|
94
117
|
expect(container.querySelector("div")).toBe(divBefore);
|
|
95
|
-
// render should not be called again (no remount)
|
|
96
118
|
expect(mockWidget.render).toHaveBeenCalledTimes(1);
|
|
97
119
|
});
|
|
98
120
|
});
|
|
99
121
|
|
|
100
|
-
it("
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
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)} />);
|
|
104
127
|
await waitFor(() => {
|
|
105
|
-
expect(mockWidget.render).
|
|
128
|
+
expect(mockWidget.render).toHaveBeenCalledTimes(1);
|
|
106
129
|
});
|
|
107
130
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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`,
|
|
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
|
+
}
|
|
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)} />);
|
|
119
151
|
await waitFor(() => {
|
|
120
|
-
expect(
|
|
152
|
+
expect(container.textContent).toContain("import failed");
|
|
121
153
|
});
|
|
122
154
|
});
|
|
123
155
|
|
|
124
|
-
it("
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
{
|
|
128
|
-
sendUpdate: vi.fn().mockResolvedValue(undefined),
|
|
129
|
-
sendCustomMessage: vi.fn().mockResolvedValue(undefined),
|
|
130
|
-
},
|
|
131
|
-
);
|
|
132
|
-
MODEL_MANAGER.set(modelId, mockModel);
|
|
133
|
-
|
|
134
|
-
const lateListenerWidget = {
|
|
135
|
-
initialize: vi.fn(),
|
|
136
|
-
render: vi.fn(({ model, el }) => {
|
|
137
|
-
// Simulate a widget view that starts with a local default and
|
|
138
|
-
// relies on change events for hydration.
|
|
139
|
-
el.textContent = "count is 5";
|
|
140
|
-
const onCount = () => {
|
|
141
|
-
el.textContent = `count is ${model.get("count")}`;
|
|
142
|
-
};
|
|
143
|
-
model.on("change:count", onCount);
|
|
144
|
-
return () => model.off("change:count", onCount);
|
|
145
|
-
}),
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const { container } = render(
|
|
149
|
-
<LoadedSlot {...mockProps} widget={lateListenerWidget} />,
|
|
150
|
-
);
|
|
151
|
-
|
|
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)} />);
|
|
152
159
|
await waitFor(() => {
|
|
153
|
-
expect(
|
|
154
|
-
expect(container.textContent).toContain("count is 8");
|
|
160
|
+
expect(container.textContent).toContain("widget exploded");
|
|
155
161
|
});
|
|
156
162
|
});
|
|
157
163
|
});
|
|
158
|
-
|
|
159
|
-
describe("isAnyWidgetModule", () => {
|
|
160
|
-
it("should accept a default object with render", () => {
|
|
161
|
-
expect(isAnyWidgetModule({ default: { render: () => undefined } })).toBe(
|
|
162
|
-
true,
|
|
163
|
-
);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("should accept a default factory function", () => {
|
|
167
|
-
expect(
|
|
168
|
-
isAnyWidgetModule({ default: async () => ({ render: () => {} }) }),
|
|
169
|
-
).toBe(true);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("should reject legacy named render exports", () => {
|
|
173
|
-
expect(isAnyWidgetModule({ render: () => undefined })).toBe(false);
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
describe("resolveAnyWidget", () => {
|
|
178
|
-
const jsUrl = "./@file/widget.js";
|
|
179
|
-
|
|
180
|
-
it("should return the default export when present", () => {
|
|
181
|
-
const widget = { render: () => undefined };
|
|
182
|
-
expect(resolveAnyWidget({ default: widget }, jsUrl)).toBe(widget);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("should return a default factory function", () => {
|
|
186
|
-
const factory = async () => ({ render: () => {} });
|
|
187
|
-
expect(resolveAnyWidget({ default: factory }, jsUrl)).toBe(factory);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("should synthesize a widget from a legacy named render export", () => {
|
|
191
|
-
const render = vi.fn();
|
|
192
|
-
const resolved = resolveAnyWidget({ render }, jsUrl);
|
|
193
|
-
expect(resolved).not.toBeNull();
|
|
194
|
-
expect(resolved).toMatchObject({ render });
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("should synthesize a widget from a legacy named initialize export", () => {
|
|
198
|
-
const initialize = vi.fn();
|
|
199
|
-
const resolved = resolveAnyWidget({ initialize }, jsUrl);
|
|
200
|
-
expect(resolved).not.toBeNull();
|
|
201
|
-
expect(resolved).toMatchObject({ initialize });
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("should return null for a module with no valid exports", () => {
|
|
205
|
-
expect(resolveAnyWidget({}, jsUrl)).toBeNull();
|
|
206
|
-
expect(resolveAnyWidget({ default: {} }, jsUrl)).toBeNull();
|
|
207
|
-
expect(resolveAnyWidget({ render: "not a function" }, jsUrl)).toBeNull();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("should return a stable widget identity across calls for one module", () => {
|
|
211
|
-
const mod = { render: vi.fn() };
|
|
212
|
-
expect(resolveAnyWidget(mod, jsUrl)).toBe(resolveAnyWidget(mod, jsUrl));
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("should not fall back to named exports when a default export is present", () => {
|
|
216
|
-
// A present-but-invalid default should surface an error, not be masked.
|
|
217
|
-
expect(
|
|
218
|
-
resolveAnyWidget({ default: {}, render: vi.fn() }, jsUrl),
|
|
219
|
-
).toBeNull();
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
describe("getInvalidAnyWidgetModuleError", () => {
|
|
224
|
-
const jsUrl = "./@file/widget.js";
|
|
225
|
-
|
|
226
|
-
it("should explain legacy named render exports", () => {
|
|
227
|
-
const error = getInvalidAnyWidgetModuleError(
|
|
228
|
-
{ render: () => undefined },
|
|
229
|
-
jsUrl,
|
|
230
|
-
);
|
|
231
|
-
expect(error.message).toContain("named exports (`render`)");
|
|
232
|
-
expect(error.message).toContain("`export default { render }`");
|
|
233
|
-
expect(error.message).toContain("not `export function render`");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("should explain legacy named initialize exports", () => {
|
|
237
|
-
const error = getInvalidAnyWidgetModuleError(
|
|
238
|
-
{ initialize: () => undefined },
|
|
239
|
-
jsUrl,
|
|
240
|
-
);
|
|
241
|
-
expect(error.message).toContain("named exports (`initialize`)");
|
|
242
|
-
expect(error.message).toContain("`export default { initialize }`");
|
|
243
|
-
expect(error.message).toContain("not `export function initialize`");
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("should avoid nested backticks for multi-hook named exports", () => {
|
|
247
|
-
const error = getInvalidAnyWidgetModuleError(
|
|
248
|
-
{ render: () => undefined, initialize: () => undefined },
|
|
249
|
-
jsUrl,
|
|
250
|
-
);
|
|
251
|
-
expect(error.message).toContain(
|
|
252
|
-
"`export default { render, initialize }` (not `named export function ...`).",
|
|
253
|
-
);
|
|
254
|
-
expect(error.message).not.toContain("`named `export");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it("should explain a missing default export", () => {
|
|
258
|
-
expect(getInvalidAnyWidgetModuleError({}, jsUrl).message).toContain(
|
|
259
|
-
"missing a default export",
|
|
260
|
-
);
|
|
261
|
-
expect(getInvalidAnyWidgetModuleError(null, jsUrl).message).toContain(
|
|
262
|
-
"missing a default export",
|
|
263
|
-
);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("should explain an invalid default export", () => {
|
|
267
|
-
const error = getInvalidAnyWidgetModuleError({ default: {} }, jsUrl);
|
|
268
|
-
expect(error.message).toContain("invalid default export");
|
|
269
|
-
expect(error.message).toContain("https://anywidget.dev/en/afm/");
|
|
270
|
-
});
|
|
271
|
-
});
|