From 6c23b97fb10fac3be733a2fa764dab212b7e6d0c Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Thu, 15 Feb 2024 00:22:02 -0500 Subject: [PATCH 1/7] simple model --- packages/anywidget/src/model.js | 356 +++++++++++++++++++++++++++++++ packages/anywidget/src/types.ts | 87 ++++++++ packages/anywidget/src/util.js | 109 ++++++++++ packages/anywidget/src/widget.js | 68 ++---- 4 files changed, 567 insertions(+), 53 deletions(-) create mode 100644 packages/anywidget/src/model.js create mode 100644 packages/anywidget/src/types.ts create mode 100644 packages/anywidget/src/util.js diff --git a/packages/anywidget/src/model.js b/packages/anywidget/src/model.js new file mode 100644 index 00000000..11582423 --- /dev/null +++ b/packages/anywidget/src/model.js @@ -0,0 +1,356 @@ +import { extract_buffers, put_buffers } from "./util.js"; + +/** + * @template {Record} State + */ +export class AnyModel { + /** @type {Omit} */ + #opts; + /** @type {import("./types.js").Comm=} */ + #comm; + /** @type {Map>} */ + #views = new Map(); + /** @type {{ [evt_name: string]: Map<() => void, (event: Event) => void> }} */ + #listeners = {}; + /** @type {State} */ + #state; + /** @type {Set} */ + #need_sync = new Set(); + /** @type {Record>} */ + #field_serializers; + /** @type {EventTarget} */ + #events = new EventTarget(); + + // TODO(Trevor): I don't fully understand the purpose of this map + // + // From Jupyter Team: keep track of the msg id for each attr for updates + // we send out so that we can ignore old messages that we send in + // order to avoid 'drunken' sliders going back and forward + /** @type {Map} */ + #expected_echo_msg_ids = new Map(); + /** @type {Promise} */ + state_change; + + /** + * @param {State} state + * @param {import("./types.js").ModelOptions} options + */ + constructor(state, options) { + this.#state = state; + this.#opts = options; + this.#comm = options.comm; + this.#comm?.on_msg(this.#handle_comm_msg.bind(this)); + this.#comm?.on_close(this.#handle_comm_close.bind(this)); + this.#field_serializers = { + layout: { + /** @param {string} layout */ + serialize(layout) { + return JSON.parse(JSON.stringify(layout)); + }, + /** @param {string} layout */ + deserialize(layout, widget_manager) { + return widget_manager.get_model(layout.slice("IPY_MODEL_".length)); + }, + }, + }; + this.state_change = this.#deserialize(state).then((de) => { + this.#state = de; + }); + } + + get widget_manager() { + return this.#opts.widget_manager; + } + + get #msg_buffer() { + return {}; + } + + /** + * Deserialize the model state. + * + * Required by any WidgetManager but we want to decode the initial + * state of the model ourselves. + * + * @template T + * @param state {T} + * @returns {Promise} + */ + static async _deserialize_state(state) { + return state; + } + + /** + * Serialize the model state. + * @template {Partial} T + * @param {T} ser + * @returns {Promise} + */ + async #deserialize(ser) { + /** @type {any} */ + let state = {}; + for (let key in ser) { + let serializer = this.#field_serializers[key]; + if (!serializer) { + state[key] = ser[key]; + continue; + } + state[key] = await serializer.deserialize( + ser[key], + this.#opts.widget_manager, + ); + } + return state; + } + + /** + * Deserialize the model state. + * @template {Partial} T + * @param {T} de + * @returns {Promise} + */ + async #serialize(de) { + /** @type {any} */ + let state = {}; + for (let key in de) { + let serializer = this.#field_serializers[key]; + if (!serializer) { + state[key] = de[key]; + continue; + } + state[key] = await serializer.serialize(de[key]); + } + return state; + } + + /** + * Handle when a comm message is received. + * @param {import("./types.js").CommMessage} msg - the comm message. + */ + async #handle_comm_msg(msg) { + switch (msg.method) { + case "update": + case "echo_update": + return this.#handle_update(msg); + case "custom": + return this.#handle_custom(msg); + default: + throw new Error("Unhandled comm msg method: " + msg); + } + } + + /** + * Close model + * + * @param comm_closed - true if the comm is already being closed. If false, the comm will be closed. + * @returns - a promise that is fulfilled when all the associated views have been removed. + */ + async #handle_comm_close() { + // can only be closed once. + if (!this.#comm) return; + this.#events.dispatchEvent(new CustomEvent("comm:close")); + this.#comm.close(); + for (let [event, map] of Object.entries(this.#listeners)) { + for (let listener of map.values()) { + this.#events.removeEventListener(event, listener); + } + } + this.#listeners = {}; + this.#comm = undefined; + for await (let view of Object.values(this.#views)) view.remove(); + this.#views.clear(); + } + + /** + * @template {keyof State} K + * @param {K} key + * @returns {State[K]} + */ + get(key) { + return this.#state[key]; + } + + /** + * @template {keyof State & string} K + * @param {K} key + * @param {State[K]} value + */ + set(key, value) { + this.#state[key] = value; + this.#events.dispatchEvent(new CustomEvent(`change`)); + this.#events.dispatchEvent(new CustomEvent(`change:${key}`)); + this.#need_sync.add(key); + } + + async save_changes() { + if (!this.#comm) return; + /** @type {Partial} */ + let to_send = {}; + for (let key of this.#need_sync) { + // @ts-expect-error - we know this is a valid key + to_send[key] = this.#state[key]; + } + let serialized = await this.#serialize(to_send); + this.#need_sync.clear(); + let split = extract_buffers(serialized); + this.#comm.send( + { + method: "update", + state: split.state, + buffer_paths: split.buffer_paths, + }, + undefined, + {}, + split.buffers, + ); + } + + /** + * @overload + * @param {string} event + * @param {() => void} callback + * @returns {void} + */ + /** + * @overload + * @param {"msg:custom"} event + * @param {(content: unknown, buffers: ArrayBuffer[]) => void} callback + * @returns {void} + */ + /** + * @param {string} event + * @param {(...args: any[]) => void} callback + */ + on(event, callback) { + /** @type {(event?: unknown) => void} */ + let handler; + if (event === "msg:custom") { + // @ts-expect-error - we know this is a valid handler + handler = (/** @type {CustomEvent} */ event) => callback(...event.detail); + } else { + handler = () => callback(); + } + this.#listeners[event] = this.#listeners[event] ?? new Map(); + this.#listeners[event].set(callback, handler); + this.#events.addEventListener(event, handler); + } + + get get_state() { + throw new Error("Not implemented"); + } + + /** + * @param {Partial} state + */ + set_state(state) { + for (let key in state) { + // @ts-expect-error - we know this is a valid key + this.#state[key] = state[key]; + } + } + + /** + * @param {string} event + * @param {() => void=} callback + */ + off(event, callback) { + let listeners = this.#listeners[event]; + if (!listeners) { + return; + } + if (!callback) { + for (let handler of listeners.values()) { + this.#events.removeEventListener(event, handler); + } + listeners.clear(); + return; + } + let handler = listeners.get(callback); + if (!handler) return; + this.#events.removeEventListener(event, handler); + listeners.delete(callback); + } + + /** @param {Partial} [diff] */ + changedAttributes(diff = {}) { + return false; + } + + /** + * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg + */ + async #handle_update(msg) { + let state = msg.data.state; + put_buffers(state, msg.data.buffer_paths, msg.buffers); + if (msg.method === "echo_update" && msg.parent_header) { + this.#resolve_echo(state, msg.parent_header.msg_id); + } + // @ts-expect-error - we don't validate this + let deserialized = await this.#deserialize(state); + this.set_state(deserialized); + } + + /** + * @param {Record} state + * @param {string} msg_id + */ + #resolve_echo(state, msg_id) { + // we may have echos coming from other clients, we only care about + // dropping echos for which we expected a reply + for (let name of Object.keys(state)) { + if (this.#expected_echo_msg_ids.has(name)) { + continue; + } + let stale = this.#expected_echo_msg_ids.get(name) !== msg_id; + if (stale) { + delete state[name]; + continue; + } + // we got our echo confirmation, so stop looking for it + this.#expected_echo_msg_ids.delete(name); + // Start accepting echo updates unless we plan to send out a new state soon + if (this.#msg_buffer?.hasOwnProperty(name)) { + delete state[name]; + } + } + } + + /** + * @param {import("./types.js").CustomMessage} msg + */ + async #handle_custom(msg) { + this.#events.dispatchEvent( + new CustomEvent("msg:custom", { + detail: [msg.data.content, msg.buffers], + }), + ); + } + + /** + * Send a custom msg over the comm. + * @param {import("./types.js").JSONValue} content - The content of the message. + * @param {unknown} [callbacks] - The callbacks for the message. + * @param {ArrayBuffer[]} [buffers] - An array of ArrayBuffers to send as part of the message. + */ + send(content, callbacks, buffers) { + if (!this.#comm) return; + this.#comm.send({ method: "custom", content }, callbacks, {}, buffers); + } + + /** @param {string} event */ + trigger(event) { + this.#events.dispatchEvent(new CustomEvent(event)); + } + + /** + * @param {string} event + * @param {() => void} callback + */ + once(event, callback) { + let handler = () => { + callback(); + this.off(event, handler); + }; + this.on(event, handler); + } +} diff --git a/packages/anywidget/src/types.ts b/packages/anywidget/src/types.ts new file mode 100644 index 00000000..346189e1 --- /dev/null +++ b/packages/anywidget/src/types.ts @@ -0,0 +1,87 @@ +export type JSONValue = + | string + | number + | boolean + | { [x: string]: JSONValue } + | Array; + +export interface Comm { + /** Comm id */ + comm_id: string; + /** Target name */ + target_name: string; + /** + * Sends a message to the sibling comm in the backend + * @param data + * @param callbacks + * @param metadata + * @param buffers + * @return message id + */ + send: ( + data: JSONValue, + callbacks?: unknown, + metadata?: Record, + buffers?: ArrayBuffer[], + ) => string; + /** + * Closes the sibling comm in the backend + * @param data + * @param callbacks + * @param metadata + * @param buffers + * @return msg id + */ + close( + data?: JSONValue, + callbacks?: unknown, + metadata?: Record, + buffers?: ArrayBuffer[] | ArrayBufferView[], + ): string; + /** + * Register a message handler + * @param callback, which is given a message + */ + on_msg: (callback: (msg: CommMessage) => void) => void; + /** + * Register a handler for when the comm is closed by the backend + * @param callback, which is given a message + */ + on_close: (callback: () => void) => void; +} + +export type CommMessage = UpdateMessage | EchoUpdateMessage | CustomMessage; +export type UpdateMessage = { + method: "update"; + buffers?: ReadonlyArray; + data: { + state: Record; + buffer_paths?: ReadonlyArray>; + }; +}; +export type EchoUpdateMessage = { + method: "echo_update"; + parent_header?: { msg_id: string }; + buffers?: ReadonlyArray; + data: { + state: Record; + buffer_paths?: ReadonlyArray>; + }; +}; +export type CustomMessage = { + method: "custom"; + buffers?: ReadonlyArray; + data: { content: unknown }; +}; + +export type WidgetManager = { get_model: (model_id: string) => unknown }; +export type FieldSerializer = { + serialize: (value: A) => B | Promise; + deserialize: (value: B, widget_manager: WidgetManager) => A | Promise; +}; + +export type ModelOptions = { + model_id: string; + comm?: Comm; + widget_manager: WidgetManager; +}; diff --git a/packages/anywidget/src/util.js b/packages/anywidget/src/util.js new file mode 100644 index 00000000..6a8c9a94 --- /dev/null +++ b/packages/anywidget/src/util.js @@ -0,0 +1,109 @@ +/** + * @param {unknown} obj + * @returns {boolean} + */ +export function is_object(obj) { + return typeof obj === "object" && obj !== null; +} + +/** + * @param {unknown} condition + * @param {string} msg + * @returns {asserts condition} + */ +export function assert(condition, msg) { + if (!condition) { + throw new Error(msg); + } +} + +/** + * Takes an object 'state' and fills in buffer[i] at 'path' buffer_paths[i] + * where buffer_paths[i] is a list indicating where in the object buffer[i] should + * be placed + * Example: state = {a: 1, b: {}, c: [0, null]} + * buffers = [array1, array2] + * buffer_paths = [['b', 'data'], ['c', 1]] + * Will lead to {a: 1, b: {data: array1}, c: [0, array2]} + * + * @param {Record} state + * @param {ReadonlyArray>} buffer_paths + * @param {ReadonlyArray} buffers + */ +export function put_buffers(state, buffer_paths = [], buffers = []) { + let data_views = buffers.map((b) => { + if (b instanceof DataView) return b; + if (b instanceof ArrayBuffer) return new DataView(b); + throw new Error("Unknown buffer type: " + b); + }); + assert( + buffer_paths.length === data_views.length, + "Not the same number of buffer_paths and buffers", + ); + for (let i = 0; i < buffer_paths.length; i++) { + let buffer = buffers[i]; + let buffer_path = buffer_paths[i]; + + // say we want to set state[x][y][z] = buffer + /** @type {any} */ + let node = state; + // we first get obj = state[x][y] + for (let path of buffer_path.slice(0, -1)) { + node = node[path]; + } + // and then set: obj[z] = buffer + node[buffer_path[buffer_path.length - 1]] = buffer; + } +} + +/** + * @param {Record} state + * @returns {{ + * state: import("./types.js").JSONValue, + * buffer_paths: Array>, + * buffers: Array + * }} + */ +export function extract_buffers(state) { + /** @type {Array>} */ + let buffer_paths = []; + /** @type {Array} */ + let buffers = []; + /** + * @param {any} obj + * @param {any} parent + * @param {string | number | null} key_in_parent + * @param {Array} path + */ + function extract_buffers_and_paths( + obj, + parent = null, + key_in_parent = null, + path = [], + ) { + if (obj instanceof ArrayBuffer || obj instanceof DataView) { + buffer_paths.push([...path]); + buffers.push("buffer" in obj ? obj.buffer : obj); + if (parent !== null && key_in_parent !== null) { + // mutate the parent to remove the buffer + parent[key_in_parent] = null; + } + return; + } + if (is_object(obj)) { + for (let [key, value] of Object.entries(obj)) { + extract_buffers_and_paths(value, obj, key, path.concat(key)); + } + } + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + extract_buffers_and_paths(obj[i], obj, i, path.concat(i)); + } + } + } + extract_buffers_and_paths(state); + /** @type {import("./types.js").JSONValue} */ + // @ts-expect-error - TODO: fix type + let json_state = state; + return { state: json_state, buffer_paths, buffers }; +} diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index 8e1167dc..0847ffc5 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -1,3 +1,4 @@ +import { AnyModel as _AnyModel } from "./model.js"; import { createEffect, createResource, @@ -251,7 +252,7 @@ class Runtime { // @ts-expect-error - Set synchronously in constructor. #widget_result; - /** @param {import("@jupyter-widgets/base").DOMWidgetModel} model */ + /** @param {_AnyModel<{ _esm: string, _css?: string, _anywidget_id: string }>} model */ constructor(model) { this.#disposer = createRoot((dispose) => { let [css, set_css] = createSignal(model.get("_css")); @@ -277,11 +278,9 @@ class Runtime { this.#widget_result = createResource(esm, async (update) => { await safe_cleanup(cleanup, "initialize"); try { - model.off(null, null, INITIALIZE_MARKER); + await model._ready_promise; let widget = await load_widget(update); - cleanup = await widget.initialize?.({ - model: model_proxy(model, INITIALIZE_MARKER), - }); + cleanup = await widget.initialize?.({ model }); return ok(widget); } catch (e) { return error(e); @@ -353,22 +352,18 @@ class Runtime { let version = globalThis.VERSION; /** @param {typeof import("@jupyter-widgets/base")} base */ -export default function ({ DOMWidgetModel, DOMWidgetView }) { - /** @type {WeakMap} */ +export default function ({ DOMWidgetView }) { + /** @type {WeakMap, Runtime>} */ let RUNTIMES = new WeakMap(); - class AnyModel extends DOMWidgetModel { - static model_name = "AnyModel"; - static model_module = "anywidget"; - static model_module_version = version; - - static view_name = "AnyView"; - static view_module = "anywidget"; - static view_module_version = version; - - /** @param {Parameters["initialize"]>} args */ - initialize(...args) { - super.initialize(...args); + /** + * @template {{ _esm: string, _css?: string, _anywidget_id: string }} T + * @extends {_AnyModel} + */ + class AnyModel extends _AnyModel { + /** @param {ConstructorParameters>} args */ + constructor(...args) { + super(...args); let runtime = new Runtime(this); this.once("destroy", () => { try { @@ -379,46 +374,13 @@ export default function ({ DOMWidgetModel, DOMWidgetView }) { }); RUNTIMES.set(this, runtime); } - - /** - * @param {Record} state - * - * We override to support binary trailets because JSON.parse(JSON.stringify()) - * does not properly clone binary data (it just returns an empty object). - * - * https://github.com/jupyter-widgets/ipywidgets/blob/47058a373d2c2b3acf101677b2745e14b76dd74b/packages/base/src/widget.ts#L562-L583 - */ - serialize(state) { - let serializers = - /** @type {DOMWidgetModel} */ (this.constructor).serializers || {}; - for (let k of Object.keys(state)) { - try { - let serialize = serializers[k]?.serialize; - if (serialize) { - state[k] = serialize(state[k], this); - } else if (k === "layout" || k === "style") { - // These keys come from ipywidgets, rely on JSON.stringify trick. - state[k] = JSON.parse(JSON.stringify(state[k])); - } else { - state[k] = structuredClone(state[k]); - } - if (typeof state[k]?.toJSON === "function") { - state[k] = state[k].toJSON(); - } - } catch (e) { - console.error("Error serializing widget state attribute: ", k); - throw e; - } - } - return state; - } } class AnyView extends DOMWidgetView { /** @type {undefined | (() => void)} */ #dispose = undefined; async render() { - let runtime = RUNTIMES.get(this.model); + let runtime = RUNTIMES.get(/** @type {any} */ (this.model)); assert(runtime, "[anywidget] runtime not found."); assert(!this.#dispose, "[anywidget] dispose already set."); this.#dispose = await runtime.create_view(this); From 7a04958965a83ee33e95398302b189338f28fff3 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Thu, 15 Feb 2024 01:05:07 -0500 Subject: [PATCH 2/7] working --- packages/anywidget/src/model.js | 86 ++++++++++++++++----------------- packages/anywidget/src/types.ts | 29 +++++++---- packages/anywidget/src/util.js | 17 +++++++ 3 files changed, 79 insertions(+), 53 deletions(-) diff --git a/packages/anywidget/src/model.js b/packages/anywidget/src/model.js index 11582423..e4ea4c43 100644 --- a/packages/anywidget/src/model.js +++ b/packages/anywidget/src/model.js @@ -1,8 +1,6 @@ -import { extract_buffers, put_buffers } from "./util.js"; +import * as utils from "./util.js"; -/** - * @template {Record} State - */ +/** @template {Record} State */ export class AnyModel { /** @type {Omit} */ #opts; @@ -38,9 +36,6 @@ export class AnyModel { constructor(state, options) { this.#state = state; this.#opts = options; - this.#comm = options.comm; - this.#comm?.on_msg(this.#handle_comm_msg.bind(this)); - this.#comm?.on_close(this.#handle_comm_close.bind(this)); this.#field_serializers = { layout: { /** @param {string} layout */ @@ -53,6 +48,9 @@ export class AnyModel { }, }, }; + this.#comm = options.comm; + this.#comm?.on_msg(this.#handle_comm_msg.bind(this)); + this.#comm?.on_close(this.#handle_comm_close.bind(this)); this.state_change = this.#deserialize(state).then((de) => { this.#state = de; }); @@ -62,6 +60,10 @@ export class AnyModel { return this.#opts.widget_manager; } + get comm_live() { + return !!this.#comm; + } + get #msg_buffer() { return {}; } @@ -109,13 +111,13 @@ export class AnyModel { * @param {T} de * @returns {Promise} */ - async #serialize(de) { + async serialize(de) { /** @type {any} */ let state = {}; for (let key in de) { let serializer = this.#field_serializers[key]; if (!serializer) { - state[key] = de[key]; + state[key] = structuredClone(de[key]); continue; } state[key] = await serializer.serialize(de[key]); @@ -128,15 +130,21 @@ export class AnyModel { * @param {import("./types.js").CommMessage} msg - the comm message. */ async #handle_comm_msg(msg) { - switch (msg.method) { - case "update": - case "echo_update": - return this.#handle_update(msg); - case "custom": - return this.#handle_custom(msg); - default: - throw new Error("Unhandled comm msg method: " + msg); + if (utils.is_update_msg(msg)) { + return this.#handle_update(msg); + } + if (utils.is_custom_msg(msg)) { + this.#emit("msg:custom", [msg.content.data.content, msg.buffers]); } + throw new Error(`unhandled comm message: ${JSON.stringify(msg)}`); + } + + /** + * @param {string} name + * @param {unknown} [value] + */ + #emit(name, value) { + this.#events.dispatchEvent(new CustomEvent(name, { detail: value })); } /** @@ -148,7 +156,7 @@ export class AnyModel { async #handle_comm_close() { // can only be closed once. if (!this.#comm) return; - this.#events.dispatchEvent(new CustomEvent("comm:close")); + this.#emit("comm:close"); this.#comm.close(); for (let [event, map] of Object.entries(this.#listeners)) { for (let listener of map.values()) { @@ -177,8 +185,8 @@ export class AnyModel { */ set(key, value) { this.#state[key] = value; - this.#events.dispatchEvent(new CustomEvent(`change`)); - this.#events.dispatchEvent(new CustomEvent(`change:${key}`)); + this.#emit(`change:${key}`); + this.#emit("change"); this.#need_sync.add(key); } @@ -190,9 +198,9 @@ export class AnyModel { // @ts-expect-error - we know this is a valid key to_send[key] = this.#state[key]; } - let serialized = await this.#serialize(to_send); + let serialized = await this.serialize(to_send); this.#need_sync.clear(); - let split = extract_buffers(serialized); + let split = utils.extract_buffers(serialized); this.#comm.send( { method: "update", @@ -235,10 +243,6 @@ export class AnyModel { this.#events.addEventListener(event, handler); } - get get_state() { - throw new Error("Not implemented"); - } - /** * @param {Partial} state */ @@ -246,7 +250,13 @@ export class AnyModel { for (let key in state) { // @ts-expect-error - we know this is a valid key this.#state[key] = state[key]; + this.#emit(`change:${key}`); } + this.#emit("change"); + } + + get_state() { + return this.#state; } /** @@ -279,11 +289,11 @@ export class AnyModel { /** * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg */ - async #handle_update(msg) { - let state = msg.data.state; - put_buffers(state, msg.data.buffer_paths, msg.buffers); - if (msg.method === "echo_update" && msg.parent_header) { - this.#resolve_echo(state, msg.parent_header.msg_id); + async #handle_update({ content, buffers, parent_header }) { + let state = content.data.state; + utils.put_buffers(state, content.data.buffer_paths, buffers); + if (content.data.method === "echo_update" && parent_header?.msg_id) { + this.#resolve_echo(state, parent_header.msg_id); } // @ts-expect-error - we don't validate this let deserialized = await this.#deserialize(state); @@ -315,17 +325,6 @@ export class AnyModel { } } - /** - * @param {import("./types.js").CustomMessage} msg - */ - async #handle_custom(msg) { - this.#events.dispatchEvent( - new CustomEvent("msg:custom", { - detail: [msg.data.content, msg.buffers], - }), - ); - } - /** * Send a custom msg over the comm. * @param {import("./types.js").JSONValue} content - The content of the message. @@ -339,7 +338,8 @@ export class AnyModel { /** @param {string} event */ trigger(event) { - this.#events.dispatchEvent(new CustomEvent(event)); + utils.assert(event === "destroy", "Only 'destroy' event is supported"); + this.#emit(event); } /** diff --git a/packages/anywidget/src/types.ts b/packages/anywidget/src/types.ts index 346189e1..94dd16d7 100644 --- a/packages/anywidget/src/types.ts +++ b/packages/anywidget/src/types.ts @@ -52,26 +52,35 @@ export interface Comm { export type CommMessage = UpdateMessage | EchoUpdateMessage | CustomMessage; export type UpdateMessage = { - method: "update"; + parent_header?: { msg_id: string }; buffers?: ReadonlyArray; - data: { - state: Record; - buffer_paths?: ReadonlyArray>; + content: { + data: { + method: "update"; + state: Record; + buffer_paths?: ReadonlyArray>; + }; }; }; export type EchoUpdateMessage = { - method: "echo_update"; parent_header?: { msg_id: string }; buffers?: ReadonlyArray; - data: { - state: Record; - buffer_paths?: ReadonlyArray>; + content: { + data: { + method: "echo_update"; + state: Record; + buffer_paths?: ReadonlyArray>; + }; }; }; export type CustomMessage = { - method: "custom"; buffers?: ReadonlyArray; - data: { content: unknown }; + content: { + data: { + method: "custom"; + content: unknown; + }; + }; }; export type WidgetManager = { get_model: (model_id: string) => unknown }; diff --git a/packages/anywidget/src/util.js b/packages/anywidget/src/util.js index 6a8c9a94..a8723525 100644 --- a/packages/anywidget/src/util.js +++ b/packages/anywidget/src/util.js @@ -107,3 +107,20 @@ export function extract_buffers(state) { let json_state = state; return { state: json_state, buffer_paths, buffers }; } + +/** + * @param {import("./types.js").CommMessage} msg + * @returns {msg is import("./types.js").CustomMessage} + */ +export function is_custom_msg(msg) { + return msg.content.data.method === "custom"; +} + +/** + * @param {import("./types.js").CommMessage} msg + * @returns {msg is import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} + */ +export function is_update_msg(msg) { + return msg.content.data.method === "update" || + msg.content.data.method === "echo_update"; +} From 190b31f594b5be81d9bc52e3f75cd661fdb85a0c Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Sun, 17 Mar 2024 16:15:46 -0400 Subject: [PATCH 3/7] feat: implement standalone view --- packages/anywidget/package.json | 1 + packages/anywidget/src/model.js | 114 +++++++++++++++---------------- packages/anywidget/src/view.js | 95 ++++++++++++++++++++++++++ packages/anywidget/src/widget.js | 69 ++++++++++--------- pnpm-lock.yaml | 5 +- 5 files changed, 193 insertions(+), 91 deletions(-) create mode 100644 packages/anywidget/src/view.js diff --git a/packages/anywidget/package.json b/packages/anywidget/package.json index 895a2241..70dcb777 100644 --- a/packages/anywidget/package.json +++ b/packages/anywidget/package.json @@ -24,6 +24,7 @@ "@anywidget/types": "workspace:~", "@anywidget/vite": "workspace:~", "@jupyter-widgets/base": "^2 || ^3 || ^4 || ^5 || ^6", + "@lumino/widgets": "^2.3.1", "solid-js": "^1.8.14" }, "devDependencies": { diff --git a/packages/anywidget/src/model.js b/packages/anywidget/src/model.js index e4ea4c43..252c87f8 100644 --- a/packages/anywidget/src/model.js +++ b/packages/anywidget/src/model.js @@ -1,13 +1,11 @@ import * as utils from "./util.js"; /** @template {Record} State */ -export class AnyModel { - /** @type {Omit} */ - #opts; +export class Model { /** @type {import("./types.js").Comm=} */ #comm; - /** @type {Map>} */ - #views = new Map(); + /** @type {Omit} */ + #options; /** @type {{ [evt_name: string]: Map<() => void, (event: Event) => void> }} */ #listeners = {}; /** @type {State} */ @@ -19,23 +17,27 @@ export class AnyModel { /** @type {EventTarget} */ #events = new EventTarget(); - // TODO(Trevor): I don't fully understand the purpose of this map - // - // From Jupyter Team: keep track of the msg id for each attr for updates + + // NOTE: (from Jupyter Team): keep track of the msg id for each attr for updates // we send out so that we can ignore old messages that we send in // order to avoid 'drunken' sliders going back and forward /** @type {Map} */ #expected_echo_msg_ids = new Map(); + + // NOTE: Required for the WidgetManager to know when the model is ready /** @type {Promise} */ state_change; + /** @type {Record>} */ + views = {} + /** * @param {State} state * @param {import("./types.js").ModelOptions} options */ constructor(state, options) { this.#state = state; - this.#opts = options; + this.#options = options; this.#field_serializers = { layout: { /** @param {string} layout */ @@ -57,7 +59,7 @@ export class AnyModel { } get widget_manager() { - return this.#opts.widget_manager; + return this.#options.widget_manager; } get comm_live() { @@ -99,7 +101,7 @@ export class AnyModel { } state[key] = await serializer.deserialize( ser[key], - this.#opts.widget_manager, + this.#options.widget_manager, ); } return state; @@ -139,6 +141,46 @@ export class AnyModel { throw new Error(`unhandled comm message: ${JSON.stringify(msg)}`); } + /** + * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg + */ + async #handle_update({ content, buffers, parent_header }) { + let state = content.data.state; + utils.put_buffers(state, content.data.buffer_paths, buffers); + if (content.data.method === "echo_update" && parent_header?.msg_id) { + this.#resolve_echo(state, parent_header.msg_id); + } + // @ts-expect-error - we don't validate this + let deserialized = await this.#deserialize(state); + this.set_state(deserialized); + } + + /** + * @param {Record} state + * @param {string} msg_id + */ + #resolve_echo(state, msg_id) { + // we may have echos coming from other clients, we only care about + // dropping echos for which we expected a reply + for (let name of Object.keys(state)) { + if (this.#expected_echo_msg_ids.has(name)) { + continue; + } + let stale = this.#expected_echo_msg_ids.get(name) !== msg_id; + if (stale) { + delete state[name]; + continue; + } + // we got our echo confirmation, so stop looking for it + this.#expected_echo_msg_ids.delete(name); + // Start accepting echo updates unless we plan to send out a new state soon + if (this.#msg_buffer?.hasOwnProperty(name)) { + delete state[name]; + } + } + } + + /** * @param {string} name * @param {unknown} [value] @@ -165,8 +207,8 @@ export class AnyModel { } this.#listeners = {}; this.#comm = undefined; - for await (let view of Object.values(this.#views)) view.remove(); - this.#views.clear(); + for await (let view of Object.values(this.views)) view.remove(); + this.views.clear(); } /** @@ -261,7 +303,7 @@ export class AnyModel { /** * @param {string} event - * @param {() => void=} callback + * @param {() => void} [callback] */ off(event, callback) { let listeners = this.#listeners[event]; @@ -281,50 +323,6 @@ export class AnyModel { listeners.delete(callback); } - /** @param {Partial} [diff] */ - changedAttributes(diff = {}) { - return false; - } - - /** - * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg - */ - async #handle_update({ content, buffers, parent_header }) { - let state = content.data.state; - utils.put_buffers(state, content.data.buffer_paths, buffers); - if (content.data.method === "echo_update" && parent_header?.msg_id) { - this.#resolve_echo(state, parent_header.msg_id); - } - // @ts-expect-error - we don't validate this - let deserialized = await this.#deserialize(state); - this.set_state(deserialized); - } - - /** - * @param {Record} state - * @param {string} msg_id - */ - #resolve_echo(state, msg_id) { - // we may have echos coming from other clients, we only care about - // dropping echos for which we expected a reply - for (let name of Object.keys(state)) { - if (this.#expected_echo_msg_ids.has(name)) { - continue; - } - let stale = this.#expected_echo_msg_ids.get(name) !== msg_id; - if (stale) { - delete state[name]; - continue; - } - // we got our echo confirmation, so stop looking for it - this.#expected_echo_msg_ids.delete(name); - // Start accepting echo updates unless we plan to send out a new state soon - if (this.#msg_buffer?.hasOwnProperty(name)) { - delete state[name]; - } - } - } - /** * Send a custom msg over the comm. * @param {import("./types.js").JSONValue} content - The content of the message. diff --git a/packages/anywidget/src/view.js b/packages/anywidget/src/view.js new file mode 100644 index 00000000..131540ba --- /dev/null +++ b/packages/anywidget/src/view.js @@ -0,0 +1,95 @@ +import { assert } from "./util.js"; +import { Widget } from "@lumino/widgets"; + +/** + * @template {Record} T + * @typedef {import("./model.js").Model} Model + */ + +/** + * @typedef LuminoMessage + * @property {string} type + * @property {boolean} isConflatable + * @property {(msg: LuminoMessage) => boolean} conflate + */ + +/** + * @template {Record} T + * @typedef ViewOptions + * @prop {Model} model + * @prop {HTMLElement} [el] + * @prop {string} [id] + */ + +/** + * @template {Record} T + */ +export class View { + /** @type {HTMLElement} */ + el; + /** @type {Model} */ + model; + /** @type {Record} */ + options; + /** @type {() => void} */ + #remove_callback = () => { }; + + /** @param {ViewOptions} options */ + constructor(options) { + this.el = options.el ?? document.createElement("div"); + this.model = options.model; + this.options = options; + this.luminoWidget = new Widget({ node: this.el }); + } + + /** + * @param {Model} model + * @param {"destroy"} name + * @param {() => void} callback + */ + listenTo(model, name, callback) { + assert(name === "destroy", "Only 'destroy' event is supported in `listenTo`."); + model.on(name, callback); + } + + /** + * @param {"remove"} name + * @param {() => void} callback + */ + once(name, callback) { + assert(name === "remove", "Only 'remove' event is supported in `once`."); + this.#remove_callback = callback; + } + + remove() { + this.#remove_callback(); + this.el.remove(); + } + + /** + * @param {LuminoMessage} msg + */ + processLuminoMessage(msg) { + switch (msg.type) { + case 'after-attach': + this.trigger('displayed'); + break; + case 'show': + this.trigger('shown'); + break; + } + } + + /** + * @param {"displayed" | "shown"} name + */ + trigger(name) { + console.log(name) + } + + /** + * Render the view. + */ + async render() { } + +} diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index 0847ffc5..5c79fa57 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -1,10 +1,6 @@ -import { AnyModel as _AnyModel } from "./model.js"; -import { - createEffect, - createResource, - createRoot, - createSignal, -} from "solid-js"; +import * as solid from "solid-js"; +import { Model } from "./model.js"; +import { View } from "./view.js"; /** * @typedef AnyWidget @@ -147,7 +143,7 @@ async function load_widget(esm) { warn_render_deprecation(); return { url, - async initialize() {}, + async initialize() { }, render: mod.render, }; } @@ -168,7 +164,9 @@ async function load_widget(esm) { let INITIALIZE_MARKER = Symbol("anywidget.initialize"); /** - * @param {import("@jupyter-widgets/base").DOMWidgetModel} model + * @template {Record} T + * + * @param {Model} model * @param {unknown} context * @return {import("@anywidget/types").AnyModel} * @@ -186,10 +184,10 @@ function model_proxy(model, context) { send: model.send.bind(model), // @ts-expect-error on(name, callback) { - model.on(name, callback, context); + model.on(name, callback); }, off(name, callback) { - model.off(name, callback, context); + model.off(name, callback); }, widget_manager: model.widget_manager, }; @@ -243,31 +241,38 @@ function throw_anywidget_error(source) { throw source; } +/** @param {HTMLElement} el */ +function empty_element(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + class Runtime { /** @type {() => void} */ - #disposer = () => {}; + #disposer = () => { }; /** @type {Set<() => void>} */ #view_disposers = new Set(); /** @type {import('solid-js').Resource>} */ // @ts-expect-error - Set synchronously in constructor. #widget_result; - /** @param {_AnyModel<{ _esm: string, _css?: string, _anywidget_id: string }>} model */ + /** @param {Model<{ _esm: string, _css?: string, _anywidget_id: string }>} model */ constructor(model) { - this.#disposer = createRoot((dispose) => { - let [css, set_css] = createSignal(model.get("_css")); + this.#disposer = solid.createRoot((dispose) => { + let [css, set_css] = solid.createSignal(model.get("_css")); model.on("change:_css", () => { let id = model.get("_anywidget_id"); console.debug(`[anywidget] css hot updated: ${id}`); set_css(model.get("_css")); }); - createEffect(() => { + solid.createEffect(() => { let id = model.get("_anywidget_id"); load_css(css(), id); }); /** @type {import("solid-js").Signal} */ - let [esm, setEsm] = createSignal(model.get("_esm")); + let [esm, setEsm] = solid.createSignal(model.get("_esm")); model.on("change:_esm", async () => { let id = model.get("_anywidget_id"); console.debug(`[anywidget] esm hot updated: ${id}`); @@ -275,10 +280,10 @@ class Runtime { }); /** @type {void | (() => import("vitest").Awaitable)} */ let cleanup; - this.#widget_result = createResource(esm, async (update) => { + this.#widget_result = solid.createResource(esm, async (update) => { await safe_cleanup(cleanup, "initialize"); try { - await model._ready_promise; + await model.state_change; let widget = await load_widget(update); cleanup = await widget.initialize?.({ model }); return ok(widget); @@ -296,20 +301,20 @@ class Runtime { } /** - * @param {import("@jupyter-widgets/base").DOMWidgetView} view + * @param {View} view * @returns {Promise<() => void>} */ async create_view(view) { let model = view.model; - let disposer = createRoot((dispose) => { + let disposer = solid.createRoot((dispose) => { /** @type {void | (() => import("vitest").Awaitable)} */ let cleanup; let resource = - createResource(this.#widget_result, async (widget_result) => { + solid.createResource(this.#widget_result, async (widget_result) => { cleanup?.(); // Clear all previous event listeners from this hook. - model.off(null, null, view); - view.$el.empty(); + // model.off(null, null, view); + empty_element(view.el); if (widget_result.state === "error") { throw_anywidget_error(widget_result.error); } @@ -323,7 +328,7 @@ class Runtime { throw_anywidget_error(e); } })[0]; - createEffect(() => { + solid.createEffect(() => { if (resource.error) { // TODO: Show error in the view? } @@ -351,17 +356,16 @@ class Runtime { // @ts-expect-error - injected by bundler let version = globalThis.VERSION; -/** @param {typeof import("@jupyter-widgets/base")} base */ -export default function ({ DOMWidgetView }) { +export default function() { /** @type {WeakMap, Runtime>} */ let RUNTIMES = new WeakMap(); /** * @template {{ _esm: string, _css?: string, _anywidget_id: string }} T - * @extends {_AnyModel} + * @extends {Model} */ - class AnyModel extends _AnyModel { - /** @param {ConstructorParameters>} args */ + class AnyModel extends Model { + /** @param {ConstructorParameters>} args */ constructor(...args) { super(...args); let runtime = new Runtime(this); @@ -376,11 +380,12 @@ export default function ({ DOMWidgetView }) { } } - class AnyView extends DOMWidgetView { + /** @extends {View} */ + class AnyView extends View { /** @type {undefined | (() => void)} */ #dispose = undefined; async render() { - let runtime = RUNTIMES.get(/** @type {any} */ (this.model)); + let runtime = RUNTIMES.get(/** @type {any} */(this.model)); assert(runtime, "[anywidget] runtime not found."); assert(!this.#dispose, "[anywidget] dispose already set."); this.#dispose = await runtime.create_view(this); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a04dd068..eede6533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -114,6 +114,9 @@ importers: '@jupyter-widgets/base': specifier: ^2 || ^3 || ^4 || ^5 || ^6 version: 6.0.7(react@18.2.0) + '@lumino/widgets': + specifier: ^2.3.1 + version: 2.3.1 solid-js: specifier: ^1.8.14 version: 1.8.14 From f118747a825892a9d67e33c3cde1fb459ade4cad Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Sun, 17 Mar 2024 18:03:16 -0400 Subject: [PATCH 4/7] feat: auto-unsubscribe --- packages/anywidget/src/model.js | 135 +++++++++++++++++-------------- packages/anywidget/src/types.ts | 4 +- packages/anywidget/src/util.js | 9 +++ packages/anywidget/src/view.js | 46 ++++------- packages/anywidget/src/widget.js | 18 +++-- packages/types/index.ts | 2 +- 6 files changed, 113 insertions(+), 101 deletions(-) diff --git a/packages/anywidget/src/model.js b/packages/anywidget/src/model.js index 252c87f8..1c8f4e76 100644 --- a/packages/anywidget/src/model.js +++ b/packages/anywidget/src/model.js @@ -1,14 +1,19 @@ import * as utils from "./util.js"; -/** @template {Record} State */ +/** + * @template {Record} T + * @typedef {import('./view.js').View} View + */ + +/** @template {Record} T */ export class Model { /** @type {import("./types.js").Comm=} */ #comm; /** @type {Omit} */ #options; - /** @type {{ [evt_name: string]: Map<() => void, (event: Event) => void> }} */ - #listeners = {}; - /** @type {State} */ + /** @type {Map void, (event: Event) => void> }>} */ + #listeners = new Map(); + /** @type {T} */ #state; /** @type {Set} */ #need_sync = new Set(); @@ -17,7 +22,6 @@ export class Model { /** @type {EventTarget} */ #events = new EventTarget(); - // NOTE: (from Jupyter Team): keep track of the msg id for each attr for updates // we send out so that we can ignore old messages that we send in // order to avoid 'drunken' sliders going back and forward @@ -28,11 +32,11 @@ export class Model { /** @type {Promise} */ state_change; - /** @type {Record>} */ - views = {} + /** @type {Record>>} */ + views = {}; /** - * @param {State} state + * @param {T} state * @param {import("./types.js").ModelOptions} options */ constructor(state, options) { @@ -86,7 +90,7 @@ export class Model { /** * Serialize the model state. - * @template {Partial} T + * @template {Partial} T * @param {T} ser * @returns {Promise} */ @@ -109,7 +113,7 @@ export class Model { /** * Deserialize the model state. - * @template {Partial} T + * @template {Partial} T * @param {T} de * @returns {Promise} */ @@ -133,10 +137,12 @@ export class Model { */ async #handle_comm_msg(msg) { if (utils.is_update_msg(msg)) { - return this.#handle_update(msg); + await this.#handle_update(msg); + return; } if (utils.is_custom_msg(msg)) { this.#emit("msg:custom", [msg.content.data.content, msg.buffers]); + return; } throw new Error(`unhandled comm message: ${JSON.stringify(msg)}`); } @@ -144,11 +150,11 @@ export class Model { /** * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg */ - async #handle_update({ content, buffers, parent_header }) { - let state = content.data.state; - utils.put_buffers(state, content.data.buffer_paths, buffers); - if (content.data.method === "echo_update" && parent_header?.msg_id) { - this.#resolve_echo(state, parent_header.msg_id); + async #handle_update(msg) { + let state = msg.content.data.state; + utils.put_buffers(state, msg.content.data.buffer_paths, msg.buffers); + if (utils.is_echo_update_msg(msg)) { + this.#handle_echo_update(state, msg.parent_header.msg_id); } // @ts-expect-error - we don't validate this let deserialized = await this.#deserialize(state); @@ -159,7 +165,7 @@ export class Model { * @param {Record} state * @param {string} msg_id */ - #resolve_echo(state, msg_id) { + #handle_echo_update(state, msg_id) { // we may have echos coming from other clients, we only care about // dropping echos for which we expected a reply for (let name of Object.keys(state)) { @@ -180,7 +186,6 @@ export class Model { } } - /** * @param {string} name * @param {unknown} [value] @@ -198,32 +203,30 @@ export class Model { async #handle_comm_close() { // can only be closed once. if (!this.#comm) return; - this.#emit("comm:close"); this.#comm.close(); - for (let [event, map] of Object.entries(this.#listeners)) { - for (let listener of map.values()) { - this.#events.removeEventListener(event, listener); - } - } - this.#listeners = {}; this.#comm = undefined; - for await (let view of Object.values(this.views)) view.remove(); - this.views.clear(); + this.#emit("comm:close"); + this.off(); + this.#listeners.clear(); + for await (let view of Object.values(this.views)) { + view.remove(); + } + this.views = {}; } /** - * @template {keyof State} K + * @template {keyof T} K * @param {K} key - * @returns {State[K]} + * @returns {T[K]} */ get(key) { return this.#state[key]; } /** - * @template {keyof State & string} K + * @template {keyof T & string} K * @param {K} key - * @param {State[K]} value + * @param {T[K]} value */ set(key, value) { this.#state[key] = value; @@ -234,7 +237,7 @@ export class Model { async save_changes() { if (!this.#comm) return; - /** @type {Partial} */ + /** @type {Partial} */ let to_send = {}; for (let key of this.#need_sync) { // @ts-expect-error - we know this is a valid key @@ -242,16 +245,12 @@ export class Model { } let serialized = await this.serialize(to_send); this.#need_sync.clear(); - let split = utils.extract_buffers(serialized); + let { state, buffer_paths, buffers } = utils.extract_buffers(serialized); this.#comm.send( - { - method: "update", - state: split.state, - buffer_paths: split.buffer_paths, - }, + { method: "update", state, buffer_paths }, undefined, {}, - split.buffers, + buffers, ); } @@ -259,19 +258,22 @@ export class Model { * @overload * @param {string} event * @param {() => void} callback + * @param {unknown} [scope] * @returns {void} */ /** * @overload * @param {"msg:custom"} event * @param {(content: unknown, buffers: ArrayBuffer[]) => void} callback + * @param {unknown} scope * @returns {void} */ /** * @param {string} event * @param {(...args: any[]) => void} callback + * @param {unknown} [scope] */ - on(event, callback) { + on(event, callback, scope = this) { /** @type {(event?: unknown) => void} */ let handler; if (event === "msg:custom") { @@ -280,13 +282,15 @@ export class Model { } else { handler = () => callback(); } - this.#listeners[event] = this.#listeners[event] ?? new Map(); - this.#listeners[event].set(callback, handler); + let scope_listeners = this.#listeners.get(scope) ?? {}; + this.#listeners.set(scope, scope_listeners); + scope_listeners[event] = scope_listeners[event] ?? new Map(); + scope_listeners[event].set(callback, handler); this.#events.addEventListener(event, handler); } /** - * @param {Partial} state + * @param {Partial} state */ set_state(state) { for (let key in state) { @@ -302,25 +306,38 @@ export class Model { } /** - * @param {string} event - * @param {() => void} [callback] + * @param {string | null} [event] + * @param {null | (() => void)} [callback] + * @param {unknown} [scope] */ - off(event, callback) { - let listeners = this.#listeners[event]; - if (!listeners) { - return; - } - if (!callback) { - for (let handler of listeners.values()) { - this.#events.removeEventListener(event, handler); + off(event, callback, scope) { + let callbacks = []; + for (let [s, scope_listeners] of this.#listeners.entries()) { + if (scope && scope !== s) { + continue; } - listeners.clear(); - return; + for (let [e, listeners] of Object.entries(scope_listeners)) { + if (event && event !== e) { + continue; + } + for (let [cb, handler] of listeners.entries()) { + if (callback && callback !== cb) { + continue; + } + callbacks.push({ event: e, handler }); + listeners.delete(cb); + } + if (!listeners.size) { + delete scope_listeners[e]; + } + } + if (Object.keys(scope_listeners).length == 0) { + this.#listeners.delete(s); + } + } + for (let { event, handler } of callbacks) { + this.#events.removeEventListener(event, handler); } - let handler = listeners.get(callback); - if (!handler) return; - this.#events.removeEventListener(event, handler); - listeners.delete(callback); } /** diff --git a/packages/anywidget/src/types.ts b/packages/anywidget/src/types.ts index 94dd16d7..67d8ea18 100644 --- a/packages/anywidget/src/types.ts +++ b/packages/anywidget/src/types.ts @@ -63,7 +63,7 @@ export type UpdateMessage = { }; }; export type EchoUpdateMessage = { - parent_header?: { msg_id: string }; + parent_header: { msg_id: string }; buffers?: ReadonlyArray; content: { data: { @@ -83,7 +83,7 @@ export type CustomMessage = { }; }; -export type WidgetManager = { get_model: (model_id: string) => unknown }; +export type WidgetManager = { get_model: (model_id: string) => any }; export type FieldSerializer = { serialize: (value: A) => B | Promise; deserialize: (value: B, widget_manager: WidgetManager) => A | Promise; diff --git a/packages/anywidget/src/util.js b/packages/anywidget/src/util.js index a8723525..a63ec43d 100644 --- a/packages/anywidget/src/util.js +++ b/packages/anywidget/src/util.js @@ -124,3 +124,12 @@ export function is_update_msg(msg) { return msg.content.data.method === "update" || msg.content.data.method === "echo_update"; } + +/** + * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg + * @return {msg is import("./types.js").EchoUpdateMessage} + */ +export function is_echo_update_msg(msg) { + return msg.content.data.method === "echo_update" && + !!msg.parent_header?.msg_id; +} diff --git a/packages/anywidget/src/view.js b/packages/anywidget/src/view.js index 131540ba..1dc08dbd 100644 --- a/packages/anywidget/src/view.js +++ b/packages/anywidget/src/view.js @@ -1,4 +1,4 @@ -import { assert } from "./util.js"; +import * as utils from "./util.js"; import { Widget } from "@lumino/widgets"; /** @@ -32,12 +32,12 @@ export class View { /** @type {Record} */ options; /** @type {() => void} */ - #remove_callback = () => { }; + #remove_callback = () => {}; /** @param {ViewOptions} options */ - constructor(options) { - this.el = options.el ?? document.createElement("div"); - this.model = options.model; + constructor({ el, model, ...options }) { + this.el = el ?? document.createElement("div"); + this.model = model; this.options = options; this.luminoWidget = new Widget({ node: this.el }); } @@ -48,8 +48,11 @@ export class View { * @param {() => void} callback */ listenTo(model, name, callback) { - assert(name === "destroy", "Only 'destroy' event is supported in `listenTo`."); - model.on(name, callback); + utils.assert( + name === "destroy", + "[anywidget]: Only 'destroy' event is supported in `listenTo`.", + ); + model.once("destroy", callback); } /** @@ -57,7 +60,10 @@ export class View { * @param {() => void} callback */ once(name, callback) { - assert(name === "remove", "Only 'remove' event is supported in `once`."); + utils.assert( + name === "remove", + "[anywidget]: Only 'remove' event is supported in `once`.", + ); this.#remove_callback = callback; } @@ -66,30 +72,8 @@ export class View { this.el.remove(); } - /** - * @param {LuminoMessage} msg - */ - processLuminoMessage(msg) { - switch (msg.type) { - case 'after-attach': - this.trigger('displayed'); - break; - case 'show': - this.trigger('shown'); - break; - } - } - - /** - * @param {"displayed" | "shown"} name - */ - trigger(name) { - console.log(name) - } - /** * Render the view. */ - async render() { } - + async render() {} } diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index 5c79fa57..1f79f16a 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -143,7 +143,7 @@ async function load_widget(esm) { warn_render_deprecation(); return { url, - async initialize() { }, + async initialize() {}, render: mod.render, }; } @@ -184,10 +184,10 @@ function model_proxy(model, context) { send: model.send.bind(model), // @ts-expect-error on(name, callback) { - model.on(name, callback); + model.on(name, callback, context); }, off(name, callback) { - model.off(name, callback); + model.off(name, callback, context); }, widget_manager: model.widget_manager, }; @@ -250,7 +250,7 @@ function empty_element(el) { class Runtime { /** @type {() => void} */ - #disposer = () => { }; + #disposer = () => {}; /** @type {Set<() => void>} */ #view_disposers = new Set(); /** @type {import('solid-js').Resource>} */ @@ -285,7 +285,9 @@ class Runtime { try { await model.state_change; let widget = await load_widget(update); - cleanup = await widget.initialize?.({ model }); + cleanup = await widget.initialize?.({ + model: model_proxy(model, INITIALIZE_MARKER), + }); return ok(widget); } catch (e) { return error(e); @@ -313,7 +315,7 @@ class Runtime { solid.createResource(this.#widget_result, async (widget_result) => { cleanup?.(); // Clear all previous event listeners from this hook. - // model.off(null, null, view); + model.off(null, null, view); empty_element(view.el); if (widget_result.state === "error") { throw_anywidget_error(widget_result.error); @@ -356,7 +358,7 @@ class Runtime { // @ts-expect-error - injected by bundler let version = globalThis.VERSION; -export default function() { +export default function () { /** @type {WeakMap, Runtime>} */ let RUNTIMES = new WeakMap(); @@ -385,7 +387,7 @@ export default function() { /** @type {undefined | (() => void)} */ #dispose = undefined; async render() { - let runtime = RUNTIMES.get(/** @type {any} */(this.model)); + let runtime = RUNTIMES.get(/** @type {any} */ (this.model)); assert(runtime, "[anywidget] runtime not found."); assert(!this.#dispose, "[anywidget] dispose already set."); this.#dispose = await runtime.create_view(this); diff --git a/packages/types/index.ts b/packages/types/index.ts index eb874c6d..b3b34cd7 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -40,7 +40,7 @@ export interface AnyModel { callbacks?: any, buffers?: ArrayBuffer[] | ArrayBufferView[], ): void; - widget_manager: IWidgetManager; + widget_manager: Pick; } export interface RenderProps { From dc3e1aeaab217163df7b7f93090d5ccf173cfff8 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Sun, 17 Mar 2024 18:15:25 -0400 Subject: [PATCH 5/7] feat: extract runtime.js --- packages/anywidget/src/runtime.js | 115 ++++++++++ packages/anywidget/src/types.ts | 10 + packages/anywidget/src/util.js | 219 ++++++++++++++++++ packages/anywidget/src/widget.js | 363 +----------------------------- 4 files changed, 348 insertions(+), 359 deletions(-) create mode 100644 packages/anywidget/src/runtime.js diff --git a/packages/anywidget/src/runtime.js b/packages/anywidget/src/runtime.js new file mode 100644 index 00000000..05015035 --- /dev/null +++ b/packages/anywidget/src/runtime.js @@ -0,0 +1,115 @@ +import * as solid from "solid-js"; +import * as util from "./util.js"; + +/** + * This is a trick so that we can cleanup event listeners added + * by the user-defined function. + */ +let INITIALIZE_MARKER = Symbol("anywidget.initialize"); + +export class Runtime { + /** @type {() => void} */ + #disposer = () => {}; + /** @type {Set<() => void>} */ + #view_disposers = new Set(); + /** @type {import('solid-js').Resource>} */ + // @ts-expect-error - Set synchronously in constructor. + #widget_result; + + /** @param {import("./model.js").Model<{ _esm: string, _css?: string, _anywidget_id: string }>} model */ + constructor(model) { + this.#disposer = solid.createRoot((dispose) => { + let [css, set_css] = solid.createSignal(model.get("_css")); + model.on("change:_css", () => { + let id = model.get("_anywidget_id"); + console.debug(`[anywidget] css hot updated: ${id}`); + set_css(model.get("_css")); + }); + solid.createEffect(() => { + let id = model.get("_anywidget_id"); + util.load_css(css(), id); + }); + + /** @type {import("solid-js").Signal} */ + let [esm, setEsm] = solid.createSignal(model.get("_esm")); + model.on("change:_esm", async () => { + let id = model.get("_anywidget_id"); + console.debug(`[anywidget] esm hot updated: ${id}`); + setEsm(model.get("_esm")); + }); + /** @type {void | (() => import("vitest").Awaitable)} */ + let cleanup; + this.#widget_result = solid.createResource(esm, async (update) => { + await util.safe_cleanup(cleanup, "initialize"); + try { + await model.state_change; + let widget = await util.load_widget(update); + cleanup = await widget.initialize?.({ + model: util.model_proxy(model, INITIALIZE_MARKER), + }); + return util.ok(widget); + } catch (e) { + return util.error(e); + } + })[0]; + return () => { + cleanup?.(); + model.off("change:_css"); + model.off("change:_esm"); + dispose(); + }; + }); + } + + /** + * @param {import("./view.js").View} view + * @returns {Promise<() => void>} + */ + async create_view(view) { + let model = view.model; + let disposer = solid.createRoot((dispose) => { + /** @type {void | (() => import("vitest").Awaitable)} */ + let cleanup; + let resource = + solid.createResource(this.#widget_result, async (widget_result) => { + cleanup?.(); + // Clear all previous event listeners from this hook. + model.off(null, null, view); + util.empty_element(view.el); + if (widget_result.state === "error") { + util.throw_anywidget_error(widget_result.error); + } + let widget = widget_result.data; + try { + cleanup = await widget.render?.({ + model: util.model_proxy(model, view), + el: view.el, + }); + } catch (e) { + util.throw_anywidget_error(e); + } + })[0]; + solid.createEffect(() => { + if (resource.error) { + // TODO: Show error in the view? + } + }); + return () => { + dispose(); + cleanup?.(); + }; + }); + // Have the runtime keep track but allow the view to dispose itself. + this.#view_disposers.add(disposer); + return () => { + let deleted = this.#view_disposers.delete(disposer); + if (deleted) disposer(); + }; + } + + dispose() { + this.#view_disposers.forEach((dispose) => dispose()); + this.#view_disposers.clear(); + this.#disposer(); + } +} diff --git a/packages/anywidget/src/types.ts b/packages/anywidget/src/types.ts index 67d8ea18..03e1aac4 100644 --- a/packages/anywidget/src/types.ts +++ b/packages/anywidget/src/types.ts @@ -94,3 +94,13 @@ export type ModelOptions = { comm?: Comm; widget_manager: WidgetManager; }; + +export type AnyWidget = { + initialize: import("@anywidget/types").Initialize; + render: import("@anywidget/types").Render; +}; + +export type AnyWidgetModule = { + render?: import("@anywidget/types").Render; + default?: AnyWidget | (() => AnyWidget | Promise); +}; diff --git a/packages/anywidget/src/util.js b/packages/anywidget/src/util.js index a63ec43d..dd7286f0 100644 --- a/packages/anywidget/src/util.js +++ b/packages/anywidget/src/util.js @@ -133,3 +133,222 @@ export function is_echo_update_msg(msg) { return msg.content.data.method === "echo_update" && !!msg.parent_header?.msg_id; } + +/** + * @param {string} str + * @returns {str is "https://${string}" | "http://${string}"} + */ +export function is_href(str) { + return str.startsWith("http://") || str.startsWith("https://"); +} + +/** + * @param {string} href + * @param {string} anywidget_id + * @returns {Promise} + */ +async function load_css_href(href, anywidget_id) { + /** @type {HTMLLinkElement | null} */ + let prev = document.querySelector(`link[id='${anywidget_id}']`); + + // Adapted from https://github.com/vitejs/vite/blob/d59e1acc2efc0307488364e9f2fad528ec57f204/packages/vite/src/client/client.ts#L185-L201 + // Swaps out old styles with new, but avoids flash of unstyled content. + // No need to await the load since we already have styles applied. + if (prev) { + let newLink = /** @type {HTMLLinkElement} */ (prev.cloneNode()); + newLink.href = href; + newLink.addEventListener("load", () => prev?.remove()); + newLink.addEventListener("error", () => prev?.remove()); + prev.after(newLink); + return; + } + + return new Promise((resolve) => { + let link = Object.assign(document.createElement("link"), { + rel: "stylesheet", + href, + onload: resolve, + }); + document.head.appendChild(link); + }); +} + +/** + * @param {string} css_text + * @param {string} anywidget_id + * @returns {void} + */ +function load_css_text(css_text, anywidget_id) { + /** @type {HTMLStyleElement | null} */ + let prev = document.querySelector(`style[id='${anywidget_id}']`); + if (prev) { + // replace instead of creating a new DOM node + prev.textContent = css_text; + return; + } + let style = Object.assign(document.createElement("style"), { + id: anywidget_id, + type: "text/css", + }); + style.appendChild(document.createTextNode(css_text)); + document.head.appendChild(style); +} + +/** + * @param {string | undefined} css + * @param {string} anywidget_id + * @returns {Promise} + */ +export async function load_css(css, anywidget_id) { + if (!css || !anywidget_id) return; + if (is_href(css)) return load_css_href(css, anywidget_id); + return load_css_text(css, anywidget_id); +} + +/** + * @param {string} esm + * @returns {Promise<{ mod: import("./types.js").AnyWidgetModule, url: string }>} + */ +export async function load_esm(esm) { + if (is_href(esm)) { + return { + mod: await import(/* webpackIgnore: true */ esm), + url: esm, + }; + } + let url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" })); + let mod = await import(/* webpackIgnore: true */ url); + URL.revokeObjectURL(url); + return { mod, url }; +} + +function warn_render_deprecation() { + console.warn(`\ +[anywidget] Deprecation Warning. Direct export of a 'render' will likely be deprecated in the future. To migrate ... + +Remove the 'export' keyword from 'render' +----------------------------------------- + +export function render({ model, el }) { ... } +^^^^^^ + +Create a default export that returns an object with 'render' +------------------------------------------------------------ + +function render({ model, el }) { ... } + ^^^^^^ +export default { render } + ^^^^^^ + +To learn more, please see: https://github.com/manzt/anywidget/pull/395 +`); +} + +/** + * @param {string} esm + * @returns {Promise} + */ +export async function load_widget(esm) { + let { mod, url } = await load_esm(esm); + if (mod.render) { + warn_render_deprecation(); + return { + url, + async initialize() {}, + render: mod.render, + }; + } + assert( + mod.default, + `[anywidget] module must export a default function or object.`, + ); + let widget = typeof mod.default === "function" + ? await mod.default() + : mod.default; + return { url, ...widget }; +} + +/** + * @template {Record} T + * + * @param {import('./model.js').Model} model + * @param {unknown} context + * @return {import("@anywidget/types").AnyModel} + * + * Prunes the view down to the minimum context necessary. + * + * Calls to `model.get` and `model.set` automatically add the + * `context`, so we can gracefully unsubscribe from events + * added by user-defined hooks. + */ +export function model_proxy(model, context) { + return { + get: model.get.bind(model), + set: model.set.bind(model), + save_changes: model.save_changes.bind(model), + send: model.send.bind(model), + // @ts-expect-error + on(name, callback) { + model.on(name, callback, context); + }, + off(name, callback) { + model.off(name, callback, context); + }, + widget_manager: model.widget_manager, + }; +} + +/** + * @param {void | (() => import('vitest').Awaitable)} fn + * @param {string} kind + */ +export async function safe_cleanup(fn, kind) { + return Promise.resolve() + .then(() => fn?.()) + .catch((e) => console.warn(`[anywidget] error cleaning up ${kind}.`, e)); +} + +/** + * @template T + * @typedef {{ data: T, state: "ok" } | { error: any, state: "error" }} Result + */ + +/** @type {(data: T) => Result} */ +export function ok(data) { + return { data, state: "ok" }; +} + +/** @type {(e: any) => Result} */ +export function error(e) { + return { error: e, state: "error" }; +} + +/** + * Cleans up the stack trace at anywidget boundary. + * You can fully inspect the entire stack trace in the console interactively, + * but the initial error message is cleaned up to be more user-friendly. + * + * @param {unknown} source + * @returns {never} + */ +export function throw_anywidget_error(source) { + if (!(source instanceof Error)) { + // Don't know what to do with this. + throw source; + } + let lines = source.stack?.split("\n") ?? []; + let anywidget_index = lines.findIndex((line) => line.includes("anywidget")); + let clean_stack = anywidget_index === -1 + ? lines + : lines.slice(0, anywidget_index + 1); + source.stack = clean_stack.join("\n"); + console.error(source); + throw source; +} + +/** @param {HTMLElement} el */ +export function empty_element(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index 1f79f16a..9225b0b4 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -1,363 +1,8 @@ -import * as solid from "solid-js"; +import * as util from "./util.js"; +import { Runtime } from "./runtime.js"; import { Model } from "./model.js"; import { View } from "./view.js"; -/** - * @typedef AnyWidget - * @prop initialize {import("@anywidget/types").Initialize} - * @prop render {import("@anywidget/types").Render} - */ - -/** - * @typedef AnyWidgetModule - * @prop render {import("@anywidget/types").Render=} - * @prop default {AnyWidget | (() => AnyWidget | Promise)=} - */ - -/** - * @param {any} condition - * @param {string} message - * @returns {asserts condition} - */ -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -/** - * @param {string} str - * @returns {str is "https://${string}" | "http://${string}"} - */ -function is_href(str) { - return str.startsWith("http://") || str.startsWith("https://"); -} - -/** - * @param {string} href - * @param {string} anywidget_id - * @returns {Promise} - */ -async function load_css_href(href, anywidget_id) { - /** @type {HTMLLinkElement | null} */ - let prev = document.querySelector(`link[id='${anywidget_id}']`); - - // Adapted from https://github.com/vitejs/vite/blob/d59e1acc2efc0307488364e9f2fad528ec57f204/packages/vite/src/client/client.ts#L185-L201 - // Swaps out old styles with new, but avoids flash of unstyled content. - // No need to await the load since we already have styles applied. - if (prev) { - let newLink = /** @type {HTMLLinkElement} */ (prev.cloneNode()); - newLink.href = href; - newLink.addEventListener("load", () => prev?.remove()); - newLink.addEventListener("error", () => prev?.remove()); - prev.after(newLink); - return; - } - - return new Promise((resolve) => { - let link = Object.assign(document.createElement("link"), { - rel: "stylesheet", - href, - onload: resolve, - }); - document.head.appendChild(link); - }); -} - -/** - * @param {string} css_text - * @param {string} anywidget_id - * @returns {void} - */ -function load_css_text(css_text, anywidget_id) { - /** @type {HTMLStyleElement | null} */ - let prev = document.querySelector(`style[id='${anywidget_id}']`); - if (prev) { - // replace instead of creating a new DOM node - prev.textContent = css_text; - return; - } - let style = Object.assign(document.createElement("style"), { - id: anywidget_id, - type: "text/css", - }); - style.appendChild(document.createTextNode(css_text)); - document.head.appendChild(style); -} - -/** - * @param {string | undefined} css - * @param {string} anywidget_id - * @returns {Promise} - */ -async function load_css(css, anywidget_id) { - if (!css || !anywidget_id) return; - if (is_href(css)) return load_css_href(css, anywidget_id); - return load_css_text(css, anywidget_id); -} - -/** - * @param {string} esm - * @returns {Promise<{ mod: AnyWidgetModule, url: string }>} - */ -async function load_esm(esm) { - if (is_href(esm)) { - return { - mod: await import(/* webpackIgnore: true */ esm), - url: esm, - }; - } - let url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" })); - let mod = await import(/* webpackIgnore: true */ url); - URL.revokeObjectURL(url); - return { mod, url }; -} - -function warn_render_deprecation() { - console.warn(`\ -[anywidget] Deprecation Warning. Direct export of a 'render' will likely be deprecated in the future. To migrate ... - -Remove the 'export' keyword from 'render' ------------------------------------------ - -export function render({ model, el }) { ... } -^^^^^^ - -Create a default export that returns an object with 'render' ------------------------------------------------------------- - -function render({ model, el }) { ... } - ^^^^^^ -export default { render } - ^^^^^^ - -To learn more, please see: https://github.com/manzt/anywidget/pull/395 -`); -} - -/** - * @param {string} esm - * @returns {Promise} - */ -async function load_widget(esm) { - let { mod, url } = await load_esm(esm); - if (mod.render) { - warn_render_deprecation(); - return { - url, - async initialize() {}, - render: mod.render, - }; - } - assert( - mod.default, - `[anywidget] module must export a default function or object.`, - ); - let widget = typeof mod.default === "function" - ? await mod.default() - : mod.default; - return { url, ...widget }; -} - -/** - * This is a trick so that we can cleanup event listeners added - * by the user-defined function. - */ -let INITIALIZE_MARKER = Symbol("anywidget.initialize"); - -/** - * @template {Record} T - * - * @param {Model} model - * @param {unknown} context - * @return {import("@anywidget/types").AnyModel} - * - * Prunes the view down to the minimum context necessary. - * - * Calls to `model.get` and `model.set` automatically add the - * `context`, so we can gracefully unsubscribe from events - * added by user-defined hooks. - */ -function model_proxy(model, context) { - return { - get: model.get.bind(model), - set: model.set.bind(model), - save_changes: model.save_changes.bind(model), - send: model.send.bind(model), - // @ts-expect-error - on(name, callback) { - model.on(name, callback, context); - }, - off(name, callback) { - model.off(name, callback, context); - }, - widget_manager: model.widget_manager, - }; -} - -/** - * @param {void | (() => import('vitest').Awaitable)} fn - * @param {string} kind - */ -async function safe_cleanup(fn, kind) { - return Promise.resolve() - .then(() => fn?.()) - .catch((e) => console.warn(`[anywidget] error cleaning up ${kind}.`, e)); -} - -/** - * @template T - * @typedef {{ data: T, state: "ok" } | { error: any, state: "error" }} Result - */ - -/** @type {(data: T) => Result} */ -function ok(data) { - return { data, state: "ok" }; -} - -/** @type {(e: any) => Result} */ -function error(e) { - return { error: e, state: "error" }; -} - -/** - * Cleans up the stack trace at anywidget boundary. - * You can fully inspect the entire stack trace in the console interactively, - * but the initial error message is cleaned up to be more user-friendly. - * - * @param {unknown} source - * @returns {never} - */ -function throw_anywidget_error(source) { - if (!(source instanceof Error)) { - // Don't know what to do with this. - throw source; - } - let lines = source.stack?.split("\n") ?? []; - let anywidget_index = lines.findIndex((line) => line.includes("anywidget")); - let clean_stack = anywidget_index === -1 - ? lines - : lines.slice(0, anywidget_index + 1); - source.stack = clean_stack.join("\n"); - console.error(source); - throw source; -} - -/** @param {HTMLElement} el */ -function empty_element(el) { - while (el.firstChild) { - el.removeChild(el.firstChild); - } -} - -class Runtime { - /** @type {() => void} */ - #disposer = () => {}; - /** @type {Set<() => void>} */ - #view_disposers = new Set(); - /** @type {import('solid-js').Resource>} */ - // @ts-expect-error - Set synchronously in constructor. - #widget_result; - - /** @param {Model<{ _esm: string, _css?: string, _anywidget_id: string }>} model */ - constructor(model) { - this.#disposer = solid.createRoot((dispose) => { - let [css, set_css] = solid.createSignal(model.get("_css")); - model.on("change:_css", () => { - let id = model.get("_anywidget_id"); - console.debug(`[anywidget] css hot updated: ${id}`); - set_css(model.get("_css")); - }); - solid.createEffect(() => { - let id = model.get("_anywidget_id"); - load_css(css(), id); - }); - - /** @type {import("solid-js").Signal} */ - let [esm, setEsm] = solid.createSignal(model.get("_esm")); - model.on("change:_esm", async () => { - let id = model.get("_anywidget_id"); - console.debug(`[anywidget] esm hot updated: ${id}`); - setEsm(model.get("_esm")); - }); - /** @type {void | (() => import("vitest").Awaitable)} */ - let cleanup; - this.#widget_result = solid.createResource(esm, async (update) => { - await safe_cleanup(cleanup, "initialize"); - try { - await model.state_change; - let widget = await load_widget(update); - cleanup = await widget.initialize?.({ - model: model_proxy(model, INITIALIZE_MARKER), - }); - return ok(widget); - } catch (e) { - return error(e); - } - })[0]; - return () => { - cleanup?.(); - model.off("change:_css"); - model.off("change:_esm"); - dispose(); - }; - }); - } - - /** - * @param {View} view - * @returns {Promise<() => void>} - */ - async create_view(view) { - let model = view.model; - let disposer = solid.createRoot((dispose) => { - /** @type {void | (() => import("vitest").Awaitable)} */ - let cleanup; - let resource = - solid.createResource(this.#widget_result, async (widget_result) => { - cleanup?.(); - // Clear all previous event listeners from this hook. - model.off(null, null, view); - empty_element(view.el); - if (widget_result.state === "error") { - throw_anywidget_error(widget_result.error); - } - let widget = widget_result.data; - try { - cleanup = await widget.render?.({ - model: model_proxy(model, view), - el: view.el, - }); - } catch (e) { - throw_anywidget_error(e); - } - })[0]; - solid.createEffect(() => { - if (resource.error) { - // TODO: Show error in the view? - } - }); - return () => { - dispose(); - cleanup?.(); - }; - }); - // Have the runtime keep track but allow the view to dispose itself. - this.#view_disposers.add(disposer); - return () => { - let deleted = this.#view_disposers.delete(disposer); - if (deleted) disposer(); - }; - } - - dispose() { - this.#view_disposers.forEach((dispose) => dispose()); - this.#view_disposers.clear(); - this.#disposer(); - } -} - -// @ts-expect-error - injected by bundler -let version = globalThis.VERSION; - export default function () { /** @type {WeakMap, Runtime>} */ let RUNTIMES = new WeakMap(); @@ -388,8 +33,8 @@ export default function () { #dispose = undefined; async render() { let runtime = RUNTIMES.get(/** @type {any} */ (this.model)); - assert(runtime, "[anywidget] runtime not found."); - assert(!this.#dispose, "[anywidget] dispose already set."); + util.assert(runtime, "[anywidget] runtime not found."); + util.assert(!this.#dispose, "[anywidget] dispose already set."); this.#dispose = await runtime.create_view(this); } remove() { From df2396f700a072b871b3670d9d59a9f75394dce0 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Sun, 17 Mar 2024 20:09:32 -0400 Subject: [PATCH 6/7] cleanup initialize and render --- packages/anywidget/src/model.js | 74 ++++++++++++++++++------------- packages/anywidget/src/runtime.js | 2 +- packages/anywidget/src/util.js | 2 +- packages/anywidget/src/view.js | 29 +++++++----- packages/anywidget/src/widget.js | 8 ++-- 5 files changed, 65 insertions(+), 50 deletions(-) diff --git a/packages/anywidget/src/model.js b/packages/anywidget/src/model.js index 1c8f4e76..d39e3ad3 100644 --- a/packages/anywidget/src/model.js +++ b/packages/anywidget/src/model.js @@ -8,11 +8,9 @@ import * as utils from "./util.js"; /** @template {Record} T */ export class Model { /** @type {import("./types.js").Comm=} */ - #comm; + comm; /** @type {Omit} */ #options; - /** @type {Map void, (event: Event) => void> }>} */ - #listeners = new Map(); /** @type {T} */ #state; /** @type {Set} */ @@ -21,6 +19,8 @@ export class Model { #field_serializers; /** @type {EventTarget} */ #events = new EventTarget(); + /** @type {Map void, (event: Event) => void> }>} */ + #listeners = new Map(); // NOTE: (from Jupyter Team): keep track of the msg id for each attr for updates // we send out so that we can ignore old messages that we send in @@ -28,6 +28,8 @@ export class Model { /** @type {Map} */ #expected_echo_msg_ids = new Map(); + #closed = false; + // NOTE: Required for the WidgetManager to know when the model is ready /** @type {Promise} */ state_change; @@ -48,15 +50,18 @@ export class Model { serialize(layout) { return JSON.parse(JSON.stringify(layout)); }, - /** @param {string} layout */ + /** + * @param {string} layout + * @param {import("./types.js").WidgetManager} widget_manager + */ deserialize(layout, widget_manager) { return widget_manager.get_model(layout.slice("IPY_MODEL_".length)); }, }, }; - this.#comm = options.comm; - this.#comm?.on_msg(this.#handle_comm_msg.bind(this)); - this.#comm?.on_close(this.#handle_comm_close.bind(this)); + this.comm = options.comm; + this.comm?.on_msg(this.#handle_comm_msg.bind(this)); + this.comm?.on_close(this.#handle_comm_close.bind(this)); this.state_change = this.#deserialize(state).then((de) => { this.#state = de; }); @@ -66,8 +71,17 @@ export class Model { return this.#options.widget_manager; } + /** @param {boolean} update */ + set comm_live(update) { + // NOTE: JupyterLab seems to try to set this. The only sensible behavior I can think of + // is to set the comm to undefined if the update is false, and do nothing otherwise. + if (update === false) { + this.comm = undefined; + } + } + get comm_live() { - return !!this.#comm; + return !!this.comm; } get #msg_buffer() { @@ -136,6 +150,9 @@ export class Model { * @param {import("./types.js").CommMessage} msg - the comm message. */ async #handle_comm_msg(msg) { + if (!this.comm) { + return; + } if (utils.is_update_msg(msg)) { await this.#handle_update(msg); return; @@ -201,17 +218,17 @@ export class Model { * @returns - a promise that is fulfilled when all the associated views have been removed. */ async #handle_comm_close() { - // can only be closed once. - if (!this.#comm) return; - this.#comm.close(); - this.#comm = undefined; - this.#emit("comm:close"); - this.off(); - this.#listeners.clear(); + this.trigger("comm:close"); + if (this.#closed) { + return; + } + this.#closed = true; + this.comm?.close(); + this.comm = undefined; for await (let view of Object.values(this.views)) { view.remove(); } - this.views = {}; + this.trigger("destroy"); } /** @@ -236,7 +253,7 @@ export class Model { } async save_changes() { - if (!this.#comm) return; + if (!this.comm) return; /** @type {Partial} */ let to_send = {}; for (let key of this.#need_sync) { @@ -246,7 +263,7 @@ export class Model { let serialized = await this.serialize(to_send); this.#need_sync.clear(); let { state, buffer_paths, buffers } = utils.extract_buffers(serialized); - this.#comm.send( + this.comm.send( { method: "update", state, buffer_paths }, undefined, {}, @@ -311,7 +328,6 @@ export class Model { * @param {unknown} [scope] */ off(event, callback, scope) { - let callbacks = []; for (let [s, scope_listeners] of this.#listeners.entries()) { if (scope && scope !== s) { continue; @@ -324,20 +340,11 @@ export class Model { if (callback && callback !== cb) { continue; } - callbacks.push({ event: e, handler }); + this.#events.removeEventListener(e, handler); listeners.delete(cb); } - if (!listeners.size) { - delete scope_listeners[e]; - } - } - if (Object.keys(scope_listeners).length == 0) { - this.#listeners.delete(s); } } - for (let { event, handler } of callbacks) { - this.#events.removeEventListener(event, handler); - } } /** @@ -347,13 +354,16 @@ export class Model { * @param {ArrayBuffer[]} [buffers] - An array of ArrayBuffers to send as part of the message. */ send(content, callbacks, buffers) { - if (!this.#comm) return; - this.#comm.send({ method: "custom", content }, callbacks, {}, buffers); + if (!this.comm) return; + this.comm.send({ method: "custom", content }, callbacks, {}, buffers); } /** @param {string} event */ trigger(event) { - utils.assert(event === "destroy", "Only 'destroy' event is supported"); + utils.assert( + event === "destroy" || event === "comm:close", + "[anywidget] Only 'destroy' or 'comm:close' event is supported `Model.trigger`", + ); this.#emit(event); } diff --git a/packages/anywidget/src/runtime.js b/packages/anywidget/src/runtime.js index 05015035..dc5354a2 100644 --- a/packages/anywidget/src/runtime.js +++ b/packages/anywidget/src/runtime.js @@ -75,7 +75,7 @@ export class Runtime { cleanup?.(); // Clear all previous event listeners from this hook. model.off(null, null, view); - util.empty_element(view.el); + util.empty(view.el); if (widget_result.state === "error") { util.throw_anywidget_error(widget_result.error); } diff --git a/packages/anywidget/src/util.js b/packages/anywidget/src/util.js index dd7286f0..338e3dc4 100644 --- a/packages/anywidget/src/util.js +++ b/packages/anywidget/src/util.js @@ -347,7 +347,7 @@ export function throw_anywidget_error(source) { } /** @param {HTMLElement} el */ -export function empty_element(el) { +export function empty(el) { while (el.firstChild) { el.removeChild(el.firstChild); } diff --git a/packages/anywidget/src/view.js b/packages/anywidget/src/view.js index 1dc08dbd..13446764 100644 --- a/packages/anywidget/src/view.js +++ b/packages/anywidget/src/view.js @@ -1,11 +1,6 @@ -import * as utils from "./util.js"; +import * as util from "./util.js"; import { Widget } from "@lumino/widgets"; -/** - * @template {Record} T - * @typedef {import("./model.js").Model} Model - */ - /** * @typedef LuminoMessage * @property {string} type @@ -13,6 +8,11 @@ import { Widget } from "@lumino/widgets"; * @property {(msg: LuminoMessage) => boolean} conflate */ +/** + * @template {Record} T + * @typedef {import("./model.js").Model} Model + */ + /** * @template {Record} T * @typedef ViewOptions @@ -31,7 +31,6 @@ export class View { model; /** @type {Record} */ options; - /** @type {() => void} */ #remove_callback = () => {}; /** @param {ViewOptions} options */ @@ -39,6 +38,7 @@ export class View { this.el = el ?? document.createElement("div"); this.model = model; this.options = options; + // TODO: We should try to drop the Lumino dependency. However, this seems required for all widgets. this.luminoWidget = new Widget({ node: this.el }); } @@ -48,11 +48,10 @@ export class View { * @param {() => void} callback */ listenTo(model, name, callback) { - utils.assert( + util.assert( name === "destroy", - "[anywidget]: Only 'destroy' event is supported in `listenTo`.", + "[anywidget] Only 'destroy' event is supported in `View.listenTo`", ); - model.once("destroy", callback); } /** @@ -60,20 +59,26 @@ export class View { * @param {() => void} callback */ once(name, callback) { - utils.assert( + util.assert( name === "remove", - "[anywidget]: Only 'remove' event is supported in `once`.", + "[anywidget] Only 'remove' event is supported in `View.once`", ); this.#remove_callback = callback; } remove() { this.#remove_callback(); + util.empty(this.el); this.el.remove(); + this.model.off(null, null, this); } /** * Render the view. + * + * Should be overridden by subclasses. + * + * @returns {Promise} */ async render() {} } diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index 9225b0b4..c8eac385 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -16,6 +16,7 @@ export default function () { constructor(...args) { super(...args); let runtime = new Runtime(this); + window.model = this; this.once("destroy", () => { try { runtime.dispose(); @@ -29,16 +30,15 @@ export default function () { /** @extends {View} */ class AnyView extends View { - /** @type {undefined | (() => void)} */ - #dispose = undefined; + /** @type {() => void} */ + #dispose = () => {}; async render() { let runtime = RUNTIMES.get(/** @type {any} */ (this.model)); util.assert(runtime, "[anywidget] runtime not found."); - util.assert(!this.#dispose, "[anywidget] dispose already set."); this.#dispose = await runtime.create_view(this); } remove() { - this.#dispose?.(); + this.#dispose(); super.remove(); } } From 157b09e5537bc434f9afe1c884611291ca07b726 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Sun, 17 Mar 2024 20:37:34 -0400 Subject: [PATCH 7/7] formatting --- packages/anywidget/src/view.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/anywidget/src/view.js b/packages/anywidget/src/view.js index 13446764..60c7bf05 100644 --- a/packages/anywidget/src/view.js +++ b/packages/anywidget/src/view.js @@ -67,6 +67,7 @@ export class View { } remove() { + this.luminoWidget?.dispose(); this.#remove_callback(); util.empty(this.el); this.el.remove();