async def test_use_effect_callback_occurs_after_full_render_is_complete(): effect_triggered = idom.Ref(False) effect_triggers_after_final_render = idom.Ref(None) @idom.component def OuterComponent(): return idom.html.div( ComponentWithEffect(), CheckNoEffectYet(), ) @idom.component def ComponentWithEffect(): @idom.hooks.use_effect def effect(): effect_triggered.current = True return idom.html.div() @idom.component def CheckNoEffectYet(): effect_triggers_after_final_render.current = not effect_triggered.current return idom.html.div() async with idom.Layout(OuterComponent()) as layout: await layout.render() assert effect_triggered.current assert effect_triggers_after_final_render.current is not None assert effect_triggers_after_final_render.current
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_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_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_use_reducer(): saved_count = idom.Ref(None) saved_dispatch = idom.Ref(None) def reducer(count, action): if action == "increment": return count + 1 elif action == "decrement": return count - 1 else: raise ValueError(f"Unknown action '{action}'") @idom.component def Counter(initial_count): saved_count.current, saved_dispatch.current = idom.hooks.use_reducer( reducer, initial_count) return idom.html.div() async with idom.Layout(Counter(0)) as layout: await layout.render() assert saved_count.current == 0 saved_dispatch.current("increment") await layout.render() assert saved_count.current == 1 saved_dispatch.current("decrement") await layout.render() assert saved_count.current == 0
async def test_nested_component_layout(): parent_set_state = idom.Ref(None) child_set_state = idom.Ref(None) @idom.component def Parent(): state, parent_set_state.current = idom.hooks.use_state(0) return idom.html.div(state, Child()) @idom.component def Child(): state, child_set_state.current = idom.hooks.use_state(0) return idom.html.div(state) async with idom.Layout(Parent()) as layout: path, changes = await layout.render() assert path == "" assert_same_items( changes, [ { "op": "add", "path": "/children", "value": ["0", { "tagName": "div", "children": ["0"] }], }, { "op": "add", "path": "/tagName", "value": "div" }, ], ) parent_set_state.current(1) path, changes = await layout.render() assert path == "" assert changes == [{ "op": "replace", "path": "/children/0", "value": "1" }] child_set_state.current(1) path, changes = await layout.render() assert path == "/children/1" assert changes == [{ "op": "replace", "path": "/children/0", "value": "1" }]
async def test_use_location(display: DisplayFixture): location = idom.Ref() @poll async def poll_location(): """This needs to be async to allow the server to respond""" return location.current @idom.component def ShowRoute(): location.current = display.backend.implementation.use_location() return html.pre({"id": "scope"}, str(location.current)) await display.show(ShowRoute) await poll_location.until_equals(Location("/", "")) for loc in [ Location("/something"), Location("/something/file.txt"), Location("/another/something"), Location("/another/something/file.txt"), Location("/another/something/file.txt", "?key=value"), Location("/another/something/file.txt", "?key1=value1&key2=value2"), ]: await display.goto(loc.pathname + loc.search) await poll_location.until_equals(loc)
async def test_use_context_default_value(): Context = idom.create_context("something") value = idom.Ref() @idom.component def ComponentProvidesContext(): return Context(ComponentUsesContext()) @idom.component def ComponentUsesContext(): value.current = idom.use_context(Context) return html.div() async with idom.Layout(ComponentProvidesContext()) as layout: await layout.render() assert value.current == "something" @idom.component def ComponentUsesContext(): value.current = idom.use_context(Context) return html.div() async with idom.Layout(ComponentUsesContext()) as layout: await layout.render() assert value.current == "something"
async def test_use_memo_automatically_infers_closure_values(): set_count = idom.Ref() did_memo = asyncio.Event() @idom.component def CounterWithEffect(): count, set_count.current = idom.hooks.use_state(0) @idom.hooks.use_memo def some_memo_func_that_uses_count(): """should automatically trigger on count change""" count # use count in this closure did_memo.set() return idom.html.div() async with idom.Layout(CounterWithEffect()) as layout: await layout.render() await did_memo.wait() did_memo.clear() for i in range(1, 3): set_count.current(i) await layout.render() await did_memo.wait() did_memo.clear()
async def test_error_in_effect_pre_unmount_cleanup_is_gracefully_handled(): set_key = idom.Ref() @idom.component def OuterComponent(): key, set_key.current = idom.use_state("first") return ComponentWithEffect(key=key) @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() with assert_idom_did_log( match_message=r"Pre-unmount effect .*? failed", error_type=ValueError, ): async with idom.Layout(OuterComponent()) as layout: await layout.render() set_key.current("second") await layout.render() # no error
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_layout_does_not_copy_element_children_by_key(): # this is a regression test for a subtle bug: # https://github.com/idom-team/idom/issues/556 set_items = idom.Ref() @idom.component def SomeComponent(): items, set_items.current = idom.use_state([1, 2, 3]) return idom.html.div([ idom.html.div( idom.html.input({"onChange": lambda event: None}), key=str(i), ) for i in items ]) async with idom.Layout(SomeComponent()) as layout: await layout.render() set_items.current([2, 3]) await layout.render() set_items.current([3]) await layout.render() set_items.current([]) await layout.render()
def test_automatic_reconnect(create_driver): # we need to wait longer here because the automatic reconnect is not instance driver = create_driver(implicit_wait_timeout=10) @idom.component def OldComponent(): return idom.html.p({"id": "old-component"}, "old") mount_point = ServerMountPoint() with mount_point: mount_point.mount(OldComponent) driver.get(mount_point.url()) # the server is disconnected but the last view state is still shown driver.find_element_by_id("old-component") set_state = idom.Ref(None) @idom.component def NewComponent(): state, set_state.current = idom.hooks.use_state(0) return idom.html.p({"id": f"new-component-{state}"}, f"new-{state}") with mount_point: mount_point.mount(NewComponent) # Note the lack of a page refresh before looking up this new component. The # client should attempt to reconnect and display the new view automatically. driver.find_element_by_id("new-component-0") # check that we can resume normal operation set_state.current(1) driver.find_element_by_id("new-component-1")
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_that_js_module_unmount_is_called(display: DisplayFixture): SomeComponent = idom.web.export( idom.web.module_from_file( "set-flag-when-unmount-is-called", JS_FIXTURES_DIR / "set-flag-when-unmount-is-called.js", ), "SomeComponent", ) set_current_component = idom.Ref(None) @idom.component def ShowCurrentComponent(): current_component, set_current_component.current = idom.hooks.use_state( lambda: SomeComponent({ "id": "some-component", "text": "initial component" })) return current_component await display.show(ShowCurrentComponent) await display.page.wait_for_selector("#some-component", state="attached") set_current_component.current( idom.html.h1({"id": "some-other-component"}, "some other component")) # the new component has been displayed await display.page.wait_for_selector("#some-other-component", state="attached") # the unmount callback for the old component was called await display.page.wait_for_selector("#unmount-flag", state="attached")
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_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_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_hooks_for_keyed_components_get_garbage_collected(): pop_item = idom.Ref(None) garbage_collect_items = [] registered_finalizers = set() @idom.component def Outer(): items, set_items = idom.hooks.use_state([1, 2, 3]) pop_item.current = lambda: set_items(items[:-1]) return idom.html.div(Inner(key=k, finalizer_id=k) for k in items) @idom.component def Inner(finalizer_id): if finalizer_id not in registered_finalizers: hook = idom.hooks.current_hook() finalize(hook, lambda: garbage_collect_items.append(finalizer_id)) registered_finalizers.add(finalizer_id) return idom.html.div(finalizer_id) async with idom.Layout(Outer()) as layout: await layout.render() pop_item.current() await layout.render() assert garbage_collect_items == [3] pop_item.current() await layout.render() assert garbage_collect_items == [3, 2] pop_item.current() await layout.render() assert garbage_collect_items == [3, 2, 1]
def test_basic_ref_behavior(): r = idom.Ref(1) assert r.current == 1 r.current = 2 assert r.current == 2 assert r.set_current(3) == 2 assert r.current == 3 r = idom.Ref() with pytest.raises(AttributeError): r.current r.current = 4 assert r.current == 4
def test_module_from_source( driver, driver_wait, display, htm, # we need this in order to run the test js module ): test_module = Module("test-module", source_file=HERE / "test_js_module.js") response_data = idom.Ref(None) @idom.component def ShowButton(): return test_module.TestButton({ "id": "test-button", "onClick": lambda event: response_data.set_current(event["data"]), "eventResponseData": 10, }) display(ShowButton) client_button = driver.find_element_by_id("test-button") client_button.click() driver_wait.until(lambda dvr: response_data.current == 10)
def test_simple_click_event(driver, display): clicked = idom.Ref(False) @idom.component def Button(): hook = hooks.current_hook() async def on_click(event): clicked.current = True hook.schedule_render() if not clicked.current: return idom.html.button({ "onClick": on_click, "id": "click" }, ["Click Me!"]) else: return idom.html.p({"id": "complete"}, ["Complete"]) display(Button) button = driver.find_element_by_id("click") button.click() driver.find_element_by_id("complete") # we care what happens in the final delete when there's no value assert clicked.current
def test_input_ignore_empty(driver, driver_wait, display): # ignore empty since that's an invalid float inp_ingore_ref = idom.Ref("1") inp_not_ignore_ref = idom.Ref("1") @idom.component def InputWrapper(): return idom.html.div( idom.widgets.Input( lambda value: setattr(inp_ingore_ref, "current", value), "number", inp_ingore_ref.current, {"id": "inp-ignore"}, ignore_empty=True, ), idom.widgets.Input( lambda value: setattr(inp_not_ignore_ref, "current", value), "number", inp_not_ignore_ref.current, {"id": "inp-not-ignore"}, ignore_empty=False, ), ) display(InputWrapper) client_inp_ignore = driver.find_element_by_id("inp-ignore") client_inp_not_ignore = driver.find_element_by_id("inp-not-ignore") send_keys(client_inp_ignore, Keys.BACKSPACE) time.sleep(0.1) # waiting and deleting again seems to decrease flakiness send_keys(client_inp_ignore, Keys.BACKSPACE) send_keys(client_inp_not_ignore, Keys.BACKSPACE) time.sleep(0.1) # waiting and deleting again seems to decrease flakiness send_keys(client_inp_not_ignore, Keys.BACKSPACE) driver_wait.until( lambda drv: client_inp_ignore.get_attribute("value") == "") driver_wait.until( lambda drv: client_inp_not_ignore.get_attribute("value") == "") # ignored empty value on change assert inp_ingore_ref.current == "1" # did not ignore empty value on change assert inp_not_ignore_ref.current == ""
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()
def test_basic_ref_behavior(): r = idom.Ref(1) assert r.current == 1 r.current = 2 assert r.current == 2 assert r.set_current(3) == 2 assert r.current == 3
async def test_switching_component_definition(): toggle_component = idom.Ref() first_used_state = idom.Ref(None) second_used_state = idom.Ref(None) @idom.component def Root(): toggle, toggle_component.current = use_toggle(True) if toggle: return FirstComponent() else: return SecondComponent() @idom.component def FirstComponent(): first_used_state.current = use_state("first")[0] # reset state after unmount use_effect(lambda: lambda: first_used_state.set_current(None)) return html.div() @idom.component def SecondComponent(): second_used_state.current = use_state("second")[0] # reset state after unmount use_effect(lambda: lambda: second_used_state.set_current(None)) return html.div() async with idom.Layout(Root()) as layout: await layout.render() assert first_used_state.current == "first" assert second_used_state.current is None toggle_component.current() await layout.render() assert first_used_state.current is None assert second_used_state.current == "second" toggle_component.current() await layout.render() assert first_used_state.current == "first" assert second_used_state.current is None
async def test_set_state_with_reducer_instead_of_value(): count = idom.Ref() set_count = idom.Ref() def increment(count): return count + 1 @idom.component def Counter(): count.current, set_count.current = idom.hooks.use_state(0) return idom.html.div(count.current) async with idom.Layout(Counter()) as layout: await layout.render() for i in range(4): assert count.current == i set_count.current(increment) await layout.render()
async def test_use_state_with_constructor(): constructor_call_count = idom.Ref(0) set_outer_state = idom.Ref() set_inner_key = idom.Ref() set_inner_state = idom.Ref() def make_default(): constructor_call_count.current += 1 return 0 @idom.component def Outer(): state, set_outer_state.current = idom.use_state(0) inner_key, set_inner_key.current = idom.use_state("first") return idom.html.div(state, Inner(key=inner_key)) @idom.component def Inner(): state, set_inner_state.current = idom.use_state(make_default) return idom.html.div(state) async with idom.Layout(Outer()) as layout: await layout.render() assert constructor_call_count.current == 1 set_outer_state.current(1) await layout.render() assert constructor_call_count.current == 1 set_inner_state.current(1) await layout.render() assert constructor_call_count.current == 1 set_inner_key.current("second") await layout.render() assert constructor_call_count.current == 2
async def test_use_scope(display: DisplayFixture): scope = idom.Ref() @idom.component def ShowScope(): scope.current = display.backend.implementation.use_scope() return html.pre({"id": "scope"}, str(scope.current)) await display.show(ShowScope) await display.page.wait_for_selector("#scope") assert isinstance(scope.current, MutableMapping)
async def test_model_key_preserves_callback_identity_for_common_elements( caplog): called_good_trigger = idom.Ref(False) good_handler = StaticEventHandler() bad_handler = StaticEventHandler() @idom.component def MyComponent(): reverse_children, set_reverse_children = use_toggle() @good_handler.use def good_trigger(): called_good_trigger.current = True set_reverse_children() @bad_handler.use def bad_trigger(): raise ValueError("Called bad trigger") children = [ idom.html.button({ "onClick": good_trigger, "id": "good" }, "good", key="good"), idom.html.button({ "onClick": bad_trigger, "id": "bad" }, "bad", key="bad"), ] if reverse_children: children.reverse() return idom.html.div(children) async with idom.Layout(MyComponent()) as layout: await layout.render() for i in range(3): event = LayoutEvent(good_handler.target, []) await layout.deliver(event) assert called_good_trigger.current # reset after checking called_good_trigger.current = False await layout.render() assert not caplog.records