@marimo-team/islands 0.19.9-dev1 → 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.
@@ -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
 
@@ -0,0 +1,216 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+
4
+ import type { AnyWidget, Experimental } from "@anywidget/types";
5
+ import { asRemoteURL } from "@/core/runtime/config";
6
+ import { resolveVirtualFileURL } from "@/core/static/files";
7
+ import { isStaticNotebook } from "@/core/static/static-state";
8
+ import { Logger } from "@/utils/Logger";
9
+ import type { Model } from "./model";
10
+ import type { ModelState, WidgetModelId } from "./types";
11
+
12
+ export const experimental: Experimental = {
13
+ invoke: async () => {
14
+ const message =
15
+ "anywidget.invoke not supported in marimo. Please file an issue at https://github.com/marimo-team/marimo/issues";
16
+ Logger.warn(message);
17
+ throw new Error(message);
18
+ },
19
+ };
20
+
21
+ export type RenderFn = (el: HTMLElement, signal: AbortSignal) => Promise<void>;
22
+
23
+ /**
24
+ * Polyfill for AbortSignal.any. Returns a signal that aborts when any of the
25
+ * input signals abort. This can be removed once the Node.js test environment
26
+ * (jsdom) supports AbortSignal.any natively.
27
+ */
28
+ function abortSignalAny(signals: AbortSignal[]): AbortSignal {
29
+ if (typeof AbortSignal.any === "function") {
30
+ return AbortSignal.any(signals);
31
+ }
32
+ const controller = new AbortController();
33
+ for (const signal of signals) {
34
+ if (signal.aborted) {
35
+ controller.abort(signal.reason);
36
+ return controller.signal;
37
+ }
38
+ signal.addEventListener("abort", () => controller.abort(signal.reason), {
39
+ once: true,
40
+ });
41
+ }
42
+ return controller.signal;
43
+ }
44
+
45
+ /**
46
+ * Deduplicates ESM imports by jsHash.
47
+ * A single import is shared across all widget instances using the same module.
48
+ */
49
+ class WidgetDefRegistry {
50
+ #cache = new Map<string, Promise<any>>();
51
+
52
+ /**
53
+ * Get (or start) the ESM import for a widget module.
54
+ * Cached by jsHash so multiple instances share one import.
55
+ */
56
+ getModule(jsUrl: string, jsHash: string): Promise<any> {
57
+ const cached = this.#cache.get(jsHash);
58
+ if (cached) {
59
+ return cached;
60
+ }
61
+
62
+ const promise = this.#doImport(jsUrl).catch((error) => {
63
+ // On failure, remove from cache so a retry with a new URL can work
64
+ this.#cache.delete(jsHash);
65
+ throw error;
66
+ });
67
+
68
+ this.#cache.set(jsHash, promise);
69
+ return promise;
70
+ }
71
+
72
+ /**
73
+ * Invalidate a cached module (e.g. for hot-reload support).
74
+ */
75
+ invalidate(jsHash: string): void {
76
+ Logger.debug(
77
+ `[WidgetDefRegistry] Invalidating module cache for hash=${jsHash}`,
78
+ );
79
+ this.#cache.delete(jsHash);
80
+ }
81
+
82
+ async #doImport(jsUrl: string): Promise<any> {
83
+ let url = asRemoteURL(jsUrl).toString();
84
+ if (isStaticNotebook()) {
85
+ url = resolveVirtualFileURL(url);
86
+ }
87
+ return import(/* @vite-ignore */ url);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Connects a Model to a resolved AnyWidget definition.
93
+ * Owns the initialize lifecycle and produces a render function.
94
+ *
95
+ * Per AFM spec:
96
+ * - initialize() is called once per model (or once per hot-reload)
97
+ * - render() (the returned function) is called once per view
98
+ */
99
+ class WidgetBinding<T extends ModelState = ModelState> {
100
+ #controller: AbortController | undefined;
101
+ #widgetDef: AnyWidget<T> | undefined;
102
+ #render: RenderFn | undefined;
103
+
104
+ /**
105
+ * Bind a widget definition to a model.
106
+ * If the same def is already bound, returns the cached render function.
107
+ * If a different def is provided (hot reload), tears down the old binding
108
+ * and re-initializes.
109
+ */
110
+ async bind(widgetDef: AnyWidget<T>, model: Model<T>): Promise<RenderFn> {
111
+ // Already initialized with the same widget - return cached render
112
+ if (this.#render && this.#widgetDef === widgetDef) {
113
+ return this.#render;
114
+ }
115
+
116
+ // If widgetDef changed (hot reload), destroy old and re-initialize
117
+ if (this.#render && this.#widgetDef !== widgetDef) {
118
+ Logger.debug(
119
+ "[WidgetBinding] Hot-reload detected, aborting previous binding",
120
+ );
121
+ this.#controller?.abort();
122
+ this.#controller = undefined;
123
+ this.#render = undefined;
124
+ }
125
+
126
+ this.#widgetDef = widgetDef;
127
+ this.#controller = new AbortController();
128
+ const bindingSignal = this.#controller.signal;
129
+
130
+ // Resolve the widget definition (call if it's a function)
131
+ const widget =
132
+ typeof widgetDef === "function" ? await widgetDef() : widgetDef;
133
+
134
+ // Call initialize once per model
135
+ const cleanup = await widget.initialize?.({ model, experimental });
136
+ if (cleanup) {
137
+ bindingSignal.addEventListener("abort", cleanup);
138
+ }
139
+
140
+ // Store and return the render closure
141
+ this.#render = async (el: HTMLElement, viewSignal: AbortSignal) => {
142
+ const renderCleanup = await widget.render?.({
143
+ model,
144
+ el,
145
+ experimental,
146
+ });
147
+ if (renderCleanup) {
148
+ // Cleanup when either the view unmounts or the binding is destroyed
149
+ const combined = abortSignalAny([viewSignal, bindingSignal]);
150
+ combined.addEventListener("abort", () => {
151
+ const reason = viewSignal.aborted
152
+ ? "view unmount"
153
+ : "binding destroyed";
154
+ Logger.debug(
155
+ `[WidgetBinding] Render cleanup triggered (reason: ${reason})`,
156
+ );
157
+ renderCleanup();
158
+ });
159
+ }
160
+ };
161
+
162
+ return this.#render;
163
+ }
164
+
165
+ /**
166
+ * Destroy this binding, aborting the initialize lifecycle.
167
+ */
168
+ destroy(): void {
169
+ Logger.debug(
170
+ "[WidgetBinding] Destroying binding, aborting initialize lifecycle",
171
+ );
172
+ this.#controller?.abort();
173
+ this.#controller = undefined;
174
+ this.#widgetDef = undefined;
175
+ this.#render = undefined;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Maps WidgetModelId to WidgetBinding instances.
181
+ * Singleton that manages the lifecycle of all bindings.
182
+ */
183
+ class BindingManager {
184
+ #bindings = new Map<WidgetModelId, WidgetBinding<any>>();
185
+
186
+ getOrCreate(modelId: WidgetModelId): WidgetBinding<any> {
187
+ let binding = this.#bindings.get(modelId);
188
+ if (!binding) {
189
+ binding = new WidgetBinding();
190
+ this.#bindings.set(modelId, binding);
191
+ }
192
+ return binding;
193
+ }
194
+
195
+ destroy(modelId: WidgetModelId): void {
196
+ const binding = this.#bindings.get(modelId);
197
+ if (binding) {
198
+ Logger.debug(`[BindingManager] Destroying binding for model=${modelId}`);
199
+ binding.destroy();
200
+ this.#bindings.delete(modelId);
201
+ }
202
+ }
203
+
204
+ has(modelId: WidgetModelId): boolean {
205
+ return this.#bindings.has(modelId);
206
+ }
207
+ }
208
+
209
+ export const WIDGET_DEF_REGISTRY = new WidgetDefRegistry();
210
+ export const BINDING_MANAGER = new BindingManager();
211
+
212
+ export const visibleForTesting = {
213
+ WidgetDefRegistry,
214
+ WidgetBinding,
215
+ BindingManager,
216
+ };