diff --git a/doc/user_guide/Overview.md b/doc/user_guide/Overview.md index 7e2a14688e..d78ccbebe7 100644 --- a/doc/user_guide/Overview.md +++ b/doc/user_guide/Overview.md @@ -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. diff --git a/examples/user_guide/Session_State_and_Callbacks.ipynb b/examples/user_guide/Session_State_and_Callbacks.ipynb index 321d599fe9..64f003388c 100644 --- a/examples/user_guide/Session_State_and_Callbacks.ipynb +++ b/examples/user_guide/Session_State_and_Callbacks.ipynb @@ -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": {}, diff --git a/panel/config.py b/panel/config.py index cec64b39eb..fda1dffe34 100644 --- a/panel/config.py +++ b/panel/config.py @@ -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.""") diff --git a/panel/io/server.py b/panel/io/server.py index e31931ecc9..9e4e318faf 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -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 diff --git a/panel/io/state.py b/panel/io/state.py index ac5d7abbcc..264e957bfa 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -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 @@ -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 @@ -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 @@ -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: diff --git a/panel/param.py b/panel/param.py index 40898088bd..a180b5dade 100644 --- a/panel/param.py +++ b/panel/param.py @@ -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, @@ -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.""") @@ -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): @@ -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 = [] @@ -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) #---------------------------------------------------------------- @@ -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( @@ -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 diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index a20da0c56a..da56bca0e7 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -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: diff --git a/panel/tests/test_param.py b/panel/tests/test_param.py index 171c7e65cf..a3479fa80e 100644 --- a/panel/tests/test_param.py +++ b/panel/tests/test_param.py @@ -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, @@ -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() diff --git a/panel/tests/ui/test_param.py b/panel/tests/ui/test_param.py new file mode 100644 index 0000000000..f6548f0ead --- /dev/null +++ b/panel/tests/ui/test_param.py @@ -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!'