Skip to content

Commit 9058882

Browse files
authored
Implement @observe(suspended) and @session.output(suspend_when_hidden, priority) (#50)
1 parent b8b2a52 commit 9058882

File tree

4 files changed

+299
-15
lines changed

4 files changed

+299
-15
lines changed

shiny/reactive.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ def __init__(
236236
self,
237237
fn: EffectFunction,
238238
*,
239+
suspended: bool = False,
239240
priority: int = 0,
240241
session: Union[MISSING_TYPE, "Session", None] = MISSING,
241242
) -> None:
@@ -249,6 +250,8 @@ def __init__(
249250
self._is_async: bool = False
250251

251252
self._priority: int = priority
253+
self._suspended = suspended
254+
self._on_resume: Callable[[], None] = lambda: None
252255

253256
self._invalidate_callbacks: list[Callable[[], None]] = []
254257
self._destroyed: bool = False
@@ -286,8 +289,16 @@ def on_invalidate_cb() -> None:
286289
for cb in self._invalidate_callbacks:
287290
cb()
288291

289-
# TODO: Wrap this stuff up in a continue callback, depending on if suspended?
290-
ctx.add_pending_flush(self._priority)
292+
if self._destroyed:
293+
return
294+
295+
def _continue() -> None:
296+
ctx.add_pending_flush(self._priority)
297+
298+
if self._suspended:
299+
self._on_resume = _continue
300+
else:
301+
_continue()
291302

292303
async def on_flush_cb() -> None:
293304
if not self._destroyed:
@@ -325,6 +336,35 @@ def destroy(self) -> None:
325336
if self._ctx is not None:
326337
self._ctx.invalidate()
327338

339+
def suspend(self) -> None:
340+
"""
341+
Causes this observer to stop scheduling flushes (re-executions) in response to
342+
invalidations. If the observer was invalidated prior to this call but it has not
343+
re-executed yet (because it waits until on_flush is called) then that
344+
re-execution will still occur, because the flush is already scheduled.
345+
"""
346+
self._suspended = True
347+
348+
def resume(self) -> None:
349+
"""
350+
Causes this observer to start re-executing in response to invalidations. If the
351+
observer was invalidated while suspended, then it will schedule itself for
352+
re-execution (pending flush).
353+
"""
354+
if self._suspended:
355+
self._suspended = False
356+
self._on_resume()
357+
self._on_resume = lambda: None
358+
359+
def set_priority(self, priority: int = 0) -> None:
360+
"""
361+
Change this observer's priority. Note that if the observer is currently
362+
invalidated, then the change in priority will not take effect until the next
363+
invalidation--unless the observer is also currently suspended, in which case the
364+
priority change will be effective upon resume.
365+
"""
366+
self._priority = priority
367+
328368
def _on_session_ended_cb(self) -> None:
329369
self.destroy()
330370

@@ -334,18 +374,27 @@ def __init__(
334374
self,
335375
fn: EffectFunctionAsync,
336376
*,
377+
suspended: bool = False,
337378
priority: int = 0,
338379
session: Union[MISSING_TYPE, "Session", None] = MISSING,
339380
) -> None:
340381
if not utils.is_async_callable(fn):
341382
raise TypeError(self.__class__.__name__ + " requires an async function")
342383

343-
super().__init__(cast(EffectFunction, fn), session=session, priority=priority)
384+
super().__init__(
385+
cast(EffectFunction, fn),
386+
suspended=suspended,
387+
session=session,
388+
priority=priority,
389+
)
344390
self._is_async = True
345391

346392

347393
def effect(
348-
*, priority: int = 0, session: Union[MISSING_TYPE, "Session", None] = MISSING
394+
*,
395+
suspended: bool = False,
396+
priority: int = 0,
397+
session: Union[MISSING_TYPE, "Session", None] = MISSING,
349398
) -> Callable[[Union[EffectFunction, EffectFunctionAsync]], Effect]:
350399
"""[summary]
351400
@@ -360,10 +409,12 @@ def effect(
360409
def create_effect(fn: Union[EffectFunction, EffectFunctionAsync]) -> Effect:
361410
if utils.is_async_callable(fn):
362411
fn = cast(EffectFunctionAsync, fn)
363-
return EffectAsync(fn, priority=priority, session=session)
412+
return EffectAsync(
413+
fn, suspended=suspended, priority=priority, session=session
414+
)
364415
else:
365416
fn = cast(EffectFunction, fn)
366-
return Effect(fn, priority=priority, session=session)
417+
return Effect(fn, suspended=suspended, priority=priority, session=session)
367418

368419
return create_effect
369420

shiny/session.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
Dict,
3333
List,
3434
Any,
35+
cast,
3536
)
3637
from starlette.requests import Request
3738

@@ -53,7 +54,7 @@
5354

5455
from htmltools import TagChildArg, TagList
5556

56-
from .reactive import Value, Effect, EffectAsync, isolate
57+
from .reactive import Value, Effect, effect, isolate
5758
from .http_staticfiles import FileResponse
5859
from .connmanager import Connection, ConnectionClosed
5960
from . import reactcore
@@ -240,6 +241,8 @@ def _manage_inputs(self, data: Dict[str, object]) -> None:
240241

241242
self.input[keys[0]]._set(val)
242243

244+
self.output.manage_hidden()
245+
243246
# ==========================================================================
244247
# Message handlers
245248
# ==========================================================================
@@ -580,11 +583,16 @@ def __delattr__(self, key: str) -> None:
580583
# ======================================================================================
581584
class Outputs:
582585
def __init__(self, session: Session) -> None:
583-
self._output_obervers: Dict[str, Effect] = {}
586+
self._effects: Dict[str, Effect] = {}
587+
self._suspend_when_hidden: Dict[str, bool] = {}
584588
self._session: Session = session
585589

586590
def __call__(
587-
self, *, name: Optional[str] = None
591+
self,
592+
*,
593+
name: Optional[str] = None,
594+
suspend_when_hidden: bool = True,
595+
priority: int = 0,
588596
) -> Callable[[render.RenderFunction], None]:
589597
def set_fn(fn: render.RenderFunction) -> None:
590598
fn_name = name or fn.__name__
@@ -594,10 +602,15 @@ def set_fn(fn: render.RenderFunction) -> None:
594602
if isinstance(fn, render.RenderFunction):
595603
fn.set_metadata(self._session, fn_name)
596604

597-
if fn_name in self._output_obervers:
598-
self._output_obervers[fn_name].destroy()
605+
if fn_name in self._effects:
606+
self._effects[fn_name].destroy()
599607

600-
@EffectAsync
608+
self._suspend_when_hidden[fn_name] = suspend_when_hidden
609+
610+
@effect(
611+
suspended=suspend_when_hidden and self._is_hidden(fn_name),
612+
priority=priority,
613+
)
601614
async def output_obs():
602615
await self._session.send_message(
603616
{"recalculating": {"name": fn_name, "status": "recalculating"}}
@@ -641,12 +654,36 @@ async def output_obs():
641654
{"recalculating": {"name": fn_name, "status": "recalculated"}}
642655
)
643656

644-
self._output_obervers[fn_name] = output_obs
657+
self._effects[fn_name] = output_obs
645658

646659
return None
647660

648661
return set_fn
649662

663+
def manage_hidden(self) -> None:
664+
"Suspends execution of hidden outputs and resumes execution of visible outputs."
665+
output_names = list(self._suspend_when_hidden.keys())
666+
for name in output_names:
667+
if self._should_suspend(name):
668+
self._effects[name].suspend()
669+
else:
670+
self._effects[name].resume()
671+
672+
def _should_suspend(self, name: str) -> bool:
673+
return self._suspend_when_hidden[name] and self._is_hidden(name)
674+
675+
def _is_hidden(self, name: str) -> bool:
676+
with isolate():
677+
hidden = cast(
678+
Optional[bool],
679+
self._session.input[f".clientdata_output_{name}_hidden"](),
680+
)
681+
682+
if hidden is None:
683+
return True
684+
else:
685+
return hidden
686+
650687

651688
# ==============================================================================
652689
# Context manager for current session (AKA current reactive domain)

shiny/shinymodule.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,19 @@ def _ns_key(self, key: str) -> str:
5959
return self._ns + "-" + key
6060

6161
def __call__(
62-
self, *, name: Optional[str] = None
62+
self,
63+
*,
64+
name: Optional[str] = None,
65+
suspend_when_hidden: bool = True,
66+
priority: int = 0
6367
) -> Callable[[RenderFunction], None]:
6468
def set_fn(fn: RenderFunction) -> None:
6569
fn_name = name or fn.__name__
6670
fn_name = self._ns_key(fn_name)
67-
return self._outputs(name=fn_name)(fn)
71+
out_fn = self._outputs(
72+
name=fn_name, suspend_when_hidden=suspend_when_hidden, priority=priority
73+
)
74+
return out_fn(fn)
6875

6976
return set_fn
7077

0 commit comments

Comments
 (0)