Skip to content

Commit 4ebb128

Browse files
committed
add docs for async effects + fix effect cleanup timing
Instead of cleaning before render we clean just before next effect this allows us to skip cleaning of no new effect is triggered for memoized effects
1 parent 657f806 commit 4ebb128

File tree

3 files changed

+77
-23
lines changed

3 files changed

+77
-23
lines changed

docs/source/life-cycle-hooks.rst

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,28 @@ Now a new connection will only be estalished if a new ``url`` is provided.
163163
Async Effects
164164
.............
165165

166-
under construction...
166+
A behavior unique to IDOM's implementation of ``use_effect`` is that it natively
167+
supports ``async`` functions:
168+
169+
.. code-block::
170+
171+
async def nonblocking_effect():
172+
resource = await do_something_asynchronously()
173+
return lambda: blocking_close(resource)
174+
175+
use_effect(nonblocking_effect)
176+
177+
178+
There are **three important subtleties** to note about using asynchronous effects:
179+
180+
1. The cleanup function must be a normal synchronous function.
181+
182+
2. Asynchronous effects which do not complete before the next effect is created
183+
following a re-render will be cancelled. This means an
184+
:class:`~asyncio.CancelledError` will be raised somewhere in the body of the effect.
185+
186+
3. An asynchronous effect may occur any time after the update which added this effect
187+
and before the next effect following a subsequent update.
167188

168189

169190
**Supplementary Hooks**

idom/core/hooks.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def use_effect(
125125
"""
126126
hook = current_hook()
127127
memoize = use_memo(args=args)
128+
last_clean_callback = use_ref(None)
128129

129130
def add_effect(function: _EffectApplyFunc) -> None:
130131

@@ -149,9 +150,13 @@ def clean_future() -> None:
149150
return clean_future
150151

151152
def effect() -> None:
152-
clean = sync_function()
153+
if last_clean_callback.current is not None:
154+
last_clean_callback.current()
155+
156+
clean = last_clean_callback.current = sync_function()
153157
if clean is not None:
154-
hook.add_effect("will_render will_unmount", clean)
158+
hook.add_effect("will_unmount", clean)
159+
155160
return None
156161

157162
return memoize(lambda: hook.add_effect("did_render", effect))
@@ -355,7 +360,6 @@ def current_hook() -> "LifeCycleHook":
355360

356361

357362
class _EventEffects(NamedTuple):
358-
will_render: List[Callable[[], Any]]
359363
did_render: List[Callable[[], Any]]
360364
will_unmount: List[Callable[[], Any]]
361365

@@ -391,7 +395,7 @@ def __init__(
391395
self._rendered_atleast_once = False
392396
self._current_state_index = 0
393397
self._state: Tuple[Any, ...] = ()
394-
self._event_effects = _EventEffects([], [], [])
398+
self._event_effects = _EventEffects([], [])
395399

396400
def schedule_render(self) -> None:
397401
if self._is_rendering:
@@ -418,15 +422,6 @@ def add_effect(self, events: str, function: Callable[[], None]) -> None:
418422
def element_will_render(self) -> None:
419423
"""The element is about to render"""
420424
self._is_rendering = True
421-
422-
for effect in self._event_effects.will_render:
423-
try:
424-
effect()
425-
except Exception:
426-
msg = f"Pre-render effect {effect} failed for {self.element}"
427-
logger.exception(msg)
428-
429-
self._event_effects.will_render.clear()
430425
self._event_effects.will_unmount.clear()
431426

432427
def element_did_render(self) -> None:

tests/test_core/test_hooks.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -285,19 +285,19 @@ def CheckNoEffectYet():
285285
assert effect_triggers_after_final_render.current
286286

287287

288-
async def test_use_effect_cleanup_occurs_on_will_render():
288+
async def test_use_effect_cleanup_occurs_before_next_effect():
289289
element_hook = HookCatcher()
290290
cleanup_triggered = idom.Ref(False)
291-
cleanup_triggered_before_next_render = idom.Ref(False)
291+
cleanup_triggered_before_next_effect = idom.Ref(False)
292292

293293
@idom.element
294294
@element_hook.capture
295295
def ElementWithEffect():
296-
if cleanup_triggered.current:
297-
cleanup_triggered_before_next_render.current = True
298-
299296
@idom.hooks.use_effect
300297
def effect():
298+
if cleanup_triggered.current:
299+
cleanup_triggered_before_next_effect.current = True
300+
301301
def cleanup():
302302
cleanup_triggered.current = True
303303

@@ -314,7 +314,7 @@ def cleanup():
314314
await layout.render()
315315

316316
assert cleanup_triggered.current
317-
assert cleanup_triggered_before_next_render.current
317+
assert cleanup_triggered_before_next_effect.current
318318

319319

320320
async def test_use_effect_cleanup_occurs_on_will_unmount():
@@ -352,7 +352,7 @@ def cleanup():
352352
assert cleanup_triggered_before_next_render.current
353353

354354

355-
async def test_use_effect_memoization():
355+
async def test_memoized_effect_on_recreated_if_args_change():
356356
element_hook = HookCatcher()
357357
set_state_callback = idom.Ref(None)
358358
effect_run_count = idom.Ref(0)
@@ -392,6 +392,44 @@ def effect():
392392
assert effect_run_count.current == 2
393393

394394

395+
async def test_memoized_effect_cleanup_only_triggered_before_new_effect():
396+
element_hook = HookCatcher()
397+
set_state_callback = idom.Ref(None)
398+
cleanup_trigger_count = idom.Ref(0)
399+
400+
first_value = 1
401+
second_value = 2
402+
403+
@idom.element
404+
@element_hook.capture
405+
def ElementWithEffect():
406+
state, set_state_callback.current = idom.hooks.use_state(first_value)
407+
408+
@idom.hooks.use_effect(args=[state])
409+
def effect():
410+
def cleanup():
411+
cleanup_trigger_count.current += 1
412+
413+
return cleanup
414+
415+
return idom.html.div()
416+
417+
async with idom.Layout(ElementWithEffect()) as layout:
418+
await layout.render()
419+
420+
assert cleanup_trigger_count.current == 0
421+
422+
element_hook.schedule_render()
423+
await layout.render()
424+
425+
assert cleanup_trigger_count.current == 0
426+
427+
set_state_callback.current(second_value)
428+
await layout.render()
429+
430+
assert cleanup_trigger_count.current == 1
431+
432+
395433
async def test_use_async_effect():
396434
effect_ran = asyncio.Event()
397435

@@ -481,7 +519,7 @@ def bad_effect():
481519
assert re.match("Post-render effect .*? failed for .*?", first_log_line)
482520

483521

484-
async def test_error_in_effect_pre_render_cleanup_is_gracefully_handled(caplog):
522+
async def test_error_in_effect_cleanup_is_gracefully_handled(caplog):
485523
element_hook = HookCatcher()
486524

487525
@idom.element
@@ -502,7 +540,7 @@ def bad_cleanup():
502540
await layout.render() # no error
503541

504542
first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0]
505-
assert re.match("Pre-render effect .*? failed for .*?", first_log_line)
543+
assert re.match("Post-render effect .*? failed for .*?", first_log_line)
506544

507545

508546
async def test_error_in_effect_pre_unmount_cleanup_is_gracefully_handled(caplog):

0 commit comments

Comments
 (0)