async def test_use_memo(): component_hook = HookCatcher() set_state_hook = idom.Ref(None) used_values = [] @idom.component @component_hook.capture def ComponentWithMemo(): state, set_state_hook.current = idom.hooks.use_state(0) value = idom.hooks.use_memo( lambda: idom.Ref( state), # use a Ref here just to ensure it's a unique obj [state], ) used_values.append(value) return idom.html.div() async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() set_state_hook.current(1) await layout.render() component_hook.schedule_render() await layout.render() assert used_values[0] is not used_values[1] assert used_values[1] is used_values[2] assert len(used_values) == 3
async def test_use_memo_with_stored_args_is_empty_tuple_after_args_are_none(): component_hook = HookCatcher() used_values = [] iter_values = iter([1, 2, 3]) args_used_in_memo = idom.Ref(()) @idom.component @component_hook.capture def ComponentWithMemo(): value = idom.hooks.use_memo( lambda: next(iter_values), args_used_in_memo.current, # noqa: ROH202 ) used_values.append(value) return idom.html.div() async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.schedule_render() args_used_in_memo.current = None await layout.render() component_hook.schedule_render() args_used_in_memo.current = () await layout.render() assert used_values == [1, 2, 2]
async def test_error_in_effect_pre_unmount_cleanup_is_gracefully_handled( caplog): outer_component_hook = HookCatcher() @idom.component @outer_component_hook.capture def OuterComponent(): return ComponentWithEffect() @idom.component def ComponentWithEffect(): @idom.hooks.use_effect def ok_effect(): def bad_cleanup(): raise ValueError("Something went wong :(") return bad_cleanup return idom.html.div() async with idom.Layout(OuterComponent()) as layout: await layout.render() outer_component_hook.schedule_render() await layout.render() # no error first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] assert re.match("Pre-unmount effect .*? failed for .*?", first_log_line)
async def test_use_callback_memoization(): component_hook = HookCatcher() set_state_hook = idom.Ref(None) used_callbacks = [] @idom.component @component_hook.capture def ComponentWithRef(): state, set_state_hook.current = idom.hooks.use_state(0) @idom.hooks.use_callback(args=[state] ) # use the deco form for coverage def cb(): return None used_callbacks.append(cb) return idom.html.div() async with idom.Layout(ComponentWithRef()) as layout: await layout.render() set_state_hook.current(1) await layout.render() component_hook.schedule_render() await layout.render() assert used_callbacks[0] is not used_callbacks[1] assert used_callbacks[1] is used_callbacks[2] assert len(used_callbacks) == 3
async def test_use_effect_cleanup_occurs_on_will_unmount(): outer_component_hook = HookCatcher() cleanup_triggered = idom.Ref(False) cleanup_triggered_before_next_render = idom.Ref(False) @idom.component @outer_component_hook.capture def OuterComponent(): if cleanup_triggered.current: cleanup_triggered_before_next_render.current = True return ComponentWithEffect() @idom.component def ComponentWithEffect(): @idom.hooks.use_effect def effect(): def cleanup(): cleanup_triggered.current = True return cleanup return idom.html.div() async with idom.Layout(OuterComponent()) as layout: await layout.render() assert not cleanup_triggered.current outer_component_hook.schedule_render() await layout.render() assert cleanup_triggered.current assert cleanup_triggered_before_next_render.current
async def test_double_updated_component_is_not_double_rendered(): hook = HookCatcher() run_count = idom.Ref(0) @idom.component @hook.capture def AnyComponent(): run_count.current += 1 return idom.html.div() async with idom.Layout(AnyComponent()) as layout: await layout.render() assert run_count.current == 1 hook.schedule_render() hook.schedule_render() await layout.render() try: await asyncio.wait_for( layout.render(), timeout=0.1, # this should have been plenty of time ) except asyncio.TimeoutError: pass # the render should still be rendering since we only update once assert run_count.current == 2
async def test_components_are_garbage_collected(): live_components = set() outer_component_hook = HookCatcher() @idom.component @outer_component_hook.capture def Outer(): component = idom.hooks.current_hook().component live_components.add(id(component)) finalize(component, live_components.remove, id(component)) hook = idom.hooks.current_hook() @idom.event(target_id="force-update") async def force_update(): hook.schedule_render() return idom.html.div({"onEvent": force_update}, Inner()) @idom.component def Inner(): component = idom.hooks.current_hook().component live_components.add(id(component)) finalize(component, live_components.remove, id(component)) return idom.html.div() async with idom.Layout(Outer()) as layout: await layout.render() assert len(live_components) == 2 last_live_components = live_components.copy() # The existing `Outer` component rerenders. A new `Inner` component is created and # the the old `Inner` component should be deleted. Thus there should be one # changed component in the set of `live_components` the old `Inner` deleted and new # `Inner` added. outer_component_hook.schedule_render() await layout.render() assert len(live_components - last_live_components) == 1 # The layout still holds a reference to the root so that's # only deleted once we release a reference to it. del layout # the hook also contains a reference to the root component del outer_component_hook gc.collect() assert not live_components
async def test_use_ref(): component_hook = HookCatcher() used_refs = [] @idom.component @component_hook.capture def ComponentWithRef(): used_refs.append(idom.hooks.use_ref(1)) return idom.html.div() async with idom.Layout(ComponentWithRef()) as layout: await layout.render() component_hook.schedule_render() await layout.render() assert used_refs[0] is used_refs[1] assert len(used_refs) == 2
async def test_use_memo_never_runs_if_args_args_empty_list(): component_hook = HookCatcher() used_values = [] iter_values = iter([1, 2, 3]) @idom.component @component_hook.capture def ComponentWithMemo(): value = idom.hooks.use_memo(lambda: next(iter_values), ()) used_values.append(value) return idom.html.div() async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.schedule_render() await layout.render() component_hook.schedule_render() await layout.render() assert used_values == [1, 1, 1]
async def test_memoized_effect_on_recreated_if_args_change(): component_hook = HookCatcher() set_state_callback = idom.Ref(None) effect_run_count = idom.Ref(0) first_value = 1 second_value = 2 @idom.component @component_hook.capture def ComponentWithMemoizedEffect(): state, set_state_callback.current = idom.hooks.use_state(first_value) @idom.hooks.use_effect(args=[state]) def effect(): effect_run_count.current += 1 return idom.html.div() async with idom.Layout(ComponentWithMemoizedEffect()) as layout: await layout.render() assert effect_run_count.current == 1 component_hook.schedule_render() await layout.render() assert effect_run_count.current == 1 set_state_callback.current(second_value) await layout.render() assert effect_run_count.current == 2 component_hook.schedule_render() await layout.render() assert effect_run_count.current == 2
async def test_use_async_effect_cancel(caplog): component_hook = HookCatcher() effect_ran = asyncio.Event() effect_was_cancelled = asyncio.Event() event_that_never_occurs = asyncio.Event() @idom.component @component_hook.capture def ComponentWithLongWaitingEffect(): @idom.hooks.use_effect async def effect(): effect_ran.set() try: await event_that_never_occurs.wait() except asyncio.CancelledError: effect_was_cancelled.set() raise return idom.html.div() async with idom.Layout(ComponentWithLongWaitingEffect()) as layout: await layout.render() await effect_ran.wait() component_hook.schedule_render() await layout.render() await asyncio.wait_for(effect_was_cancelled.wait(), 1) # So I know we said the event never occurs but... to ensure the effect's future is # cancelled before the test is cleaned up we need to set the event. This is because # the cancellation doesn't propogate before the test is resolved which causes # delayed log messages that impact other tests. event_that_never_occurs.set()
async def test_use_async_effect_cleanup(): component_hook = HookCatcher() effect_ran = asyncio.Event() cleanup_ran = asyncio.Event() @idom.component @component_hook.capture def ComponentWithAsyncEffect(): @idom.hooks.use_effect async def effect(): effect_ran.set() return cleanup_ran.set return idom.html.div() async with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() await effect_ran.wait() component_hook.schedule_render() await layout.render() await asyncio.wait_for(cleanup_ran.wait(), 1)
async def test_memoized_effect_cleanup_only_triggered_before_new_effect(): component_hook = HookCatcher() set_state_callback = idom.Ref(None) cleanup_trigger_count = idom.Ref(0) first_value = 1 second_value = 2 @idom.component @component_hook.capture def ComponentWithEffect(): state, set_state_callback.current = idom.hooks.use_state(first_value) @idom.hooks.use_effect(args=[state]) def effect(): def cleanup(): cleanup_trigger_count.current += 1 return cleanup return idom.html.div() async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() assert cleanup_trigger_count.current == 0 component_hook.schedule_render() await layout.render() assert cleanup_trigger_count.current == 0 set_state_callback.current(second_value) await layout.render() assert cleanup_trigger_count.current == 1