From a9cfadf564ab1fda27c90e29b24b4936bc725d4c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Feb 2022 22:42:58 -0800 Subject: [PATCH 1/9] fix #652 In short, because components do not have a node in the model state tree managed by the layout, keys for elements in the components were being registered to the parent of the component rather than to a node for the component which contained the elements. The short-term solution was to make it so components wrap their contents in a div without a key. The long-term solution is to refactor the Layout() and give components a dedicated node in the model state tree --- src/idom/core/component.py | 4 +-- tests/test_core/test_layout.py | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/idom/core/component.py b/src/idom/core/component.py index ccc76c070..d9a048aae 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -57,9 +57,7 @@ def definition_id(self) -> int: def render(self) -> VdomDict: model = self._func(*self._args, **self._kwargs) - if isinstance(model, ComponentType): - model = {"tagName": "div", "children": [model]} - return model + return {"tagName": "div", "children": [model]} def __repr__(self) -> str: sig = inspect.signature(self._func) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 879e7a7bd..199bf1f1e 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -10,6 +10,7 @@ import idom from idom import html from idom.config import IDOM_DEBUG_MODE +from idom.core.component import component from idom.core.dispatcher import render_json_patch from idom.core.hooks import use_effect, use_state from idom.core.layout import LayoutEvent @@ -923,3 +924,56 @@ def SecondComponent(): assert first_used_state.current == "first" assert second_used_state.current is None + + +async def test_element_keys_inside_components_do_not_reset_state_of_component(): + """This is a regression test for a bug. + + You would not expect that calling `set_child_key_num` would trigger state to be + reset in any `Child()` components but there was a bug where that happened. + """ + + effect_calls_without_state = [] + set_child_key_num = StaticEventHandler() + did_call_effect = asyncio.Event() + + @component + def Parent(): + state, set_state = use_state(0) + return html.div( + html.button( + {"onClick": set_child_key_num.use(lambda: set_state(state + 1))}, + "click me", + ), + Child("some-key"), + Child(f"key-{state}"), + ) + + @component + def Child(child_key): + state, set_state = use_state(0) + + @use_effect + async def record_if_state_is_reset(): + if state: + return + effect_calls_without_state.append(child_key) + set_state(1) + did_call_effect.set() + + return html.div( + child_key, + key=child_key, + ) + + with idom.Layout(Parent()) as layout: + await layout.render() + await did_call_effect.wait() + assert effect_calls_without_state == ["some-key", "key-0"] + did_call_effect.clear() + + for i in range(1, 5): + await layout.deliver(LayoutEvent(set_child_key_num.target, [])) + await layout.render() + assert effect_calls_without_state == ["some-key", "key-0"] + did_call_effect.clear() From 090007ad598cb117e8d434663af5c224a22ca7c4 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 16 Feb 2022 00:12:34 -0800 Subject: [PATCH 2/9] implement element fragment + try to fix tests --- setup.cfg | 1 + .../idom-client-react/src/components.js | 16 ++++- src/idom/core/component.py | 3 +- src/idom/core/layout.py | 6 ++ temp.py | 9 +++ tests/assert_utils.py | 5 ++ tests/general_utils.py | 22 ------ tests/test_core/test_dispatcher.py | 21 +++--- tests/test_core/test_hooks.py | 14 ++-- tests/test_core/test_layout.py | 71 +++++++++++++++---- 10 files changed, 115 insertions(+), 53 deletions(-) create mode 100644 temp.py create mode 100644 tests/assert_utils.py delete mode 100644 tests/general_utils.py diff --git a/setup.cfg b/setup.cfg index 0e801da50..7e05d27bd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,6 +24,7 @@ testpaths = tests xfail_strict = True markers = slow: marks tests as slow (deselect with '-m "not slow"') +python_files = assert_*.py test_*.py [coverage:report] fail_under = 100 diff --git a/src/client/packages/idom-client-react/src/components.js b/src/client/packages/idom-client-react/src/components.js index 465f94801..54a3c1685 100644 --- a/src/client/packages/idom-client-react/src/components.js +++ b/src/client/packages/idom-client-react/src/components.js @@ -20,6 +20,10 @@ export function Layout({ saveUpdateHook, sendEvent, loadImportSource }) { React.useEffect(() => saveUpdateHook(patchModel), [patchModel]); + if (!Object.keys(model).length) { + return html`<${React.Fragment} />`; + } + return html` <${LayoutContext.Provider} value=${{ sendEvent, loadImportSource }}> <${Element} model=${model} /> @@ -28,7 +32,7 @@ export function Layout({ saveUpdateHook, sendEvent, loadImportSource }) { } export function Element({ model }) { - if (!model.tagName) { + if (model.error !== undefined) { if (model.error) { return html`
${model.error}
`; } else { @@ -45,11 +49,19 @@ export function Element({ model }) { function StandardElement({ model }) { const layoutContext = React.useContext(LayoutContext); + + let type; + if (model.tagName == "") { + type = React.Fragment; + } else { + type = model.tagName; + } + // Use createElement here to avoid warning about variable numbers of children not // having keys. Warning about this must now be the responsibility of the server // providing the models instead of the client rendering them. return React.createElement( - model.tagName, + type, createElementAttributes(model, layoutContext.sendEvent), ...createElementChildren( model, diff --git a/src/idom/core/component.py b/src/idom/core/component.py index d9a048aae..6ef8c9291 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -56,8 +56,7 @@ def definition_id(self) -> int: return id(self._func) def render(self) -> VdomDict: - model = self._func(*self._args, **self._kwargs) - return {"tagName": "div", "children": [model]} + return self._func(*self._args, **self._kwargs) def __repr__(self) -> str: sig = inspect.signature(self._func) diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index cf7824808..afd9ff723 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -199,6 +199,12 @@ def _render_component( raw_model = component.render() finally: life_cycle_hook.unset_current() + + # wrap the model in a fragment (i.e. tagName="") to ensure components have + # a separate node in the model state tree. This could be removed if this + # components are given a node in the tree some other way + raw_model = {"tagName": "", "children": [raw_model]} + self._render_model(old_state, new_state, raw_model) except Exception as error: logger.exception(f"Failed to render {component}") diff --git a/temp.py b/temp.py new file mode 100644 index 000000000..ac310c47b --- /dev/null +++ b/temp.py @@ -0,0 +1,9 @@ +import idom + + +@idom.component +def Demo(): + return idom.vdom("", idom.html.h1("hello")) + + +idom.run(Demo) diff --git a/tests/assert_utils.py b/tests/assert_utils.py new file mode 100644 index 000000000..49105fb80 --- /dev/null +++ b/tests/assert_utils.py @@ -0,0 +1,5 @@ +def assert_same_items(left, right): + """Check that two unordered sequences are equal (only works if reprs are equal)""" + sorted_left = list(sorted(left, key=repr)) + sorted_right = list(sorted(right, key=repr)) + assert sorted_left == sorted_right diff --git a/tests/general_utils.py b/tests/general_utils.py deleted file mode 100644 index 978dc6a2f..000000000 --- a/tests/general_utils.py +++ /dev/null @@ -1,22 +0,0 @@ -# dialect=pytest - -from contextlib import contextmanager - - -@contextmanager -def patch_slots_object(obj, attr, new_value): - # we do this since `mock.patch..object attempts to use __dict__ - # which is not necessarilly present on an object with __slots__` - old_value = getattr(obj, attr) - setattr(obj, attr, new_value) - try: - yield new_value - finally: - setattr(obj, attr, old_value) - - -def assert_same_items(left, right): - """Check that two unordered sequences are equal (only works if reprs are equal)""" - sorted_left = list(sorted(left, key=repr)) - sorted_right = list(sorted(right, key=repr)) - assert sorted_left == sorted_right diff --git a/tests/test_core/test_dispatcher.py b/tests/test_core/test_dispatcher.py index a0e6bef36..4f0cf34b0 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -57,15 +57,20 @@ async def recv(): def make_events_and_expected_model(): events = [LayoutEvent(STATIC_EVENT_HANDLER.target, [])] * 4 expected_model = { - "tagName": "div", - "attributes": {"count": 4}, - "eventHandlers": { - EVENT_NAME: { - "target": STATIC_EVENT_HANDLER.target, - "preventDefault": False, - "stopPropagation": False, + "tagName": "", + "children": [ + { + "tagName": "div", + "attributes": {"count": 4}, + "eventHandlers": { + EVENT_NAME: { + "target": STATIC_EVENT_HANDLER.target, + "preventDefault": False, + "stopPropagation": False, + } + }, } - }, + ], } return events, expected_model diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index cdc87d12f..93a220a4c 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -7,7 +7,7 @@ from idom.core.dispatcher import render_json_patch from idom.core.hooks import LifeCycleHook from idom.testing import HookCatcher, assert_idom_logged -from tests.general_utils import assert_same_items +from tests.assert_utils import assert_same_items async def test_must_be_rendering_in_layout_to_use_hooks(): @@ -38,21 +38,25 @@ def SimpleStatefulComponent(): assert_same_items( patch_1.changes, [ - {"op": "add", "path": "/children", "value": ["0"]}, - {"op": "add", "path": "/tagName", "value": "div"}, + {"op": "add", "path": "/tagName", "value": ""}, + { + "op": "add", + "path": "/children", + "value": [{"children": ["0"], "tagName": "div"}], + }, ], ) patch_2 = await render_json_patch(layout) assert patch_2.path == "" assert patch_2.changes == [ - {"op": "replace", "path": "/children/0", "value": "1"} + {"op": "replace", "path": "/children/0/children/0", "value": "1"} ] patch_3 = await render_json_patch(layout) assert patch_3.path == "" assert patch_3.changes == [ - {"op": "replace", "path": "/children/0", "value": "2"} + {"op": "replace", "path": "/children/0/children/0", "value": "2"} ] diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 199bf1f1e..1fa4669e8 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -20,7 +20,7 @@ assert_idom_logged, capture_idom_logs, ) -from tests.general_utils import assert_same_items +from tests.assert_utils import assert_same_items @pytest.fixture(autouse=True) @@ -79,13 +79,21 @@ def SimpleComponent(): path, changes = await render_json_patch(layout) assert path == "" - assert changes == [{"op": "add", "path": "/tagName", "value": "div"}] + assert_same_items( + changes, + [ + {"op": "add", "path": "/children", "value": [{"tagName": "div"}]}, + {"op": "add", "path": "/tagName", "value": ""}, + ], + ) set_state_hook.current("table") path, changes = await render_json_patch(layout) assert path == "" - assert changes == [{"op": "replace", "path": "/tagName", "value": "table"}] + assert changes == [ + {"op": "replace", "path": "/children/0/tagName", "value": "table"} + ] async def test_nested_component_layout(): @@ -112,9 +120,20 @@ def Child(): { "op": "add", "path": "/children", - "value": ["0", {"tagName": "div", "children": ["0"]}], + "value": [ + { + "children": [ + "0", + { + "children": [{"children": ["0"], "tagName": "div"}], + "tagName": "", + }, + ], + "tagName": "div", + } + ], }, - {"op": "add", "path": "/tagName", "value": "div"}, + {"op": "add", "path": "/tagName", "value": ""}, ], ) @@ -122,13 +141,17 @@ def Child(): path, changes = await render_json_patch(layout) assert path == "" - assert changes == [{"op": "replace", "path": "/children/0", "value": "1"}] + assert changes == [ + {"op": "replace", "path": "/children/0/children/0", "value": "1"} + ] child_set_state.current(1) path, changes = await render_json_patch(layout) assert path == "/children/1" - assert changes == [{"op": "replace", "path": "/children/0", "value": "1"}] + assert changes == [ + {"op": "replace", "path": "/children/0/children/0", "value": "1"} + ] @pytest.mark.skipif( @@ -202,16 +225,21 @@ def BadChild(): assert_same_items( patch.changes, [ + {"op": "add", "path": "/tagName", "value": ""}, { "op": "add", "path": "/children", "value": [ - {"tagName": "div", "children": ["hello"]}, - {"tagName": "", "error": ""}, - {"tagName": "div", "children": ["hello"]}, + { + "children": [ + {"children": [...], "tagName": ""}, + {"error": "", "tagName": ""}, + {"children": [...], "tagName": ""}, + ], + "tagName": "div", + } ], }, - {"op": "add", "path": "/tagName", "value": "div"}, ], ) @@ -233,9 +261,24 @@ def Child(): { "op": "add", "path": "/children", - "value": [{"tagName": "div", "children": [{"tagName": "h1"}]}], + "value": [ + { + "children": [ + { + "children": [ + { + "children": [{"tagName": "h1"}], + "tagName": "div", + } + ], + "tagName": "", + } + ], + "tagName": "div", + } + ], }, - {"op": "add", "path": "/tagName", "value": "div"}, + {"op": "add", "path": "/tagName", "value": ""}, ], ) @@ -422,7 +465,7 @@ def Child(): hook.latest.schedule_render() update = await render_json_patch(layout) - assert update.path == "/children/0/children/0" + assert update.path == "/children/0/children/0/children/0" async def test_log_on_dispatch_to_missing_event_handler(caplog): From 07366cfcf566d84b37a0ebf069308b30b67696d4 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 16 Feb 2022 23:39:42 -0800 Subject: [PATCH 3/9] fix errors --- docs/source/_custom_js/package-lock.json | 2 +- src/idom/core/layout.py | 14 ++++-- src/idom/html.py | 14 ++++++ tests/test_core/test_layout.py | 54 ++++++++++++++++++------ 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/docs/source/_custom_js/package-lock.json b/docs/source/_custom_js/package-lock.json index 2749a0445..7362825e4 100644 --- a/docs/source/_custom_js/package-lock.json +++ b/docs/source/_custom_js/package-lock.json @@ -19,7 +19,7 @@ } }, "../../../src/client/packages/idom-client-react": { - "version": "0.36.1", + "version": "0.36.2", "license": "MIT", "dependencies": { "fast-json-patch": "^3.0.0-1", diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index afd9ff723..5232995d7 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -417,10 +417,18 @@ def _render_model_children( def _render_model_children_without_old_state( self, new_state: _ModelState, raw_children: List[Any] ) -> None: + child_type_key_tuples = list(_process_child_type_and_key(raw_children)) + + new_keys = {item[2] for item in child_type_key_tuples} + if len(new_keys) != len(raw_children): + key_counter = Counter(item[2] for item in child_type_key_tuples) + duplicate_keys = [key for key, count in key_counter.items() if count > 1] + raise ValueError( + f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" + ) + new_children = new_state.model.current["children"] = [] - for index, (child, child_type, key) in enumerate( - _process_child_type_and_key(raw_children) - ): + for index, (child, child_type, key) in enumerate(child_type_key_tuples): if child_type is _DICT_TYPE: child_state = _make_element_model_state(new_state, index, key) self._render_model(None, child_state, child) diff --git a/src/idom/html.py b/src/idom/html.py index dd7ac24af..c5e4fbfc6 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -1,4 +1,8 @@ """ +**Fragment** + +- :func:`_` + **Dcument metadata** - :func:`base` @@ -148,6 +152,8 @@ - :func:`slot` - :func:`template` + +.. autofunction:: _ """ from __future__ import annotations @@ -158,6 +164,14 @@ from .core.vdom import coalesce_attributes_and_children, make_vdom_constructor +def _(*children: Any) -> VdomDict: + """An HTML fragment - this element will not appear in the DOM""" + attributes, children = coalesce_attributes_and_children(children) + if attributes: + raise ValueError("Fragments cannot have attributes") + return {"tagName": "", "children": children} + + # Dcument metadata base = make_vdom_constructor("base") head = make_vdom_constructor("head") diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 1fa4669e8..babd5521f 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -148,7 +148,7 @@ def Child(): child_set_state.current(1) path, changes = await render_json_patch(layout) - assert path == "/children/1" + assert path == "/children/0/children/1" assert changes == [ {"op": "replace", "path": "/children/0/children/0", "value": "1"} ] @@ -225,21 +225,31 @@ def BadChild(): assert_same_items( patch.changes, [ - {"op": "add", "path": "/tagName", "value": ""}, { "op": "add", "path": "/children", "value": [ { "children": [ - {"children": [...], "tagName": ""}, + { + "children": [ + {"children": ["hello"], "tagName": "div"} + ], + "tagName": "", + }, {"error": "", "tagName": ""}, - {"children": [...], "tagName": ""}, + { + "children": [ + {"children": ["hello"], "tagName": "div"} + ], + "tagName": "", + }, ], "tagName": "div", } ], }, + {"op": "add", "path": "/tagName", "value": ""}, ], ) @@ -598,9 +608,14 @@ def Inner(): { "op": "add", "path": "/children", - "value": [{"children": ["hello"], "tagName": "div"}], + "value": [ + { + "children": [{"children": ["hello"], "tagName": "div"}], + "tagName": "", + } + ], }, - {"op": "add", "path": "/tagName", "value": "div"}, + {"op": "add", "path": "/tagName", "value": ""}, ], ) @@ -685,18 +700,31 @@ def HasNestedEventHandler(): async def test_duplicate_sibling_keys_causes_error(caplog): + hook = HookCatcher() + @idom.component + @hook.capture def ComponentReturnsDuplicateKeys(): return idom.html.div( - idom.html.div(key="duplicate"), idom.html.div(key="duplicate") + idom.html.div(key="duplicate"), + idom.html.div(key="duplicate"), ) - with assert_idom_logged( - error_type=ValueError, - match_error=r"Duplicate keys \['duplicate'\] at '/'", - clear_matched_records=True, - ): - with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: + with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: + with assert_idom_logged( + error_type=ValueError, + match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", + clear_matched_records=True, + ): + await layout.render() + + hook.latest.schedule_render() + + with assert_idom_logged( + error_type=ValueError, + match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", + clear_matched_records=True, + ): await layout.render() From bec20b969de873fc9add3b757b128955e8370e0f Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 16 Feb 2022 23:43:55 -0800 Subject: [PATCH 4/9] allow components to return None --- src/idom/core/layout.py | 5 ++++- src/idom/core/proto.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index 5232995d7..ce75f69c6 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -203,7 +203,10 @@ def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - raw_model = {"tagName": "", "children": [raw_model]} + raw_model = { + "tagName": "", + "children": [] if raw_model is None else [raw_model], + } self._render_model(old_state, new_state, raw_model) except Exception as error: diff --git a/src/idom/core/proto.py b/src/idom/core/proto.py index 8a5782ca8..5415c4a07 100644 --- a/src/idom/core/proto.py +++ b/src/idom/core/proto.py @@ -39,7 +39,7 @@ def definition_id(self) -> int: Usually the :func:`id` of this class or an underlying function. """ - def render(self) -> VdomDict: + def render(self) -> VdomDict | ComponentType | None: """Render the component's :class:`VdomDict`.""" From 70f29d239695b8d75c896c9e395689b0c3b5c019 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 16 Feb 2022 23:52:37 -0800 Subject: [PATCH 5/9] fix one more test --- tests/test_core/test_layout.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index babd5521f..412c2b727 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -185,15 +185,30 @@ def BadChild(): "op": "add", "path": "/children", "value": [ - {"tagName": "div", "children": ["hello"]}, { - "tagName": "", - "error": "ValueError: error from bad child", - }, - {"tagName": "div", "children": ["hello"]}, + "tagName": "div", + "children": [ + { + "tagName": "", + "children": [ + {"tagName": "div", "children": ["hello"]} + ], + }, + { + "tagName": "", + "error": "ValueError: error from bad child", + }, + { + "tagName": "", + "children": [ + {"tagName": "div", "children": ["hello"]} + ], + }, + ], + } ], }, - {"op": "add", "path": "/tagName", "value": "div"}, + {"op": "add", "path": "/tagName", "value": ""}, ], ) From cb2d2163618a9a2dc5c5fba77a44e28942b133dd Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Feb 2022 21:28:09 -0800 Subject: [PATCH 6/9] add tests for coverage --- src/idom/core/component.py | 41 ++++++------ src/idom/core/layout.py | 23 +++---- src/idom/core/proto.py | 11 ++-- src/idom/html.py | 2 +- tests/test_core/test_layout.py | 112 ++++++++++++++++++++++++++++++--- tests/test_html.py | 9 +++ 6 files changed, 145 insertions(+), 53 deletions(-) diff --git a/src/idom/core/component.py b/src/idom/core/component.py index 6ef8c9291..95b90941b 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -8,28 +8,26 @@ def component( - function: Callable[..., Union[ComponentType, VdomDict]] + function: Callable[..., Union[ComponentType, VdomDict | None]] ) -> Callable[..., "Component"]: - """A decorator for defining an :class:`Component`. + """A decorator for defining a new component. Parameters: - function: The function that will render a :class:`VdomDict`. + function: The component's :meth:`idom.core.proto.ComponentType.render` function. """ sig = inspect.signature(function) - key_is_kwarg = "key" in sig.parameters and sig.parameters["key"].kind in ( + + if "key" in sig.parameters and sig.parameters["key"].kind in ( inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - if key_is_kwarg: + ): raise TypeError( f"Component render function {function} uses reserved parameter 'key'" ) @wraps(function) def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Component: - if key_is_kwarg: - kwargs["key"] = key - return Component(function, key, args, kwargs) + return Component(function, key, args, kwargs, sig) return constructor @@ -37,7 +35,7 @@ def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Compone class Component: """An object for rending component models.""" - __slots__ = "__weakref__", "_func", "_args", "_kwargs", "key" + __slots__ = "__weakref__", "_func", "_args", "_kwargs", "_sig", "key", "type" def __init__( self, @@ -45,28 +43,25 @@ def __init__( key: Optional[Any], args: Tuple[Any, ...], kwargs: Dict[str, Any], + sig: inspect.Signature, ) -> None: + self.key = key + self.type = function self._args = args - self._func = function self._kwargs = kwargs - self.key = key - - @property - def definition_id(self) -> int: - return id(self._func) + self._sig = sig - def render(self) -> VdomDict: - return self._func(*self._args, **self._kwargs) + def render(self) -> VdomDict | ComponentType | None: + return self.type(*self._args, **self._kwargs) def __repr__(self) -> str: - sig = inspect.signature(self._func) try: - args = sig.bind(*self._args, **self._kwargs).arguments + args = self._sig.bind(*self._args, **self._kwargs).arguments except TypeError: - return f"{self._func.__name__}(...)" + return f"{self.type.__name__}(...)" else: items = ", ".join(f"{k}={v!r}" for k, v in args.items()) if items: - return f"{self._func.__name__}({id(self):02x}, {items})" + return f"{self.type.__name__}({id(self):02x}, {items})" else: - return f"{self._func.__name__}({id(self):02x})" + return f"{self.type.__name__}({id(self):02x})" diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index ce75f69c6..cf55b5ebd 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -18,6 +18,7 @@ Set, Tuple, TypeVar, + cast, ) from uuid import uuid4 from weakref import ref as weakref @@ -203,12 +204,11 @@ def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - raw_model = { - "tagName": "", - "children": [] if raw_model is None else [raw_model], - } + wrapper_model = {"tagName": ""} + if raw_model is not None: + wrapper_model["children"] = [raw_model] - self._render_model(old_state, new_state, raw_model) + self._render_model(old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") new_state.model.current = { @@ -242,15 +242,6 @@ def _render_model( new_state.key = new_state.model.current["key"] = raw_model["key"] if "importSource" in raw_model: new_state.model.current["importSource"] = raw_model["importSource"] - - if old_state is not None and old_state.key != new_state.key: - self._unmount_model_states([old_state]) - if new_state.is_component_state: - self._model_states_by_life_cycle_state_id[ - new_state.life_cycle_state.id - ] = new_state - old_state = None - self._render_model_attributes(old_state, new_state, raw_model) self._render_model_children(old_state, new_state, raw_model.get("children", [])) @@ -380,6 +371,7 @@ def _render_model_children( new_children.append(new_child_state.model.current) new_state.children_by_key[key] = new_child_state elif child_type is _COMPONENT_TYPE: + child = cast(ComponentType, child) old_child_state = old_state.children_by_key.get(key) if old_child_state is None: new_child_state = _make_component_model_state( @@ -390,8 +382,7 @@ def _render_model_children( self._rendering_queue.put, ) elif old_child_state.is_component_state and ( - old_child_state.life_cycle_state.component.definition_id - != child.definition_id + old_child_state.life_cycle_state.component.type != child.type ): self._unmount_model_states([old_child_state]) old_child_state = None diff --git a/src/idom/core/proto.py b/src/idom/core/proto.py index 5415c4a07..785df86af 100644 --- a/src/idom/core/proto.py +++ b/src/idom/core/proto.py @@ -32,15 +32,14 @@ class ComponentType(Protocol): key: Key | None """An identifier which is unique amongst a component's immediate siblings""" - @property - def definition_id(self) -> int: - """A globally unique identifier for this component definition. + type: type[Any] | Callable[..., Any] + """The function or class defining the behavior of this component - Usually the :func:`id` of this class or an underlying function. - """ + This is used to see if two component instances share the same definition. + """ def render(self) -> VdomDict | ComponentType | None: - """Render the component's :class:`VdomDict`.""" + """Render the component's view model.""" _Self = TypeVar("_Self") diff --git a/src/idom/html.py b/src/idom/html.py index c5e4fbfc6..dcbed2e32 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -168,7 +168,7 @@ def _(*children: Any) -> VdomDict: """An HTML fragment - this element will not appear in the DOM""" attributes, children = coalesce_attributes_and_children(children) if attributes: - raise ValueError("Fragments cannot have attributes") + raise TypeError("Fragments cannot have attributes") return {"tagName": "", "children": children} diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 412c2b727..0ac7fe63e 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -13,13 +13,14 @@ from idom.core.component import component from idom.core.dispatcher import render_json_patch from idom.core.hooks import use_effect, use_state -from idom.core.layout import LayoutEvent +from idom.core.layout import Layout, LayoutEvent from idom.testing import ( HookCatcher, StaticEventHandler, assert_idom_logged, capture_idom_logs, ) +from idom.utils import Ref from tests.assert_utils import assert_same_items @@ -96,6 +97,15 @@ def SimpleComponent(): ] +async def test_component_can_return_none(): + @idom.component + def SomeComponent(): + return None + + with idom.Layout(SomeComponent()) as layout: + assert (await layout.render()).new == {"tagName": ""} + + async def test_nested_component_layout(): parent_set_state = idom.Ref(None) child_set_state = idom.Ref(None) @@ -716,14 +726,18 @@ def HasNestedEventHandler(): async def test_duplicate_sibling_keys_causes_error(caplog): hook = HookCatcher() + should_error = True @idom.component @hook.capture def ComponentReturnsDuplicateKeys(): - return idom.html.div( - idom.html.div(key="duplicate"), - idom.html.div(key="duplicate"), - ) + if should_error: + return idom.html.div( + idom.html.div(key="duplicate"), + idom.html.div(key="duplicate"), + ) + else: + return idom.html.div() with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: with assert_idom_logged( @@ -735,6 +749,11 @@ def ComponentReturnsDuplicateKeys(): hook.latest.schedule_render() + should_error = False + await layout.render() + + should_error = True + hook.latest.schedule_render() with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", @@ -838,9 +857,9 @@ def use_toggle(): def Root(): toggle, set_toggle.current = use_toggle() if toggle: - return idom.html.div(SomeComponent("x")) + return SomeComponent("x") else: - return idom.html.div(idom.html.div(SomeComponent("y"))) + return idom.html.div(SomeComponent("y")) @idom.component def SomeComponent(name): @@ -1063,3 +1082,82 @@ async def record_if_state_is_reset(): await layout.render() assert effect_calls_without_state == ["some-key", "key-0"] did_call_effect.clear() + + +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)) + + 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_changing_event_handlers_in_the_next_render(): + set_event_name = Ref() + event_handler = StaticEventHandler() + did_trigger = Ref(False) + + @component + def Root(): + event_name, set_event_name.current = use_state("first") + return html.button( + {event_name: event_handler.use(lambda: did_trigger.set_current(True))} + ) + + with Layout(Root()) as layout: + await layout.render() + await layout.deliver(LayoutEvent(event_handler.target, [])) + assert did_trigger.current + did_trigger.current = False + + set_event_name.current("second") + await layout.render() + await layout.deliver(LayoutEvent(event_handler.target, [])) + assert did_trigger.current + did_trigger.current = False + + +async def test_change_element_to_string_causes_unmount(): + set_toggle = Ref() + did_unmount = Ref(False) + + @component + def Root(): + toggle, set_toggle.current = use_toggle(True) + if toggle: + return html.div(Child()) + else: + return html.div("some-string") + + @component + def Child(): + use_effect(lambda: lambda: did_unmount.set_current(True)) + + with Layout(Root()) as layout: + await layout.render() + + set_toggle.current() + + await layout.render() + + assert did_unmount.current diff --git a/tests/test_html.py b/tests/test_html.py index e61ef9c22..976daf419 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -142,3 +142,12 @@ def test_script_may_only_have_one_child(): def test_child_of_script_must_be_string(): with pytest.raises(ValueError, match="The child of a 'script' must be a string"): html.script(1) + + +def test_simple_fragment(): + assert html._(1, 2, 3) == {"tagName": "", "children": [1, 2, 3]} + + +def test_fragment_can_have_no_attributes(): + with pytest.raises(TypeError, match="Fragments cannot have attributes"): + html._({"some-attribute": 1}) From e683dca0b5687c5e58c75e60e3631d917a11a06a Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Feb 2022 21:29:09 -0800 Subject: [PATCH 7/9] remove flake8 pre-commit --- .pre-commit-config.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f602709ee..2f88278c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,10 +3,6 @@ repos: rev: 22.1.0 hooks: - id: black - - repo: https://github.com/PyCQA/flake8 - rev: 3.7.9 - hooks: - - id: flake8 - repo: https://github.com/pycqa/isort rev: 5.6.3 hooks: From 351b5610e1d18294fa3ba012c6634df1879583c9 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Feb 2022 21:58:10 -0800 Subject: [PATCH 8/9] require markupsafe<2.1 https://github.com/pallets/markupsafe/issues/283 --- requirements/pkg-extras.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index 4cc2425eb..3e7d3aab1 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -12,6 +12,7 @@ uvicorn[standard] >=0.13.4 # extra=flask flask<2.0 +markupsafe<2.1 flask-cors flask-sockets From 023ff171561b576489e8b6c9679e32462bcd1338 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Feb 2022 22:13:18 -0800 Subject: [PATCH 9/9] fix mypy --- src/idom/core/component.py | 2 +- src/idom/core/layout.py | 4 ++-- src/idom/core/proto.py | 2 +- src/idom/html.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/idom/core/component.py b/src/idom/core/component.py index 95b90941b..04830780c 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -39,7 +39,7 @@ class Component: def __init__( self, - function: Callable[..., Union[ComponentType, VdomDict]], + function: Callable[..., ComponentType | VdomDict | None], key: Optional[Any], args: Tuple[Any, ...], kwargs: Dict[str, Any], diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index cf55b5ebd..e1e7ba027 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -32,7 +32,7 @@ from ._event_proxy import _wrap_in_warning_event_proxies from .hooks import LifeCycleHook -from .proto import ComponentType, EventHandlerDict, VdomJson +from .proto import ComponentType, EventHandlerDict, VdomDict, VdomJson from .vdom import validate_vdom_json @@ -204,7 +204,7 @@ def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - wrapper_model = {"tagName": ""} + wrapper_model: VdomDict = {"tagName": ""} if raw_model is not None: wrapper_model["children"] = [raw_model] diff --git a/src/idom/core/proto.py b/src/idom/core/proto.py index 785df86af..4fd1c030d 100644 --- a/src/idom/core/proto.py +++ b/src/idom/core/proto.py @@ -87,7 +87,7 @@ class _VdomDictOptional(TypedDict, total=False): children: Sequence[ # recursive types are not allowed yet: # https://github.com/python/mypy/issues/731 - Union[ComponentType, Dict[str, Any], str] + Union[ComponentType, Dict[str, Any], str, Any] ] attributes: VdomAttributes eventHandlers: EventHandlerDict # noqa diff --git a/src/idom/html.py b/src/idom/html.py index dcbed2e32..ace4dc862 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -166,10 +166,10 @@ def _(*children: Any) -> VdomDict: """An HTML fragment - this element will not appear in the DOM""" - attributes, children = coalesce_attributes_and_children(children) + attributes, coalesced_children = coalesce_attributes_and_children(children) if attributes: raise TypeError("Fragments cannot have attributes") - return {"tagName": "", "children": children} + return {"tagName": "", "children": coalesced_children} # Dcument metadata