Skip to content

Commit cd55de6

Browse files
committed
Refactor layout rendering (fix #624)
1 parent c43a641 commit cd55de6

File tree

2 files changed

+123
-145
lines changed

2 files changed

+123
-145
lines changed

docs/source/about/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Unreleased
5050
- :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format.
5151
- :pull:`1196` - Rewrite the ``event-to-object`` package to be more robust at handling properties on events.
5252
- :pull:`1312` - Custom JS components will now automatically assume you are using ReactJS in the absence of a ``bind`` function.
53+
- :pull:`1312` - Refactor layout rendering logic to improve readability and maintainability.
5354
- :pull:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``.
5455
- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``)
5556
- :pull:`1278` - ``reactpy.utils.reactpy_to_string`` will now retain the user's original casing for ``data-*`` and ``aria-*`` attributes.

src/reactpy/core/layout.py

Lines changed: 122 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
wait,
1111
)
1212
from collections import Counter
13-
from collections.abc import Callable, Sequence
13+
from collections.abc import Callable
1414
from contextlib import AsyncExitStack, suppress
1515
from logging import getLogger
1616
from types import TracebackType
@@ -46,7 +46,6 @@
4646
LayoutEventMessage,
4747
LayoutUpdateMessage,
4848
VdomChild,
49-
VdomDict,
5049
VdomJson,
5150
)
5251
from reactpy.utils import Ref
@@ -146,11 +145,34 @@ async def _parallel_render(self) -> LayoutUpdateMessage:
146145
async def _create_layout_update(
147146
self, old_state: _ModelState
148147
) -> LayoutUpdateMessage:
149-
new_state = _copy_component_model_state(old_state)
150-
component = new_state.life_cycle_state.component
148+
component = old_state.life_cycle_state.component
149+
try:
150+
parent: _ModelState | None = old_state.parent
151+
except AttributeError:
152+
parent = None
151153

152154
async with AsyncExitStack() as exit_stack:
153-
await self._render_component(exit_stack, old_state, new_state, component)
155+
new_state = await self._render_component(
156+
exit_stack,
157+
old_state,
158+
parent,
159+
old_state.index,
160+
old_state.key,
161+
component,
162+
)
163+
164+
if parent is not None:
165+
parent.children_by_key[new_state.key] = new_state
166+
old_parent_model = parent.model.current
167+
old_parent_children = old_parent_model.setdefault("children", [])
168+
parent.model.current = {
169+
**old_parent_model,
170+
"children": [
171+
*old_parent_children[: new_state.index],
172+
new_state.model.current,
173+
*old_parent_children[new_state.index + 1 :],
174+
],
175+
}
154176

155177
if REACTPY_CHECK_VDOM_SPEC.current:
156178
validate_vdom_json(new_state.model.current)
@@ -165,9 +187,40 @@ async def _render_component(
165187
self,
166188
exit_stack: AsyncExitStack,
167189
old_state: _ModelState | None,
168-
new_state: _ModelState,
190+
parent: _ModelState | None,
191+
index: int,
192+
key: Any,
169193
component: Component,
170-
) -> None:
194+
) -> _ModelState:
195+
if old_state is None:
196+
new_state = _make_component_model_state(
197+
parent, index, key, component, self._schedule_render_task
198+
)
199+
elif (
200+
old_state.is_component_state
201+
and old_state.life_cycle_state.component.type != component.type
202+
):
203+
await self._unmount_model_states([old_state])
204+
new_state = _make_component_model_state(
205+
parent, index, key, component, self._schedule_render_task
206+
)
207+
old_state = None
208+
elif not old_state.is_component_state:
209+
await self._unmount_model_states([old_state])
210+
new_state = _make_component_model_state(
211+
parent, index, key, component, self._schedule_render_task
212+
)
213+
old_state = None
214+
elif parent is None:
215+
new_state = _copy_component_model_state(old_state)
216+
new_state.life_cycle_state = _update_life_cycle_state(
217+
old_state.life_cycle_state, component
218+
)
219+
else:
220+
new_state = _update_component_model_state(
221+
old_state, parent, index, component, self._schedule_render_task
222+
)
223+
171224
life_cycle_state = new_state.life_cycle_state
172225
life_cycle_hook = life_cycle_state.hook
173226

