diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4cafcee61..a119e699c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,4 @@ -# Description - -A summary of the changes. - -# Checklist: +## Checklist Please update this checklist as you complete each item: diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index c2d2384d9..9e4ce9b9c 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -26,6 +26,7 @@ Unreleased **Fixed** - :issue:`789` - Conditionally rendered components cannot use contexts +- :issue:`773` - Use strict equality check for text, numeric, and binary types in hooks **Changed** diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 0abb47795..45e7c7dd9 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -113,7 +113,7 @@ def dispatch( next_value = new(self.value) else: next_value = new - if next_value is not self.value: + if not strictly_equal(next_value, self.value): self.value = next_value hook.schedule_render() @@ -317,7 +317,7 @@ def render(self) -> VdomDict: return vdom("", *self.children) def should_render(self, new: ContextProvider[_StateType]) -> bool: - if self._value is not new._value: + if not strictly_equal(self._value, new._value): for hook in self._subscribers: hook.set_context_provider(new) hook.schedule_render() @@ -465,7 +465,10 @@ def use_memo( elif ( len(memo.deps) != len(dependencies) # if deps are same length check identity for each item - or any(current is not new for current, new in zip(memo.deps, dependencies)) + or not all( + strictly_equal(current, new) + for current, new in zip(memo.deps, dependencies) + ) ): memo.deps = dependencies changed = True @@ -765,3 +768,33 @@ def _schedule_render(self) -> None: logger.exception( f"Failed to schedule render via {self._schedule_render_callback}" ) + + +def strictly_equal(x: Any, y: Any) -> bool: + """Check if two values are identical or, for a limited set or types, equal. + + Only the following types are checked for equality rather than identity: + + - ``int`` + - ``float`` + - ``complex`` + - ``str`` + - ``bytes`` + - ``bytearray`` + - ``memoryview`` + """ + return x is y or (type(x) in _NUMERIC_TEXT_BINARY_TYPES and x == y) + + +_NUMERIC_TEXT_BINARY_TYPES = { + # numeric + int, + float, + complex, + # text + str, + # binary types + bytes, + bytearray, + memoryview, +} diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index a2eeb9508..9c3d8c2f3 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -5,7 +5,12 @@ import idom from idom import html from idom.config import IDOM_DEBUG_MODE -from idom.core.hooks import COMPONENT_DID_RENDER_EFFECT, LifeCycleHook, current_hook +from idom.core.hooks import ( + COMPONENT_DID_RENDER_EFFECT, + LifeCycleHook, + current_hook, + strictly_equal, +) from idom.core.layout import Layout from idom.core.serve import render_json_patch from idom.testing import DisplayFixture, HookCatcher, assert_idom_did_log, poll @@ -1272,3 +1277,102 @@ def SecondCondition(): set_state.current(False) await layout.render() assert used_context_values == ["the-value-1", "the-value-2"] + + +@pytest.mark.parametrize( + "x, y, result", + [ + ("text", "text", True), + ("text", "not-text", False), + (b"text", b"text", True), + (b"text", b"not-text", False), + (bytearray([1, 2, 3]), bytearray([1, 2, 3]), True), + (bytearray([1, 2, 3]), bytearray([1, 2, 3, 4]), False), + (1.0, 1.0, True), + (1.0, 2.0, False), + (1j, 1j, True), + (1j, 2j, False), + # ints less than 5 and greater than 256 are always identical + (-100000, -100000, True), + (100000, 100000, True), + (123, 456, False), + ], +) +def test_strictly_equal(x, y, result): + assert strictly_equal(x, y) is result + + +STRICT_EQUALITY_VALUE_CONSTRUCTORS = [ + lambda: "string-text", + lambda: b"byte-text", + lambda: bytearray([1, 2, 3]), + lambda: bytearray([1, 2, 3]), + lambda: 1.0, + lambda: 10000000, + lambda: 1j, +] + + +@pytest.mark.parametrize("get_value", STRICT_EQUALITY_VALUE_CONSTRUCTORS) +async def test_use_state_compares_with_strict_equality(get_value): + render_count = idom.Ref(0) + set_state = idom.Ref() + + @idom.component + def SomeComponent(): + _, set_state.current = idom.use_state(get_value()) + render_count.current += 1 + + async with idom.Layout(SomeComponent()) as layout: + await layout.render() + assert render_count.current == 1 + set_state.current(get_value()) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(layout.render(), timeout=0.1) + + +@pytest.mark.parametrize("get_value", STRICT_EQUALITY_VALUE_CONSTRUCTORS) +async def test_use_effect_compares_with_strict_equality(get_value): + effect_count = idom.Ref(0) + value = idom.Ref("string") + hook = HookCatcher() + + @idom.component + @hook.capture + def SomeComponent(): + @idom.use_effect(dependencies=[value.current]) + def incr_effect_count(): + effect_count.current += 1 + + async with idom.Layout(SomeComponent()) as layout: + await layout.render() + assert effect_count.current == 1 + value.current = "string" # new string instance but same value + hook.latest.schedule_render() + await layout.render() + # effect does not trigger + assert effect_count.current == 1 + + +@pytest.mark.parametrize("get_value", STRICT_EQUALITY_VALUE_CONSTRUCTORS) +async def test_use_context_compares_with_strict_equality(get_value): + hook = HookCatcher() + context = idom.create_context(None) + inner_render_count = idom.Ref(0) + + @idom.component + @hook.capture + def OuterComponent(): + return context(InnerComponent(), value=get_value()) + + @idom.component + def InnerComponent(): + idom.use_context(context) + inner_render_count.current += 1 + + async with idom.Layout(OuterComponent()) as layout: + await layout.render() + assert inner_render_count.current == 1 + hook.latest.schedule_render() + await layout.render() + assert inner_render_count.current == 1