Repository: holoviz/panel

Issue:
DataLink issues in Pipeline
I am having issues with `DataLink` in a `pn.PIpeline`, either in Jupyter or `panel serve`.

Versions:

* holoviews: 1.11.0
* param 1.8.2a1
* panel 0.3.1

I have a two stage Pipeline. First stage generates random 3D data. The second stage explores this dataset. The second stage includes a Image, with a slider that show 2D slice of this dataset, and a `hv.Table`.
The Image have a PointDraw stream on it which allows drawing points on the image at center of each pixel.
The points Element created by PointDraw stream is linked to the table via `DataLink`. The Image * points and table are in separate Panes.
When adding the points to the Image, the table should be updated with the points' coordinates. However, in this current Pipeline setup the table is not being populated.

```
import xarray as xr
import numpy as np
import holoviews as hv
from holoviews.streams import PointDraw
from holoviews.plotting.links import DataLink
import panel as pn
import param
hv.extension('bokeh')

class DataLoader(param.Parameterized):
    
    generate_data = param.Action(
        default=lambda self: self._generate_data(),
        precedence=1,
        doc="""Button to generate data.""")

    data = param.Parameter()
    
    def _generate_data(self):
        # Random data
        x = np.arange(0, 10)
        y = x
        z = x
        data = np.random.rand(len(x), len(y), len(z))
        self.data = xr.DataArray(data,
                                 coords={'x': x, 'y': y, 'wavenumber': z},
                                 dims=['x', 'y', 'wavenumber'],
                                 name='Signal')
        
    def display_xarray(self):
        msg = """```
        {}
        ```
        """.format(repr(self.data))
        self._panel[1] = pn.pane.Markdown(msg)

    def panel(self):
        
        self._panel = pn.Column(
            pn.Param(self, parameters=['generate_data'], show_name=False),
            pn.Spacer(height=200, width=200)
        )

        return self._panel


class DataViewer(param.Parameterized):
    
    data = param.Parameter()

    def view_image(self):
        return hv.Dataset(self.data).to(hv.Image, ['x', 'y'], dynamic=True)

    def point_draw_spatial_map(self, data):
        # Workaround: there will be a simpler way coming soon with last
        # attribute
        key = (self._spatial_map.callback.args
               or tuple(d.values[0] for d in self._spatial_map.kdims))
        img = self._spatial_map[key]
        closest = img.closest(list(zip(data['x'], data['y'])))
        return hv.Points(closest)

    def view_spatial_map(self):

        # Plot image
        self._spatial_map = self.view_image()

        # Create a PointDraw stream
        self._point_stream = PointDraw(data={'x': [], 'y': []})

        # Create a DynamicMap linked to a PointDraw stream using
        # a function that snap points to center of a pixel
        self._spatial_points = hv.DynamicMap(self.point_draw_spatial_map,
                                             streams=[self._point_stream])
        # Compose plot
        spatial_map_layout = pn.panel(self._spatial_map * self._spatial_points)
        spatial_map_panel = pn.Column(spatial_map_layout[0], spatial_map_layout[1])

        return spatial_map_panel

    def view_spatial_table(self):

        # Create a table
        self._spatial_table = hv.Table(
            self._spatial_points.data, ['x', 'y']
        ).opts(height=200, width=200)

        # Link the data in points to table
        DataLink(self._spatial_points, self._spatial_table)

        return pn.panel(self._spatial_table)

    def panel(self):

        spatial_map = self.view_spatial_map()

        spatial_table = self.view_spatial_table()

        return pn.Row(
            spatial_map,
            spatial_table,
        )

stages = [
    ('Load Data', DataLoader),
    ('Browser', DataViewer),
]

pipeline = pn.pipeline.Pipeline(stages, debug=True)

# Change width of buttons
pipeline.layout[0][1]._widget_box.width = 100
pipeline.layout[0][2]._widget_box.width = 100

pipeline.layout.servable()
```


Discussion:

Resolving patch:
diff --git a/panel/holoviews.py b/panel/holoviews.py
--- a/panel/holoviews.py
+++ b/panel/holoviews.py
@@ -259,13 +259,15 @@ def find_links(root_view, root_model):
             if tgt_links:
                 found.append((link, plot, tgt_links[0][0]))
 
+    new_found = set(found) - root_view._found_links
     callbacks = []
-    for link, src_plot, tgt_plot in found:
+    for link, src_plot, tgt_plot in new_found:
         cb = Link._callbacks['bokeh'][type(link)]
         if src_plot is None or (getattr(link, '_requires_target', False)
                                 and tgt_plot is None):
             continue
         callbacks.append(cb(root_model, link, src_plot, tgt_plot))
+    root_view._found_links.update(new_found)
     return callbacks
 
 
diff --git a/panel/layout.py b/panel/layout.py
--- a/panel/layout.py
+++ b/panel/layout.py
@@ -69,6 +69,7 @@ def update_model():
                     for pane in old:
                         if pane not in self.objects:
                             pane._cleanup(root)
+                    self._preprocess(root) #preprocess links between new elements
                 processed = self._process_param_change(msg)
                 model.update(**processed)
 
diff --git a/panel/viewable.py b/panel/viewable.py
--- a/panel/viewable.py
+++ b/panel/viewable.py
@@ -47,6 +47,7 @@ def __init__(self, **params):
         super(Viewable, self).__init__(**params)
         self._documents = {}
         self._models = {}
+        self._found_links = set()
 
     def __repr__(self, depth=0):
         return '{cls}({params})'.format(cls=type(self).__name__,


Test patch:
diff --git a/panel/tests/test_holoviews.py b/panel/tests/test_holoviews.py
--- a/panel/tests/test_holoviews.py
+++ b/panel/tests/test_holoviews.py
@@ -257,7 +257,37 @@ def test_holoviews_link_across_panes(document, comm):
 
     range_tool = row.select_one({'type': RangeTool})
     assert isinstance(range_tool, RangeTool)
-    range_tool.x_range = p2.x_range
+    assert range_tool.x_range == p2.x_range
+    
+
+@hv_available
+def test_holoviews_link_after_adding_item(document, comm):
+    from bokeh.models.tools import RangeTool
+    from holoviews.plotting.links import RangeToolLink
+
+    c1 = hv.Curve([])
+    c2 = hv.Curve([])
+    
+    RangeToolLink(c1, c2)
+    
+    layout = Row(c1)
+    row = layout._get_root(document, comm=comm)
+    
+    assert len(row.children) == 1
+    p1, = row.children
+    
+    assert isinstance(p1, Figure)
+    range_tool = row.select_one({'type': RangeTool})
+    assert range_tool is None
+    
+    layout.append(c2)
+    _, p2 = row.children
+    assert isinstance(p2, Figure)
+    range_tool = row.select_one({'type': RangeTool})
+    assert isinstance(range_tool, RangeTool)
+    assert range_tool.x_range == p2.x_range
+    
+    
 
 
 @hv_available
@@ -290,4 +320,4 @@ def test_holoviews_link_within_pane(document, comm):
 
     range_tool = row.select_one({'type': RangeTool})
     assert isinstance(range_tool, RangeTool)
-    range_tool.x_range = p2.x_range
+    assert range_tool.x_range == p2.x_range