Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/anywidget/__tests__/widget.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as widgets from "@jupyter-widgets/base";
import * as baseManager from "@jupyter-widgets/base-manager";
import { afterEach, expect, it } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";
import { afterEach, expect, it } from "vitest";
import { page, userEvent } from "vitest/browser";

import create_anywidget from "../src/widget.js";

Expand Down
8 changes: 8 additions & 0 deletions packages/anywidget/scripts/jlab.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ module.exports = {
filename: "[name].[contenthash:8].js",
path: path.resolve(out, "static"),
},
module: {
rules: [
{
test: /\.ts$/,
loader: "builtin:swc-loader",
},
],
},
plugins: [
new rspack.DefinePlugin({
"globalThis.VERSION": JSON.stringify(pkg.version),
Expand Down
127 changes: 127 additions & 0 deletions packages/anywidget/src/binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { Experimental, Host } from "@anywidget/types";
import type { DOMWidgetModel, DOMWidgetView } from "@jupyter-widgets/base";

import type { AnyWidget } from "./load.ts";
import { INITIALIZE_MARKER, model_proxy } from "./model-proxy.ts";
import { type Awaitable, promise_with_resolvers, safe_cleanup } from "./util.ts";

function is_safe_cleanup_function(x: unknown): x is () => Awaitable<void> {
return typeof x === "function";
}

export class WidgetBinding {
#controller: AbortController | undefined;
#widget_def: AnyWidget | undefined;
#exports: unknown;
#model: DOMWidgetModel;
ready: Promise<unknown>;
#resolvers: PromiseWithResolvers<unknown>;

constructor(model: DOMWidgetModel) {
this.#model = model;
this.#resolvers = promise_with_resolvers();
this.ready = this.#resolvers.promise;
}

async bind(
widget_def: AnyWidget,
{ experimental }: { experimental: Experimental },
): Promise<void> {
if (this.#widget_def === widget_def) return;

if (this.#widget_def && this.#widget_def !== widget_def) {
this.#controller?.abort();
this.#resolvers = promise_with_resolvers();
this.ready = this.#resolvers.promise;
}

this.#widget_def = widget_def;
this.#controller = new AbortController();
let signal = this.#controller.signal;
let model = this.#model;

model.off(null, null, INITIALIZE_MARKER);

let result = await widget_def.initialize?.({
model: model_proxy(model, INITIALIZE_MARKER),
signal,
experimental,
});

if (signal.aborted) {
await safe_cleanup(is_safe_cleanup_function(result) ? result : undefined, "esm update");
return;
}

if (is_safe_cleanup_function(result)) {
signal.addEventListener("abort", () => safe_cleanup(result, "esm update"));
this.#exports = undefined;
} else if (typeof result === "object" && result !== null) {
this.#exports = result;
} else {
this.#exports = undefined;
}

this.#resolvers.resolve(this.#exports);
}

async create_view(
view: DOMWidgetView,
{ signal, experimental, host }: { signal: AbortSignal; experimental: Experimental; host: Host },
): Promise<(() => void) | undefined> {
await this.ready;
if (!this.#widget_def?.render) return;
let controller = new AbortController();
let combined = AbortSignal.any([signal, controller.signal]);
let cleanup = await this.#widget_def.render({
model: model_proxy(this.#model, view),
el: view.el,
signal: combined,
host,
experimental,
});
if (combined.aborted) {
await safe_cleanup(cleanup, "dispose view - already aborted");
return;
}
combined.addEventListener("abort", () => safe_cleanup(cleanup, "dispose view - aborted"));
return () => controller.abort();
}

get exports(): unknown {
return this.#exports;
}

destroy(): void {
this.#controller?.abort();
this.#controller = undefined;
this.#widget_def = undefined;
}
}

class BindingManager {
#bindings = new Map<DOMWidgetModel, WidgetBinding>();

get_or_create(model: DOMWidgetModel): WidgetBinding {
let binding = this.#bindings.get(model);
if (!binding) {
binding = new WidgetBinding(model);
this.#bindings.set(model, binding);
}
return binding;
}

get(model: DOMWidgetModel): WidgetBinding | undefined {
return this.#bindings.get(model);
}

destroy(model: DOMWidgetModel): void {
let binding = this.#bindings.get(model);
if (binding) {
binding.destroy();
this.#bindings.delete(model);
}
}
}

export let BINDINGS = new BindingManager();
63 changes: 63 additions & 0 deletions packages/anywidget/src/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Host } from "@anywidget/types";
import type { DOMWidgetModel, DOMWidgetView } from "@jupyter-widgets/base";

import { BINDINGS } from "./binding.ts";
import { invoke } from "./invoke.ts";
import { model_proxy } from "./model-proxy.ts";
import { parse_widget_ref } from "./widget-ref.ts";

export function create_host(model: DOMWidgetModel, { signal }: { signal: AbortSignal }): Host {
let host: Host = {
// @ts-expect-error - model_proxy returns AnyModel; generic T is erased at runtime
async getModel(ref) {
let model_id = parse_widget_ref(ref);
let child_model = await model.widget_manager.get_model(model_id);
let context = Symbol("anywidget.host.getModel");
signal.addEventListener("abort", () => child_model.off(null, null, context));
return model_proxy(child_model, context);
},
// @ts-expect-error - generic T is erased at runtime, exports typed as unknown
async getWidget(ref) {
let model_id = parse_widget_ref(ref);
let child_model = await model.widget_manager.get_model(model_id);
let child_binding = BINDINGS.get(child_model);
if (!child_binding) {
throw new Error(`[anywidget] No binding found for widget ${model_id}`);
}
let exports = await Promise.race([
child_binding.ready,
new Promise((_, reject) =>
AbortSignal.timeout(10000).addEventListener("abort", () =>
reject(new Error(`[anywidget] Timed out waiting for widget ${model_id} to initialize`)),
),
),
]);
return {
exports,
async render({ el, signal: view_signal }) {
let child_view_signal = view_signal ?? signal;
// Create a minimal view-like object for the binding
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- intentionally creating a partial DOMWidgetView for child rendering
let child_view = {
model: child_model,
el,
$el: {
empty() {
el.innerHTML = "";
},
},
} as unknown as DOMWidgetView;
await child_binding.create_view(child_view, {
signal: child_view_signal,
experimental: {
// @ts-expect-error - bind isn't working
invoke: invoke.bind(null, child_model),
},
host,
});
},
};
},
};
return host;
}
2 changes: 1 addition & 1 deletion packages/anywidget/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import create from "./widget.js";
import create from "./widget.ts";

// @ts-expect-error -- define is a global provided by the notebook runtime.
define(["@jupyter-widgets/base"], create);
40 changes: 40 additions & 0 deletions packages/anywidget/src/invoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { AnyModel } from "@anywidget/types";
import * as uuid from "@lukeed/uuid";

export interface InvokeOptions {
buffers?: DataView[];
signal?: AbortSignal;
}

export function invoke<T>(
model: AnyModel,
name: string,
msg?: unknown,
options: InvokeOptions = {},
): Promise<[T, DataView[]]> {
// crypto.randomUUID() is not available in non-secure contexts (i.e., http://)
// so we use simple (non-secure) polyfill.
let id = uuid.v4();
let signal = options.signal ?? AbortSignal.timeout(3000);

return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
}
signal.addEventListener("abort", () => {
model.off("msg:custom", handler);
reject(signal.reason);
});

function handler(
msg: { id: string; kind: "anywidget-command-response"; response: T },
buffers: DataView[],
): void {
if (!(msg.id === id)) return;
resolve([msg.response, buffers]);
model.off("msg:custom", handler);
}
model.on("msg:custom", handler);
model.send({ id, kind: "anywidget-command", name, msg }, undefined, options.buffers ?? []);
});
}
115 changes: 115 additions & 0 deletions packages/anywidget/src/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Initialize, Render } from "@anywidget/types";

