@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.
Files changed (29) hide show
  1. package/dist/{code-visibility-Con-26vn.js → code-visibility-ChrxMIRR.js} +28 -28
  2. package/dist/main.js +1920 -1619
  3. package/dist/{reveal-component-CfKy-mTN.js → reveal-component-BLijd8xt.js} +1 -1
  4. package/package.json +2 -2
  5. package/src/core/islands/bootstrap.ts +3 -3
  6. package/src/core/websocket/useMarimoKernelConnection.tsx +3 -3
  7. package/src/mount.tsx +3 -3
  8. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -305
  9. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +121 -229
  10. package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
  11. package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
  12. package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
  13. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
  14. package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
  15. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
  16. package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
  17. package/src/plugins/impl/anywidget/host.ts +54 -0
  18. package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
  19. package/src/plugins/impl/anywidget/model.ts +59 -239
  20. package/src/plugins/impl/anywidget/registry.ts +268 -0
  21. package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
  22. package/src/plugins/impl/anywidget/runtime.ts +408 -0
  23. package/src/plugins/impl/anywidget/types.ts +14 -0
  24. package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
  25. package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
  26. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
  27. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
  28. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
  29. 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 { 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 {
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
- // Mock a minimal AnyWidget implementation
23
- const mockWidget = {
24
- initialize: vi.fn(),
25
- render: vi.fn(),
26
- };
27
-
28
- describe("LoadedSlot", () => {
29
- const modelId = asModelId("test-model-id");
30
- let mockModel: Model<{ count: number }>;
31
-
32
- const mockProps = {
33
- widget: mockWidget,
34
- data: {
35
- jsUrl: "http://example.com/widget.js",
36
- jsHash: "abc123",
37
- modelId: modelId,
38
- },
39
- host: document.createElement(
40
- "div",
41
- ) as unknown as HTMLElementNotDerivedFromRef,
42
- 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>;
43
46
  };
44
47
 
45
48
  beforeEach(() => {
46
- vi.clearAllMocks();
47
- // Create and register a mock model before each test
48
- mockModel = new Model(
49
- { count: 0 },
50
- {
51
- sendUpdate: vi.fn().mockResolvedValue(undefined),
52
- sendCustomMessage: vi.fn().mockResolvedValue(undefined),
53
- },
54
- );
55
- 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
+ });
56
61
  });
57
62
 
58
63
  afterEach(() => {
59
- BINDING_MANAGER.destroy(modelId);
60
- });
61
-
62
- it("should render a div with ref", () => {
63
- const { container } = render(<LoadedSlot {...mockProps} />);
64
- expect(container.querySelector("div")).not.toBeNull();
65
- });
66
-
67
- it("should call runAnyWidgetModule on initialization", async () => {
68
- render(<LoadedSlot {...mockProps} />);
69
-
70
- // Wait a render
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.render).toHaveBeenCalled();
86
+ expect(mockWidget.initialize).toHaveBeenCalledTimes(1);
87
+ expect(mockWidget.render).toHaveBeenCalledTimes(1);
73
88
  });
74
89
  });
75
90
 
76
- it("should not remount when value update drops model_id", async () => {
77
- // Regression: when the frontend sends a state update (e.g. {zoom_level: 0}),
78
- // it overwrites the UIElement value that originally held {model_id: "..."}.
79
- // The key must stay stable because modelId comes from data, not value.
80
- const { container, rerender } = render(<LoadedSlot {...mockProps} />);
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
- // Simulate a value update that does NOT include model_id
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("should re-run widget when widget prop changes", async () => {
101
- const { rerender } = render(<LoadedSlot {...mockProps} />);
102
-
103
- // 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)} />);
104
127
  await waitFor(() => {
105
- expect(mockWidget.render).toHaveBeenCalled();
128
+ expect(mockWidget.render).toHaveBeenCalledTimes(1);
106
129
  });
107
130
 
108
- // Create a new widget mock
109
- const newMockWidget = {
110
- initialize: vi.fn(),
111
- render: vi.fn(),
112
- };
113
-
114
- // Change the widget
115
- rerender(<LoadedSlot {...mockProps} widget={newMockWidget} />);
116
- await TestUtils.nextTick();
117
-
118
- // Wait for re-render with new widget
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(newMockWidget.render).toHaveBeenCalled();
152
+ expect(container.textContent).toContain("import failed");
121
153
  });
122
154
  });
123
155
 
124
- it("should hydrate view state even when listener attaches late", async () => {
125
- mockModel = new Model(
126
- { count: 8 },
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(lateListenerWidget.render).toHaveBeenCalled();
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
- });