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: 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/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 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 ccc76c070..04830780c 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,39 +35,33 @@ 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, - function: Callable[..., Union[ComponentType, VdomDict]], + function: Callable[..., ComponentType | VdomDict | None], 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: - model = self._func(*self._args, **self._kwargs) - if isinstance(model, ComponentType): - model = {"tagName": "div", "children": [model]} - return model + 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 cf7824808..e1e7ba027 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 @@ -31,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 @@ -199,7 +200,15 @@ def _render_component( raw_model = component.render() finally: life_cycle_hook.unset_current() - self._render_model(old_state, new_state, raw_model) + + # 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: VdomDict = {"tagName": ""} + if raw_model is not None: + wrapper_model["children"] = [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 = { @@ -233,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", [])) @@ -371,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( @@ -381,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 @@ -411,10 +411,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/core/proto.py b/src/idom/core/proto.py index 8a5782ca8..4fd1c030d 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: - """Render the component's :class:`VdomDict`.""" + def render(self) -> VdomDict | ComponentType | None: + """Render the component's view model.""" _Self = TypeVar("_Self") @@ -88,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 dd7ac24af..ace4dc862 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, coalesced_children = coalesce_attributes_and_children(children) + if attributes: + raise TypeError("Fragments cannot have attributes") + return {"tagName": "", "children": coalesced_children} + + # Dcument metadata base = make_vdom_constructor("base") head = make_vdom_constructor("head") 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 879e7a7bd..0ac7fe63e 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -10,16 +10,18 @@ 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 +from idom.core.layout import Layout, LayoutEvent from idom.testing import ( HookCatcher, StaticEventHandler, assert_idom_logged, capture_idom_logs, ) -from tests.general_utils import assert_same_items +from idom.utils import Ref +from tests.assert_utils import assert_same_items @pytest.fixture(autouse=True) @@ -78,13 +80,30 @@ 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_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(): @@ -111,9 +130,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": ""}, ], ) @@ -121,13 +151,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 path == "/children/0/children/1" + assert changes == [ + {"op": "replace", "path": "/children/0/children/0", "value": "1"} + ] @pytest.mark.skipif( @@ -161,15 +195,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": ""}, ], ) @@ -205,12 +254,27 @@ def BadChild(): "op": "add", "path": "/children", "value": [ - {"tagName": "div", "children": ["hello"]}, - {"tagName": "", "error": ""}, - {"tagName": "div", "children": ["hello"]}, + { + "children": [ + { + "children": [ + {"children": ["hello"], "tagName": "div"} + ], + "tagName": "", + }, + {"error": "", "tagName": ""}, + { + "children": [ + {"children": ["hello"], "tagName": "div"} + ], + "tagName": "", + }, + ], + "tagName": "div", + } ], }, - {"op": "add", "path": "/tagName", "value": "div"}, + {"op": "add", "path": "/tagName", "value": ""}, ], ) @@ -232,9 +296,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": ""}, ], ) @@ -421,7 +500,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): @@ -554,9 +633,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": ""}, ], ) @@ -641,18 +725,40 @@ 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( + error_type=ValueError, + match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", + clear_matched_records=True, + ): + await layout.render() - with assert_idom_logged( - error_type=ValueError, - match_error=r"Duplicate keys \['duplicate'\] at '/'", - clear_matched_records=True, - ): - with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: + 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'", + clear_matched_records=True, + ): await layout.render() @@ -751,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): @@ -923,3 +1029,135 @@ 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() + + +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})