@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.19.9-dev0",
3
+ "version": "0.19.9-dev10",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -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 { getMarimoInternal, MODEL_MANAGER, type Model } from "./model";
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
- let url = asRemoteURL(jsUrl).toString();
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 render = await getMarimoInternal(model).resolveWidget(widgetDef);
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(widget, model, htmlRef.current, controller.signal);
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
+ });