@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.
- package/dist/assets/{edit-page-BpIuEeU6.js → edit-page-Bx2U8f0j.js} +6 -6
- package/dist/assets/{index-DFrkvKWf.js → index-CD6Gw4UH.js} +23 -23
- package/dist/assets/panels-DW8vF5Az.js +1 -0
- package/dist/assets/{run-page-_7E2SCeQ.js → run-page-D4d7rFuk.js} +1 -1
- package/dist/assets/utilities.esm-dm9SQStE.js +3 -0
- package/dist/index.html +2 -2
- 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/dist/assets/panels-IsIZwIow.js +0 -1
- package/dist/assets/utilities.esm-MA1QpjVT.js +0 -3
|
@@ -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
|
|
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
|
|