diff --git a/requirements/test-env.txt b/requirements/test-env.txt index aa007c295..ac59ea45b 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -10,3 +10,6 @@ playwright # I'm not quite sure why this needs to be installed for tests with Sanic to pass sanic-testing + +# Used to generate model changes from layout update messages +jsonpointer 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..0fd78ec22 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -2,6 +2,7 @@ import sys from collections import namedtuple +from collections.abc import Sequence from types import TracebackType from typing import ( TYPE_CHECKING, @@ -14,13 +15,12 @@ Mapping, NamedTuple, Optional, - Sequence, Type, TypeVar, Union, ) -from typing_extensions import Protocol, TypedDict, runtime_checkable +from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable _Type = TypeVar("_Type") @@ -98,7 +98,7 @@ async def __aexit__( VdomChild = Union[ComponentType, "VdomDict", str] """A single child element of a :class:`VdomDict`""" -VdomChildren = Sequence[VdomChild] +VdomChildren = "Sequence[VdomChild]" """Describes a series of :class:`VdomChild` elements""" VdomAttributesAndChildren = Union[ @@ -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: Sequence[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..293e773a2 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -11,11 +11,11 @@ current_hook, strictly_equal, ) -from idom.core.layout import Layout, LayoutUpdate +from idom.core.layout import Layout 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 -from tests.tooling.common import DEFAULT_TYPE_DELAY +from tests.tooling.common import DEFAULT_TYPE_DELAY, update_message async def test_must_be_rendering_in_layout_to_use_hooks(): @@ -42,30 +42,27 @@ def SimpleStatefulComponent(): async with idom.Layout(sse) as layout: update_1 = await layout.render() - assert update_1 == LayoutUpdate( + assert update_1 == update_message( path="", - old=None, - new={ + model={ "tagName": "", "children": [{"tagName": "div", "children": ["0"]}], }, ) update_2 = await layout.render() - assert update_2 == LayoutUpdate( + assert update_2 == update_message( path="", - old=update_1.new, - new={ + model={ "tagName": "", "children": [{"tagName": "div", "children": ["1"]}], }, ) update_3 = await layout.render() - assert update_3 == LayoutUpdate( + assert update_3 == update_message( path="", - old=update_2.new, - new={ + model={ "tagName": "", "children": [{"tagName": "div", "children": ["2"]}], }, diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 58648c3cb..805ff5653 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 from idom.testing import ( HookCatcher, StaticEventHandler, @@ -20,6 +20,7 @@ capture_idom_logs, ) from idom.utils import Ref +from tests.tooling.common import event_message, update_message from tests.tooling.hooks import use_force_render, use_toggle @@ -58,7 +59,7 @@ def Component(): layout = idom.Layout(component) with pytest.raises(Exception): - await layout.deliver(LayoutEvent("something", [])) + await layout.deliver(event_message("something")) with pytest.raises(Exception): layout.update(component) @@ -77,19 +78,17 @@ def SimpleComponent(): async with idom.Layout(SimpleComponent()) as layout: update_1 = await layout.render() - assert update_1 == LayoutUpdate( + assert update_1 == update_message( path="", - old=None, - new={"tagName": "", "children": [{"tagName": "div"}]}, + model={"tagName": "", "children": [{"tagName": "div"}]}, ) set_state_hook.current("table") update_2 = await layout.render() - assert update_2 == LayoutUpdate( + assert update_2 == update_message( path="", - old=update_1.new, - new={"tagName": "", "children": [{"tagName": "table"}]}, + model={"tagName": "", "children": [{"tagName": "table"}]}, ) @@ -99,7 +98,7 @@ def SomeComponent(): return None async with idom.Layout(SomeComponent()) as layout: - assert (await layout.render()).new == {"tagName": ""} + assert (await layout.render())["model"] == {"tagName": ""} async def test_nested_component_layout(): @@ -135,28 +134,25 @@ def make_child_model(state): async with idom.Layout(Parent()) as layout: update_1 = await layout.render() - assert update_1 == LayoutUpdate( + assert update_1 == update_message( path="", - old=None, - new=make_parent_model(0, make_child_model(0)), + model=make_parent_model(0, make_child_model(0)), ) parent_set_state.current(1) update_2 = await layout.render() - assert update_2 == LayoutUpdate( + assert update_2 == update_message( path="", - old=update_1.new, - new=make_parent_model(1, make_child_model(0)), + model=make_parent_model(1, make_child_model(0)), ) child_set_state.current(1) update_3 = await layout.render() - assert update_3 == LayoutUpdate( + assert update_3 == update_message( path="/children/0/children/1", - old=update_2.new["children"][0]["children"][1], - new=make_child_model(1), + model=make_child_model(1), ) @@ -180,10 +176,9 @@ 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()) == update_message( path="", - old=None, - new={ + model={ "tagName": "", "children": [ { @@ -232,10 +227,9 @@ 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()) == update_message( path="", - old=None, - new={ + model={ "tagName": "", "children": [ { @@ -271,10 +265,9 @@ def Child(): return {"tagName": "div", "children": {"tagName": "h1"}} async with idom.Layout(Main()) as layout: - assert (await layout.render()) == LayoutUpdate( + assert (await layout.render()) == update_message( path="", - old=None, - new={ + model={ "tagName": "", "children": [ { @@ -478,7 +471,7 @@ def Child(): hook.latest.schedule_render() update = await layout.render() - assert update.path == "/children/0/children/0/children/0" + assert update["path"] == "/children/0/children/0/children/0" async def test_log_on_dispatch_to_missing_event_handler(caplog): @@ -487,7 +480,7 @@ def SomeComponent(): return idom.html.div() async with idom.Layout(SomeComponent()) as layout: - await layout.deliver(LayoutEvent(target="missing", data=[])) + await layout.deliver(event_message("missing")) assert re.match( "Ignored event - handler 'missing' does not exist or its component unmounted", @@ -528,7 +521,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 = event_message(good_handler.target) await layout.deliver(event) assert called_good_trigger.current @@ -579,7 +572,7 @@ def callback(): async with idom.Layout(RootComponent()) as layout: await layout.render() for _ in range(3): - event = LayoutEvent(good_handler.target, []) + event = event_message(good_handler.target) await layout.deliver(event) assert called_good_trigger.current @@ -599,10 +592,9 @@ def Inner(): return idom.html.div("hello") async with idom.Layout(Outer()) as layout: - assert (await layout.render()) == LayoutUpdate( + assert (await layout.render()) == update_message( path="", - old=None, - new={ + model={ "tagName": "", "children": [ { @@ -767,7 +759,7 @@ def raise_error(): async with idom.Layout(ComponentWithBadEventHandler()) as layout: await layout.render() - event = LayoutEvent(bad_handler.target, []) + event = event_message(bad_handler.target) await layout.deliver(event) @@ -1038,7 +1030,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(event_message(set_child_key_num.target)) await layout.render() assert effect_calls_without_state == {"some-key", "key-0"} did_call_effect.clear() @@ -1086,13 +1078,13 @@ def Root(): async with Layout(Root()) as layout: await layout.render() - await layout.deliver(LayoutEvent(event_handler.target, [])) + await layout.deliver(event_message(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(event_message(event_handler.target)) assert did_trigger.current did_trigger.current = False @@ -1144,7 +1136,7 @@ def Child(): async with idom.Layout(Parent()) as layout: update = await layout.render() - assert update.new == { + assert update["model"] == { "tagName": "", "children": [ { diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 8e3f05ded..20eb9fa86 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -1,23 +1,20 @@ import asyncio from typing import Any, Sequence +from jsonpointer import set_pointer + 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 +from idom.core.serve import serve_layout +from idom.core.types import LayoutUpdateMessage from idom.testing import StaticEventHandler +from tests.tooling.common import event_message EVENT_NAME = "onEvent" STATIC_EVENT_HANDLER = StaticEventHandler() -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) - result = patch.apply_to({"a": 1, "b": [1]}) - assert result == {"a": 2, "b": [1, 2]} - - def make_send_recv_callbacks(events_to_inject): changes = [] @@ -46,7 +43,7 @@ async def recv(): def make_events_and_expected_model(): - events = [LayoutEvent(STATIC_EVENT_HANDLER.target, [])] * 4 + events = [event_message(STATIC_EVENT_HANDLER.target)] * 4 expected_model = { "tagName": "", "children": [ @@ -67,12 +64,17 @@ 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 = {} for update in changes: - model_from_changes = update.apply_to(model_from_changes) + if update["path"]: + model_from_changes = set_pointer( + model_from_changes, update["path"], update["model"] + ) + else: + model_from_changes.update(update["model"]) assert model_from_changes == expected_model @@ -89,7 +91,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 +123,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(event_message(blocked_handler.target)) await will_block.wait() - await recv_queue.put(LayoutEvent(non_blocked_handler.target, [])) + await recv_queue.put(event_message(non_blocked_handler.target)) await second_event_did_execute.wait() diff --git a/tests/tooling/common.py b/tests/tooling/common.py index c995eacde..e0706b561 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,2 +1,15 @@ +from typing import Any + +from idom.core.types import LayoutEventMessage, LayoutUpdateMessage + + # see: https://github.com/microsoft/playwright-python/issues/1614 DEFAULT_TYPE_DELAY = 100 # miliseconds + + +def event_message(target: str, *data: Any) -> LayoutEventMessage: + return {"type": "layout-event", "target": target, "data": data} + + +def update_message(path: str, model: Any) -> LayoutUpdateMessage: + return {"type": "layout-update", "path": path, "model": model} 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