diff --git a/doc/how_to/custom_components/esm/custom_layout.md b/doc/how_to/custom_components/esm/custom_layout.md index d5ad7bc692..e4a95d12b6 100644 --- a/doc/how_to/custom_components/esm/custom_layout.md +++ b/doc/how_to/custom_components/esm/custom_layout.md @@ -89,11 +89,9 @@ split_js = SplitJS( ) split_js.servable() ``` - ::: :::{tab-item} `ReactComponent` - ```{pyodide} import panel as pn @@ -215,6 +213,36 @@ split_react.right=pn.widgets.CodeEditor( :::: +Now, let's change it back: + +::::{tab-set} + +:::{tab-item} `JSComponent` +```{pyodide} +split_js.right=pn.widgets.CodeEditor( + value="Right", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", +) +``` +::: + +:::{tab-item} `ReactComponent` +```{pyodide} +split_react.right=pn.widgets.CodeEditor( + value="Right", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", +) +``` +::: + +:::: + ## Layout a List of Objects A Panel `Column` or `Row` works as a list of objects. It is *list-like*. In this section, we will show you how to create your own *list-like* layout using Panel's `NamedListLike` class. @@ -375,7 +403,6 @@ You can now use `[...]` indexing and methods like `.append`, `.insert`, `pop`, e ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} grid_js.append( pn.widgets.CodeEditor( diff --git a/doc/how_to/custom_components/index.md b/doc/how_to/custom_components/index.md index 389e9c9dd7..742bec5c5a 100644 --- a/doc/how_to/custom_components/index.md +++ b/doc/how_to/custom_components/index.md @@ -2,9 +2,9 @@ These How-to pages provide solutions for common tasks related to extending Panel with custom components. -## `Viewer` Components +## `Viewer` and `PyComponent` -Build custom components by combining existing components. +Build custom components by combining existing components in Python. ::::{grid} 1 2 2 3 :gutter: 1 1 1 2 @@ -16,6 +16,13 @@ Build custom components by combining existing components. How to build custom components that are combinations of existing components. ::: +:::{grid-item-card} {octicon}`pencil;2.5em;sd-mr-1 sd-animate-grow50` Combine Existing Components +:link: python/create_custom widget +:link-type: doc + +How to build a custom widget by subclassing `PyComponent` and `WidgetBase`. +::: + :::: ### Examples @@ -47,6 +54,7 @@ Build a custom component wrapping a table and some widgets using the `Viewer` pa :maxdepth: 2 custom_viewer +python/create_custom_widget examples/plot_viewer examples/table_viewer ``` diff --git a/doc/how_to/custom_components/python/create_custom_widget.md b/doc/how_to/custom_components/python/create_custom_widget.md new file mode 100644 index 0000000000..7bdfdb21ed --- /dev/null +++ b/doc/how_to/custom_components/python/create_custom_widget.md @@ -0,0 +1,174 @@ +# Build a Widget in Python + +In this guide we will demonstrate how to create a custom widget that enables users to select a list of features and set their values entirely in Python. + +We will leverage the `PyComponent` class to construct this custom widget. The `PyComponent` allows us to combine multiple Panel components into a more complex and functional widget. The resulting class will combine a `MultiSelect` widget with a dynamic number of `FloatInput` widgets. + +## Code Overview + +Below is the complete implementation of the `FeatureInput` custom widget: + +```{pyodide} +import panel as pn +import param + +from panel.widgets.base import WidgetBase +from panel.custom import PyComponent + +class FeatureInput(WidgetBase, PyComponent): + """ + The `FeatureInput` enables a user to select from a list of features and set their values. + """ + + value = param.Dict( + doc="The names of the features selected and their set values", allow_None=False + ) + + features = param.Dict( + doc="The names of the available features and their default values" + ) + + selected_features = param.ListSelector( + doc="The list of selected features" + ) + + _selected_widgets = param.ClassSelector( + class_=pn.Column, doc="The widgets used to edit the selected features" + ) + + def __init__(self, **params): + params["value"] = params.get("value", {}) + params["features"] = params.get("features", {}) + params["selected_features"] = params.get("selected_features", []) + + params["_selected_widgets"] = self.param._selected_widgets.class_() + + super().__init__(**params) + + selected_features_widget = pn.widgets.MultiChoice.from_param( + self.param.selected_features, sizing_mode="stretch_width" + ) + + def __panel__(self): + return pn.Column(selected_features_widget, self._selected_widgets) + + @param.depends("features", watch=True, on_init=True) + def _reset_selected_features(self): + selected_features = [] + for feature in self.selected_features.copy(): + if feature in self.features.copy(): + selected_features.append(feature) + + self.param.selected_features.objects = list(self.features) + self.selected_features = selected_features + + @param.depends("selected_features", watch=True, on_init=True) + def _handle_selected_features_change(self): + org_value = self.value + + self._update_selected_widgets(org_value) + self._update_value() + + def _update_value(self, *args): # pylint: disable=unused-argument + new_value = {} + + for widget in self._selected_widgets: + new_value[widget.name] = widget.value + + self.value = new_value + + def _update_selected_widgets(self, org_value): + new_widgets = {} + + for feature in self.selected_features: + value = org_value.get(feature, self.features[feature]) + widget = self._new_widget(feature, value) + new_widgets[feature] = widget + + self._selected_widgets[:] = list(new_widgets.values()) + + def _new_widget(self, feature, value): + widget = pn.widgets.FloatInput( + name=feature, value=value, sizing_mode="stretch_width" + ) + pn.bind(self._update_value, widget, watch=True) + return widget +``` + +This is a lot to take in so let us break it down into a few pieces: + +### Inheritance + +The `FeatureInput` class inherits from `pn.custom.PyComponent` and `pn.widgets.WidgetBase`. This multiple inheritance structure allows us to create custom components that behave one of the three core component types that Panel defines `Widget`, `Pane` and `Panel` (i.e. a layout). You should always inherit from the component type base class first, i.e. `WidgetBase` in this case and the component implementation class second, i.e. `PyComponent` in this case. + +### Parameter Definitions + +It defines the following parameters: + +- `value`: A dictionary that stores the selected features and their corresponding values. +- `features`: A dictionary of available features and their default values. +- `selected_features`: The list of features that have been selected. +- `_selected_widgets`: A "private" column layout that contains the widgets for editing the selected features. + +### State handling + +The two most important methods in configuring state are the constructor (`__init__`) and the (`__panel__`) method which will be invoked to create the component lazily at render time. + +#### Constructor + +In the `__init__` method, we initialize the widget parameters and create a `MultiChoice` widget for selecting features. We also set up a column to hold the selected feature widgets. + +#### `__panel__` + +`PyComponent` classes must define a `__panel__` method which tells Panel how the component should be rendered. Here we return a layout of the `MultiSelect` and a column containing the selected features. + +#### Syncing state + +We use `@param.depends` decorators to define methods that react to changes in the `features` and `selected_features` parameters: + +- `_reset_selected_features`: Ensures that only available features are selected. +- `_handle_selected_features_change`: Updates the widgets and the `value` parameter when the selected features change. + +#### Widget Updates + +The `_update_value` method updates the `value` parameter based on the current values of the feature widgets. The `_update_selected_widgets` method creates and updates the widgets for the selected features. + +## Creating the Application + +Now, let's create an application to demonstrate our custom `FeatureInput` widget in action. We will define a set of features related to a wind turbine and use our widget to select and set their values: + +```{pyodide} +features = { + "Blade Length (m)": 73.5, + "Cut-in Wind Speed (m/s)": 3.5, + "Cut-out Wind Speed (m/s)": 25, + "Grid Connection Capacity (MW)": 5, + "Hub Height (m)": 100, + "Rated Wind Speed (m/s)": 12, + "Rotor Diameter (m)": 150, + "Turbine Efficiency (%)": 45, + "Water Depth (m)": 30, + "Wind Speed (m/s)": 10, +} +selected_features = ["Wind Speed (m/s)", "Rotor Diameter (m)"] +widget = FeatureInput( + features=features, + selected_features=selected_features, + width=500, +) + +pn.FlexBox( + pn.Column( + "## Widget", + widget, + ), + pn.Column( + "## Value", + pn.pane.JSON(widget.param.value, width=500, height=200), + ), +) +``` + +## References + +- [PyComponent](../../reference/custom_components/PyComponent.html) diff --git a/examples/reference/custom_components/PyComponent.ipynb b/examples/reference/custom_components/PyComponent.ipynb new file mode 100644 index 0000000000..0c403e697e --- /dev/null +++ b/examples/reference/custom_components/PyComponent.ipynb @@ -0,0 +1,321 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "6dd69519-afc9-4065-ad12-8253c708f5af", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "id": "a796364a-f1cd-411a-b7fd-d4354794474e", + "metadata": {}, + "source": [ + "A `PyComponent`, unlike a `Viewer` class inherits the entire API of Panel components, including all layout and styling related parameters. It doesn't just imitate a Panel component, it actually **is** one and therefore is a good option to build some composite widget made up of other widgets, a layout with some special behavior or a pane that renders some type of object in a way that is useful but does not require novel functionality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9164b959-7dc5-4579-b65c-1b3827803ca0", + "metadata": {}, + "outputs": [], + "source": [ + "from panel.custom import PyComponent\n", + "from panel.widgets import WidgetBase \n", + "\n", + "class CounterButton(PyComponent, WidgetBase):\n", + "\n", + " value = param.Integer(default=0)\n", + "\n", + " def __panel__(self):\n", + " return pn.widgets.Button(\n", + " name=self._button_name, on_click=self._on_click\n", + " )\n", + "\n", + " def _on_click(self, event):\n", + " self.value += 1\n", + "\n", + " @param.depends(\"value\")\n", + " def _button_name(self):\n", + " return f\"count is {self.value}\"\n", + "\n", + "CounterButton()" + ] + }, + { + "cell_type": "markdown", + "id": "78fda29d-eedd-45e4-a02c-e0771ac6b182", + "metadata": {}, + "source": [ + ":::{note}\n", + "If you are looking to create new components using JavaScript, check out [`JSComponent`](JSComponent.md), [`ReactComponent`](ReactComponent.md), or [`AnyWidgetComponent`](AnyWidgetComponent.md) instead.\n", + ":::\n", + "\n", + "## API\n", + "\n", + "### Attributes\n", + "\n", + "The `PyComponent` class inherits the entire Panel `Viewable` API including all sizing, layout and styling related parameters such as `width`, `height`, `sizing_mode` etc.\n", + "\n", + "### Methods\n", + "\n", + "- **`__panel__`**: Must be implemented. Should return the Panel component or object to be displayed. Will be lazily evaluated and cached on render.\n", + "- **`servable`**: This method serves the component using Panel's built-in server when running `panel serve ...`.\n", + "- **`show`**: Displays the component in a new browser tab when running `python ...`.\n", + "\n", + "## Usage\n", + "\n", + "### Styling with CSS\n", + "\n", + "You can style the component by styling the component(s) returned by `__panel__` using their `styles` or `stylesheets` attributes.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68f36432-40e1-4098-a195-12474dbf4a83", + "metadata": {}, + "outputs": [], + "source": [ + "class StyledCounterButton(PyComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _stylesheets = [\n", + " \"\"\"\n", + " :host(.solid) .bk-btn.bk-btn-default\n", + " {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + " }\n", + " :host(.solid) .bk-btn.bk-btn-default:hover {\n", + " background: #4099da;\n", + " }\n", + " \"\"\"\n", + " ]\n", + "\n", + " def _on_click(self, event):\n", + " self.value += 1\n", + "\n", + " @param.depends(\"value\")\n", + " def _button_name(self):\n", + " return f\"Clicked {self.value} times\"\n", + "\n", + " def __panel__(self):\n", + " return pn.widgets.Button(\n", + " name=self._button_name,\n", + " on_click=self._on_click,\n", + " stylesheets=self._stylesheets\n", + " )\n", + "\n", + "\n", + "StyledCounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "3b134810-94f9-4ccf-a257-aa07cb48d6f3", + "metadata": {}, + "source": [ + "See the [Apply CSS](../../how_to/styling/apply_css.md) guide for more information on styling Panel components.\n", + "\n", + "## Displaying A Single Child\n", + "\n", + "You can display Panel components (`Viewable`s) by defining a `Child` parameter.\n", + "\n", + "Let's start with the simplest example:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42f21642-ad17-4c21-b186-d894c90da554", + "metadata": {}, + "outputs": [], + "source": [ + "from panel.custom import Child\n", + "\n", + "class SingleChild(PyComponent):\n", + "\n", + " object = Child()\n", + "\n", + " def __panel__(self):\n", + " return pn.Column(\"A Single Child\", self.param.object.rx())\n", + "\n", + "single_child = SingleChild(object=pn.pane.Markdown(\"A **Markdown** pane!\"))\n", + "\n", + "single_child.servable()" + ] + }, + { + "cell_type": "markdown", + "id": "9ba02dcb-c83b-4212-ac1f-8e14e895d485", + "metadata": {}, + "source": [ + "Calling `self.param.object.rx()` creates a reactive expression which updates when the `object` parameter is updated.\n", + "\n", + "Let's replace the `object` with a `Button`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e386a52-d256-47a9-9139-a0e81e7bad88", + "metadata": {}, + "outputs": [], + "source": [ + "single_child.object = pn.widgets.Button(name=\"Click me\")" + ] + }, + { + "cell_type": "markdown", + "id": "9dbf41ea-b18f-4421-977a-291612cda6f7", + "metadata": {}, + "source": [ + "Let's change it back" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "880176b1-5052-4da1-8fbb-cce9e39524ab", + "metadata": {}, + "outputs": [], + "source": [ + "single_child.object = pn.pane.Markdown(\"A **Markdown** pane!\")" + ] + }, + { + "cell_type": "markdown", + "id": "10cc810a-6001-49d3-a5e1-798252e96a5d", + "metadata": {}, + "source": [ + "If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d929e9b1-62da-4122-bc78-7129988de486", + "metadata": {}, + "outputs": [], + "source": [ + "SingleChild(object=\"A **Markdown** pane!\").servable()" + ] + }, + { + "cell_type": "markdown", + "id": "417c59c4-4e0d-46d1-a762-98af457034d5", + "metadata": {}, + "source": [ + "If you want to allow a certain type of Panel components only, you can specify the specific type in the `class_` argument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f02e8336-a3fa-4e58-b457-d71f5d84fd18", + "metadata": {}, + "outputs": [], + "source": [ + "class SingleChild(PyComponent):\n", + "\n", + " object = Child(class_=pn.pane.Markdown)\n", + "\n", + " def __panel__(self):\n", + " return pn.Column(\"A Single Child\", self.param.object.rx())\n", + "\n", + "\n", + "SingleChild(object=pn.pane.Markdown(\"A **Markdown** pane!\")).servable()" + ] + }, + { + "cell_type": "markdown", + "id": "8ba7de66-21a3-4ab8-8eb2-0e6fd7d488fd", + "metadata": {}, + "source": [ + "The `class_` argument also supports a tuple of types:\n", + "\n", + "```python\n", + " object = Child(class_=(pn.pane.Markdown, pn.widgets.Button))\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "000a0276-717b-4984-9d1e-e936ba8c1a05", + "metadata": {}, + "source": [ + "## Displaying a List of Children\n", + "\n", + "You can also display a `List` of `Viewable` objects using the `Children` parameter type:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4fa17c5-65d2-4c1a-a311-aad61a4bf433", + "metadata": {}, + "outputs": [], + "source": [ + "from panel.custom import Children\n", + "\n", + "\n", + "class MultipleChildren(PyComponent):\n", + "\n", + " objects = Children()\n", + "\n", + " def __panel__(self):\n", + " return pn.Column(objects=self.param['objects'], styles={\"background\": \"silver\"})\n", + "\n", + "\n", + "MultipleChildren(\n", + " objects=[\n", + " pn.panel(\"A **Markdown** pane!\"),\n", + " pn.widgets.Button(name=\"Click me!\"),\n", + " {\"text\": \"I'm shown as a JSON Pane\"},\n", + " ]\n", + ").servable()\n" + ] + }, + { + "cell_type": "markdown", + "id": "60ebdbd0-48c8-4f21-9a57-7b49e31523ae", + "metadata": {}, + "source": [ + ":::note\n", + "You can change the `item_type` to a specific subtype of `Viewable` or a tuple of `Viewable` subtypes.\n", + ":::\n", + "\n", + "## References\n", + "\n", + "### Tutorials\n", + "\n", + "- [Reusable Components](../../../tutorials/intermediate/reusable_components.md)\n", + "\n", + "### How-To Guides\n", + "\n", + "- [Combine Existing Widgets](../../../how_to/custom_components/python/create_custom_widget)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/reference/custom_components/Viewer.ipynb b/examples/reference/custom_components/Viewer.ipynb index 51c07997d1..0466c9b2de 100644 --- a/examples/reference/custom_components/Viewer.ipynb +++ b/examples/reference/custom_components/Viewer.ipynb @@ -17,7 +17,7 @@ "id": "a796364a-f1cd-411a-b7fd-d4354794474e", "metadata": {}, "source": [ - "`Viewer` simplifies the creation of custom Panel components using Python and Panel components only." + "A `Viewer` is a good abstraction for combining some business logic about your application with Panel components and then letting you use it **as if it was** a Panel component. It provides a useful abstraction extension of a simple `Parameterized` class. Note however that it is not actually a Panel component, i.e. it does not behave like a widget, layout or pane. If you want to build a Panel component in Python the `PyComponent` class is a better option." ] }, { @@ -339,7 +339,7 @@ "\n", "### How-To Guides\n", "\n", - "- [Combine Existing Widgets](../../../how_to/custom_components/custom_viewer.md)\n" + "- [Combine Existing Widgets](../../../how_to/custom_components/custom_viewer.md)" ] } ], diff --git a/panel/custom.py b/panel/custom.py index 7de55b4a3a..9e2e87df45 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -33,7 +33,7 @@ from .util import camel_to_kebab, classproperty from .util.checks import import_available from .viewable import ( # noqa - Child, Children, Layoutable, Viewable, is_viewable_param, + Child, Children, Layoutable, Viewable, Viewer, is_viewable_param, ) from .widgets.base import WidgetBase # noqa @@ -46,6 +46,88 @@ ExportSpec = dict[str, list[str | tuple[str, ...]]] +class PyComponent(Viewable, Layoutable): + """ + The `PyComponent` combines the convenience of `Viewer` components + that allow creating custom components by declaring a `__panel__` + method with the ability of controlling layout and styling + related parameters directly on the class. Internally the + `PyComponent` will forward layout parameters to the underlying + object, which is created lazily on render. + """ + + def __init__(self, **params): + super().__init__(**params) + self._view__ = None + self._changing__ = {} + self.param.watch(self._sync__view, [p for p in Layoutable.param if p != 'name']) + + def _sync__view(self, *events): + if not events or self._view__ is None: + return + target = self._view__ if events[0].obj is self else self + params = { + e.name: e.new for e in events if e.name not in self._changing__ + or self._changing__[e.name] is not e.new + } + if not params: + return + try: + self._changing__.update(params) + with param.parameterized._syncing(target, list(params)): + target.param.update(params) + finally: + for p in params: + if p in self._changing__: + del self._changing__[p] + + def _cleanup(self, root: Model | None = None) -> None: + if self._view__ is None: + return + super()._cleanup(root) + if root and root.ref['id'] in self._models: + del self._models[root.ref['id']] + self._view__._cleanup(root) + + def _create__view(self): + from .pane import panel + from .param import ParamMethod + + if hasattr(self.__panel__, "_dinfo"): + view = ParamMethod(self.__panel__, lazy=True) + else: + view = panel(self.__panel__()) + self._view__ = view + params = view.param.values() + overrides, sync = {}, {} + for p in Layoutable.param: + if p != 'name' and view.param[p].default != params[p]: + overrides[p] = params[p] + elif p != 'name': + sync[p] = getattr(self, p) + view.param.watch(self._sync__view, [p for p in Layoutable.param if p != 'name']) + self.param.update(overrides) + with param.parameterized._syncing(view, list(sync)): + view.param.update(sync) + + def _get_model( + self, doc: Document, root: Optional['Model'] = None, + parent: Optional['Model'] = None, comm: Optional[Comm] = None + ) -> 'Model': + if self._view__ is None: + self._create__view() + model = self._view__._get_model(doc, root, parent, comm) + root = model if root is None else root + self._models[root.ref['id']] = (model, parent) + return model + + def select( + self, selector: Optional[type | Callable[Viewable, bool]] = None + ) -> list[Viewable]: + return super().select(selector) + self._view__.select(selector) + + + class ReactiveESMMetaclass(ReactiveMetaBase): def __init__(mcs, name: str, bases: tuple[type, ...], dict_: Mapping[str, Any]): @@ -73,6 +155,7 @@ class ReactiveESM(ReactiveCustomBase, metaclass=ReactiveESMMetaclass): variable. Use this to define a `render` function as shown in the example below. + ``` import panel as pn import param @@ -97,6 +180,7 @@ class CounterButton(pn.ReactiveESM): """ CounterButton().servable() + ``` ''' _bokeh_model = _BkReactiveESM @@ -372,6 +456,7 @@ class JSComponent(ReactiveESM): variable. Use this to define a `render` function as shown in the example below. + ``` import panel as pn import param @@ -396,6 +481,7 @@ class CounterButton(JSComponent): """ CounterButton().servable() + ``` ''' __abstract = True @@ -412,6 +498,7 @@ class ReactComponent(ReactiveESM): variable. Use this to define a `render` function as shown in the example below. + ``` import panel as pn import param @@ -432,6 +519,7 @@ class CounterButton(ReactComponent): """ CounterButton().servable() + ``` ''' __abstract = True diff --git a/panel/tests/test_custom.py b/panel/tests/test_custom.py index 03ba2e27f9..45ef95e0ae 100644 --- a/panel/tests/test_custom.py +++ b/panel/tests/test_custom.py @@ -1,10 +1,49 @@ import param -from panel.custom import ReactiveESM +from panel.custom import PyComponent, ReactiveESM +from panel.layout import Row from panel.pane import Markdown from panel.viewable import Viewable +class SimplePyComponent(PyComponent): + + def __panel__(self): + return Row(1, 2, 3, height=42) + + +def test_py_component_syncs(document, comm): + spy = SimplePyComponent(width=42) + + spy.get_root(document, comm) + + assert isinstance(spy._view__, Row) + assert spy._view__.width == 42 + assert spy.height == 42 + + spy.width = 84 + + assert spy._view__.width == 84 + + spy._view__.width = 42 + + assert spy._view__.width == 42 + + +def test_py_component_cleanup(document, comm): + spy = SimplePyComponent(width=42) + + model = spy.get_root(document, comm) + + assert model.ref['id'] in spy._models + assert model.ref['id'] in spy._view__._models + + spy._cleanup(model) + + assert not spy._models + assert not spy._view__._models + + class ESMWithChildren(ReactiveESM): child = param.ClassSelector(class_=Viewable) diff --git a/panel/widgets/base.py b/panel/widgets/base.py index 0ecd04102a..0d75ad2465 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -251,7 +251,7 @@ def _get_model( parent: Optional[Model] = None, comm: Optional[Comm] = None ) -> Model: model = self._composite._get_model(doc, root, parent, comm) - root = root or model + root = model if root is None else root self._models[root.ref['id']] = (model, parent) return model