Skip to content

Commit e82ffdf

Browse files
authored
Fix issue from #1081 (#1085)
* identify issue from #1081 * fix the bug * update doc * make ruff happy * add changelog entry
1 parent f065655 commit e82ffdf

File tree

7 files changed

+215
-2
lines changed

7 files changed

+215
-2
lines changed

Diff for: docs/source/about/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Unreleased
3131

3232
- :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`)
3333
- :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`)
34+
- :issue:`1086` - fix rendering bug when children change positions (via :pull:`1085`)
3435

3536

3637
v1.0.0

Diff for: src/py/reactpy/pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ testpaths = "tests"
139139
xfail_strict = true
140140
python_files = "*asserts.py test_*.py"
141141
asyncio_mode = "auto"
142+
log_cli_level = "INFO"
142143

143144
# --- MyPy -----------------------------------------------------------------------------
144145

Diff for: src/py/reactpy/reactpy/core/layout.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ def _update_component_model_state(
489489
index=new_index,
490490
key=old_model_state.key,
491491
model=Ref(), # does not copy the model
492-
patch_path=old_model_state.patch_path,
492+
patch_path=f"{new_parent.patch_path}/children/{new_index}",
493493
children_by_key={},
494494
targets_by_event={},
495495
life_cycle_state=(

Diff for: src/py/reactpy/reactpy/utils.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class Ref(Generic[_RefValue]):
2727
You can compare the contents for two ``Ref`` objects using the ``==`` operator.
2828
"""
2929

30-
__slots__ = "current"
30+
__slots__ = ("current",)
3131

3232
def __init__(self, initial_value: _RefValue = _UNDEFINED) -> None:
3333
if initial_value is not _UNDEFINED:

Diff for: src/py/reactpy/tests/test_core/test_layout.py

+60
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@
1313
from reactpy.core.component import component
1414
from reactpy.core.hooks import use_effect, use_state
1515
from reactpy.core.layout import Layout
16+
from reactpy.core.types import State
1617
from reactpy.testing import (
1718
HookCatcher,
1819
StaticEventHandler,
1920
assert_reactpy_did_log,
2021
capture_reactpy_logs,
2122
)
2223
from reactpy.utils import Ref
24+
from tests.tooling import select
2325
from tests.tooling.common import event_message, update_message
2426
from tests.tooling.hooks import use_force_render, use_toggle
27+
from tests.tooling.layout import layout_runner
28+
from tests.tooling.select import element_exists, find_element
2529

2630

2731
@pytest.fixture(autouse=True)
@@ -1190,3 +1194,59 @@ def Child():
11901194
done, pending = await asyncio.wait([render_task], timeout=0.1)
11911195
assert not done and pending
11921196
render_task.cancel()
1197+
1198+
1199+
async def test_ensure_model_path_udpates():
1200+
"""
1201+
This is regression test for a bug in which we failed to update the path of a bug
1202+
that arose when the "path" of a component within the overall model was not updated
1203+
when the component changes position amongst its siblings. This meant that when
1204+
a component whose position had changed would attempt to update the view at its old
1205+
position.
1206+
"""
1207+
1208+
@component
1209+
def Item(item: str, all_items: State[list[str]]):
1210+
color = use_state(None)
1211+
1212+
def deleteme(event):
1213+
all_items.set_value([i for i in all_items.value if (i != item)])
1214+
1215+
def colorize(event):
1216+
color.set_value("blue" if not color.value else None)
1217+
1218+
return html.div(
1219+
{"id": item, "color": color.value},
1220+
html.button({"on_click": colorize}, f"Color {item}"),
1221+
html.button({"on_click": deleteme}, f"Delete {item}"),
1222+
)
1223+
1224+
@component
1225+
def App():
1226+
items = use_state(["A", "B", "C"])
1227+
return html._([Item(item, items, key=item) for item in items.value])
1228+
1229+
async with layout_runner(reactpy.Layout(App())) as runner:
1230+
tree = await runner.render()
1231+
1232+
# Delete item B
1233+
b, b_info = find_element(tree, select.id_equals("B"))
1234+
assert b_info.path == (0, 1, 0)
1235+
b_delete, _ = find_element(b, select.text_equals("Delete B"))
1236+
await runner.trigger(b_delete, "on_click", {})
1237+
1238+
tree = await runner.render()
1239+
1240+
# Set color of item C
1241+
assert not element_exists(tree, select.id_equals("B"))
1242+
c, c_info = find_element(tree, select.id_equals("C"))
1243+
assert c_info.path == (0, 1, 0)
1244+
c_color, _ = find_element(c, select.text_equals("Color C"))
1245+
await runner.trigger(c_color, "on_click", {})
1246+
1247+
tree = await runner.render()
1248+
1249+
# Ensure position and color of item C are correct
1250+
c, c_info = find_element(tree, select.id_equals("C"))
1251+
assert c_info.path == (0, 1, 0)
1252+
assert c["attributes"]["color"] == "blue"

Diff for: src/py/reactpy/tests/tooling/layout.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import AsyncIterator
5+
from contextlib import asynccontextmanager
6+
from typing import Any
7+
8+
from jsonpointer import set_pointer
9+
10+
from reactpy.core.layout import Layout
11+
from reactpy.core.types import VdomJson
12+
from tests.tooling.common import event_message
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@asynccontextmanager
18+
async def layout_runner(layout: Layout) -> AsyncIterator[LayoutRunner]:
19+
async with layout:
20+
yield LayoutRunner(layout)
21+
22+
23+
class LayoutRunner:
24+
def __init__(self, layout: Layout) -> None:
25+
self.layout = layout
26+
self.model = {}
27+
28+
async def render(self) -> VdomJson:
29+
update = await self.layout.render()
30+
logger.info(f"Rendering element at {update['path'] or '/'!r}")
31+
if not update["path"]:
32+
self.model = update["model"]
33+
else:
34+
self.model = set_pointer(
35+
self.model, update["path"], update["model"], inplace=False
36+
)
37+
return self.model
38+
39+
async def trigger(self, element: VdomJson, event_name: str, *data: Any) -> None:
40+
event_handler = element.get("eventHandlers", {}).get(event_name, {})
41+
logger.info(f"Triggering {event_name!r} with target {event_handler['target']}")
42+
if not event_handler:
43+
raise ValueError(f"Element has no event handler for {event_name}")
44+
await self.layout.deliver(event_message(event_handler["target"], *data))

Diff for: src/py/reactpy/tests/tooling/select.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterator, Sequence
4+
from dataclasses import dataclass
5+
from typing import Callable
6+
7+
from reactpy.core.types import VdomJson
8+
9+
Selector = Callable[[VdomJson, "ElementInfo"], bool]
10+
11+
12+
def id_equals(id: str) -> Selector:
13+
return lambda element, _: element.get("attributes", {}).get("id") == id
14+
15+
16+
def class_equals(class_name: str) -> Selector:
17+
return (
18+
lambda element, _: class_name
19+
in element.get("attributes", {}).get("class", "").split()
20+
)
21+
22+
23+
def text_equals(text: str) -> Selector:
24+
return lambda element, _: _element_text(element) == text
25+
26+
27+
def _element_text(element: VdomJson) -> str:
28+
if isinstance(element, str):
29+
return element
30+
return "".join(_element_text(child) for child in element.get("children", []))
31+
32+
33+
def element_exists(element: VdomJson, selector: Selector) -> bool:
34+
return next(find_elements(element, selector), None) is not None
35+
36+
37+
def find_element(
38+
element: VdomJson,
39+
selector: Selector,
40+
*,
41+
first: bool = False,
42+
) -> tuple[VdomJson, ElementInfo]:
43+
"""Find an element by a selector.
44+
45+
Parameters:
46+
element:
47+
The tree to search.
48+
selector:
49+
A function that returns True if the element matches.
50+
first:
51+
If True, return the first element found. If False, raise an error if
52+
multiple elements are found.
53+
54+
Returns:
55+
Element info, or None if not found.
56+
"""
57+
find_iter = find_elements(element, selector)
58+
found = next(find_iter, None)
59+
if found is None:
60+
raise ValueError("Element not found")
61+
if not first:
62+
try:
63+
next(find_iter)
64+
raise ValueError("Multiple elements found")
65+
except StopIteration:
66+
pass
67+
return found
68+
69+
70+
def find_elements(
71+
element: VdomJson, selector: Selector
72+
) -> Iterator[tuple[VdomJson, ElementInfo]]:
73+
"""Find an element by a selector.
74+
75+
Parameters:
76+
element:
77+
The tree to search.
78+
selector:
79+
A function that returns True if the element matches.
80+
81+
Returns:
82+
Element info, or None if not found.
83+
"""
84+
return _find_elements(element, selector, (), ())
85+
86+
87+
def _find_elements(
88+
element: VdomJson,
89+
selector: Selector,
90+
parents: Sequence[VdomJson],
91+
path: Sequence[int],
92+
) -> tuple[VdomJson, ElementInfo] | None:
93+
info = ElementInfo(parents, path)
94+
if selector(element, info):
95+
yield element, info
96+
97+
for index, child in enumerate(element.get("children", [])):
98+
if isinstance(child, dict):
99+
yield from _find_elements(
100+
child, selector, (*parents, element), (*path, index)
101+
)
102+
103+
104+
@dataclass
105+
class ElementInfo:
106+
parents: Sequence[VdomJson]
107+
path: Sequence[int]

0 commit comments

Comments
 (0)