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 new file mode 100644 index 00000000..d39e3ad3 --- /dev/null +++ b/packages/anywidget/src/model.js @@ -0,0 +1,381 @@ +import * as utils from "./util.js"; + +/** + * @template {Record<string, unknown>} T + * @typedef {import('./view.js').View<T>} View + */ + +/** @template {Record<string, unknown>} T */ +export class Model { + /** @type {import("./types.js").Comm=} */ + comm; + /** @type {Omit<import("./types.js").ModelOptions, "comm">} */ + #options; + /** @type {T} */ + #state; + /** @type {Set<string>} */ + #need_sync = new Set(); + /** @type {Record<string, import("./types.js").FieldSerializer<any, any>>} */ + #field_serializers; + /** @type {EventTarget} */ + #events = new EventTarget(); + /** @type {Map<any, { [evt_name: string]: 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 + // order to avoid 'drunken' sliders going back and forward + /** @type {Map<string, string>} */ + #expected_echo_msg_ids = new Map(); + + #closed = false; + + // NOTE: Required for the WidgetManager to know when the model is ready + /** @type {Promise<void>} */ + state_change; + + /** @type {Record<string, Promise<View<T>>>} */ + views = {}; + + /** + * @param {T} state + * @param {import("./types.js").ModelOptions} options + */ + constructor(state, options) { + this.#state = state; + this.#options = options; + this.#field_serializers = { + layout: { + /** @param {string} layout */ + serialize(layout) { + return JSON.parse(JSON.stringify(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.state_change = this.#deserialize(state).then((de) => { + this.#state = de; + }); + } + + get widget_manager() { + 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; + } + + 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<T>} + */ + static async _deserialize_state(state) { + return state; + } + + /** + * Serialize the model state. + * @template {Partial<T>} T + * @param {T} ser + * @returns {Promise<T>} + */ + 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.#options.widget_manager, + ); + } + return state; + } + + /** + * Deserialize the model state. + * @template {Partial<T>} T + * @param {T} de + * @returns {Promise<T>} + */ + async serialize(de) { + /** @type {any} */ + let state = {}; + for (let key in de) { + let serializer = this.#field_serializers[key]; + if (!serializer) { + state[key] = structuredClone(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) { + if (!this.comm) { + return; + } + if (utils.is_update_msg(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)}`); + } + + /** + * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg + */ + 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); + this.set_state(deserialized); + } + + /** + * @param {Record<string, unknown>} state + * @param {string} 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)) { + 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] + */ + #emit(name, value) { + this.#events.dispatchEvent(new CustomEvent(name, { detail: value })); + } + + /** + * 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() { + 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.trigger("destroy"); + } + + /** + * @template {keyof T} K + * @param {K} key + * @returns {T[K]} + */ + get(key) { + return this.#state[key]; + } + + /** + * @template {keyof T & string} K + * @param {K} key + * @param {T[K]} value + */ + set(key, value) { + this.#state[key] = value; + this.#emit(`change:${key}`); + this.#emit("change"); + this.#need_sync.add(key); + } + + async save_changes() { + if (!this.comm) return; + /** @type {Partial<T>} */ + 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 { state, buffer_paths, buffers } = utils.extract_buffers(serialized); + this.comm.send( + { method: "update", state, buffer_paths }, + undefined, + {}, + buffers, + ); + } + + /** + * @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, scope = this) { + /** @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(); + } + 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<T>} state + */ + set_state(state) { + 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; + } + + /** + * @param {string | null} [event] + * @param {null | (() => void)} [callback] + * @param {unknown} [scope] + */ + off(event, callback, scope) { + for (let [s, scope_listeners] of this.#listeners.entries()) { + if (scope && scope !== s) { + continue; + } + 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; + } + this.#events.removeEventListener(e, handler); + listeners.delete(cb); + } + } + } + } + + /** + * 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) { + utils.assert( + event === "destroy" || event === "comm:close", + "[anywidget] Only 'destroy' or 'comm:close' event is supported `Model.trigger`", + ); + this.#emit(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/runtime.js b/packages/anywidget/src/runtime.js new file mode 100644 index 00000000..dc5354a2 --- /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<util.Result<import("./types.js").AnyWidget & { url: string }>>} */ + // @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<string>} */ + 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<void>)} */ + 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<any>} view + * @returns {Promise<() => void>} + */ + async create_view(view) { + let model = view.model; + let disposer = solid.createRoot((dispose) => { + /** @type {void | (() => import("vitest").Awaitable<void>)} */ + 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(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 new file mode 100644 index 00000000..03e1aac4 --- /dev/null +++ b/packages/anywidget/src/types.ts @@ -0,0 +1,106 @@ +export type JSONValue = + | string + | number + | boolean + | { [x: string]: JSONValue } + | Array<JSONValue>; + +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<string, unknown>, + 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<string, unknown>, + 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 = { + parent_header?: { msg_id: string }; + buffers?: ReadonlyArray<ArrayBuffer | DataView>; + content: { + data: { + method: "update"; + state: Record<string, unknown>; + buffer_paths?: ReadonlyArray<ReadonlyArray<string | number>>; + }; + }; +}; +export type EchoUpdateMessage = { + parent_header: { msg_id: string }; + buffers?: ReadonlyArray<ArrayBuffer | DataView>; + content: { + data: { + method: "echo_update"; + state: Record<string, unknown>; + buffer_paths?: ReadonlyArray<ReadonlyArray<string | number>>; + }; + }; +}; +export type CustomMessage = { + buffers?: ReadonlyArray<ArrayBuffer | DataView>; + content: { + data: { + method: "custom"; + content: unknown; + }; + }; +}; + +export type WidgetManager = { get_model: (model_id: string) => any }; +export type FieldSerializer<A, B> = { + serialize: (value: A) => B | Promise<B>; + deserialize: (value: B, widget_manager: WidgetManager) => A | Promise<A>; +}; + +export type ModelOptions = { + model_id: string; + 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<AnyWidget>); +}; diff --git a/packages/anywidget/src/util.js b/packages/anywidget/src/util.js new file mode 100644 index 00000000..338e3dc4 --- /dev/null +++ b/packages/anywidget/src/util.js @@ -0,0 +1,354 @@ +/** + * @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<string, unknown>} state + * @param {ReadonlyArray<ReadonlyArray<string | number>>} buffer_paths + * @param {ReadonlyArray<ArrayBufferLike | DataView>} 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<string, unknown>} state + * @returns {{ + * state: import("./types.js").JSONValue, + * buffer_paths: Array<Array<string | number>>, + * buffers: Array<ArrayBuffer> + * }} + */ +export function extract_buffers(state) { + /** @type {Array<Array<string | number>>} */ + let buffer_paths = []; + /** @type {Array<ArrayBuffer>} */ + let buffers = []; + /** + * @param {any} obj + * @param {any} parent + * @param {string | number | null} key_in_parent + * @param {Array<string | number>} 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 }; +} + +/** + * @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"; +} + +/** + * @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; +} + +/** + * @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<void>} + */ +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<void>} + */ +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<import("./types.js").AnyWidget & { url: string }>} + */ +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<string, any>} T + * + * @param {import('./model.js').Model<T>} 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<void>)} 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 {<T>(data: T) => Result<T>} */ +export function ok(data) { + return { data, state: "ok" }; +} + +/** @type {(e: any) => Result<any>} */ +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(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} diff --git a/packages/anywidget/src/view.js b/packages/anywidget/src/view.js new file mode 100644 index 00000000..60c7bf05 --- /dev/null +++ b/packages/anywidget/src/view.js @@ -0,0 +1,85 @@ +import * as util from "./util.js"; +import { Widget } from "@lumino/widgets"; + +/** + * @typedef LuminoMessage + * @property {string} type + * @property {boolean} isConflatable + * @property {(msg: LuminoMessage) => boolean} conflate + */ + +/** + * @template {Record<string, unknown>} T + * @typedef {import("./model.js").Model<T>} Model + */ + +/** + * @template {Record<string, unknown>} T + * @typedef ViewOptions + * @prop {Model<T>} model + * @prop {HTMLElement} [el] + * @prop {string} [id] + */ + +/** + * @template {Record<string, unknown>} T + */ +export class View { + /** @type {HTMLElement} */ + el; + /** @type {Model<T>} */ + model; + /** @type {Record<string, unknown>} */ + options; + #remove_callback = () => {}; + + /** @param {ViewOptions<T>} options */ + constructor({ el, model, ...options }) { + 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 }); + } + + /** + * @param {Model<T>} model + * @param {"destroy"} name + * @param {() => void} callback + */ + listenTo(model, name, callback) { + util.assert( + name === "destroy", + "[anywidget] Only 'destroy' event is supported in `View.listenTo`", + ); + } + + /** + * @param {"remove"} name + * @param {() => void} callback + */ + once(name, callback) { + util.assert( + name === "remove", + "[anywidget] Only 'remove' event is supported in `View.once`", + ); + this.#remove_callback = callback; + } + + remove() { + this.luminoWidget?.dispose(); + 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<void>} + */ + async render() {} +} diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index 8e1167dc..c8eac385 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -1,375 +1,22 @@ -import { - createEffect, - createResource, - createRoot, - createSignal, -} 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<AnyWidget>)=} - */ - -/** - * @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<void>} - */ -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<void>} - */ -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<AnyWidget & { url: string }>} - */ -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"); - -/** - * @param {import("@jupyter-widgets/base").DOMWidgetModel} 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<void>)} 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 {<T>(data: T) => Result<T>} */ -function ok(data) { - return { data, state: "ok" }; -} - -/** @type {(e: any) => Result<any>} */ -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; -} - -class Runtime { - /** @type {() => void} */ - #disposer = () => {}; - /** @type {Set<() => void>} */ - #view_disposers = new Set(); - /** @type {import('solid-js').Resource<Result<AnyWidget & { url: string }>>} */ - // @ts-expect-error - Set synchronously in constructor. - #widget_result; - - /** @param {import("@jupyter-widgets/base").DOMWidgetModel} model */ - constructor(model) { - this.#disposer = createRoot((dispose) => { - let [css, set_css] = 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(() => { - let id = model.get("_anywidget_id"); - load_css(css(), id); - }); - - /** @type {import("solid-js").Signal<string>} */ - let [esm, setEsm] = 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<void>)} */ - let cleanup; - this.#widget_result = createResource(esm, async (update) => { - await safe_cleanup(cleanup, "initialize"); - try { - model.off(null, null, INITIALIZE_MARKER); - 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(); - }; - }); - } +export default function () { + /** @type {WeakMap<AnyModel<any>, Runtime>} */ + let RUNTIMES = new WeakMap(); /** - * @param {import("@jupyter-widgets/base").DOMWidgetView} view - * @returns {Promise<() => void>} + * @template {{ _esm: string, _css?: string, _anywidget_id: string }} T + * @extends {Model<T>} */ - async create_view(view) { - let model = view.model; - let disposer = createRoot((dispose) => { - /** @type {void | (() => import("vitest").Awaitable<void>)} */ - let cleanup; - let resource = - createResource(this.#widget_result, async (widget_result) => { - cleanup?.(); - // Clear all previous event listeners from this hook. - model.off(null, null, view); - view.$el.empty(); - 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]; - 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; - -/** @param {typeof import("@jupyter-widgets/base")} base */ -export default function ({ DOMWidgetModel, DOMWidgetView }) { - /** @type {WeakMap<AnyModel, 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<InstanceType<DOMWidgetModel>["initialize"]>} args */ - initialize(...args) { - super.initialize(...args); + class AnyModel extends Model { + /** @param {ConstructorParameters<typeof Model<T>>} args */ + constructor(...args) { + super(...args); let runtime = new Runtime(this); + window.model = this; this.once("destroy", () => { try { runtime.dispose(); @@ -379,52 +26,19 @@ export default function ({ DOMWidgetModel, DOMWidgetView }) { }); RUNTIMES.set(this, runtime); } - - /** - * @param {Record<string, any>} 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; + /** @extends {View<any>} */ + class AnyView extends View { + /** @type {() => void} */ + #dispose = () => {}; async render() { - let runtime = RUNTIMES.get(this.model); - assert(runtime, "[anywidget] runtime not found."); - assert(!this.#dispose, "[anywidget] dispose already set."); + let runtime = RUNTIMES.get(/** @type {any} */ (this.model)); + util.assert(runtime, "[anywidget] runtime not found."); this.#dispose = await runtime.create_view(this); } remove() { - this.#dispose?.(); + this.#dispose(); super.remove(); } } 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<T extends ObjectHash = ObjectHash> { callbacks?: any, buffers?: ArrayBuffer[] | ArrayBufferView[], ): void; - widget_manager: IWidgetManager; + widget_manager: Pick<IWidgetManager, "get_model">; } export interface RenderProps<T extends ObjectHash = ObjectHash> { 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