Skip to content

Commit 1a9cdf9

Browse files
committed
fix #419 and #412
The `Layout` is now a prototype, and `Layout.update` is no longer a public API. This is combined with a much more significant refactor of the underlying rendering logic. The biggest issue that has been resolved relates to the relationship between `LifeCycleHook` and `Layout`. Previously, the `LifeCycleHook` accepted a layout instance in its constructor and called `Layout.update`. Additionally, the `Layout` would manipulate the `LifeCycleHook.component` attribute whenever the component instance changed after a render. The former behavior leads to a non-linear code path that's a touch to follow. The latter behavior is the most egregious design issue since there's absolutely no local indication that the component instance can be swapped out (not even a comment). The new refactor no longer binds component or layout instances to a `LifeCycleHook`. Instead, the hook simply receives an unparametrized callback that can be triggered to schedule a render. While some error logs lose clarity (since we can't say what component caused them). This change precludes a need for the layout to ever mutate the hook. To accomodate this change, the internal representation of the layout's state had to change. Previsouly, a class-based approach was take, where methods of the state-holding classes were meant to handle all use cases. Now we rely much more heavily on very simple (and mostly static) data structures that have purpose built constructor functions that much more narrowly address each use case. After these refactors, `ComponentTypes` no longer needs a unique `id` attribute. Instead, a unique ID is generated internally which is associated with the `LifeCycleState`, not component instances since they are inherently transient.
1 parent b7e7292 commit 1a9cdf9

21 files changed

+830
-424
lines changed

Diff for: docs/source/auto/api-reference.rst

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ API Reference
1919
.. automodule:: idom.core.layout
2020
:members:
2121

22+
.. automodule:: idom.core.proto
23+
:members:
24+
2225
.. automodule:: idom.core.vdom
2326
:members:
2427

Diff for: docs/source/core-abstractions.rst

+23-20
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,30 @@ whose body contains a hook usage. We'll demonstrate that with a simple
5454
Component Layout
5555
----------------
5656

57-
Displaying components requires you to turn them into :ref:`VDOM <VDOM Mimetype>` -
58-
this is done using a :class:`~idom.core.layout.Layout`. Layouts are responsible for
59-
rendering components (turning them into VDOM) and scheduling their re-renders when they
60-
:meth:`~idom.core.layout.Layout.update`. To create a layout, you'll need a
61-
:class:`~idom.core.component.Component` instance, which will become its root, and won't
62-
ever be removed from the model. Then you'll just need to call and await a
63-
:meth:`~idom.core.layout.Layout.render` which will return a :ref:`JSON Patch`:
57+
Displaying components requires you to turn them into :ref:`VDOM <VDOM Mimetype>`. This
58+
transformation, known as "rendering a component", is done by a
59+
:class:`~idom.core.proto.LayoutType`. Layouts are responsible for rendering components
60+
and scheduling their re-renders when they change. IDOM's concrete
61+
:class:`~idom.core.layout.Layout` implementation renders
62+
:class:`~idom.core.component.Component` instances into
63+
:class:`~idom.core.layout.LayoutUpdate` and responds to
64+
:class:`~idom.core.layout.LayoutEvent` objects respectively.
65+
66+
To create a layout, you'll need a :class:`~idom.core.component.Component` instance, that
67+
will become its root. This component won't ever be removed from the model. Then, you'll
68+
just need to call and await a :meth:`~idom.core.layout.Layout.render` which will return
69+
a :ref:`JSON Patch`:
70+
6471

6572
.. testcode::
6673

6774
with idom.Layout(ClickCount()) as layout:
68-
patch = await layout.render()
75+
update = await layout.render()
6976

70-
The layout also handles the triggering of event handlers. Normally these are
71-
automatically sent to a :ref:`Dispatcher <Layout Dispatcher>`, but for now we'll do it
72-
manually. To do this we need to pass a fake event with its "target" (event handler
73-
identifier), to the layout's :meth:`~idom.core.layout.Layout.dispatch` method, after
77+
The layout also handles the deliver of events to their handlers. Normally these are sent
78+
through a :ref:`Dispatcher <Layout Dispatcher>` first, but for now we'll do it manually.
79+
To accomplish this we need to pass a fake event with its "target" (event handler
80+
identifier), to the layout's :meth:`~idom.core.layout.Layout.deliver` method, after
7481
which we can re-render and see what changed:
7582

7683
.. testcode::
@@ -92,17 +99,13 @@ which we can re-render and see what changed:
9299

93100

94101
with idom.Layout(ClickCount()) as layout:
95-
patch_1 = await layout.render()
102+
update_1 = await layout.render()
96103

97104
fake_event = LayoutEvent(target=static_handler.target, data=[{}])
98-
await layout.dispatch(fake_event)
99-
patch_2 = await layout.render()
100-
101-
for change in patch_2.changes:
102-
if change["path"] == "/children/0":
103-
count_did_increment = change["value"] == "Click count: 1"
105+
await layout.deliver(fake_event)
104106

105-
assert count_did_increment
107+
update_2 = await layout.render()
108+
assert update_2.new["children"][0] == "Click count: 1"
106109

107110
.. note::
108111

Diff for: src/idom/core/component.py

+8-26
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,17 @@
55

66
from __future__ import annotations
77

8-
import abc
98
import inspect
109
from functools import wraps
1110
from typing import Any, Callable, Dict, Optional, Tuple, Union
12-
from uuid import uuid4
13-
14-
from typing_extensions import Protocol, runtime_checkable
1511

12+
from .proto import ComponentType
1613
from .vdom import VdomDict
1714

1815

19-
ComponentConstructor = Callable[..., "ComponentType"]
20-
ComponentRenderFunction = Callable[..., Union["ComponentType", VdomDict]]
21-
22-
23-
def component(function: ComponentRenderFunction) -> Callable[..., "Component"]:
16+
def component(
17+
function: Callable[..., Union[ComponentType, VdomDict]]
18+
) -> Callable[..., "Component"]:
2419
"""A decorator for defining an :class:`Component`.
2520
2621
Parameters:
@@ -34,34 +29,21 @@ def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Compone
3429
return constructor
3530

3631

37-
@runtime_checkable
38-
class ComponentType(Protocol):
39-
"""The expected interface for all component-like objects"""
40-
41-
id: str
42-
key: Optional[Any]
43-
44-
@abc.abstractmethod
45-
def render(self) -> VdomDict:
46-
"""Render the component's :class:`VdomDict`."""
47-
48-
4932
class Component:
5033
"""An object for rending component models."""
5134

52-
__slots__ = "__weakref__", "_func", "_args", "_kwargs", "id", "key"
35+
__slots__ = "__weakref__", "_func", "_args", "_kwargs", "key"
5336

5437
def __init__(
5538
self,
56-
function: ComponentRenderFunction,
39+
function: Callable[..., Union[ComponentType, VdomDict]],
5740
key: Optional[Any],
5841
args: Tuple[Any, ...],
5942
kwargs: Dict[str, Any],
6043
) -> None:
6144
self._args = args
6245
self._func = function
6346
self._kwargs = kwargs
64-
self.id = uuid4().hex
6547
self.key = key
6648
if key is not None:
6749
kwargs["key"] = key
@@ -81,6 +63,6 @@ def __repr__(self) -> str:
8163
else:
8264
items = ", ".join(f"{k}={v!r}" for k, v in args.items())
8365
if items:
84-
return f"{self._func.__name__}({self.id}, {items})"
66+
return f"{self._func.__name__}({id(self)}, {items})"
8567
else:
86-
return f"{self._func.__name__}({self.id})"
68+
return f"{self._func.__name__}({id(self)})"

Diff for: src/idom/core/dispatcher.py

+81-38
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,43 @@
55

66
from __future__ import annotations
77

8-
import sys
98
from asyncio import Future, Queue
109
from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait
10+
from contextlib import asynccontextmanager
1111
from logging import getLogger
12-
from typing import Any, AsyncIterator, Awaitable, Callable, List, Sequence, Tuple
12+
from typing import (
13+
Any,
14+
AsyncIterator,
15+
Awaitable,
16+
Callable,
17+
Dict,
18+
List,
19+
NamedTuple,
20+
Sequence,
21+
Tuple,
22+
cast,
23+
)
1324
from weakref import WeakSet
1425

1526
from anyio import create_task_group
27+
from jsonpatch import apply_patch, make_patch
1628

29+
from idom.core.vdom import VdomJson
1730
from idom.utils import Ref
1831

19-
from .layout import Layout, LayoutEvent, LayoutUpdate
20-
21-
22-
if sys.version_info >= (3, 7): # pragma: no cover
23-
from contextlib import asynccontextmanager # noqa
24-
else: # pragma: no cover
25-
from async_generator import asynccontextmanager
32+
from .layout import LayoutEvent, LayoutUpdate
33+
from .proto import LayoutType
2634

2735

2836
logger = getLogger(__name__)
2937

30-
SendCoroutine = Callable[[Any], Awaitable[None]]
38+
39+
SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]]
3140
RecvCoroutine = Callable[[], Awaitable[LayoutEvent]]
3241

