22/* eslint-disable @typescript-eslint/no-explicit-any */
33
44import type { AnyWidget , Experimental } from "@anywidget/types" ;
5- import { get , isEqual , set } from "lodash-es" ;
5+ import { isEqual } from "lodash-es" ;
66import { useEffect , useMemo , useRef } from "react" ;
7+ import useEvent from "react-use-event-hook" ;
78import { z } from "zod" ;
89import { MarimoIncomingMessageEvent } from "@/core/dom/events" ;
910import { asRemoteURL } from "@/core/runtime/config" ;
@@ -17,10 +18,13 @@ import { createPlugin } from "@/plugins/core/builder";
1718import { rpc } from "@/plugins/core/rpc" ;
1819import type { IPluginProps } from "@/plugins/types" ;
1920import {
20- type Base64String ,
21- byteStringToBinary ,
22- typedAtob ,
23- } from "@/utils/json/base64" ;
21+ decodeFromWire ,
22+ isWireFormat ,
23+ serializeBuffersToBase64 ,
24+ type WireFormat ,
25+ } from "@/utils/data-views" ;
26+ import { prettyError } from "@/utils/errors" ;
27+ import type { Base64String } from "@/utils/json/base64" ;
2428import { Logger } from "@/utils/Logger" ;
2529import { ErrorBanner } from "../common/error-banner" ;
2630import { MODEL_MANAGER , Model } from "./model" ;
@@ -29,44 +33,56 @@ interface Data {
2933 jsUrl : string ;
3034 jsHash : string ;
3135 css ?: string | null ;
32- bufferPaths ?: ( string | number ) [ ] [ ] | null ;
33- initialValue : T ;
3436}
3537
36- type T = Record < string , any > ;
38+ type T = Record < string , unknown > ;
3739
38- // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
3940type PluginFunctions = {
40- send_to_widget : < T > ( req : { content ?: any } ) => Promise < null | undefined > ;
41+ send_to_widget : < T > ( req : {
42+ content : unknown ;
43+ buffers : Base64String [ ] ;
44+ } ) => Promise < null | undefined > ;
4145} ;
4246
43- export const AnyWidgetPlugin = createPlugin < T > ( "marimo-anywidget" )
47+ export const AnyWidgetPlugin = createPlugin < WireFormat < T > > ( "marimo-anywidget" )
4448 . withData (
4549 z . object ( {
4650 jsUrl : z . string ( ) ,
4751 jsHash : z . string ( ) ,
4852 css : z . string ( ) . nullish ( ) ,
49- bufferPaths : z
50- . array ( z . array ( z . union ( [ z . string ( ) , z . number ( ) ] ) ) )
51- . nullish ( ) ,
52- initialValue : z . object ( { } ) . passthrough ( ) ,
5353 } ) ,
5454 )
5555 . withFunctions < PluginFunctions > ( {
5656 send_to_widget : rpc
57- . input ( z . object ( { content : z . any ( ) } ) )
57+ . input (
58+ z . object ( {
59+ content : z . unknown ( ) ,
60+ buffers : z . array ( z . string ( ) . transform ( ( v ) => v as Base64String ) ) ,
61+ } ) ,
62+ )
5863 . output ( z . null ( ) . optional ( ) ) ,
5964 } )
6065 . renderer ( ( props ) => < AnyWidgetSlot { ...props } /> ) ;
6166
62- type Props = IPluginProps < T , Data , PluginFunctions > ;
63-
64- const AnyWidgetSlot = ( props : Props ) => {
65- const { css, jsUrl, jsHash, bufferPaths } = props . data ;
67+ const AnyWidgetSlot = (
68+ props : IPluginProps < WireFormat < T > , Data , PluginFunctions > ,
69+ ) => {
70+ const { css, jsUrl, jsHash } = props . data ;
6671
72+ // Decode wire format { state, bufferPaths, buffers } to state with DataViews
6773 const valueWithBuffers = useMemo ( ( ) => {
68- return resolveInitialValue ( props . value , bufferPaths ?? [ ] ) ;
69- } , [ props . value , bufferPaths ] ) ;
74+ if ( isWireFormat ( props . value ) ) {
75+ const decoded = decodeFromWire ( props . value ) ;
76+ Logger . debug ( "AnyWidget decoded wire format:" , {
77+ bufferPaths : props . value . bufferPaths ,
78+ buffersCount : props . value . buffers ?. length ,
79+ decodedKeys : Object . keys ( decoded ) ,
80+ } ) ;
81+ return decoded ;
82+ }
83+ Logger . warn ( "AnyWidget value is not wire format:" , props . value ) ;
84+ return props . value ;
85+ } , [ props . value ] ) ;
7086
7187 // JS is an ESM file with a render function on it
7288 // export function render({ model, el }) {
@@ -135,6 +151,12 @@ const AnyWidgetSlot = (props: Props) => {
135151 } ;
136152 } , [ css , props . host ] ) ;
137153
154+ // Wrap setValue to serialize DataViews back to base64 before sending
155+ // Structure matches ipywidgets protocol: { state, bufferPaths, buffers }
156+ const wrappedSetValue = useEvent ( ( partialValue : Partial < T > ) =>
157+ props . setValue ( serializeBuffersToBase64 ( partialValue ) ) ,
158+ ) ;
159+
138160 if ( error ) {
139161 return < ErrorBanner error = { error } /> ;
140162 }
@@ -162,6 +184,7 @@ const AnyWidgetSlot = (props: Props) => {
162184 key = { key }
163185 { ...props }
164186 widget = { module . default }
187+ setValue = { wrappedSetValue }
165188 value = { valueWithBuffers }
166189 />
167190 ) ;
@@ -191,10 +214,19 @@ async function runAnyWidgetModule(
191214 const widget =
192215 typeof widgetDef === "function" ? await widgetDef ( ) : widgetDef ;
193216 await widget . initialize ?.( { model, experimental } ) ;
194- const unsub = await widget . render ?.( { model, el, experimental } ) ;
195- return ( ) => {
196- unsub ?.( ) ;
197- } ;
217+ try {
218+ const unsub = await widget . render ?.( { model, el, experimental } ) ;
219+ return ( ) => {
220+ unsub ?.( ) ;
221+ } ;
222+ } catch ( error ) {
223+ Logger . error ( "Error rendering anywidget" , error ) ;
224+ el . classList . add ( "text-error" ) ;
225+ el . innerHTML = `Error rendering anywidget: ${ prettyError ( error ) } ` ;
226+ return ( ) => {
227+ // No-op
228+ } ;
229+ }
198230}
199231
200232function isAnyWidgetModule ( mod : any ) : mod is { default : AnyWidget } {
@@ -218,6 +250,13 @@ function hasModelId(message: unknown): message is { model_id: string } {
218250 ) ;
219251}
220252
253+ interface Props
254+ extends Omit < IPluginProps < T , Data , PluginFunctions > , "setValue" > {
255+ widget : AnyWidget ;
256+ value : T ;
257+ setValue : ( value : Partial < T > ) => void ;
258+ }
259+
221260const LoadedSlot = ( {
222261 value,
223262 setValue,
@@ -228,15 +267,9 @@ const LoadedSlot = ({
228267} : Props & { widget : AnyWidget } ) => {
229268 const htmlRef = useRef < HTMLDivElement > ( null ) ;
230269
270+ // value is already decoded from wire format
231271 const model = useRef < Model < T > > (
232- new Model (
233- // Merge the initial value with the current value
234- // since we only send partial updates to the backend
235- { ...data . initialValue , ...value } ,
236- setValue ,
237- functions . send_to_widget ,
238- getDirtyFields ( value , data . initialValue ) ,
239- ) ,
272+ new Model ( value , setValue , functions . send_to_widget , new Set ( ) ) ,
240273 ) ;
241274
242275 // Listen to incoming messages
@@ -289,16 +322,3 @@ export const visibleForTesting = {
289322 isAnyWidgetModule,
290323 getDirtyFields,
291324} ;
292-
293- export function resolveInitialValue (
294- raw : Record < string , any > ,
295- bufferPaths : readonly ( readonly ( string | number ) [ ] ) [ ] ,
296- ) {
297- const out = structuredClone ( raw ) ;
298- for ( const bufferPath of bufferPaths ) {
299- const base64String : Base64String = get ( raw , bufferPath ) ;
300- const bytes = byteStringToBinary ( typedAtob ( base64String ) ) ;
301- set ( out , bufferPath , new DataView ( bytes . buffer ) ) ;
302- }
303- return out ;
304- }
0 commit comments