@marimo-team/islands 0.19.9-dev0 → 0.19.9-dev10
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/main.js +964 -894
- package/package.json +1 -1
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +14 -12
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +6 -9
- package/src/plugins/impl/anywidget/__tests__/model.test.ts +17 -0
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +240 -0
- package/src/plugins/impl/anywidget/model.ts +96 -148
- package/src/plugins/impl/anywidget/widget-binding.ts +216 -0
package/package.json
CHANGED
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
import type { AnyWidget } from "@anywidget/types";
|
|
5
5
|
import { useEffect, useRef } from "react";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { asRemoteURL } from "@/core/runtime/config";
|
|
8
|
-
import { resolveVirtualFileURL } from "@/core/static/files";
|
|
9
|
-
import { isStaticNotebook } from "@/core/static/static-state";
|
|
10
7
|
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
11
8
|
import type { HTMLElementNotDerivedFromRef } from "@/hooks/useEventListener";
|
|
12
9
|
import { createPlugin } from "@/plugins/core/builder";
|
|
@@ -14,8 +11,9 @@ import type { IPluginProps } from "@/plugins/types";
|
|
|
14
11
|
import { prettyError } from "@/utils/errors";
|
|
15
12
|
import { Logger } from "@/utils/Logger";
|
|
16
13
|
import { ErrorBanner } from "../common/error-banner";
|
|
17
|
-
import {
|
|
14
|
+
import { MODEL_MANAGER, type Model } from "./model";
|
|
18
15
|
import type { ModelState, WidgetModelId } from "./types";
|
|
16
|
+
import { BINDING_MANAGER, WIDGET_DEF_REGISTRY } from "./widget-binding";
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
19
|
* AnyWidget asset data
|
|
@@ -48,12 +46,7 @@ export function useAnyWidgetModule(opts: { jsUrl: string; jsHash: string }) {
|
|
|
48
46
|
error,
|
|
49
47
|
refetch,
|
|
50
48
|
} = useAsyncData(async () => {
|
|
51
|
-
|
|
52
|
-
// In static notebooks, resolve virtual files to blob URLs for import()
|
|
53
|
-
if (isStaticNotebook()) {
|
|
54
|
-
url = resolveVirtualFileURL(url);
|
|
55
|
-
}
|
|
56
|
-
return await import(/* @vite-ignore */ url);
|
|
49
|
+
return await WIDGET_DEF_REGISTRY.getModule(jsUrl, jsHash);
|
|
57
50
|
// Re-render on jsHash change (which is a hash of the contents of the file)
|
|
58
51
|
// instead of a jsUrl change because URLs may change without the contents
|
|
59
52
|
// actually changing (and we don't want to re-render on every change).
|
|
@@ -66,6 +59,7 @@ export function useAnyWidgetModule(opts: { jsUrl: string; jsHash: string }) {
|
|
|
66
59
|
const hasError = Boolean(error);
|
|
67
60
|
useEffect(() => {
|
|
68
61
|
if (hasError && jsUrl) {
|
|
62
|
+
WIDGET_DEF_REGISTRY.invalidate(jsHash);
|
|
69
63
|
refetch();
|
|
70
64
|
}
|
|
71
65
|
}, [hasError, jsUrl]);
|
|
@@ -180,6 +174,7 @@ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
180
174
|
async function runAnyWidgetModule<T extends AnyWidgetState>(
|
|
181
175
|
widgetDef: AnyWidget<T>,
|
|
182
176
|
model: Model<T>,
|
|
177
|
+
modelId: WidgetModelId,
|
|
183
178
|
el: HTMLElement,
|
|
184
179
|
signal: AbortSignal,
|
|
185
180
|
): Promise<void> {
|
|
@@ -187,7 +182,8 @@ async function runAnyWidgetModule<T extends AnyWidgetState>(
|
|
|
187
182
|
el.innerHTML = "";
|
|
188
183
|
|
|
189
184
|
try {
|
|
190
|
-
const
|
|
185
|
+
const binding = BINDING_MANAGER.getOrCreate(modelId);
|
|
186
|
+
const render = await binding.bind(widgetDef, model);
|
|
191
187
|
await render(el, signal);
|
|
192
188
|
} catch (error) {
|
|
193
189
|
Logger.error("Error rendering anywidget", error);
|
|
@@ -231,7 +227,13 @@ const LoadedSlot = <T extends AnyWidgetState>({
|
|
|
231
227
|
return;
|
|
232
228
|
}
|
|
233
229
|
const controller = new AbortController();
|
|
234
|
-
runAnyWidgetModule(
|
|
230
|
+
runAnyWidgetModule(
|
|
231
|
+
widget,
|
|
232
|
+
model,
|
|
233
|
+
modelId,
|
|
234
|
+
htmlRef.current,
|
|
235
|
+
controller.signal,
|
|
236
|
+
);
|
|
235
237
|
return () => controller.abort();
|
|
236
238
|
// We re-run the widget when the modelId changes, which means the cell
|
|
237
239
|
// that created the Widget has been re-run.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { render, waitFor } from "@testing-library/react";
|
|
4
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { TestUtils } from "@/__tests__/test-helpers";
|
|
6
6
|
import type { HTMLElementNotDerivedFromRef } from "@/hooks/useEventListener";
|
|
7
7
|
import { visibleForTesting } from "../AnyWidgetPlugin";
|
|
8
8
|
import { MODEL_MANAGER, Model } from "../model";
|
|
9
9
|
import type { WidgetModelId } from "../types";
|
|
10
|
+
import { BINDING_MANAGER } from "../widget-binding";
|
|
10
11
|
|
|
11
12
|
const { LoadedSlot } = visibleForTesting;
|
|
12
13
|
|
|
@@ -19,14 +20,6 @@ const mockWidget = {
|
|
|
19
20
|
render: vi.fn(),
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
vi.mock("../AnyWidgetPlugin", async () => {
|
|
23
|
-
const originalModule = await vi.importActual("../AnyWidgetPlugin");
|
|
24
|
-
return {
|
|
25
|
-
...originalModule,
|
|
26
|
-
runAnyWidgetModule: vi.fn(),
|
|
27
|
-
};
|
|
28
|
-
});
|
|
29
|
-
|
|
30
23
|
describe("LoadedSlot", () => {
|
|
31
24
|
const modelId = asModelId("test-model-id");
|
|
32
25
|
let mockModel: Model<{ count: number }>;
|
|
@@ -56,6 +49,10 @@ describe("LoadedSlot", () => {
|
|
|
56
49
|
MODEL_MANAGER.set(modelId, mockModel);
|
|
57
50
|
});
|
|
58
51
|
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
BINDING_MANAGER.destroy(modelId);
|
|
54
|
+
});
|
|
55
|
+
|
|
59
56
|
it("should render a div with ref", () => {
|
|
60
57
|
const { container } = render(<LoadedSlot {...mockProps} />);
|
|
61
58
|
expect(container.querySelector("div")).not.toBeNull();
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
visibleForTesting,
|
|
17
17
|
} from "../model";
|
|
18
18
|
import type { WidgetModelId } from "../types";
|
|
19
|
+
import { BINDING_MANAGER } from "../widget-binding";
|
|
19
20
|
|
|
20
21
|
const { ModelManager } = visibleForTesting;
|
|
21
22
|
|
|
@@ -376,4 +377,20 @@ describe("ModelManager", () => {
|
|
|
376
377
|
});
|
|
377
378
|
await expect(modelManager.get(testId)).rejects.toThrow();
|
|
378
379
|
});
|
|
380
|
+
|
|
381
|
+
it("should destroy binding on close message", async () => {
|
|
382
|
+
const model = new Model({ count: 0 }, createMockComm());
|
|
383
|
+
modelManager.set(testId, model);
|
|
384
|
+
|
|
385
|
+
// Create a binding for this model
|
|
386
|
+
BINDING_MANAGER.getOrCreate(testId);
|
|
387
|
+
expect(BINDING_MANAGER.has(testId)).toBe(true);
|
|
388
|
+
|
|
389
|
+
await handleWidgetMessage(modelManager, {
|
|
390
|
+
model_id: testId,
|
|
391
|
+
message: { method: "close" },
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(BINDING_MANAGER.has(testId)).toBe(false);
|
|
395
|
+
});
|
|
379
396
|
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Model } from "../model";
|
|
4
|
+
import type { ModelState, WidgetModelId } from "../types";
|
|
5
|
+
import { visibleForTesting } from "../widget-binding";
|
|
6
|
+
|
|
7
|
+
const { WidgetDefRegistry, WidgetBinding, BindingManager } = visibleForTesting;
|
|
8
|
+
|
|
9
|
+
// Helper to create typed model IDs for tests
|
|
10
|
+
const asModelId = (id: string): WidgetModelId => id as WidgetModelId;
|
|
11
|
+
|
|
12
|
+
function createMockComm() {
|
|
13
|
+
return {
|
|
14
|
+
sendUpdate: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
sendCustomMessage: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("WidgetDefRegistry", () => {
|
|
20
|
+
let registry: InstanceType<typeof WidgetDefRegistry>;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
registry = new WidgetDefRegistry();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should cache modules by jsHash and return same promise", () => {
|
|
27
|
+
// Two calls with same hash should return the exact same promise object
|
|
28
|
+
const promise1 = registry.getModule("http://localhost/widget.js", "hash1");
|
|
29
|
+
const promise2 = registry.getModule("http://localhost/widget.js", "hash1");
|
|
30
|
+
expect(promise1).toBe(promise2);
|
|
31
|
+
// Catch the unhandled rejection from the import() attempt
|
|
32
|
+
promise1.catch(() => undefined);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should deduplicate concurrent imports for the same hash", () => {
|
|
36
|
+
const promise1 = registry.getModule("http://localhost/a.js", "same-hash");
|
|
37
|
+
const promise2 = registry.getModule("http://localhost/b.js", "same-hash");
|
|
38
|
+
// Same hash means same promise, even with different URLs
|
|
39
|
+
expect(promise1).toBe(promise2);
|
|
40
|
+
promise1.catch(() => undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should create different promises for different hashes", () => {
|
|
44
|
+
const promise1 = registry.getModule("http://localhost/a.js", "hash-a");
|
|
45
|
+
const promise2 = registry.getModule("http://localhost/b.js", "hash-b");
|
|
46
|
+
expect(promise1).not.toBe(promise2);
|
|
47
|
+
promise1.catch(() => undefined);
|
|
48
|
+
promise2.catch(() => undefined);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should invalidate cached modules", () => {
|
|
52
|
+
const promise1 = registry.getModule("http://localhost/a.js", "hash1");
|
|
53
|
+
promise1.catch(() => undefined);
|
|
54
|
+
registry.invalidate("hash1");
|
|
55
|
+
const promise2 = registry.getModule("http://localhost/a.js", "hash1");
|
|
56
|
+
promise2.catch(() => undefined);
|
|
57
|
+
expect(promise1).not.toBe(promise2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should remove from cache on import failure so retry creates new promise", async () => {
|
|
61
|
+
const promise1 = registry.getModule("http://localhost/a.js", "fail-hash");
|
|
62
|
+
// The import will fail in Node (http: scheme not supported)
|
|
63
|
+
await expect(promise1).rejects.toThrow();
|
|
64
|
+
// After failure, cache should be cleared, so next call creates a new promise
|
|
65
|
+
const promise2 = registry.getModule("http://localhost/a.js", "fail-hash");
|
|
66
|
+
expect(promise1).not.toBe(promise2);
|
|
67
|
+
promise2.catch(() => undefined);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("WidgetBinding", () => {
|
|
72
|
+
let binding: InstanceType<typeof WidgetBinding>;
|
|
73
|
+
let model: Model<ModelState>;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
binding = new WidgetBinding();
|
|
77
|
+
model = new Model<ModelState>({ count: 0 }, createMockComm());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should initialize once and return a render function", async () => {
|
|
81
|
+
const initCleanup = vi.fn();
|
|
82
|
+
const renderCleanup = vi.fn();
|
|
83
|
+
const widgetDef = {
|
|
84
|
+
initialize: vi.fn().mockResolvedValue(initCleanup),
|
|
85
|
+
render: vi.fn().mockResolvedValue(renderCleanup),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const renderFn = await binding.bind(widgetDef, model);
|
|
89
|
+
expect(widgetDef.initialize).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(typeof renderFn).toBe("function");
|
|
91
|
+
|
|
92
|
+
// Render into an element
|
|
93
|
+
const el = document.createElement("div");
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
await renderFn(el, controller.signal);
|
|
96
|
+
expect(widgetDef.render).toHaveBeenCalledTimes(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should return cached render for same widget def", async () => {
|
|
100
|
+
const widgetDef = {
|
|
101
|
+
initialize: vi.fn(),
|
|
102
|
+
render: vi.fn(),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const render1 = await binding.bind(widgetDef, model);
|
|
106
|
+
const render2 = await binding.bind(widgetDef, model);
|
|
107
|
+
expect(render1).toBe(render2);
|
|
108
|
+
// Initialize should only be called once
|
|
109
|
+
expect(widgetDef.initialize).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should re-initialize on hot reload (different widget def)", async () => {
|
|
113
|
+
const cleanup1 = vi.fn();
|
|
114
|
+
const widgetDef1 = {
|
|
115
|
+
initialize: vi.fn().mockResolvedValue(cleanup1),
|
|
116
|
+
render: vi.fn(),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const widgetDef2 = {
|
|
120
|
+
initialize: vi.fn(),
|
|
121
|
+
render: vi.fn(),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const render1 = await binding.bind(widgetDef1, model);
|
|
125
|
+
const render2 = await binding.bind(widgetDef2, model);
|
|
126
|
+
|
|
127
|
+
expect(render1).not.toBe(render2);
|
|
128
|
+
expect(cleanup1).toHaveBeenCalledTimes(1); // Old binding cleaned up
|
|
129
|
+
expect(widgetDef2.initialize).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should cleanup render on view signal abort", async () => {
|
|
133
|
+
const renderCleanup = vi.fn();
|
|
134
|
+
const widgetDef = {
|
|
135
|
+
initialize: vi.fn(),
|
|
136
|
+
render: vi.fn().mockResolvedValue(renderCleanup),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const renderFn = await binding.bind(widgetDef, model);
|
|
140
|
+
const el = document.createElement("div");
|
|
141
|
+
const viewController = new AbortController();
|
|
142
|
+
await renderFn(el, viewController.signal);
|
|
143
|
+
|
|
144
|
+
// Aborting the view signal should trigger render cleanup
|
|
145
|
+
viewController.abort();
|
|
146
|
+
expect(renderCleanup).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should cleanup everything on destroy", async () => {
|
|
150
|
+
const initCleanup = vi.fn();
|
|
151
|
+
const renderCleanup = vi.fn();
|
|
152
|
+
const widgetDef = {
|
|
153
|
+
initialize: vi.fn().mockResolvedValue(initCleanup),
|
|
154
|
+
render: vi.fn().mockResolvedValue(renderCleanup),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const renderFn = await binding.bind(widgetDef, model);
|
|
158
|
+
const el = document.createElement("div");
|
|
159
|
+
const viewController = new AbortController();
|
|
160
|
+
await renderFn(el, viewController.signal);
|
|
161
|
+
|
|
162
|
+
binding.destroy();
|
|
163
|
+
expect(initCleanup).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(renderCleanup).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should handle widget def as a function", async () => {
|
|
168
|
+
const widget = {
|
|
169
|
+
initialize: vi.fn(),
|
|
170
|
+
render: vi.fn(),
|
|
171
|
+
};
|
|
172
|
+
const widgetDefFn = vi.fn().mockResolvedValue(widget);
|
|
173
|
+
|
|
174
|
+
await binding.bind(widgetDefFn, model);
|
|
175
|
+
expect(widgetDefFn).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(widget.initialize).toHaveBeenCalledTimes(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should handle widget with no initialize or render", async () => {
|
|
180
|
+
const widgetDef = {};
|
|
181
|
+
const renderFn = await binding.bind(widgetDef, model);
|
|
182
|
+
expect(typeof renderFn).toBe("function");
|
|
183
|
+
|
|
184
|
+
// Render should not throw
|
|
185
|
+
const el = document.createElement("div");
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
await renderFn(el, controller.signal);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("BindingManager", () => {
|
|
192
|
+
let manager: InstanceType<typeof BindingManager>;
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
manager = new BindingManager();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should create bindings on demand", () => {
|
|
199
|
+
const modelId = asModelId("model-1");
|
|
200
|
+
expect(manager.has(modelId)).toBe(false);
|
|
201
|
+
|
|
202
|
+
const binding = manager.getOrCreate(modelId);
|
|
203
|
+
expect(binding).toBeDefined();
|
|
204
|
+
expect(manager.has(modelId)).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should return the same binding for the same model id", () => {
|
|
208
|
+
const modelId = asModelId("model-1");
|
|
209
|
+
const binding1 = manager.getOrCreate(modelId);
|
|
210
|
+
const binding2 = manager.getOrCreate(modelId);
|
|
211
|
+
expect(binding1).toBe(binding2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should destroy and remove bindings", async () => {
|
|
215
|
+
const modelId = asModelId("model-1");
|
|
216
|
+
const binding = manager.getOrCreate(modelId);
|
|
217
|
+
|
|
218
|
+
const model = new Model<ModelState>({ count: 0 }, createMockComm());
|
|
219
|
+
const initCleanup = vi.fn();
|
|
220
|
+
const widgetDef = {
|
|
221
|
+
initialize: vi.fn().mockResolvedValue(initCleanup),
|
|
222
|
+
render: vi.fn(),
|
|
223
|
+
};
|
|
224
|
+
await binding.bind(widgetDef, model);
|
|
225
|
+
|
|
226
|
+
manager.destroy(modelId);
|
|
227
|
+
expect(manager.has(modelId)).toBe(false);
|
|
228
|
+
expect(initCleanup).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should handle idempotent destroy", () => {
|
|
232
|
+
const modelId = asModelId("model-1");
|
|
233
|
+
manager.getOrCreate(modelId);
|
|
234
|
+
|
|
235
|
+
// Should not throw
|
|
236
|
+
manager.destroy(modelId);
|
|
237
|
+
manager.destroy(modelId);
|
|
238
|
+
expect(manager.has(modelId)).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|