@@ -180,8 +233,10 @@ async def _render_component(
180233
# wrap the model in a fragment (i.e. tagName="") to ensure components have
181234
# a separate node in the model state tree. This could be removed if this
182235
# components are given a node in the tree some other way
183-
wrapper_model = VdomDict(tagName="", children=[raw_model])
184-
await self._render_model(exit_stack, old_state, new_state, wrapper_model)
236+
new_state.model.current = {"tagName": ""}
237+
await self._render_model_children(
238+
exit_stack, old_state, new_state, [raw_model]
239+
)
185240
except Exception as error:
186241
logger.exception(f"Failed to render {component}")
187242
new_state.model.current = {
@@ -193,32 +248,26 @@ async def _render_component(
193248
finally:
194249
await life_cycle_hook.affect_component_did_render()
195250

196-
try:
197-
parent = new_state.parent
198-
except AttributeError:
199-
pass # only happens for root component
200-
else:
201-
key, index = new_state.key, new_state.index
202-
parent.children_by_key[key] = new_state
203-
# need to add this model to parent's children without mutating parent model
204-
old_parent_model = parent.model.current
205-
old_parent_children = old_parent_model.setdefault("children", [])
206-
parent.model.current = {
207-
**old_parent_model,
208-
"children": [
209-
*old_parent_children[:index],
210-
new_state.model.current,
211-
*old_parent_children[index + 1 :],
212-
],
213-
}
251+
return new_state
214252

215253
async def _render_model(
216254
self,
217255
exit_stack: AsyncExitStack,
218256
old_state: _ModelState | None,
219-
new_state: _ModelState,
257+
parent: _ModelState,
258+
index: int,
259+
key: Any,
220260
raw_model: Any,
221-
) -> None:
261+
) -> _ModelState:
262+
if old_state is None:
263+
new_state = _make_element_model_state(parent, index, key)
264+
elif old_state.is_component_state:
265+
await self._unmount_model_states([old_state])
266+
new_state = _make_element_model_state(parent, index, key)
267+
old_state = None
268+
else:
269+
new_state = _update_element_model_state(old_state, parent, index)
270+
222271
try:
223272
new_state.model.current = {"tagName": raw_model["tagName"]}
224273
except Exception as e: # nocov
@@ -232,6 +281,7 @@ async def _render_model(
232281
await self._render_model_children(
233282
exit_stack, old_state, new_state, raw_model.get("children", [])
234283
)
284+
return new_state
235285

236286
def _render_model_attributes(
237287
self,
@@ -313,130 +363,48 @@ async def _render_model_children(
313363
else:
314364
raw_children = [raw_children]
315365

316-
if old_state is None:
317-
if raw_children:
318-
await self._render_model_children_without_old_state(
319-
exit_stack, new_state, raw_children
320-
)
321-
return None
322-
elif not raw_children:
323-
await self._unmount_model_states(list(old_state.children_by_key.values()))
324-
return None
325-
326-
children_info = _get_children_info(raw_children)
366+
children_info, new_keys = _get_children_info(raw_children)
327367

328-
new_keys = {k for _, _, k in children_info}
329-
if len(new_keys) != len(children_info):
368+
if new_keys is None:
330369
key_counter = Counter(item[2] for item in children_info)
331370
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
332371
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
333372
raise ValueError(msg)
334373

335-
old_keys = set(old_state.children_by_key).difference(new_keys)
336-
if old_keys:
337-
await self._unmount_model_states(
338-
[old_state.children_by_key[key] for key in old_keys]
339-
)
374+
if old_state is not None:
375+
old_keys = set(old_state.children_by_key).difference(new_keys)
376+
if old_keys:
377+
await self._unmount_model_states(
378+
[old_state.children_by_key[key] for key in old_keys]
379+
)
340380

341-
new_state.model.current["children"] = []
342-
for index, (child, child_type, key) in enumerate(children_info):
343-
old_child_state = old_state.children_by_key.get(key)
344-
if child_type is _DICT_TYPE:
345-
old_child_state = old_state.children_by_key.get(key)
346-
if old_child_state is None:
347-
new_child_state = _make_element_model_state(
348-
new_state,
349-
index,
350-
key,
351-
)
352-
elif old_child_state.is_component_state:
353-
await self._unmount_model_states([old_child_state])
354-
new_child_state = _make_element_model_state(
355-
new_state,
356-
index,
357-
key,
358-
)
359-
old_child_state = None
360-
else:
361-
new_child_state = _update_element_model_state(
362-
old_child_state,
363-
new_state,
364-
index,
365-
)
366-
await self._render_model(
367-
exit_stack, old_child_state, new_child_state, child
381+
if raw_children:
382+
new_state.model.current["children"] = []
383+
for index, (child, child_type, key) in enumerate(children_info):
384+
old_child_state = (
385+
old_state.children_by_key.get(key)
386+
if old_state is not None
387+
else None
368388
)
369-
new_state.append_child(new_child_state.model.current)
370-
new_state.children_by_key[key] = new_child_state
371-
elif child_type is _COMPONENT_TYPE:
372-
child = cast(Component, child)
373-
old_child_state = old_state.children_by_key.get(key)
374-
if old_child_state is None:
375-
new_child_state = _make_component_model_state(
376-
new_state,
377-
index,
378-
key,
379-
child,
380-
self._schedule_render_task,
389+
if child_type is _DICT_TYPE:
390+
new_child_state = await self._render_model(
391+
exit_stack, old_child_state, new_state, index, key, child
381392
)
382-
elif old_child_state.is_component_state and (
383-
old_child_state.life_cycle_state.component.type != child.type
384-
):
385-
await self._unmount_model_states([old_child_state])
386-
old_child_state = None
387-
new_child_state = _make_component_model_state(
388-
new_state,
389-
index,
390-
key,
391-
child,
392-
self._schedule_render_task,
393+
elif child_type is _COMPONENT_TYPE:
394+
child = cast(Component, child)
395+
new_child_state = await self._render_component(
396+
exit_stack, old_child_state, new_state, index, key, child
393397
)
394398
else:
395-
new_child_state = _update_component_model_state(
396-
old_child_state,
397-
new_state,
398-
index,
399-
child,
400-
self._schedule_render_task,
401-
)
402-
await self._render_component(
403-
exit_stack, old_child_state, new_child_state, child
404-
)
405-
else:
406-
old_child_state = old_state.children_by_key.get(key)
407-
if old_child_state is not None:
408-
await self._unmount_model_states([old_child_state])
409-
new_state.append_child(child)
410-
411-
async def _render_model_children_without_old_state(
412-
self,
413-
exit_stack: AsyncExitStack,
414-
new_state: _ModelState,
415-
raw_children: list[Any],
416-
) -> None:
417-
children_info = _get_children_info(raw_children)
418-
419-
new_keys = {k for _, _, k in children_info}
420-
if len(new_keys) != len(children_info):
421-
key_counter = Counter(k for _, _, k in children_info)
422-
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
423-
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
424-
raise ValueError(msg)
399+
if old_child_state is not None:
400+
await self._unmount_model_states([old_child_state])
401+
new_child_state = child
425402

426-
new_state.model.current["children"] = []
427-
for index, (child, child_type, key) in enumerate(children_info):
428-
if child_type is _DICT_TYPE:
429-
child_state = _make_element_model_state(new_state, index, key)
430-
await self._render_model(exit_stack, None, child_state, child)
431-
new_state.append_child(child_state.model.current)
432-
new_state.children_by_key[key] = child_state
433-
elif child_type is _COMPONENT_TYPE:
434-
child_state = _make_component_model_state(
435-
new_state, index, key, child, self._schedule_render_task
436-
)
437-
await self._render_component(exit_stack, None, child_state, child)
438-
else:
439-
new_state.append_child(child)
403+
if isinstance(new_child_state, _ModelState):
404+
new_state.append_child(new_child_state.model.current)
405+
new_state.children_by_key[key] = new_child_state
406+
else:
407+
new_state.append_child(new_child_state)
440408

441409
async def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
442410
to_unmount = old_states[::-1] # unmount in reversed order of rendering
@@ -488,7 +456,7 @@ def _new_root_model_state(
488456

489457

490458
def _make_component_model_state(
491-
parent: _ModelState,
459+
parent: _ModelState | None,
492460
index: int,
493461
key: Any,
494462
component: Component,
@@ -499,7 +467,7 @@ def _make_component_model_state(
499467
index=index,
500468
key=key,
501469
model=Ref(),
502-
patch_path=f"{parent.patch_path}/children/{index}",
470+
patch_path=f"{parent.patch_path}/children/{index}" if parent else "",
503471
children_by_key={},
504472
targets_by_event={},
505473
life_cycle_state=_make_life_cycle_state(component, schedule_render),
@@ -714,8 +682,13 @@ async def get(self) -> _Type:
714682
return value
715683

716684

717-
def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]:
685+
def _get_children_info(
686+
children: list[VdomChild],
687+
) -> tuple[list[_ChildInfo], set[Key] | None]:
718688
infos: list[_ChildInfo] = []
689+
keys: set[Key] = set()
690+
has_duplicates = False
691+
719692
for index, child in enumerate(children):
720693
if child is None:
721694
continue
@@ -733,9 +706,13 @@ def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]:
733706
if key is None:
734707
key = index
735708

709+
if key in keys:
710+
has_duplicates = True
711+
keys.add(key)
712+
736713
infos.append((child, child_type, key))
737714

738-
return infos
715+
return infos, None if has_duplicates else keys
739716

740717

741718
_ChildInfo: TypeAlias = tuple[Any, "_ElementType", Key]

0 commit comments

Comments
 (0)