From 5461addac7c4487507e64ccda210ab7eccd5ac83 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 20 Jan 2023 15:38:45 -0800 Subject: [PATCH] remove json patch --- src/client/package-lock.json | 32 +++++++++- .../packages/idom-client-react/package.json | 4 +- .../idom-client-react/src/components.js | 26 ++++++-- .../idom-client-react/src/element-utils.js | 1 + .../idom-client-react/src/json-patch.js | 60 ------------------- .../packages/idom-client-react/src/mount.js | 4 +- src/idom/backend/default.py | 6 ++ src/idom/backend/flask.py | 26 ++++---- src/idom/backend/sanic.py | 18 ++---- src/idom/backend/starlette.py | 17 ++---- src/idom/backend/tornado.py | 12 ++-- src/idom/core/layout.py | 60 +++++++------------ src/idom/core/serve.py | 58 ++++-------------- src/idom/core/types.py | 24 +++++++- src/idom/testing/backend.py | 11 +++- tests/test_core/test_hooks.py | 8 +-- tests/test_core/test_layout.py | 36 +++++------ tests/test_core/test_serve.py | 20 +++---- tests/tooling/loop.py | 20 +++++-- 19 files changed, 201 insertions(+), 242 deletions(-) delete mode 100644 src/client/packages/idom-client-react/src/json-patch.js diff --git a/src/client/package-lock.json b/src/client/package-lock.json index 9cec2141d..cc053757c 100644 --- a/src/client/package-lock.json +++ b/src/client/package-lock.json @@ -694,6 +694,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/foreach": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", + "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -917,6 +922,14 @@ "node": ">=0.4.0" } }, + "node_modules/json-pointer": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", + "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", + "dependencies": { + "foreach": "^2.0.4" + } + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -1780,8 +1793,8 @@ "version": "0.43.0", "license": "MIT", "dependencies": { - "fast-json-patch": "^3.1.1", - "htm": "^3.0.3" + "htm": "^3.0.3", + "json-pointer": "^0.6.2" }, "devDependencies": { "jsdom": "16.5.0", @@ -2206,6 +2219,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "foreach": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", + "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -2320,9 +2338,9 @@ "idom-client-react": { "version": "file:packages/idom-client-react", "requires": { - "fast-json-patch": "^3.1.1", "htm": "^3.0.3", "jsdom": "16.5.0", + "json-pointer": "^0.6.2", "lodash": "^4.17.21", "prettier": "^2.5.1", "uvu": "^0.5.1" @@ -2401,6 +2419,14 @@ } } }, + "json-pointer": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", + "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", + "requires": { + "foreach": "^2.0.4" + } + }, "json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", diff --git a/src/client/packages/idom-client-react/package.json b/src/client/packages/idom-client-react/package.json index 7ba630ec0..61674e289 100644 --- a/src/client/packages/idom-client-react/package.json +++ b/src/client/packages/idom-client-react/package.json @@ -1,8 +1,8 @@ { "author": "Ryan Morshead", "dependencies": { - "fast-json-patch": "^3.1.1", - "htm": "^3.0.3" + "htm": "^3.0.3", + "json-pointer": "^0.6.2" }, "description": "A client for IDOM implemented in React", "devDependencies": { diff --git a/src/client/packages/idom-client-react/src/components.js b/src/client/packages/idom-client-react/src/components.js index 7ca471c6e..4f2953d32 100644 --- a/src/client/packages/idom-client-react/src/components.js +++ b/src/client/packages/idom-client-react/src/components.js @@ -1,8 +1,8 @@ import React from "react"; import ReactDOM from "react-dom"; import htm from "htm"; +import { set as setJsonPointer } from "json-pointer"; -import { useJsonPatchCallback } from "./json-patch.js"; import { useImportSource } from "./import-source.js"; import { LayoutContext } from "./contexts.js"; @@ -14,17 +14,30 @@ import { const html = htm.bind(React.createElement); export function Layout({ saveUpdateHook, sendEvent, loadImportSource }) { - const [model, patchModel] = useJsonPatchCallback({}); + const currentModel = React.useState({})[0]; + const forceUpdate = useForceUpdate(); + + const patchModel = React.useCallback( + ({ path, model }) => { + if (!path) { + Object.assign(currentModel, model); + } else { + setJsonPointer(currentModel, path, model); + } + forceUpdate(); + }, + [currentModel] + ); React.useEffect(() => saveUpdateHook(patchModel), [patchModel]); - if (!Object.keys(model).length) { + if (!Object.keys(currentModel).length) { return html`<${React.Fragment} />`; } return html` <${LayoutContext.Provider} value=${{ sendEvent, loadImportSource }}> - <${Element} model=${model} /> + <${Element} model=${currentModel} /> `; } @@ -200,3 +213,8 @@ function _ImportedElement({ model, importSource }) { return html`
`; } + +function useForceUpdate() { + const [, updateState] = React.useState(); + return React.useCallback(() => updateState({}), []); +} diff --git a/src/client/packages/idom-client-react/src/element-utils.js b/src/client/packages/idom-client-react/src/element-utils.js index cbf13470d..2300d6d8b 100644 --- a/src/client/packages/idom-client-react/src/element-utils.js +++ b/src/client/packages/idom-client-react/src/element-utils.js @@ -51,6 +51,7 @@ function createEventHandler(eventName, sendEvent, eventSpec) { sendEvent({ data: data, target: eventSpec["target"], + type: "layout-event", }); }; } diff --git a/src/client/packages/idom-client-react/src/json-patch.js b/src/client/packages/idom-client-react/src/json-patch.js deleted file mode 100644 index 5323f11a9..000000000 --- a/src/client/packages/idom-client-react/src/json-patch.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import jsonpatch from "fast-json-patch"; - -export function useJsonPatchCallback(initial) { - const doc = React.useRef(initial); - const forceUpdate = useForceUpdate(); - - const applyPatch = React.useCallback( - (path, patch) => { - if (!path) { - // We CANNOT mutate the part of the document because React checks some - // attributes of the model (e.g. model.attributes.style is checked for - // identity). - doc.current = applyNonMutativePatch( - doc.current, - patch, - false, - false, - true - ); - } else { - // We CAN mutate the document here though because we know that nothing above - // The patch `path` is changing. Thus, maintaining the identity for that section - // of the model is accurate. - applyMutativePatch(doc.current, [ - { - op: "replace", - path: path, - // We CANNOT mutate the part of the document where the actual patch is being - // applied. Instead we create a copy because React checks some attributes of - // the model (e.g. model.attributes.style is checked for identity). The part - // of the document above the `path` can be mutated though because we know it - // has not changed. - value: applyNonMutativePatch( - jsonpatch.getValueByPointer(doc.current, path), - patch - ), - }, - ]); - } - forceUpdate(); - }, - [doc] - ); - - return [doc.current, applyPatch]; -} - -function applyNonMutativePatch(doc, patch) { - return jsonpatch.applyPatch(doc, patch, false, false, true).newDocument; -} - -function applyMutativePatch(doc, patch) { - jsonpatch.applyPatch(doc, patch, false, true, true).newDocument; -} - -function useForceUpdate() { - const [, updateState] = React.useState(); - return React.useCallback(() => updateState({}), []); -} diff --git a/src/client/packages/idom-client-react/src/mount.js b/src/client/packages/idom-client-react/src/mount.js index 926f2a8ae..5b12985bb 100644 --- a/src/client/packages/idom-client-react/src/mount.js +++ b/src/client/packages/idom-client-react/src/mount.js @@ -51,8 +51,8 @@ function mountLayoutWithReconnectingWebSocket( }; socket.onmessage = (event) => { - const [pathPrefix, patch] = JSON.parse(event.data); - updateHookPromise.promise.then((update) => update(pathPrefix, patch)); + const message = JSON.parse(event.data); + updateHookPromise.promise.then((update) => update(message)); }; socket.onclose = (event) => { diff --git a/src/idom/backend/default.py b/src/idom/backend/default.py index c874f50ab..dda5b6bee 100644 --- a/src/idom/backend/default.py +++ b/src/idom/backend/default.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +from logging import getLogger +from sys import exc_info from typing import Any, NoReturn from idom.types import RootComponentConstructor @@ -9,6 +11,9 @@ from .utils import all_implementations +logger = getLogger(__name__) + + def configure( app: Any, component: RootComponentConstructor, options: None = None ) -> None: @@ -53,6 +58,7 @@ def _default_implementation() -> BackendImplementation[Any]: try: implementation = next(all_implementations()) except StopIteration: # pragma: no cover + logger.debug("Backend implementation import failed", exc_info=exc_info()) raise RuntimeError("No built-in server implementation installed.") else: _DEFAULT_IMPLEMENTATION = implementation diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index 95c054b83..8cb2b4980 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -38,8 +38,7 @@ from idom.backend.hooks import ConnectionContext from idom.backend.hooks import use_connection as _use_connection from idom.backend.types import Connection, Location -from idom.core.layout import LayoutEvent, LayoutUpdate -from idom.core.serve import serve_json_patch +from idom.core.serve import serve_layout from idom.core.types import ComponentType, RootComponentConstructor from idom.utils import Ref @@ -182,8 +181,8 @@ def model_stream(ws: WebSocket, path: str = "") -> None: def send(value: Any) -> None: ws.send(json.dumps(value)) - def recv() -> LayoutEvent: - return LayoutEvent(**json.loads(ws.receive())) + def recv() -> Any: + return json.loads(ws.receive()) _dispatch_in_thread( ws, @@ -203,7 +202,7 @@ def _dispatch_in_thread( path: str, component: ComponentType, send: Callable[[Any], None], - recv: Callable[[], Optional[LayoutEvent]], + recv: Callable[[], Optional[Any]], ) -> NoReturn: dispatch_thread_info_created = ThreadEvent() dispatch_thread_info_ref: idom.Ref[Optional[_DispatcherThreadInfo]] = idom.Ref(None) @@ -213,18 +212,15 @@ def run_dispatcher() -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - thread_send_queue: "ThreadQueue[LayoutUpdate]" = ThreadQueue() - async_recv_queue: "AsyncQueue[LayoutEvent]" = AsyncQueue() + thread_send_queue: "ThreadQueue[Any]" = ThreadQueue() + async_recv_queue: "AsyncQueue[Any]" = AsyncQueue() async def send_coro(value: Any) -> None: thread_send_queue.put(value) - async def recv_coro() -> Any: - return await async_recv_queue.get() - async def main() -> None: search = request.query_string.decode() - await serve_json_patch( + await serve_layout( idom.Layout( ConnectionContext( component, @@ -239,7 +235,7 @@ async def main() -> None: ), ), send_coro, - recv_coro, + async_recv_queue.get, ) main_future = asyncio.ensure_future(main(), loop=loop) @@ -282,9 +278,9 @@ def run_send() -> None: class _DispatcherThreadInfo(NamedTuple): dispatch_loop: asyncio.AbstractEventLoop - dispatch_future: "asyncio.Future[Any]" - thread_send_queue: "ThreadQueue[LayoutUpdate]" - async_recv_queue: "AsyncQueue[LayoutEvent]" + dispatch_future: asyncio.Future[Any] + thread_send_queue: ThreadQueue[Any] + async_recv_queue: AsyncQueue[Any] @dataclass diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index bb5e70952..bdbe4a7ac 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -14,14 +14,8 @@ from sanic_cors import CORS from idom.backend.types import Connection, Location -from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import ( - RecvCoroutine, - SendCoroutine, - Stop, - VdomJsonPatch, - serve_json_patch, -) +from idom.core.layout import Layout +from idom.core.serve import RecvCoroutine, SendCoroutine, Stop, serve_layout from idom.core.types import RootComponentConstructor from ._common import ( @@ -169,7 +163,7 @@ async def model_stream( scope = asgi_app.transport.scope send, recv = _make_send_recv_callbacks(socket) - await serve_json_patch( + await serve_layout( Layout( ConnectionContext( constructor(), @@ -198,14 +192,14 @@ async def model_stream( def _make_send_recv_callbacks( socket: WebSocketConnection, ) -> Tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: VdomJsonPatch) -> None: + async def sock_send(value: Any) -> None: await socket.send(json.dumps(value)) - async def sock_recv() -> LayoutEvent: + async def sock_recv() -> Any: data = await socket.recv() if data is None: raise Stop() - return LayoutEvent(**json.loads(data)) + return json.loads(data) return sock_send, sock_recv diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index 21d5200af..1e82d7c85 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -16,13 +16,8 @@ from idom.backend.hooks import ConnectionContext from idom.backend.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import ( - RecvCoroutine, - SendCoroutine, - VdomJsonPatch, - serve_json_patch, -) +from idom.core.layout import Layout +from idom.core.serve import RecvCoroutine, SendCoroutine, serve_layout from idom.core.types import RootComponentConstructor from ._common import ( @@ -151,7 +146,7 @@ async def model_stream(socket: WebSocket) -> None: search = socket.scope["query_string"].decode() try: - await serve_json_patch( + await serve_layout( Layout( ConnectionContext( constructor(), @@ -172,10 +167,10 @@ async def model_stream(socket: WebSocket) -> None: def _make_send_recv_callbacks( socket: WebSocket, ) -> Tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: VdomJsonPatch) -> None: + async def sock_send(value: Any) -> None: await socket.send_text(json.dumps(value)) - async def sock_recv() -> LayoutEvent: - return LayoutEvent(**json.loads(await socket.receive_text())) + async def sock_recv() -> Any: + return json.loads(await socket.receive_text()) return sock_send, sock_recv diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index a9a112ffc..f2a6ff09d 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -17,8 +17,8 @@ from idom.backend.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import VdomJsonPatch, serve_json_patch +from idom.core.layout import Layout +from idom.core.serve import serve_layout from idom.core.types import ComponentConstructor from ._common import ( @@ -183,15 +183,15 @@ def initialize( async def open(self, path: str = "", *args: Any, **kwargs: Any) -> None: message_queue: "AsyncQueue[str]" = AsyncQueue() - async def send(value: VdomJsonPatch) -> None: + async def send(value: Any) -> None: await self.write_message(json.dumps(value)) - async def recv() -> LayoutEvent: - return LayoutEvent(**json.loads(await message_queue.get())) + async def recv() -> Any: + return json.loads(await message_queue.get()) self._message_queue = message_queue self._dispatch_future = asyncio.ensure_future( - serve_json_patch( + serve_layout( Layout( ConnectionContext( self._component_constructor(), diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index e3151fcff..ee7f67da6 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -28,35 +28,20 @@ from ._event_proxy import _wrap_in_warning_event_proxies from .hooks import LifeCycleHook -from .types import ComponentType, EventHandlerDict, VdomDict, VdomJson +from .types import ( + ComponentType, + EventHandlerDict, + LayoutEventMessage, + LayoutUpdateMessage, + VdomDict, + VdomJson, +) from .vdom import validate_vdom_json logger = getLogger(__name__) -class LayoutUpdate(NamedTuple): - """A change to a view as a result of a :meth:`Layout.render`""" - - path: str - """A "/" delimited path to the element from the root of the layout""" - - old: Optional[VdomJson] - """The old state of the layout""" - - new: VdomJson - """The new state of the layout""" - - -class LayoutEvent(NamedTuple): - """An event that should be relayed to its handler by :meth:`Layout.deliver`""" - - target: str - """The ID of the event handler.""" - data: List[Any] - """A list of event data passed to the event handler.""" - - class Layout: """Responsible for "rendering" components. That is, turning them into VDOM.""" @@ -104,25 +89,26 @@ async def __aexit__(self, *exc: Any) -> None: return None - async def deliver(self, event: LayoutEvent) -> None: + async def deliver(self, event: LayoutEventMessage) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event # associated with a backend model that has been deleted. We only handle # events if the element and the handler exist in the backend. Otherwise # we just ignore the event. - handler = self._event_handlers.get(event.target) + handler = self._event_handlers.get(event["target"]) if handler is not None: try: - await handler.function(_wrap_in_warning_event_proxies(event.data)) + await handler.function(_wrap_in_warning_event_proxies(event["data"])) except Exception: logger.exception(f"Failed to execute event handler {handler}") else: logger.info( - f"Ignored event - handler {event.target!r} does not exist or its component unmounted" + f"Ignored event - handler {event['target']!r} " + "does not exist or its component unmounted" ) - async def render(self) -> LayoutUpdate: + async def render(self) -> LayoutUpdateMessage: """Await the next available render. This will block until a component is updated""" while True: model_state_id = await self._rendering_queue.get() @@ -141,24 +127,18 @@ async def render(self) -> LayoutUpdate: validate_vdom_json(root_model.model.current) return update - def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: + def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component with ExitStack() as exit_stack: self._render_component(exit_stack, old_state, new_state, component) - old_model: Optional[VdomJson] - try: - old_model = old_state.model.current - except AttributeError: - old_model = None - - return LayoutUpdate( - path=new_state.patch_path, - old=old_model, - new=new_state.model.current, - ) + return { + "type": "layout-update", + "path": new_state.patch_path, + "model": new_state.model.current, + } def _render_component( self, diff --git a/src/idom/core/serve.py b/src/idom/core/serve.py index 69071555f..470c37ecc 100644 --- a/src/idom/core/serve.py +++ b/src/idom/core/serve.py @@ -1,25 +1,22 @@ from __future__ import annotations -from asyncio import ensure_future -from asyncio.tasks import ensure_future +from asyncio import create_task from logging import getLogger -from typing import Any, Awaitable, Callable, Dict, List, NamedTuple, cast +from typing import Awaitable, Callable from anyio import create_task_group -from jsonpatch import apply_patch -from .layout import LayoutEvent, LayoutUpdate -from .types import LayoutType, VdomJson +from idom.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) -SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]] +SendCoroutine = Callable[[LayoutUpdateMessage], Awaitable[None]] """Send model patches given by a dispatcher""" -RecvCoroutine = Callable[[], Awaitable[LayoutEvent]] -"""Called by a dispatcher to return a :class:`idom.core.layout.LayoutEvent` +RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage]] +"""Called by a dispatcher to return a :class:`idom.core.layout.LayoutEventMessage` The event will then trigger an :class:`idom.core.proto.EventHandlerType` in a layout. """ @@ -33,8 +30,8 @@ class Stop(BaseException): """ -async def serve_json_patch( - layout: LayoutType[LayoutUpdate, LayoutEvent], +async def serve_layout( + layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine, recv: RecvCoroutine, ) -> None: @@ -45,49 +42,20 @@ async def serve_json_patch( task_group.start_soon(_single_outgoing_loop, layout, send) task_group.start_soon(_single_incoming_loop, layout, recv) except Stop: - logger.info("Stopped dispatch task") - - -async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch: - """Render a class:`VdomJsonPatch` from a layout""" - return VdomJsonPatch.create_from(await layout.render()) - - -class VdomJsonPatch(NamedTuple): - """An object describing an update to a :class:`Layout` in the form of a JSON patch""" - - path: str - """The path where changes should be applied""" - - changes: List[Dict[str, Any]] - """A list of JSON patches to apply at the given path""" - - def apply_to(self, model: VdomJson) -> VdomJson: - """Return the model resulting from the changes in this update""" - return cast( - VdomJson, - apply_patch( - model, [{**c, "path": self.path + c["path"]} for c in self.changes] - ), - ) - - @classmethod - def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch: - """Return a patch given an layout update""" - return cls(update.path, [{"op": "replace", "path": "", "value": update.new}]) + logger.info(f"Stopped serving {layout}") async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine + layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine ) -> None: while True: - await send(await render_json_patch(layout)) + await send(await layout.render()) async def _single_incoming_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine + layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], recv: RecvCoroutine ) -> None: while True: # We need to fire and forget here so that we avoid waiting on the completion # of this event handler before receiving and running the next one. - ensure_future(layout.deliver(await recv())) + create_task(layout.deliver(await recv())) diff --git a/src/idom/core/types.py b/src/idom/core/types.py index b98a2aca2..38cf66bb7 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -20,7 +20,7 @@ Union, ) -from typing_extensions import Protocol, TypedDict, runtime_checkable +from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable _Type = TypeVar("_Type") @@ -213,3 +213,25 @@ def __call__( event_handlers: Optional[EventHandlerMapping] = ..., ) -> VdomDict: ... + + +class LayoutUpdateMessage(TypedDict): + """A message describing an update to a layout""" + + type: Literal["layout-update"] + """The type of message""" + path: str + """JSON Pointer path to the model element being updated""" + model: VdomJson + """The model to assign at the given JSON Pointer path""" + + +class LayoutEventMessage(TypedDict): + """Message describing an event originating from an element in the layout""" + + type: Literal["layout-event"] + """The type of message""" + target: str + """The ID of the event handler.""" + data: List[Any] + """A list of event data passed to the event handler.""" diff --git a/src/idom/testing/backend.py b/src/idom/testing/backend.py index 58b116b56..3d6634ca8 100644 --- a/src/idom/testing/backend.py +++ b/src/idom/testing/backend.py @@ -10,6 +10,7 @@ from idom.backend import default as default_server from idom.backend.types import BackendImplementation from idom.backend.utils import find_available_port +from idom.config import IDOM_TESTING_DEFAULT_TIMEOUT from idom.core.component import component from idom.core.hooks import use_callback, use_effect, use_state from idom.core.types import ComponentConstructor @@ -41,10 +42,14 @@ def __init__( app: Any | None = None, implementation: BackendImplementation[Any] | None = None, options: Any | None = None, + timeout: float | None = None, ) -> None: self.host = host self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) self.mount, self._root_component = _hotswap() + self.timeout = ( + IDOM_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout + ) if app is not None: if implementation is None: @@ -119,17 +124,17 @@ async def __aenter__(self) -> BackendFixture: async def stop_server() -> None: server_future.cancel() try: - await asyncio.wait_for(server_future, timeout=3) + await asyncio.wait_for(server_future, timeout=self.timeout) except asyncio.CancelledError: pass self._exit_stack.push_async_callback(stop_server) try: - await asyncio.wait_for(started.wait(), timeout=3) + await asyncio.wait_for(started.wait(), timeout=self.timeout) except Exception: # pragma: no cover # see if we can await the future for a more helpful error - await asyncio.wait_for(server_future, timeout=3) + await asyncio.wait_for(server_future, timeout=self.timeout) raise return self diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 4a2faa07f..73d5bf111 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -11,7 +11,7 @@ current_hook, strictly_equal, ) -from idom.core.layout import Layout, LayoutUpdate +from idom.core.layout import Layout, LayoutUpdateMessage from idom.testing import DisplayFixture, HookCatcher, assert_idom_did_log, poll from idom.testing.logs import assert_idom_did_not_log from idom.utils import Ref @@ -42,7 +42,7 @@ def SimpleStatefulComponent(): async with idom.Layout(sse) as layout: update_1 = await layout.render() - assert update_1 == LayoutUpdate( + assert update_1 == LayoutUpdateMessage( path="", old=None, new={ @@ -52,7 +52,7 @@ def SimpleStatefulComponent(): ) update_2 = await layout.render() - assert update_2 == LayoutUpdate( + assert update_2 == LayoutUpdateMessage( path="", old=update_1.new, new={ @@ -62,7 +62,7 @@ def SimpleStatefulComponent(): ) update_3 = await layout.render() - assert update_3 == LayoutUpdate( + assert update_3 == LayoutUpdateMessage( path="", old=update_2.new, new={ diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 58648c3cb..7cfa2cb61 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -12,7 +12,7 @@ from idom.config import IDOM_DEBUG_MODE from idom.core.component import component from idom.core.hooks import use_effect, use_state -from idom.core.layout import Layout, LayoutEvent, LayoutUpdate +from idom.core.layout import Layout, LayoutEventMessage, LayoutUpdateMessage from idom.testing import ( HookCatcher, StaticEventHandler, @@ -58,7 +58,7 @@ def Component(): layout = idom.Layout(component) with pytest.raises(Exception): - await layout.deliver(LayoutEvent("something", [])) + await layout.deliver(LayoutEventMessage("something", [])) with pytest.raises(Exception): layout.update(component) @@ -77,7 +77,7 @@ def SimpleComponent(): async with idom.Layout(SimpleComponent()) as layout: update_1 = await layout.render() - assert update_1 == LayoutUpdate( + assert update_1 == LayoutUpdateMessage( path="", old=None, new={"tagName": "", "children": [{"tagName": "div"}]}, @@ -86,7 +86,7 @@ def SimpleComponent(): set_state_hook.current("table") update_2 = await layout.render() - assert update_2 == LayoutUpdate( + assert update_2 == LayoutUpdateMessage( path="", old=update_1.new, new={"tagName": "", "children": [{"tagName": "table"}]}, @@ -135,7 +135,7 @@ def make_child_model(state): async with idom.Layout(Parent()) as layout: update_1 = await layout.render() - assert update_1 == LayoutUpdate( + assert update_1 == LayoutUpdateMessage( path="", old=None, new=make_parent_model(0, make_child_model(0)), @@ -144,7 +144,7 @@ def make_child_model(state): parent_set_state.current(1) update_2 = await layout.render() - assert update_2 == LayoutUpdate( + assert update_2 == LayoutUpdateMessage( path="", old=update_1.new, new=make_parent_model(1, make_child_model(0)), @@ -153,7 +153,7 @@ def make_child_model(state): child_set_state.current(1) update_3 = await layout.render() - assert update_3 == LayoutUpdate( + assert update_3 == LayoutUpdateMessage( path="/children/0/children/1", old=update_2.new["children"][0]["children"][1], new=make_child_model(1), @@ -180,7 +180,7 @@ def BadChild(): with assert_idom_did_log(match_error="error from bad child"): async with idom.Layout(Main()) as layout: - assert (await layout.render()) == LayoutUpdate( + assert (await layout.render()) == LayoutUpdateMessage( path="", old=None, new={ @@ -232,7 +232,7 @@ def BadChild(): with assert_idom_did_log(match_error="error from bad child"): async with idom.Layout(Main()) as layout: - assert (await layout.render()) == LayoutUpdate( + assert (await layout.render()) == LayoutUpdateMessage( path="", old=None, new={ @@ -271,7 +271,7 @@ def Child(): return {"tagName": "div", "children": {"tagName": "h1"}} async with idom.Layout(Main()) as layout: - assert (await layout.render()) == LayoutUpdate( + assert (await layout.render()) == LayoutUpdateMessage( path="", old=None, new={ @@ -487,7 +487,7 @@ def SomeComponent(): return idom.html.div() async with idom.Layout(SomeComponent()) as layout: - await layout.deliver(LayoutEvent(target="missing", data=[])) + await layout.deliver(LayoutEventMessage(target="missing", data=[])) assert re.match( "Ignored event - handler 'missing' does not exist or its component unmounted", @@ -528,7 +528,7 @@ def bad_trigger(): async with idom.Layout(MyComponent()) as layout: await layout.render() for i in range(3): - event = LayoutEvent(good_handler.target, []) + event = LayoutEventMessage(good_handler.target, []) await layout.deliver(event) assert called_good_trigger.current @@ -579,7 +579,7 @@ def callback(): async with idom.Layout(RootComponent()) as layout: await layout.render() for _ in range(3): - event = LayoutEvent(good_handler.target, []) + event = LayoutEventMessage(good_handler.target, []) await layout.deliver(event) assert called_good_trigger.current @@ -599,7 +599,7 @@ def Inner(): return idom.html.div("hello") async with idom.Layout(Outer()) as layout: - assert (await layout.render()) == LayoutUpdate( + assert (await layout.render()) == LayoutUpdateMessage( path="", old=None, new={ @@ -767,7 +767,7 @@ def raise_error(): async with idom.Layout(ComponentWithBadEventHandler()) as layout: await layout.render() - event = LayoutEvent(bad_handler.target, []) + event = LayoutEventMessage(bad_handler.target, []) await layout.deliver(event) @@ -1038,7 +1038,7 @@ async def record_if_state_is_reset(): did_call_effect.clear() for i in range(1, 5): - await layout.deliver(LayoutEvent(set_child_key_num.target, [])) + await layout.deliver(LayoutEventMessage(set_child_key_num.target, [])) await layout.render() assert effect_calls_without_state == {"some-key", "key-0"} did_call_effect.clear() @@ -1086,13 +1086,13 @@ def Root(): async with Layout(Root()) as layout: await layout.render() - await layout.deliver(LayoutEvent(event_handler.target, [])) + await layout.deliver(LayoutEventMessage(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, [])) + await layout.deliver(LayoutEventMessage(event_handler.target, [])) assert did_trigger.current did_trigger.current = False diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 8e3f05ded..9d715bfa5 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -2,8 +2,8 @@ from typing import Any, Sequence import idom -from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from idom.core.serve import VdomJsonPatch, serve_json_patch +from idom.core.layout import Layout, LayoutEventMessage, LayoutUpdateMessage +from idom.core.serve import LayoutUpdateMessage, serve_layout from idom.testing import StaticEventHandler @@ -12,8 +12,8 @@ def test_vdom_json_patch_create_from_apply_to(): - update = LayoutUpdate("", {"a": 1, "b": [1]}, {"a": 2, "b": [1, 2]}) - patch = VdomJsonPatch.create_from(update) + update = LayoutUpdateMessage("", {"a": 1, "b": [1]}, {"a": 2, "b": [1, 2]}) + patch = LayoutUpdateMessage.create_from(update) result = patch.apply_to({"a": 1, "b": [1]}) assert result == {"a": 2, "b": [1, 2]} @@ -46,7 +46,7 @@ async def recv(): def make_events_and_expected_model(): - events = [LayoutEvent(STATIC_EVENT_HANDLER.target, [])] * 4 + events = [LayoutEventMessage(STATIC_EVENT_HANDLER.target, [])] * 4 expected_model = { "tagName": "", "children": [ @@ -67,7 +67,7 @@ def make_events_and_expected_model(): def assert_changes_produce_expected_model( - changes: Sequence[LayoutUpdate], + changes: Sequence[LayoutUpdateMessage], expected_model: Any, ) -> None: model_from_changes = {} @@ -89,7 +89,7 @@ def Counter(): async def test_dispatch(): events, expected_model = make_events_and_expected_model() changes, send, recv = make_send_recv_callbacks(events) - await asyncio.wait_for(serve_json_patch(Layout(Counter()), send, recv), 1) + await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1) assert_changes_produce_expected_model(changes, expected_model) @@ -121,15 +121,15 @@ async def handle_event(): recv_queue = asyncio.Queue() asyncio.ensure_future( - serve_json_patch( + serve_layout( idom.Layout(ComponentWithTwoEventHandlers()), send_queue.put, recv_queue.get, ) ) - await recv_queue.put(LayoutEvent(blocked_handler.target, [])) + await recv_queue.put(LayoutEventMessage(blocked_handler.target, [])) await will_block.wait() - await recv_queue.put(LayoutEvent(non_blocked_handler.target, [])) + await recv_queue.put(LayoutEventMessage(non_blocked_handler.target, [])) await second_event_did_execute.wait() diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py index 6169f5176..58f7d9fe3 100644 --- a/tests/tooling/loop.py +++ b/tests/tooling/loop.py @@ -9,9 +9,6 @@ from idom.config import IDOM_TESTING_DEFAULT_TIMEOUT -TIMEOUT = 3 - - @contextmanager def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]: """Open a new event loop and cleanly stop it @@ -29,11 +26,19 @@ def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLo try: _cancel_all_tasks(loop, as_current) if as_current: - loop.run_until_complete(wait_for(loop.shutdown_asyncgens(), TIMEOUT)) + loop.run_until_complete( + wait_for( + loop.shutdown_asyncgens(), + IDOM_TESTING_DEFAULT_TIMEOUT.current, + ) + ) if sys.version_info >= (3, 9): # shutdown_default_executor only available in Python 3.9+ loop.run_until_complete( - wait_for(loop.shutdown_default_executor(), TIMEOUT) + wait_for( + loop.shutdown_default_executor(), + IDOM_TESTING_DEFAULT_TIMEOUT.current, + ) ) finally: if as_current: @@ -69,7 +74,10 @@ def one_task_finished(future): if is_current: loop.run_until_complete( - wait_for(asyncio.gather(*to_cancel, return_exceptions=True), TIMEOUT) + wait_for( + asyncio.gather(*to_cancel, return_exceptions=True), + IDOM_TESTING_DEFAULT_TIMEOUT.current, + ) ) else: # user was responsible for cancelling all tasks