Skip to content

Commit

Permalink
Allow resolving change handlers and useState for nested refs (#7743)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Feb 27, 2025
1 parent e1e5bbd commit 0eb63a0
Show file tree
Hide file tree
Showing 6 changed files with 285 additions and 46 deletions.
6 changes: 4 additions & 2 deletions panel/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,9 @@ def _get_model(
children = self._get_children(model.data, doc, root, model, comm)
model.data.update(**children)
model.children = list(children) # type: ignore
self._models[root.ref['id']] = (model, parent)
ref = root.ref['id']
self._models[ref] = (model, parent)
self._patch_datamodel_ref(model.data, ref)
self._link_props(props['data'], self._linked_properties, doc, root, comm)
self._register_events('dom_event', 'data_event', model=model, doc=doc, comm=comm)
self._setup_autoreload()
Expand Down Expand Up @@ -572,7 +574,7 @@ def _handle_msg(self, data: Any) -> None:
def _send_msg(self, data: Any) -> None:
"""
Sends data to the frontend which can be observed on the frontend
with the `model.on_msg("msg:custom", callback)` API.
with the `model.on("msg:custom", callback)` API.
Parameters
----------
Expand Down
40 changes: 36 additions & 4 deletions panel/models/anywidget_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,34 @@ class AnyWidgetModelAdapter {

get(name: any) {
let value
if (name in this.model.data.attributes) {
value = this.model.data.attributes[name]
const propPath = name.split(".")
let targetModel: any = this.model.data

for (let i = 0; i < propPath.length - 1; i++) {
if (targetModel && targetModel.attributes && propPath[i] in targetModel.attributes) {
targetModel = targetModel.attributes[propPath[i]]
} else {
// Stop if any part of the path is missing
targetModel = null
break
}
}

if (targetModel && targetModel.attributes && propPath[propPath.length - 1] in targetModel.attributes) {
value = targetModel.attributes[propPath[propPath.length - 1]]
} else {
value = this.model.attributes[name]
}

if (value instanceof ArrayBuffer) {
value = new DataView(value)
}

return value
}

set(name: string, value: any) {
if (name in this.model.data.attributes) {
if (name.split(".")[0] in this.model.data.attributes) {
this.data_changes = {...this.data_changes, [name]: value}
} else if (name in this.model.attributes) {
this.model_changes = {...this.model_changes, [name]: value}
Expand All @@ -39,7 +54,24 @@ class AnyWidgetModelAdapter {
save_changes() {
this.model.setv(this.model_changes)
this.model_changes = {}
this.model.data.setv(this.data_changes)
for (const key in this.data_changes) {
const propPath = key.split(".")
let targetModel: any = this.model.data
for (let i = 0; i < propPath.length - 1; i++) {
if (targetModel && targetModel.attributes && propPath[i] in targetModel.attributes) {
targetModel = targetModel.attributes[propPath[i]]
} else {
console.warn(`Skipping '${key}': '${propPath[i]}' does not exist.`)
targetModel = null
break
}
}
if (targetModel && targetModel.attributes && propPath[propPath.length - 1] in targetModel.attributes) {
targetModel.setv({[propPath[propPath.length - 1]]: this.data_changes[key]})
} else {
console.warn(`Skipping '${key}': Final property '${propPath[propPath.length - 1]}' not found.`)
}
}
this.data_changes = {}
}

Expand Down
35 changes: 28 additions & 7 deletions panel/models/react_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,35 @@ class Child extends React.Component {
function react_getter(target, name) {
if (name == "useState") {
return (prop) => {
const data_model = target.model.data
if (Reflect.has(data_model, prop)) {
const [value, setValue] = React.useState(data_model.attributes[prop]);
react_proxy.on(prop, () => setValue(data_model.attributes[prop]))
React.useEffect(() => data_model.setv({[prop]: value}), [value])
return [value, setValue]
const data_model = target.model.data;
const propPath = prop.split(".");
let targetModel = data_model;
let resolvedProp = null;
for (let i = 0; i < propPath.length - 1; i++) {
if (targetModel && targetModel.properties && propPath[i] in targetModel.properties) {
targetModel = targetModel[propPath[i]];
} else {
// Stop if any part of the path is missing
targetModel = null;
break;
}
}
if (targetModel && targetModel.attributes && propPath[propPath.length - 1] in targetModel.attributes) {
resolvedProp = propPath[propPath.length - 1];
}
if (resolvedProp && targetModel) {
const [value, setValue] = React.useState(targetModel.attributes[resolvedProp]);
react_proxy.on(prop, () => setValue(targetModel.attributes[resolvedProp]));
React.useEffect(() => {
targetModel.setv({ [resolvedProp]: value });
}, [value]);
return [value, setValue];
}
return undefined
throw ReferenceError("Could not resolve " + prop + " on " + target.model.class_name)
}
} else if (name === "get_child") {
return (child) => {
Expand Down
61 changes: 53 additions & 8 deletions panel/models/reactive_esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function model_getter(target: ReactiveESMView, name: string) {
if (p.startsWith("change:")) {
p = p.slice("change:".length)
}
if (p in model.attributes || p in model.data.attributes) {
if (p in model.attributes || p.split(".")[0] in model.data.attributes) {
model.unwatch(target, p, callback)
continue
} else if (p === "msg:custom") {
Expand All @@ -108,7 +108,7 @@ export function model_getter(target: ReactiveESMView, name: string) {
if (p.startsWith("change:")) {
p = p.slice("change:".length)
}
if (p in model.attributes || p in model.data.attributes) {
if (p in model.attributes || p.split(".")[0] in model.data.attributes) {
model.watch(target, p, callback)
continue
} else if (p === "msg:custom") {
Expand Down Expand Up @@ -533,8 +533,27 @@ export class ReactiveESM extends HTMLBox {
} else {
this._esm_watchers[prop] = [[view, cb]]
}
if (prop in this.data.properties) {
this.data.property(prop).change.connect(cb)

const propPath = prop.split(".")
let target: any = this.data
let resolvedProp: string | null = null
for (let i = 0; i < propPath.length - 1; i++) {
if (target && target.properties && propPath[i] in target.properties) {
target = target[propPath[i]]
} else {
// Break if any level of the path is invalid
target = null
break
}
}

if (target && target.properties && propPath[propPath.length - 1] in target.properties) {
resolvedProp = propPath[propPath.length - 1]
}

// Attach watcher if property is found
if (resolvedProp && target) {
target.property(resolvedProp).change.connect(cb)
} else if (prop in this.properties) {
this.property(prop).change.connect(cb)
}
Expand All @@ -544,22 +563,48 @@ export class ReactiveESM extends HTMLBox {
if (!(prop in this._esm_watchers)) {
return false
}

// Filter out the specific callback for this view
const remaining = []
for (const [wview, wcb] of this._esm_watchers[prop]) {
if (wview !== view || wcb !== cb) {
remaining.push([wview, cb])
remaining.push([wview, wcb])
}
}

// Update or delete watcher list
if (remaining.length > 0) {
this._esm_watchers[prop] = remaining
} else {
delete this._esm_watchers[prop]
}
if (prop in this.data.properties) {
return this.data.property(prop).change.disconnect(cb)

// Resolve nested properties
const propPath = prop.split(".")
let target: any = this.data
let resolvedProp: string | null = null

for (let i = 0; i < propPath.length - 1; i++) {
if (target && target.properties && propPath[i] in target.properties) {
target = target[propPath[i]]
} else {
// Stop if the path does not exist
target = null
break
}
}

if (target && target.properties && propPath[propPath.length - 1] in target.properties) {
resolvedProp = propPath[propPath.length - 1]
}

// Detach watcher if property is found
if (resolvedProp && target) {
return target.property(resolvedProp).change.disconnect(cb)
} else if (prop in this.properties) {
return this.property(prop).change.disconnect(cb)
}

return false
}

Expand All @@ -569,7 +614,7 @@ export class ReactiveESM extends HTMLBox {
const remaining = []
for (const [wview, cb] of this._esm_watchers[p]) {
if (wview === view) {
prop.change.disconnect(cb)
prop?.change.disconnect(cb)
} else {
remaining.push([wview, cb])
}
Expand Down
38 changes: 14 additions & 24 deletions panel/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,17 @@ def _process_param_change(self, params):
] + props['stylesheets']
return props

@classmethod
def _patch_datamodel_ref(cls, props, ref):
"""
Ensure all DataModels have reference to the root model to ensure
that they can be cleaned up correctly.
"""
ref_str = f"__ref:{ref}"
for m in props.select({'type': DataModel}):
if ref_str not in m.tags:
m.tags.append(ref_str)

def _set_on_model(self, msg: Mapping[str, Any], root: Model, model: Model) -> None:
if not msg:
return
Expand Down Expand Up @@ -1622,6 +1633,8 @@ def _set_on_model(self, msg: Mapping[str, Any], root: Model, model: Model) -> No
self._changing[root.ref['id']] = prev_changing
else:
del self._changing[root.ref['id']]
if isinstance(model, DataModel):
self._patch_datamodel_ref(model, root.ref['id'])



Expand Down Expand Up @@ -2063,21 +2076,6 @@ def _linked_properties(self) -> tuple[str, ...]:
linked_properties.append(children_param)
return tuple(linked_properties)

@classmethod
def _patch_datamodel_ref(cls, props, ref):
"""
Ensure all DataModels have reference to the root model to ensure
that they can be cleaned up correctly.
"""
if isinstance(props, dict):
for v in props.values():
cls._patch_datamodel_ref(v, ref)
elif isinstance(props, list):
for v in props:
cls._patch_datamodel_ref(v, ref)
elif isinstance(props, DataModel):
props.tags.append(f"__ref:{ref}")

def _get_model(
self, doc: Document, root: Model | None = None,
parent: Model | None = None, comm: Comm | None = None
Expand Down Expand Up @@ -2105,10 +2103,7 @@ def _get_model(

ref = root.ref['id']
data_model: DataModel = model.data # type: ignore
for v in data_model.properties_with_values():
if isinstance(v, DataModel):
v.tags.append(f"__ref:{ref}")
self._patch_datamodel_ref(data_model.properties_with_values(), ref)
self._patch_datamodel_ref(data_model, ref)
model.update(children=self._get_children(doc, root, model, comm))
self._register_events('dom_event', model=model, doc=doc, comm=comm)
self._link_props(data_model, self._linked_properties, doc, root, comm)
Expand Down Expand Up @@ -2138,11 +2133,6 @@ def match(node, pattern):
for cb in event_cbs:
cb(event)

def _set_on_model(self, msg: Mapping[str, Any], root: Model, model: Model) -> None:
super()._set_on_model(msg, root, model)
if isinstance(model, DataModel):
self._patch_datamodel_ref(model.properties_with_values(), root.ref['id'])

def _update_model(
self, events: dict[str, param.parameterized.Event], msg: dict[str, Any],
root: Model, model: Model, doc: Document, comm: Comm | None
Expand Down
Loading

0 comments on commit 0eb63a0

Please sign in to comment.