3342

3443
async def dispatch_single_view(
35-
layout: Layout,
44+
layout: LayoutType[LayoutUpdate, LayoutEvent],
3645
send: SendCoroutine,
3746
recv: RecvCoroutine,
3847
) -> None:
@@ -49,14 +58,14 @@ async def dispatch_single_view(
4958

5059
@asynccontextmanager
5160
async def create_shared_view_dispatcher(
52-
layout: Layout, run_forever: bool = False
61+
layout: LayoutType[LayoutUpdate, LayoutEvent],
5362
) -> AsyncIterator[_SharedViewDispatcherFuture]:
5463
"""Enter a dispatch context where all subsequent view instances share the same state"""
5564
with layout:
5665
(
5766
dispatch_shared_view,
5867
model_state,
59-
all_update_queues,
68+
all_patch_queues,
6069
) = await _make_shared_view_dispatcher(layout)
6170

6271
dispatch_tasks: List[Future[None]] = []
@@ -85,16 +94,16 @@ def dispatch_shared_view_soon(
8594
update_future.cancel()
8695
break
8796
else:
88-
update: LayoutUpdate = update_future.result()
97+
patch = VdomJsonPatch.create_from(update_future.result())
8998

90-
model_state.current = update.apply_to(model_state.current)
99+
model_state.current = patch.apply_to(model_state.current)
91100
# push updates to all dispatcher callbacks
92-
for queue in all_update_queues:
93-
queue.put_nowait(update)
101+
for queue in all_patch_queues:
102+
queue.put_nowait(patch)
94103

95104

96105
def ensure_shared_view_dispatcher_future(
97-
layout: Layout,
106+
layout: LayoutType[LayoutUpdate, LayoutEvent],
98107
) -> Tuple[Future[None], SharedViewDispatcher]:
99108
"""Ensure the future of a dispatcher created by :func:`create_shared_view_dispatcher`"""
100109
dispatcher_future: Future[SharedViewDispatcher] = Future()
@@ -104,59 +113,93 @@ async def dispatch_shared_view_forever() -> None:
104113
(
105114
dispatch_shared_view,
106115
model_state,
107-
all_update_queues,
116+
all_patch_queues,
108117
) = await _make_shared_view_dispatcher(layout)
109118

110119
dispatcher_future.set_result(dispatch_shared_view)
111120

112121
while True:
113-
update = await layout.render()
114-
model_state.current = update.apply_to(model_state.current)
122+
patch = await render_json_patch(layout)
123+
model_state.current = patch.apply_to(model_state.current)
115124
# push updates to all dispatcher callbacks
116-
for queue in all_update_queues:
117-
queue.put_nowait(update)
125+
for queue in all_patch_queues:
126+
queue.put_nowait(patch)
118127

119128
async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None:
120129
await (await dispatcher_future)(send, recv)
121130

122131
return ensure_future(dispatch_shared_view_forever()), dispatch
123132

124133

134+
async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch:
135+
"""Render a class:`VdomJsonPatch` from a layout"""
136+
return VdomJsonPatch.create_from(await layout.render())
137+
138+
139+
class VdomJsonPatch(NamedTuple):
140+
"""An object describing an update to a :class:`Layout` in the form of a JSON patch"""
141+
142+
path: str
143+
"""The path where changes should be applied"""
144+
145+
changes: List[Dict[str, Any]]
146+
"""A list of JSON patches to apply at the given path"""
147+
148+
def apply_to(self, model: VdomJson) -> VdomJson:
149+
"""Return the model resulting from the changes in this update"""
150+
return cast(
151+
VdomJson,
152+
apply_patch(
153+
model, [{**c, "path": self.path + c["path"]} for c in self.changes]
154+
),
155+
)
156+
157+
@classmethod
158+
def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch:
159+
"""Return a patch given an layout update"""
160+
return cls(update.path, make_patch(update.old or {}, update.new).patch)
161+
162+
125163
async def _make_shared_view_dispatcher(
126-
layout: Layout,
127-
) -> Tuple[SharedViewDispatcher, Ref[Any], WeakSet[Queue[LayoutUpdate]]]:
128-
initial_update = await layout.render()
129-
model_state = Ref(initial_update.apply_to({}))
164+
layout: LayoutType[LayoutUpdate, LayoutEvent],
165+
) -> Tuple[SharedViewDispatcher, Ref[Any], WeakSet[Queue[VdomJsonPatch]]]:
166+
update = await layout.render()
167+
model_state = Ref(update.new)
130168

131169
# We push updates to queues instead of pushing directly to send() callbacks in
132170
# order to isolate the render loop from any errors dispatch callbacks might
133171
# raise.
134-
all_update_queues: WeakSet[Queue[LayoutUpdate]] = WeakSet()
172+
all_patch_queues: WeakSet[Queue[VdomJsonPatch]] = WeakSet()
135173

136174
async def dispatch_shared_view(send: SendCoroutine, recv: RecvCoroutine) -> None:
137-
update_queue: Queue[LayoutUpdate] = Queue()
175+
patch_queue: Queue[VdomJsonPatch] = Queue()
138176
async with create_task_group() as inner_task_group:
139-
all_update_queues.add(update_queue)
140-
await send(LayoutUpdate.create_from({}, model_state.current))
177+
all_patch_queues.add(patch_queue)
178+
effective_update = LayoutUpdate("", None, model_state.current)
179+
await send(VdomJsonPatch.create_from(effective_update))
141180
inner_task_group.start_soon(_single_incoming_loop, layout, recv)
142-
inner_task_group.start_soon(_shared_outgoing_loop, send, update_queue)
181+
inner_task_group.start_soon(_shared_outgoing_loop, send, patch_queue)
143182
return None
144183

145-
return dispatch_shared_view, model_state, all_update_queues
184+
return dispatch_shared_view, model_state, all_patch_queues
146185

147186

148-
async def _single_outgoing_loop(layout: Layout, send: SendCoroutine) -> None:
187+
async def _single_outgoing_loop(
188+
layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine
189+
) -> None:
149190
while True:
150-
await send(await layout.render())
191+
await send(await render_json_patch(layout))
151192

152193

153-
async def _single_incoming_loop(layout: Layout, recv: RecvCoroutine) -> None:
194+
async def _single_incoming_loop(
195+
layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine
196+
) -> None:
154197
while True:
155-
await layout.dispatch(await recv())
198+
await layout.deliver(await recv())
156199

157200

158201
async def _shared_outgoing_loop(
159-
send: SendCoroutine, queue: Queue[LayoutUpdate]
202+
send: SendCoroutine, queue: Queue[VdomJsonPatch]
160203
) -> None:
161204
while True:
162205
await send(await queue.get())

0 commit comments

Comments
 (0)