Only executing the dependencies of a tab when active

Hello,
I have a question which is a follow up of Inactive Tab computes when dynamic=True.

I want the code for rendering a tab only be executed when the tab is active, even if there are upstream changes to the parameters which would trigger the re-computation.

I have set dynamic=True when creating panel.Tabs and used a param.ParamMethod with lazy=True to wrap the content.

import panel as pn
import param
from time import sleep
from panel.widgets.base import WidgetBase

main_tabs = pn.Tabs(dynamic=True)
layout = pn.template.FastListTemplate()
layout.main.append(main_tabs)

class Tab1(WidgetBase):
    increment_button = pn.widgets.Button(name="Increase test_index", button_type="primary")
    @param.depends("increment_button.clicks", watch=False)
    def _count_clicks(self):
        return self.increment_button.clicks
    def __panel__(self):
        return  pn.Row(
            pn.Column("Content for Tab 1", self.increment_button),
            pn.Column("Clicks:", self._count_clicks)
        )

class Tab2(WidgetBase):
    test_index = param.Integer(default=None, allow_refs=True)
    def __init__(self, **params):
        super().__init__(**params)
    @param.depends("test_index", watch=False)
    def _content(self):
        sleep(1)  # Simulating a long computation
        return f"Content for Tab 2 (test_index: {self.test_index})"
    def __panel__(self):
        content = pn.param.ParamMethod(self._content, lazy=True)
        return content
tab1 = Tab1()
tab2 = Tab2(test_index=tab1.increment_button.param.clicks)

main_tabs.append(("Tab 1", tab1))
main_tabs.append(("Tab 2", tab2))

layout.servable()

The method tab2._content() is not executed until I make tab2 active, even if test_index has changed, which is what I want.
But after tab2 has been made active once, even if I move back to tab1, the method tab2._content() is executed every time test_index is changed.

I understand that one way to achieve what I want is to check in tab2._content() if tab2 is currently active and skip the computation otherwise, plus add a dependency on main_tabs.active to make sure the content is rendered when the tab is selected
But I was wondering if there is an in-built way in panel, instead of re-implementing the logic myself.

I implemented the following solution, in case it might be useful for others.

  • I have an ActiveTabWatcher Parameterized class which exposes an active boolean parameter (true when a specific tab is active).
  • I pass the active parameter (as a reference) to the widget which needs to display the content only when the tab is active.
  • I created a decorator depends_when_active which can be used instead of param.depends on the function that needs to be computed only if the tab is active

Here is the full code:

import panel as pn
import param
from time import sleep
import functools
from panel.widgets.base import WidgetBase

class ActiveTabWatcher(param.Parameterized):
    """
    Watches a specific tab in a `pn.Tabs` widget and exposes its active state
    as a reactive `param.Boolean` parameter.

    The `active` parameter is `True` when the watched tab is currently selected
    and `False` otherwise. It updates automatically whenever the active tab changes.
    """

    # This parameter will be True when the watched tab is active, False otherwise
    active = param.Boolean(default=False)

    def __init__(self, tabs: pn.Tabs, index_of_tab_to_watch: int, **params):
        """
        Initialize the watcher and bind it to the given tab.

        Parameters
        ----------
        tabs : pn.Tabs
            The `Tabs` widget to monitor.
        index_of_tab_to_watch : int
            Zero-based index of the tab whose active state should be tracked.
        **params
            Additional keyword arguments forwarded to `param.Parameterized`.
        """
        # define a parameter that will be True when the watched tab is active, False otherwise
        self.active = (tabs.active == index_of_tab_to_watch)
        # define a callback to update the parameter when the active tab changes
        def _update_tab_active_param(active):
            self.active = (active == index_of_tab_to_watch)
        # bind the callback to the Tabs active parameter
        param.bind(_update_tab_active_param, tabs.param.active, watch=True)
        super().__init__(**params)

def depends_when_active(*dependencies, watch: bool = False):
    """A custom version of `param.depends` that only triggers the decorated function when the "active" parameter is (or becomes) True.
    This is useful for avoiding expensive computations when a certain tab or section of the UI is not active.
    The "active" parameter is expected to be a `param.Boolean` that is present in the class where the decorated method is defined.
    """
    def decorator(func):
        @param.depends(*dependencies, "active", watch=watch)
        @functools.wraps(func)
        def wrapper(self, *args, **kwargs):
            # Get the current values of the dependencies
            deps_vals = [getattr(self, dep) for dep in dependencies]
            if not hasattr(wrapper, "dependencies_value"):
                # First call, consider dependencies as changed
                # N.B. This will also create the attribute "dependencies_changed" on the first call to the wrapper
                wrapper.dependencies_changed = True                
            else:
                # Check if any of the dependencies have changed only if they haven't already been marked as changed
                if not wrapper.dependencies_changed:
                    wrapper.dependencies_changed = any(
                        deps_vals[i] != wrapper.dependencies_value[i] for i in range(len(dependencies))
                    )
            # Store current dependencies values for next comparison
            wrapper.dependencies_value = deps_vals  
            # Only call the original function if the "active" parameter is True and dependencies have changed
            if self.active and wrapper.dependencies_changed:
                wrapper.dependencies_changed = False
                # store the last computed value in the wrapper function itself so that it can be returned when the new value of the function is not computed
                wrapper.last_return_value = func(self, *args, **kwargs)                
            # Return the last computed value even if "active" is False or dependencies haven't changed
            return getattr(wrapper, "last_return_value", None)
        return wrapper
    return decorator



main_tabs = pn.Tabs()
layout = pn.template.FastListTemplate()
layout.main.append(main_tabs)

class Tab1(WidgetBase):
    increment_button = pn.widgets.Button(name="Increase test_index", button_type="primary")
    @param.depends("increment_button.clicks", watch=False)
    def _count_clicks(self):
        return self.increment_button.clicks
    def __panel__(self):
        return  pn.Row(
            pn.Column("Content for Tab 1", self.increment_button),
            pn.Column("Clicks:", self._count_clicks)
        )

class Tab2(WidgetBase):
    test_index = param.Integer(default=None, allow_refs=True)
    active = param.Boolean(default=False, allow_refs=True)  # This parameter will be True when Tab 2 is active, False otherwise
    def __init__(self, **params):
        super().__init__(**params)

    @depends_when_active("test_index", watch=False)
    def _content(self):
        sleep(1)  # Simulating a long computation
        return f"Content for Tab 2 (test_index: {self.test_index})"
    def __panel__(self):
        return self._content
    
tab2_active = ActiveTabWatcher(main_tabs, index_of_tab_to_watch=1)  # Tab 2 is at index 1
tab1 = Tab1()
tab2 = Tab2(active=tab2_active.param.active, 
            test_index=tab1.increment_button.param.clicks)

main_tabs.append(("Tab 1", tab1))
main_tabs.append(("Tab 2", tab2))
main_tabs.append(("Tab 3", "Content for Tab 3"))

layout.servable()
1 Like