import { assert } from "./util.ts";

export interface AnyWidget {
initialize?: Initialize;
render?: Render;
}

export interface AnyWidgetModule {
render?: Render;
default?: AnyWidget | (() => AnyWidget | Promise<AnyWidget>);
}

function is_href(str: string): str is `https://${string}` | `http://${string}` {
return str.startsWith("http://") || str.startsWith("https://");
}

async function load_css_href(href: string, anywidget_id: string): Promise<void> {
let prev = document.querySelector<HTMLLinkElement>(`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) {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Node.cloneNode() returns Node; we know prev is HTMLLinkElement so the clone is too
let newLink = prev.cloneNode() as HTMLLinkElement;
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);
});
}

function load_css_text(css_text: string, anywidget_id: string): void {
let prev = document.querySelector<HTMLStyleElement>(`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);
}

export async function load_css(css: string | undefined, anywidget_id: string): Promise<void> {
if (!css || !anywidget_id) return;
if (is_href(css)) return load_css_href(css, anywidget_id);
return load_css_text(css, anywidget_id);
}

async function load_esm(esm: string): Promise<AnyWidgetModule> {
if (is_href(esm)) {
return await import(/* webpackIgnore: true */ /* @vite-ignore */ esm);
}
let url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" }));
let mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ url);
URL.revokeObjectURL(url);
return mod;
}

function warn_render_deprecation(anywidget_id: string): void {
console.warn(`\
[anywidget] Deprecation Warning for ${anywidget_id}: 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 }
^^^^^^

Pin to anywidget>=0.9.0 in your pyproject.toml
----------------------------------------------

dependencies = ["anywidget>=0.9.0"]

To learn more, please see: https://github.com/manzt/anywidget/pull/395.
`);
}

export async function load_widget(esm: string, anywidget_id: string): Promise<AnyWidget> {
let mod = await load_esm(esm);
if (mod.render) {
warn_render_deprecation(anywidget_id);
return {
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 widget;
}
Loading