Skip to content

Commit

Permalink
Add ability to defer load until after page is rendered (#3882)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Sep 26, 2022
1 parent 4bcf011 commit e0e7db7
Show file tree
Hide file tree
Showing 9 changed files with 148 additions and 21 deletions.
1 change: 1 addition & 0 deletions doc/user_guide/Overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ The `pn.config` object allows setting various configuration variables, the confi
> - `js_files`: External JS files to load. Dictionary should map from exported name to the URL of the JS file.
> - `loading_spinner`: The style of the global loading indicator, e.g. 'arcs', 'bars', 'dots', 'petals'.
> - `loading_color`: The color of the global loading indicator as a hex color, e.g. #6a6a6a
> - `defer_load`: Whether reactive function evaluation is deferred until the page is rendered.
> - `nthreads`: If set will start a `ThreadPoolExecutor` to dispatch events to for concurrent execution on separate cores. By default no thread pool is launched, while setting nthreads=0 launches `min(32, os.cpu_count() + 4)` threads.
> - `raw_css`: List of raw CSS strings to add to load.
> - `reuse_sessions`: Whether to reuse a session for the initial request to speed up the initial page render. Note that if the initial page differs between sessions, e.g. because it uses query parameters to modify the rendered content, then this option will result in the wrong content being rendered. See the [Performance and Debugging guide](Performance_and_Debugging.rst#Reuse-sessions) for more information.
Expand Down
19 changes: 19 additions & 0 deletions examples/user_guide/Session_State_and_Callbacks.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,25 @@
"# pn.serve(app) "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Alternatively we may also use the `defer_load` option to wait to evaluate a function until the page is loaded. This will render a placeholder and display the global `config.loading_spinner`:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def render_on_load():\n",
" return pn.widgets.Select(options=['A', 'B', 'C'])\n",
"\n",
"pn.Row(pn.panel(render_on_load, defer_load=True))"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
3 changes: 3 additions & 0 deletions panel/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ class _config(_base_config):
autoreload = param.Boolean(default=False, doc="""
Whether to autoreload server when script changes.""")

defer_load = param.Boolean(default=False, doc="""
Whether to defer load of rendered functions.""")

load_entry_points = param.Boolean(default=True, doc="""
Load entry points from external packages.""")

Expand Down
3 changes: 2 additions & 1 deletion panel/io/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ def initialize_document(self, doc):
super().initialize_document(doc)
if doc in state._templates and doc not in state._templates[doc]._documents:
template = state._templates[doc]
template.server_doc(title=template.title, location=True, doc=doc)
with set_curdoc(doc):
template.server_doc(title=template.title, location=True, doc=doc)

bokeh.command.util.Application = Application # type: ignore

Expand Down
15 changes: 15 additions & 0 deletions panel/io/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ class _state(param.Parameterized):
Object with encrypt and decrypt methods to support encryption
of secret variables including OAuth information.""")

loaded = param.Boolean(default=False, doc="""
Whether the page is fully loaded.""")

rel_path = param.String(default='', readonly=True, doc="""
Relative path from the current app being served to the root URL.
If application is embedded in a different server via autoload.js
Expand Down Expand Up @@ -162,6 +165,7 @@ class _state(param.Parameterized):
# Dictionary of callbacks to be triggered on app load
_onload: ClassVar[Dict[Document, Callable[[], None]]] = WeakKeyDictionary()
_on_session_created: ClassVar[List[Callable[[BokehSessionContext], []]]] = []
_loaded: ClassVar[WeakKeyDictionary[Document, bool]] = WeakKeyDictionary()

# Module that was run during setup
_setup_module = None
Expand Down Expand Up @@ -323,6 +327,7 @@ def _schedule_on_load(self, doc: Document, event) -> None:

def _on_load(self, doc: Optional[Document] = None) -> None:
doc = doc or self.curdoc
self._loaded[doc] = True
callbacks = self._onload.pop(doc, [])
if not callbacks:
return
Expand Down Expand Up @@ -816,6 +821,16 @@ def cookies(self) -> Dict[str, str]:
def headers(self) -> Dict[str, str | List[str]]:
return self.curdoc.session_context.request.headers if self.curdoc and self.curdoc.session_context else {}

@property
def loaded(self) -> bool:
curdoc = self.curdoc
if curdoc:
if curdoc in self._loaded:
return self._loaded[curdoc]
elif curdoc.session_context:
return False
return True

@property
def location(self) -> Location | None:
if self.curdoc and self.curdoc not in self._locations:
Expand Down
59 changes: 40 additions & 19 deletions panel/param.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from packaging.version import Version
from param.parameterized import classlist, discard_events

from .config import config
from .io import init_doc, state
from .layout import (
Column, Panel, Row, Spacer, Tabs,
Expand Down Expand Up @@ -752,6 +753,10 @@ class ParamMethod(ReplacementPane):
return any object which itself can be rendered as a Pane.
"""

defer_load = param.Boolean(default=None, doc="""
Whether to defer load until after the page is rendered.
Can be set as parameter or by setting panel.config.defer_load.""")

lazy = param.Boolean(default=False, doc="""
Whether to lazily evaluate the contents of the object
only when it is required for rendering.""")
Expand All @@ -760,12 +765,18 @@ class ParamMethod(ReplacementPane):
Whether to show loading indicator while pane is updating.""")

def __init__(self, object=None, **params):
if (
self.param.defer_load.default is None and
'defer_load' not in params and config.defer_load
):
params['defer_load'] = config.defer_load
super().__init__(object, **params)
self._evaled = not self.lazy
self._evaled = not (self.lazy or self.defer_load)
self._link_object_params()
if object is not None:
self._validate_object()
self._replace_pane()
if not self.defer_load:
self._replace_pane()

@param.depends('object', watch=True)
def _validate_object(self):
Expand Down Expand Up @@ -807,20 +818,23 @@ async def _eval_async(self, awaitable):
self._inner_layout.loading = False

def _replace_pane(self, *args, force=False):
self._evaled = bool(self._models) or force or not self.lazy
if self._evaled:
self._inner_layout.loading = self.loading_indicator
try:
if self.object is None:
new_object = Spacer()
else:
new_object = self.eval(self.object)
if inspect.isawaitable(new_object):
param.parameterized.async_executor(partial(self._eval_async, new_object))
return
self._update_inner(new_object)
finally:
self._inner_layout.loading = False
deferred = self.defer_load and not state.loaded
if not self._inner_layout.loading:
self._inner_layout.loading = bool(self.loading_indicator or deferred)
self._evaled |= force or not (self.lazy or deferred)
if not self._evaled:
return
try:
if self.object is None:
new_object = Spacer()
else:
new_object = self.eval(self.object)
if inspect.isawaitable(new_object):
param.parameterized.async_executor(partial(self._eval_async, new_object))
return
self._update_inner(new_object)
finally:
self._inner_layout.loading = False

def _update_pane(self, *events):
callbacks = []
Expand Down Expand Up @@ -881,7 +895,10 @@ def _get_model(
parent: Optional[Model] = None, comm: Optional[Comm] = None
) -> Model:
if not self._evaled:
self._replace_pane(force=True)
deferred = self.defer_load and not state.loaded
if deferred:
state.onload(partial(self._replace_pane, force=True))
self._replace_pane(force=not deferred)
return super()._get_model(doc, root, parent, comm)

#----------------------------------------------------------------
Expand All @@ -905,10 +922,12 @@ class ParamFunction(ParamMethod):

priority: ClassVar[float | bool | None] = 0.6

_applies_kw: ClassVar[bool] = True

def _link_object_params(self):
deps = getattr(self.object, '_dinfo', {})
dep_params = list(deps.get('dependencies', [])) + list(deps.get('kw', {}).values())
if not dep_params and not self.lazy:
if not dep_params and not self.lazy and not self.defer_load:
fn = getattr(self.object, '__bound_function__', self.object)
fn_name = getattr(fn, '__name__', repr(self.object))
self.param.warning(
Expand Down Expand Up @@ -939,10 +958,12 @@ def _link_object_params(self):
#----------------------------------------------------------------

@classmethod
def applies(cls, obj: Any) -> float | bool | None:
def applies(cls, obj: Any, **kwargs) -> float | bool | None:
if isinstance(obj, types.FunctionType):
if hasattr(obj, '_dinfo'):
return True
if kwargs.get('defer_load') or (cls.param.defer_load.default is None and config.defer_load):
return True
return None
return False

Expand Down
1 change: 1 addition & 0 deletions panel/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ def server_cleanup():
state._locations.clear()
state._templates.clear()
state._views.clear()
state._loaded.clear()
state.cache.clear()
state._scheduled.clear()
if state._thread_pool is not None:
Expand Down
39 changes: 38 additions & 1 deletion panel/tests/test_param.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
TextInput as BkTextInput, Toggle,
)

from panel.io.state import set_curdoc, state
from panel.layout import Row, Tabs
from panel.pane import (
HTML, Bokeh, Matplotlib, Pane, PaneBase, panel,
HTML, Bokeh, Matplotlib, Pane, PaneBase, Str, panel,
)
from panel.param import (
JSONInit, Param, ParamFunction, ParamMethod,
Expand Down Expand Up @@ -1055,6 +1056,42 @@ def view(a):
assert inner_pane._models == {}


def test_param_function_pane_defer_load(document, comm):
test = View()

@param.depends(test.param.a)
def view(a):
return Div(text='%d' % a)

pane = panel(view, defer_load=True)
inner_pane = pane._pane
assert isinstance(inner_pane, Str)

# Ensure pane thinks page is not loaded
state._loaded[document] = False

# Create pane
with set_curdoc(document):
row = pane.get_root(document, comm=comm)
assert isinstance(row, BkRow)
assert len(row.children) == 1
model = row.children[0]
assert pane._models[row.ref['id']][0] is row
assert isinstance(model, Div)
assert model.text == '<pre> </pre>'

# Test on_load
state._on_load(document)
model = row.children[0]
assert isinstance(model, Div)
assert model.text == '0'

# Cleanup pane
pane._cleanup(row)
assert pane._models == {}
assert inner_pane._models == {}


def test_param_function_pane_update(document, comm):
test = View()

Expand Down
29 changes: 29 additions & 0 deletions panel/tests/ui/test_param.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import time

import pytest

pytestmark = pytest.mark.ui

from panel.io.server import serve
from panel.pane import panel


def test_param_defer_load(page, port):
def defer_load():
time.sleep(0.5)
return 'I render after load!'

component = panel(defer_load, defer_load=True)

serve(component, port=port, threaded=True, show=False)

time.sleep(0.2)

page.goto(f"http://localhost:{port}")

assert page.locator(".bk.pn-loading")
assert page.locator('.bk.markdown').count() == 0

time.sleep(0.5)

assert page.text_content('.bk.markdown') == 'I render after load!'

0 comments on commit e0e7db7

Please sign in to comment.