From 2f1d55a3dc8d5028866c41d9a92e052fc10368c5 Mon Sep 17 00:00:00 2001 From: "Maarten A. Breddels" Date: Tue, 28 Mar 2023 15:02:28 +0200 Subject: [PATCH] fix: backport of #3738 (pack_models/structuredClone fix) --- packages/base/src/widget.ts | 81 ++++++++++++++++++++----- packages/base/test/karma.conf.js | 3 + packages/base/test/src/dummy-manager.ts | 27 ++++++++- packages/base/test/src/widget_test.ts | 62 +++++++++++++++++++ 4 files changed, 155 insertions(+), 18 deletions(-) diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index 8a0f0b9f49..6dfd330717 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -1,37 +1,49 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. +import * as backbonePatch from './backbone-patch'; import * as managerBase from './manager-base'; import * as utils from './utils'; -import * as backbonePatch from './backbone-patch'; import * as Backbone from 'backbone'; import $ from 'jquery'; import { - NativeView + NativeView } from './nativeview'; -import { - Widget, Panel -} from '@lumino/widgets'; +import { Panel, Widget } from '@lumino/widgets'; + +import { JSONObject, JSONValue } from '@lumino/coreutils'; + +import { Dict } from './utils'; import { - Message, MessageLoop + Message, MessageLoop } from '@lumino/messaging'; -import { - IClassicComm, ICallbacks -} from './services-shim'; +import { ICallbacks, IClassicComm } from './services-shim'; import { - JUPYTER_WIDGETS_VERSION + JUPYTER_WIDGETS_VERSION } from './version'; import { - KernelMessage + KernelMessage } from '@jupyterlab/services'; +/** + * The magic key used in the widget graph serialization. + */ +const IPY_MODEL_ = 'IPY_MODEL_'; + +/** + * A best-effort method for performing deep copies. + */ +const deepcopyJSON = (x: JSONValue) => JSON.parse(JSON.stringify(x)); + +const deepcopy = (globalThis as any).structuredClone || deepcopyJSON; + /** * Replace model ids with models recursively. */ @@ -57,6 +69,37 @@ function unpack_models(value: any, manager: managerBase.ManagerBase): Promi } } +/** Replace models with ids recursively. + * + * If the commonly-used `unpack_models` is given as the `deseralize` method, + * pack_models would be the appropriate `serialize`. + * However, the default serialize method will have the same effect, when + * `unpack_models` is used as the deserialize method. + * This is to ensure backwards compatibility, see: + * https://github.com/jupyter-widgets/ipywidgets/pull/3738/commits/f9e27328bb631eb5247a7a6563595d3e655492c7#diff-efb19099381ae8911dd7f69b015a0138d08da7164512c1ee112aa75100bc9be2 + */ +export function pack_models( + value: WidgetModel | Dict | WidgetModel[] | any, + widget?: WidgetModel +): any | Dict | string | (Dict | string)[] { + if (Array.isArray(value)) { + const model_ids: string[] = []; + for (const model of value) { + model_ids.push(pack_models(model, widget)); + } + return model_ids; + } else if (value instanceof WidgetModel) { + return `${IPY_MODEL_}${value.model_id}`; + } else if (value instanceof Object && typeof value !== 'string') { + const packed: { [key: string]: string } = {}; + Object.keys(value).forEach((key) => { + packed[key] = pack_models(value[key], widget); + }); + } else { + return value; + } +} + /** * Type declaration for general widget serializers. @@ -491,18 +534,24 @@ class WidgetModel extends Backbone.Model { * primitive object that is a snapshot of the widget state that may have * binary array buffers. */ - serialize(state: {[key: string]: any}) { - const deepcopy = - (globalThis as any).structuredClone || ((x: any) => JSON.parse(JSON.stringify(x))); + serialize(state: {[key: string]: any}) : JSONObject { const serializers = (this.constructor as typeof WidgetModel).serializers || {}; for (const k of Object.keys(state)) { try { - if (serializers[k] && serializers[k].serialize) { - state[k] = (serializers[k].serialize)(state[k], this); + const keySerializers : any = serializers[k] || null; + let serialize = keySerializers?.serialize || null; + if (serialize == null && keySerializers?.deserialize === unpack_models) { + // handle https://github.com/jupyter-widgets/ipywidgets/issues/3735 + serialize = deepcopyJSON; + } + + if (serialize) { + state[k] = serialize(state[k], this); } else { // the default serializer just deep-copies the object state[k] = deepcopy(state[k]); } + if (state[k] && state[k].toJSON) { state[k] = state[k].toJSON(); } diff --git a/packages/base/test/karma.conf.js b/packages/base/test/karma.conf.js index 4098dfbdd8..56ab31c8f7 100644 --- a/packages/base/test/karma.conf.js +++ b/packages/base/test/karma.conf.js @@ -3,6 +3,9 @@ module.exports = function (config) { basePath: '..', frameworks: ['mocha'], reporters: ['mocha'], + mochaReporter: { + showDiff: true + }, files: ['test/build/bundle.js'], port: 9876, colors: true, diff --git a/packages/base/test/src/dummy-manager.ts b/packages/base/test/src/dummy-manager.ts index 4ac92fba88..074aa483af 100644 --- a/packages/base/test/src/dummy-manager.ts +++ b/packages/base/test/src/dummy-manager.ts @@ -1,9 +1,9 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import * as widgets from '../../lib'; import * as services from '@jupyterlab/services'; import * as Backbone from 'backbone'; +import * as widgets from '../../lib'; import * as sinon from 'sinon'; void sinon; @@ -177,4 +177,27 @@ class BinaryWidgetView extends TestWidgetView { _rendered = 0; } -let testWidgets = {TestWidget, TestWidgetView, BinaryWidget, BinaryWidgetView}; +class ContainerWidget extends TestWidget { + static serializers = { + ...widgets.WidgetModel.serializers, + children: { deserialize: widgets.unpack_models }, + }; + defaults() { + return { + ...super.defaults(), + _model_name: 'ContainerWidget', + _view_name: 'ContainerWidgetView', + // @ts-ignore + children: [], + }; + } +} + +class ContainerWidgetView extends TestWidgetView { + render(): void { + this._rendered += 1; + } + _rendered = 0; +} + +let testWidgets = {TestWidget, TestWidgetView, BinaryWidget, BinaryWidgetView, ContainerWidget, ContainerWidgetView}; diff --git a/packages/base/test/src/widget_test.ts b/packages/base/test/src/widget_test.ts index da4a37af0b..170899d81f 100644 --- a/packages/base/test/src/widget_test.ts +++ b/packages/base/test/src/widget_test.ts @@ -62,6 +62,68 @@ describe('unpack_models', function() { }); }); +describe('serialize/deserialize', function () { + before(async function () { + this.manager = new DummyManager(); + this.widgetChild = await this.manager.new_widget({ + model_name: 'WidgetModel', + model_module: '@jupyter-widgets/base', + model_module_version: '1.2.0', + view_name: 'WidgetView', + view_module: '@jupyter-widgets/base', + view_module_version: '1.2.0', + model_id: 'widgetChild', + }); + + this.widgetChild2 = await this.manager.new_widget({ + model_name: 'WidgetModel', + model_module: '@jupyter-widgets/base', + model_module_version: '1.2.0', + view_name: 'WidgetView', + view_module: '@jupyter-widgets/base', + view_module_version: '1.2.0', + model_id: 'widgetChild2', + }); + + this.widgetContainer = await this.manager.new_widget( + { + model_name: 'ContainerWidget', + model_module: 'test-widgets', + model_module_version: '1.2.0', + view_name: 'ContainerWidgetView', + view_module: 'test-widgets', + view_module_version: '1.2.0', + model_id: 'widgetContainer', + }, + { children: [`IPY_MODEL_${this.widgetChild.model_id}`] } + ); + }); + it('serializes', function () { + const state = this.widgetContainer.get_state(false); + const serializedState = this.widgetContainer.serialize(state); + expect(serializedState).to.deep.equal({ + _model_module: 'test-widgets', + _model_module_version: '1.0.0', + _model_name: 'ContainerWidget', + _view_count: null, + _view_module: 'test-widgets', + _view_module_version: '1.0.0', + _view_name: 'ContainerWidgetView', + children: ['IPY_MODEL_widgetChild'], + }); + }); + it('deserializes', async function () { + const serializedState = { children: ['IPY_MODEL_widgetChild2'] }; + const state = await ( + this.widgetContainer.constructor as typeof WidgetModel + )._deserialize_state(serializedState, this.manager); + await this.widgetContainer.set_state(state); + expect(this.widgetContainer.get('children')).to.deep.equal([ + this.widgetChild2, + ]); + }); +}); + describe('WidgetModel', function() { before(async function() { this.setup = async function() {