@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.
- 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
|
@@ -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
|
|
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
|
-
|
|
39
|
-
let
|
|
40
|
-
if (
|
|
41
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
62
|
+
return entry.deferred.promise;
|
|
69
63
|
}
|
|
70
64
|
|
|
71
65
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
87
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
+
};
|