Skip to content

Commit 6d5e17d

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.
1 parent f273aac commit 6d5e17d

19 files changed

+748
-391
lines changed

docs/source/core-abstractions.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ which we can re-render and see what changed:
9595
patch_1 = await layout.render()
9696

9797
fake_event = LayoutEvent(target=static_handler.target, data=[{}])
98-
await layout.dispatch(fake_event)
98+
await layout.deliver(fake_event)
9999
patch_2 = await layout.render()
100100

101101
for change in patch_2.changes:

src/idom/core/component.py

+5-21
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,18 @@
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
1211
from uuid import uuid4
1312

14-
from typing_extensions import Protocol, runtime_checkable
15-
13+
from .proto import ComponentType
1614
from .vdom import VdomDict
1715

1816

19-
ComponentConstructor = Callable[..., "ComponentType"]
20-
ComponentRenderFunction = Callable[..., Union["ComponentType", VdomDict]]
21-
22-
23-
def component(function: ComponentRenderFunction) -> Callable[..., "Component"]:
17+
def component(
18+
function: Callable[..., Union[ComponentType, VdomDict]]
19+
) -> Callable[..., "Component"]:
2420
"""A decorator for defining an :class:`Component`.
2521
2622
Parameters:
@@ -34,26 +30,14 @@ def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Compone
3430
return constructor
3531

3632

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-
4933
class Component:
5034
"""An object for rending component models."""
5135

5236
__slots__ = "__weakref__", "_func", "_args", "_kwargs", "id", "key"
5337

5438
def __init__(
5539
self,
56-
function: ComponentRenderFunction,
40+
function: Callable[..., Union[ComponentType, VdomDict]],
5741
key: Optional[Any],
5842
args: Tuple[Any, ...],
5943
kwargs: Dict[str, Any],

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)