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.latest.schedule_render() hook.latest.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_use_debug_mode_with_factory(): set_message = idom.Ref() component_hook = HookCatcher() @idom.component @component_hook.capture def SomeComponent(): message, set_message.current = idom.use_state("hello") idom.use_debug_value(lambda: f"message is {message!r}") return idom.html.div() async with idom.Layout(SomeComponent()) as layout: with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'hello'"): await layout.render() set_message.current("bye") with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'bye'"): await layout.render() component_hook.latest.schedule_render() with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"): await layout.render()
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(dependencies=[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.latest.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_duplicate_sibling_keys_causes_error(caplog): hook = HookCatcher() should_error = True @idom.component @hook.capture def ComponentReturnsDuplicateKeys(): if should_error: return idom.html.div( idom.html.div(key="duplicate"), idom.html.div(key="duplicate"), ) else: return idom.html.div() async with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: with assert_idom_did_log( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", ): await layout.render() hook.latest.schedule_render() should_error = False await layout.render() should_error = True hook.latest.schedule_render() with assert_idom_did_log( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", ): await layout.render()
async def test_use_context_only_renders_for_value_change(): Context = idom.create_context(None) provider_hook = HookCatcher() render_count = idom.Ref(0) set_state = idom.Ref() @idom.component @provider_hook.capture def ComponentProvidesContext(): state, set_state.current = idom.use_state(0) return Context(ComponentInContext(), value=state) @idom.component def ComponentInContext(): render_count.current += 1 return html.div() async with idom.Layout(ComponentProvidesContext()) as layout: await layout.render() assert render_count.current == 1 set_state.current(1) await layout.render() assert render_count.current == 2 provider_hook.latest.schedule_render() await layout.render() assert render_count.current == 2
async def test_changing_key_of_component_resets_state(): set_key = Ref() did_init_state = Ref(0) hook = HookCatcher() @component @hook.capture def Root(): key, set_key.current = use_state("key-1") return Child(key=key) @component def Child(): use_state( lambda: did_init_state.set_current(did_init_state.current + 1)) async with Layout(Root()) as layout: await layout.render() assert did_init_state.current == 1 set_key.current("key-2") await layout.render() assert did_init_state.current == 2 hook.latest.schedule_render() await layout.render() assert did_init_state.current == 2
async def test_use_effect_cleanup_occurs_before_next_effect(): component_hook = HookCatcher() cleanup_triggered = idom.Ref(False) cleanup_triggered_before_next_effect = idom.Ref(False) @idom.component @component_hook.capture def ComponentWithEffect(): @idom.hooks.use_effect(dependencies=None) def effect(): if cleanup_triggered.current: cleanup_triggered_before_next_effect.current = True def cleanup(): cleanup_triggered.current = True return cleanup return idom.html.div() async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() assert not cleanup_triggered.current component_hook.latest.schedule_render() await layout.render() assert cleanup_triggered.current assert cleanup_triggered_before_next_effect.current
async def test_use_memo_with_stored_deps_is_empty_tuple_after_deps_are_none(): component_hook = HookCatcher() used_values = [] iter_values = iter([1, 2, 3]) deps_used_in_memo = idom.Ref(()) @idom.component @component_hook.capture def ComponentWithMemo(): value = idom.hooks.use_memo( lambda: next(iter_values), deps_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.latest.schedule_render() deps_used_in_memo.current = None await layout.render() component_hook.latest.schedule_render() deps_used_in_memo.current = () await layout.render() assert used_values == [1, 2, 2]
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.latest.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_keyed_components_preserve_hook_on_parent_update(): outer_hook = HookCatcher() inner_hook = HookCatcher() @idom.component @outer_hook.capture def Outer(): return Inner(key=1) @idom.component @inner_hook.capture def Inner(): return idom.html.div() async with idom.Layout(Outer()) as layout: await layout.render() old_inner_hook = inner_hook.latest outer_hook.latest.schedule_render() await layout.render() assert old_inner_hook is inner_hook.latest
async def test_components_are_garbage_collected(): live_components = set() outer_component_hook = HookCatcher() def add_to_live_components(constructor): def wrapper(*args, **kwargs): component = constructor(*args, **kwargs) component_id = id(component) live_components.add(component_id) finalize(component, live_components.discard, component_id) return component return wrapper @add_to_live_components @idom.component @outer_component_hook.capture def Outer(): return Inner() @add_to_live_components @idom.component def Inner(): 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.latest.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 our reference to the layout. del layout # the hook also contains a reference to the root component del outer_component_hook 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.latest.schedule_render() await layout.render() assert used_refs[0] is used_refs[1] assert len(used_refs) == 2
async def test_update_path_to_component_that_is_not_direct_child_is_correct(): hook = HookCatcher() @idom.component def Parent(): return idom.html.div(idom.html.div(Child())) @idom.component @hook.capture def Child(): return idom.html.div() async with idom.Layout(Parent()) as layout: await layout.render() hook.latest.schedule_render() update = await render_json_patch(layout) assert update.path == "/children/0/children/0/children/0"
async def test_component_should_render_always_false(): render_count = idom.Ref(0) root_hook = HookCatcher() @idom.component @root_hook.capture def Root(): return ComponentShouldRender(SomeComponent(), should_render=lambda new: False) @idom.component def SomeComponent(): render_count.current += 1 return html.div() async with idom.Layout(Root()) as layout: for _ in range(4): await layout.render() root_hook.latest.schedule_render() assert render_count.current == 1
async def test_use_memo_never_runs_if_deps_is_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.latest.schedule_render() await layout.render() component_hook.latest.schedule_render() await layout.render() assert used_values == [1, 1, 1]
async def test_component_error_in_should_render_is_handled_gracefully(): root_hook = HookCatcher() @idom.component @root_hook.capture def Root(): def bad_should_render(new): raise ValueError("The error message") return ComponentShouldRender(html.div(), should_render=bad_should_render) with assert_idom_did_log( match_message= r".* component failed to check if .* should be rendered", error_type=ValueError, match_error="The error message", ): async with idom.Layout(Root()) as layout: await layout.render() root_hook.latest.schedule_render() await layout.render()
async def test_memoized_effect_on_recreated_if_dependencies_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(dependencies=[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.latest.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.latest.schedule_render() await layout.render() assert effect_run_count.current == 2
async def test_error_in_effect_cleanup_is_gracefully_handled(caplog): caplog.clear() component_hook = HookCatcher() @idom.component @component_hook.capture def ComponentWithEffect(): @idom.hooks.use_effect(dependencies=None ) # force this to run every time def ok_effect(): def bad_cleanup(): raise ValueError("Something went wong :(") return bad_cleanup return idom.html.div() with assert_idom_did_log( match_error=r"Layout post-render effect .* failed"): async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() # no error
async def test_error_in_effect_cleanup_is_gracefully_handled(): component_hook = HookCatcher() @idom.component @component_hook.capture def ComponentWithEffect(): hook = current_hook() def bad_effect(): raise ValueError("The error message") hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect) return idom.html.div() with assert_idom_did_log( match_message="Component post-render effect .*? failed", error_type=ValueError, match_error="The error message", ): async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() # no error
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(dependencies=None ) # force this to run every time 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.latest.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_changing_key_of_parent_element_unmounts_children(): random.seed(0) root_hook = HookCatcher() state = idom.Ref(None) @idom.component @root_hook.capture def Root(): return idom.html.div(HasState(), key=str(random.random())) @idom.component def HasState(): state.current = idom.hooks.use_state(random.random)[0] return idom.html.div() async with idom.Layout(Root()) as layout: await layout.render() for i in range(5): last_state = state.current root_hook.latest.schedule_render() await layout.render() assert last_state != state.current
async def test_schedule_render_from_unmounted_hook(): parent_set_state = idom.Ref() @idom.component def Parent(): state, parent_set_state.current = idom.hooks.use_state(1) return Child(key=state, state=state) child_hook = HookCatcher() @idom.component @child_hook.capture def Child(state): idom.hooks.use_effect(lambda: lambda: print("unmount", state)) return idom.html.div(state) with assert_idom_did_log( r"Did not render component with model state ID .*? - component already unmounted", ): async with idom.Layout(Parent()) as layout: await layout.render() old_hook = child_hook.latest # cause initial child to be unmounted parent_set_state.current(2) await layout.render() # trigger render for hook that's been unmounted old_hook.schedule_render() # schedule one more render just to make it so `layout.render()` doesn't hang # when the scheduled render above gets skipped parent_set_state.current(3) await layout.render()
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(dependencies=[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.latest.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
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(dependencies=None ) # force this to run every time async def effect(): effect_ran.set() return cleanup_ran.set return idom.html.div() async with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() await asyncio.wait_for(cleanup_ran.wait(), 1)