Skip to content

Commit

Permalink
feat: extract runtime.js
Browse files Browse the repository at this point in the history
  • Loading branch information
manzt committed Mar 17, 2024
1 parent f118747 commit dc3e1ae
Show file tree
Hide file tree
Showing 4 changed files with 348 additions and 359 deletions.
115 changes: 115 additions & 0 deletions packages/anywidget/src/runtime.js
Original file line number Diff line number Diff line change
@@ -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_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();
}
}
10 changes: 10 additions & 0 deletions packages/anywidget/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyWidget>);
};
219 changes: 219 additions & 0 deletions packages/anywidget/src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<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_element(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
Loading

0 comments on commit dc3e1ae

Please sign in to comment.