Skip to content

Commit

Permalink
improvement: use anywidget types and widget.get_state() (#3921)
Browse files Browse the repository at this point in the history
Part of #3909. It ublocks part of the error, but then hits
`widget_manager not supported in marimo`.

I'm not sure what itll take to support `widget_manager`, but i think
this is a relic of anywidget tightly integrated with jupyter
  • Loading branch information
mscolnick authored Feb 26, 2025
1 parent 5552fc5 commit 50fc11f
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 255 deletions.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dist"
],
"dependencies": {
"@anywidget/types": "^0.2.0",
"@codemirror/autocomplete": "^6.18.4",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-markdown": "^6.3.2",
Expand Down
8 changes: 8 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

149 changes: 2 additions & 147 deletions frontend/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,19 @@ import { z } from "zod";
import type { IPluginProps } from "@/plugins/types";
import { useEffect, useMemo, useRef } from "react";
import { useAsyncData } from "@/hooks/useAsyncData";
import { dequal } from "dequal";
import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
import { ErrorBanner } from "../common/error-banner";
import { createPlugin } from "@/plugins/core/builder";
import { rpc } from "@/plugins/core/rpc";
import type { AnyModel, AnyWidget, EventHandler, Experimental } from "./types";
import type { AnyWidget, Experimental } from "@anywidget/types";
import { Logger } from "@/utils/Logger";
import {
type HTMLElementNotDerivedFromRef,
useEventListener,
} from "@/hooks/useEventListener";
import { MarimoIncomingMessageEvent } from "@/core/dom/events";
import { updateBufferPaths } from "@/utils/data-views";
import { debounce } from "lodash-es";
import { Model } from "./model";

interface Data {
jsUrl: string;
Expand Down Expand Up @@ -203,147 +202,3 @@ const LoadedSlot = ({

return <div ref={ref} />;
};

export class Model<T extends Record<string, any>> implements AnyModel<T> {
private ANY_CHANGE_EVENT = "change";

constructor(
private data: T,
private onChange: (value: Partial<T>) => void,
private send_to_widget: (req: { content?: any }) => Promise<
null | undefined
>,
) {}

private dirtyFields = new Set<keyof T>();

off(eventName?: string | null, callback?: EventHandler | null): void {
if (!eventName) {
this.listeners = {};
return;
}

if (!callback) {
this.listeners[eventName] = new Set();
return;
}

this.listeners[eventName]?.delete(callback);
}

send(
content: any,
callbacks?: any,
buffers?: ArrayBuffer[] | ArrayBufferView[],
): void {
if (buffers) {
Logger.warn("buffers not supported in marimo anywidget.send");
}
this.send_to_widget({ content }).then(callbacks);
}

widget_manager = new Proxy(
{},
{
get() {
throw new Error("widget_manager not supported in marimo");
},
set() {
throw new Error("widget_manager not supported in marimo");
},
},
);

private listeners: Record<string, Set<EventHandler>> = {};

get<K extends keyof T>(key: K): T[K] {
return this.data[key];
}

set<K extends keyof T>(key: K, value: T[K]): void {
this.data = { ...this.data, [key]: value };
this.dirtyFields.add(key);
this.emit(`change:${key as K & string}`, value);
this.emitAnyChange();
}

save_changes(): void {
if (this.dirtyFields.size === 0) {
return;
}
const partialData: Partial<T> = {};
this.dirtyFields.forEach((key) => {
partialData[key] = this.data[key];
});
this.dirtyFields.clear();
this.onChange(partialData);
}

updateAndEmitDiffs(value: T): void {
Object.keys(value).forEach((key) => {
const k = key as keyof T;
if (!dequal(this.data[k], value[k])) {
this.set(k, value[k]);
}
});
}

/**
* When receiving a message from the backend.
* We want to notify all listeners with `msg:custom`
*/
receiveCustomMessage(message: any, buffers?: DataView[]): void {
const response = WidgetMessageSchema.safeParse(message);
if (response.success) {
const data = response.data;
switch (data.method) {
case "update":
this.updateAndEmitDiffs(data.state as T);
break;
case "custom":
this.listeners["msg:custom"]?.forEach((cb) =>
cb(data.content, buffers),
);
break;
}
} else {
Logger.error("Failed to parse message", response.error);
Logger.error("Message", message);
}
}

on(eventName: string, callback: EventHandler): void {
if (!this.listeners[eventName]) {
this.listeners[eventName] = new Set();
}
this.listeners[eventName].add(callback);
}

private emit<K extends keyof T>(event: `change:${K & string}`, value: T[K]) {
if (!this.listeners[event]) {
return;
}
this.listeners[event].forEach((cb) => cb(value));
}

// Debounce 0 to send off one request in a single frame
private emitAnyChange = debounce(() => {
this.listeners[this.ANY_CHANGE_EVENT]?.forEach((cb) => cb());
}, 0);
}

const WidgetMessageSchema = z.union([
z.object({
method: z.literal("update"),
state: z.record(z.any()),
}),
z.object({
method: z.literal("custom"),
content: z.any(),
}),
z.object({
method: z.literal("echo_update"),
buffer_paths: z.array(z.array(z.union([z.string(), z.number()]))),
state: z.record(z.any()),
}),
]);
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { Model } from "../AnyWidgetPlugin";
import { Model } from "../model";
import { vi, describe, it, expect, beforeEach } from "vitest";

describe("Model", () => {
Expand Down Expand Up @@ -124,12 +124,7 @@ describe("Model", () => {

describe("widget_manager", () => {
it("should throw error when accessing widget_manager", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => (model.widget_manager as any).foo).toThrow(
"widget_manager not supported in marimo",
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => ((model.widget_manager as any).foo = "bar")).toThrow(
expect(() => model.widget_manager.get_model("foo")).toThrow(
"widget_manager not supported in marimo",
);
});
Expand Down
146 changes: 146 additions & 0 deletions frontend/src/plugins/impl/anywidget/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/* Copyright 2024 Marimo. All rights reserved. */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Logger } from "@/utils/Logger";
import type { AnyModel } from "@anywidget/types";
import { dequal } from "dequal";
import { debounce } from "lodash-es";
import { z } from "zod";

export type EventHandler = (...args: any[]) => void;

export class Model<T extends Record<string, any>> implements AnyModel<T> {
private ANY_CHANGE_EVENT = "change";

constructor(
private data: T,
private onChange: (value: Partial<T>) => void,
private sendToWidget: (req: { content?: any }) => Promise<null | undefined>,
) {}

private dirtyFields = new Set<keyof T>();
private listeners: Record<string, Set<EventHandler>> = {};

off(eventName?: string | null, callback?: EventHandler | null): void {
if (!eventName) {
this.listeners = {};
return;
}

if (!callback) {
this.listeners[eventName] = new Set();
return;
}

this.listeners[eventName]?.delete(callback);
}

send(
content: any,
callbacks?: any,
buffers?: ArrayBuffer[] | ArrayBufferView[],
): void {
if (buffers) {
Logger.warn("buffers not supported in marimo anywidget.send");
}
this.sendToWidget({ content }).then(callbacks);
}

widget_manager = {
get_model<TT extends Record<string, any>>(
_model_id: string,
): Promise<AnyModel<TT>> {
throw new Error("widget_manager not supported in marimo");
},
};

get<K extends keyof T>(key: K): T[K] {
return this.data[key];
}

set<K extends keyof T>(key: K, value: T[K]): void {
this.data = { ...this.data, [key]: value };
this.dirtyFields.add(key);
this.emit(`change:${key as K & string}`, value);
this.emitAnyChange();
}

save_changes(): void {
if (this.dirtyFields.size === 0) {
return;
}
const partialData: Partial<T> = {};
this.dirtyFields.forEach((key) => {
partialData[key] = this.data[key];
});
this.dirtyFields.clear();
this.onChange(partialData);
}

updateAndEmitDiffs(value: T): void {
Object.keys(value).forEach((key) => {
const k = key as keyof T;
if (!dequal(this.data[k], value[k])) {
this.set(k, value[k]);
}
});
}

/**
* When receiving a message from the backend.
* We want to notify all listeners with `msg:custom`
*/
receiveCustomMessage(message: any, buffers?: DataView[]): void {
const response = WidgetMessageSchema.safeParse(message);
if (response.success) {
const data = response.data;
switch (data.method) {
case "update":
this.updateAndEmitDiffs(data.state as T);
break;
case "custom":
this.listeners["msg:custom"]?.forEach((cb) =>
cb(data.content, buffers),
);
break;
}
} else {
Logger.error("Failed to parse message", response.error);
Logger.error("Message", message);
}
}

on(eventName: string, callback: EventHandler): void {
if (!this.listeners[eventName]) {
this.listeners[eventName] = new Set();
}
this.listeners[eventName].add(callback);
}

private emit<K extends keyof T>(event: `change:${K & string}`, value: T[K]) {
if (!this.listeners[event]) {
return;
}
this.listeners[event].forEach((cb) => cb(value));
}

// Debounce 0 to send off one request in a single frame
private emitAnyChange = debounce(() => {
this.listeners[this.ANY_CHANGE_EVENT]?.forEach((cb) => cb());
}, 0);
}

const WidgetMessageSchema = z.union([
z.object({
method: z.literal("update"),
state: z.record(z.any()),
}),
z.object({
method: z.literal("custom"),
content: z.any(),
}),
z.object({
method: z.literal("echo_update"),
buffer_paths: z.array(z.array(z.union([z.string(), z.number()]))),
state: z.record(z.any()),
}),
]);
Loading

0 comments on commit 50fc11f

Please sign in to comment.