@marimo-team/frontend 0.19.9-dev7 → 0.19.9-dev9

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.
@@ -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
+ });
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
 
4
- import type { AnyModel, AnyWidget, Experimental } from "@anywidget/types";
4
+ import type { AnyModel } from "@anywidget/types";
5
5
  import { debounce } from "lodash-es";
6
6
  import type { NotificationMessageData } from "@/core/kernel/messages";
7
7
  import { getRequestClient } from "@/core/network/requests";
@@ -20,62 +20,62 @@ import { Logger } from "@/utils/Logger";
20
20
  import { repl } from "@/utils/repl";
21
21
  import type { AnyWidgetMessage } from "./schemas";
22
22
  import type { EventHandler, ModelState, WidgetModelId } from "./types";
23
+ import { BINDING_MANAGER } from "./widget-binding";
24
+
25
+ interface ModelEntry {
26
+ deferred: Deferred<Model<ModelState>>;
27
+ controller: AbortController;
28
+ }
23
29
 
24
30
  class ModelManager {
25
- /**
26
- * Map of model ids to deferred promises
27
- */
28
- #models = new Map<WidgetModelId, Deferred<Model<ModelState>>>();
29
- /**
30
- * Timeout for model lookup
31
- */
31
+ #entries = new Map<WidgetModelId, ModelEntry>();
32
32
  #timeout: number;
33
33
 
34
34
  constructor(timeout = 10_000) {
35
35
  this.#timeout = timeout;
36
36
  }
37
37
 
38
- get(key: WidgetModelId): Promise<Model<any>> {
39
- let deferred = this.#models.get(key);
40
- if (deferred) {
41
- return deferred.promise;
38
+ #getOrCreateEntry(key: WidgetModelId): ModelEntry {
39
+ let entry = this.#entries.get(key);
40
+ if (!entry) {
41
+ entry = {
42
+ deferred: new Deferred<Model<ModelState>>(),
43
+ controller: new AbortController(),
44
+ };
45
+ this.#entries.set(key, entry);
42
46
  }
43
-
44
- // If the model is not yet created, create the new deferred promise without resolving it
45
- deferred = new Deferred<Model<ModelState>>();
46
- this.#models.set(key, deferred);
47
-
48
- // Add timeout to prevent hanging
49
- setTimeout(() => {
50
- // Already settled
51
- if (deferred.status !== "pending") {
52
- return;
53
- }
54
-
55
- deferred.reject(new Error(`Model not found for key: ${key}`));
56
- this.#models.delete(key);
57
- }, this.#timeout);
58
-
59
- return deferred.promise;
47
+ return entry;
60
48
  }
61
49
 
62
- set(key: WidgetModelId, model: Model<any>): void {
63
- let deferred = this.#models.get(key);
64
- if (!deferred) {
65
- deferred = new Deferred<Model<ModelState>>();
66
- this.#models.set(key, deferred);
50
+ get(key: WidgetModelId): Promise<Model<any>> {
51
+ const entry = this.#getOrCreateEntry(key);
52
+ if (entry.deferred.status === "pending") {
53
+ // Add timeout to prevent hanging
54
+ setTimeout(() => {
55
+ if (entry.deferred.status !== "pending") {
56
+ return;
57
+ }
58
+ entry.deferred.reject(new Error(`Model not found for key: ${key}`));
59
+ this.#entries.delete(key);
60
+ }, this.#timeout);
67
61
  }
68
- deferred.resolve(model);
62
+ return entry.deferred.promise;
69
63
  }
70
64
 
71
65
  /**
72
- * Check if a model exists and has been resolved (not pending).
73
- * This is useful for checking if a model was already created by the plugin
74
- * before the 'open' message arrives.
66
+ * Create a model with a managed lifecycle signal.
67
+ * The signal is aborted when the model is deleted.
75
68
  */
76
- has(key: WidgetModelId): boolean {
77
- const deferred = this.#models.get(key);
78
- return deferred !== undefined && deferred.status === "resolved";
69
+ create(
70
+ key: WidgetModelId,
71
+ factory: (signal: AbortSignal) => Model<ModelState>,
72
+ ): void {
73
+ const entry = this.#getOrCreateEntry(key);
74
+ entry.deferred.resolve(factory(entry.controller.signal));
75
+ }
76
+
77
+ set(key: WidgetModelId, model: Model<any>): void {
78
+ this.#getOrCreateEntry(key).deferred.resolve(model);
79
79
  }
80
80
 
81
81
  /**
@@ -83,15 +83,19 @@ class ModelManager {
83
83
  * Returns undefined if the model doesn't exist or is still pending.
84
84
  */
85
85
  getSync(key: WidgetModelId): Model<any> | undefined {
86
- const deferred = this.#models.get(key);
87
- if (deferred && deferred.status === "resolved") {
88
- return deferred.value;
86
+ const entry = this.#entries.get(key);
87
+ if (entry && entry.deferred.status === "resolved") {
88
+ return entry.deferred.value;
89
89
  }
90
90
  return undefined;
91
91
  }
92
92
 
93
93
  delete(key: WidgetModelId): void {
94
- this.#models.delete(key);
94
+ Logger.debug(
95
+ `[ModelManager] Deleting model=${key}, aborting lifecycle signal`,
96
+ );
97
+ this.#entries.get(key)?.controller.abort();
98
+ this.#entries.delete(key);
95
99
  }
96
100
  }
97
101
 
@@ -102,28 +106,7 @@ interface MarimoComm<T> {
102
106
 
103
107
  const marimoSymbol = Symbol("marimo");
104
108
 
105
- const experimental: Experimental = {
106
- invoke: async () => {
107
- const message =
108
- "anywidget.invoke not supported in marimo. Please file an issue at https://github.com/marimo-team/marimo/issues";
109
- Logger.warn(message);
110
- throw new Error(message);
111
- },
112
- };
113
-
114
- type RenderFn = (el: HTMLElement, signal: AbortSignal) => Promise<void>;
115
-
116
109
  interface MarimoInternalApi<T extends ModelState> {
117
- /**
118
- * Resolve the widget definition and initialize if needed.
119
- * Returns a render function that can be called for each view.
120
- *
121
- * Per AFM spec:
122
- * - widgetDef() is called once per model
123
- * - initialize() is called once per model
124
- * - render() (the returned function) is called once per view
125
- */
126
- resolveWidget: (widgetDef: AnyWidget<T>) => Promise<RenderFn>;
127
110
  /**
128
111
  * Update model state and emit change events for any differences.
129
112
  */
@@ -135,10 +118,6 @@ interface MarimoInternalApi<T extends ModelState> {
135
118
  message: Extract<AnyWidgetMessage, { method: "custom" }>,
136
119
  buffers?: readonly DataView[],
137
120
  ) => void;
138
- /**
139
- * Destroy the model, triggering initialize cleanup.
140
- */
141
- destroy: () => void;
142
121
  }
143
122
 
144
123
  /**
@@ -159,18 +138,19 @@ export class Model<T extends ModelState> implements AnyModel<T> {
159
138
  #data: T;
160
139
  #comm: MarimoComm<T>;
161
140
  #listeners: Record<string, Set<EventHandler> | undefined> = {};
162
- #controller = new AbortController();
163
- #widgetDef: AnyWidget<T> | undefined;
164
- #render:
165
- | ((el: HTMLElement, signal: AbortSignal) => Promise<void>)
166
- | undefined;
167
141
 
168
142
  static _modelManager: ModelManager = MODEL_MANAGER;
169
143
 
170
- constructor(data: T, comm: MarimoComm<T>) {
144
+ constructor(data: T, comm: MarimoComm<T>, signal?: AbortSignal) {
171
145
  this.#data = data;
172
146
  this.#comm = comm;
173
147
  this.#dirtyFields = new Map();
148
+ if (signal) {
149
+ signal.addEventListener("abort", () => {
150
+ Logger.debug("[Model] Signal aborted, clearing all listeners");
151
+ this.#listeners = {};
152
+ });
153
+ }
174
154
  }
175
155
 
176
156
  /**
@@ -183,52 +163,6 @@ export class Model<T extends ModelState> implements AnyModel<T> {
183
163
  message: Extract<AnyWidgetMessage, { method: "custom" }>,
184
164
  buffers?: readonly DataView[],
185
165
  ) => this.#emitCustomMessage(message, buffers),
186
- resolveWidget: async (widgetDef: AnyWidget<T>): Promise<RenderFn> => {
187
- // Already initialized with the same widget - return cached render
188
- if (this.#render && this.#widgetDef === widgetDef) {
189
- return this.#render;
190
- }
191
-
192
- // If widgetDef changed (hot reload), destroy old and re-initialize
193
- if (this.#render && this.#widgetDef !== widgetDef) {
194
- this.#controller.abort();
195
- this.#controller = new AbortController();
196
- this.#render = undefined;
197
- }
198
-
199
- this.#widgetDef = widgetDef;
200
-
201
- // Resolve the widget definition (call if it's a function)
202
- const widget =
203
- typeof widgetDef === "function" ? await widgetDef() : widgetDef;
204
-
205
- // Call initialize once per model
206
- const cleanup = await widget.initialize?.({ model: this, experimental });
207
- if (cleanup) {
208
- this.#controller.signal.addEventListener("abort", cleanup);
209
- }
210
-
211
- // Store and return the render closure
212
- this.#render = async (el: HTMLElement, signal: AbortSignal) => {
213
- const renderCleanup = await widget.render?.({
214
- model: this,
215
- el,
216
- experimental,
217
- });
218
- if (renderCleanup) {
219
- // Cleanup when either the view unmounts or the model is destroyed
220
- AbortSignal.any([signal, this.#controller.signal]).addEventListener(
221
- "abort",
222
- renderCleanup,
223
- );
224
- }
225
- };
226
-
227
- return this.#render;
228
- },
229
- destroy: () => {
230
- this.#controller.abort();
231
- },
232
166
  };
233
167
 
234
168
  off(eventName?: string | null, callback?: EventHandler | null): void {
@@ -387,8 +321,6 @@ export async function handleWidgetMessage(
387
321
  const modelId = notification.model_id as WidgetModelId;
388
322
  const msg = notification.message;
389
323
 
390
- Logger.debug("AnyWidget message", msg);
391
-
392
324
  // Decode base64 buffers to DataViews (present in open/update/custom messages)
393
325
  const base64Buffers: Base64String[] =
394
326
  "buffers" in msg ? (msg.buffers as Base64String[]) : [];
@@ -411,25 +343,44 @@ export async function handleWidgetMessage(
411
343
  return;
412
344
  }
413
345
 
414
- const model = new Model(stateWithBuffers, {
415
- async sendUpdate(changeData) {
416
- const { state, buffers, bufferPaths } =
417
- serializeBuffersToBase64(changeData);
418
- await getRequestClient().sendModelValue({
419
- modelId,
420
- message: { method: "update", state, bufferPaths },
421
- buffers,
422
- });
423
- },
424
- async sendCustomMessage(content, buffers) {
425
- await getRequestClient().sendModelValue({
426
- modelId,
427
- message: { method: "custom", content },
428
- buffers: buffers.map(dataViewToBase64),
429
- });
430
- },
431
- });
432
- modelManager.set(modelId, model);
346
+ modelManager.create(
347
+ modelId,
348
+ (signal) =>
349
+ new Model(
350
+ stateWithBuffers,
351
+ {
352
+ async sendUpdate(changeData) {
353
+ if (signal.aborted) {
354
+ Logger.debug(
355
+ `[Model] sendUpdate suppressed for model=${modelId} (signal aborted)`,
356
+ );
357
+ return;
358
+ }
359
+ const { state, buffers, bufferPaths } =
360
+ serializeBuffersToBase64(changeData);
361
+ await getRequestClient().sendModelValue({
362
+ modelId,
363
+ message: { method: "update", state, bufferPaths },
364
+ buffers,
365
+ });
366
+ },
367
+ async sendCustomMessage(content, buffers) {
368
+ if (signal.aborted) {
369
+ Logger.debug(
370
+ `[Model] sendCustomMessage suppressed for model=${modelId} (signal aborted)`,
371
+ );
372
+ return;
373
+ }
374
+ await getRequestClient().sendModelValue({
375
+ modelId,
376
+ message: { method: "custom", content },
377
+ buffers: buffers.map(dataViewToBase64),
378
+ });
379
+ },
380
+ },
381
+ signal,
382
+ ),
383
+ );
433
384
  return;
434
385
  }
435
386
 
@@ -444,11 +395,8 @@ export async function handleWidgetMessage(
444
395
  }
445
396
 
446
397
  case "close": {
447
- const model = modelManager.getSync(modelId);
448
- if (model) {
449
- getMarimoInternal(model).destroy();
450
- }
451
- modelManager.delete(modelId);
398
+ BINDING_MANAGER.destroy(modelId);
399
+ modelManager.delete(modelId); // aborts the model's signal, clearing listeners
452
400
  return;
453
401
  }
454
402