diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bd4173691..6e44c0c26 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -42,7 +42,7 @@ pip install flask sanic tornado **Build JavaScript Packages:** - `hatch run javascript:build` -- takes 15 seconds. NEVER CANCEL. Set timeout to 60+ minutes for safety. -- This builds three packages: event-to-object, @reactpy/client, and @reactpy/app +- This builds two packages: @reactpy/client, and @reactpy/app **Build Python Package:** @@ -174,7 +174,6 @@ print(f"✓ Hook-based component: {type(counter)}") - `static/` -- Bundled JavaScript files - `_html.py` -- HTML element factory functions - `src/js/` -- JavaScript packages that get bundled with Python - - `packages/event-to-object/` -- Event serialization package - `packages/@reactpy/client/` -- Client-side React integration - `packages/@reactpy/app/` -- Application framework - `src/build_scripts/` -- Build automation scripts @@ -252,7 +251,7 @@ def my_component(initial_value=0): ### Working with JavaScript: - JavaScript packages are in `src/js/packages/` -- Three main packages: event-to-object, @reactpy/client, @reactpy/app +- Two main packages: @reactpy/client, @reactpy/app - Built JavaScript gets bundled into `src/reactpy/static/` - Always rebuild JavaScript after changes: `hatch run javascript:build` diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b45399242..c0c5305f6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,10 +38,3 @@ jobs: with: job-name: "Publish to NPM" run-cmd: "hatch run javascript:publish_client" - - publish-event-to-object: - if: startsWith(github.event.release.name, 'event-to-object ') || startsWith(github.event.release.tag_name, 'event-to-object-') - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "Publish to NPM" - run-cmd: "hatch run javascript:publish_event_to_object" diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c7bac0f..e1389f528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,10 @@ Don't forget to remove deprecated code on each major release! - Added type hints to `reactpy.html` attributes. - Added support for nested components in web modules - Added support for inline JavaScript as event handlers or other attributes that expect a callable via `reactpy.types.InlineJavaScript` -- Event functions can now call `event.preventDefault()` and `event.stopPropagation()` methods directly on the event data object, rather than using the `@event` decorator. -- Event data now supports accessing properties via dot notation (ex. `event.target.value`). +- Event functions can now call `event.preventDefault()`, `event.stopPropagation()`, and `event.stopImmediatePropagation()` methods directly on the event data object, rather than using the `@event` decorator. +- Event data now supports accessing properties via dot notation (ex. `await event.target.value`). - Added support for partial functions in EventHandler -- Added `reactpy.types.Event` to provide type hints for the standard `data` function argument (for example `def on_click(event: Event): ...`). +- Added `reactpy.types.Event` to provide type hints for the standard `data` function argument (for example `async def on_click(event: Event): ...`). - Added `asgi` and `jinja` installation extras (for example `pip install reactpy[asgi, jinja]`). - Added `reactpy.executors.asgi.ReactPy` that can be used to run ReactPy in standalone mode via ASGI. - Added `reactpy.executors.asgi.ReactPyCsr` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided. @@ -40,6 +40,15 @@ Don't forget to remove deprecated code on each major release! - Events now support `debounce` and `throttle`, configurable per event via `event.debounce = ` and `EventHandler(fn, throttle=)` respectively. - `debounce` waits until activity stops, then fires once. Default is 200 ms on `input`/`select`/`textarea`, 0 ms elsewhere. - `throttle` caps the rate how often an event is allowed to execute. No default; opt in per event. +- Added a live event proxy which lets event handlers read properties from, and call methods on, the browser objects which triggered them (ex. `await event.target.value`, `await event.target.focus()`) rather than being limited to whatever was serializable at the moment the event was dispatched. + - Only an id and the event's `type` travel with the event. Whatever a handler reaches past is fetched from the browser on demand, one round trip at a time, and resolved against the object itself. + - Properties which are in flight at the same moment go out as a single message, and `await event.read("target.value", "key")` reads several at once. Two handlers which want the same property at the same moment share that one message, and one which walks away from an answer it only borrowed leaves the message in place for the one which asked. + - A read belongs to the moment it is awaited: it reports the current state of the browser object, not the state it held when the event was dispatched. + - Whatever a read found is remembered against the event, so reading one property twice costs one round trip - and a handler which wants what the browser holds by now asks again with `await event.read("target.value", refresh=True)`. + - Whatever comes back out of a method call can be walked too: the page keeps what the call answered for as long as it keeps the event, so a reference into a result resolves against that answer rather than against the method. + - Unanswered requests are bounded by `reactpy.config.REACTPY_EVENT_PROXY_TIMEOUT`, `reactpy.config.REACTPY_EVENT_PROXY_MAX_TIMEOUTS`, and `reactpy.config.REACTPY_EVENT_PROXY_MAX_DEPTH`. + - What that budget counts is unanswered messages rather than properties: a batch which asked for four things and got no answer at all costs one of it, not four. The same depth bounds an argument handed to a client side method as well as the data which comes back. + - A client which reconnects is a fresh chance at live access: binding a new sender brings it back if an earlier one was switched off for going quiet. ### Changed @@ -53,7 +62,6 @@ Don't forget to remove deprecated code on each major release! - Renamed the `use_location` hook's `pathname` attribute to `path`. - Renamed `reactpy.config.REACTPY_DEBUG_MODE` to `reactpy.config.REACTPY_DEBUG`. - ReactPy no longer auto-converts `snake_case` props to `camelCase`. It is now the responsibility of the user to ensure that props are in the correct format. -- Rewrite the `event-to-object` package to be more robust at handling properties on events. - Custom JS components will now automatically assume you are using ReactJS in the absence of a `bind` function. - Refactor layout rendering logic to improve readability and maintainability. - The JavaScript package `@reactpy/client` now exports `React` and `ReactDOM`, which allows third-party components to re-use the same React instance as ReactPy. @@ -65,6 +73,7 @@ Don't forget to remove deprecated code on each major release! - `reactpy.types.VdomDictConstructor` has been renamed to `reactpy.types.VdomConstructor`. - `REACTPY_ASYNC_RENDERING` can now de-duplicate renders where necessary. - `REACTPY_ASYNC_RENDERING` is now defaulted to `True` for up to 40x performance improvements in environments with high concurrency. +- Event handlers must be coroutines. Whatever is handed to `EventHandler`, `@event`, or an `on_*` attribute has to be awaitable, and anything which is not is refused with a `TypeError` as it is made into a handler. This is what makes it safe for a handler to read live event data without stalling the layout, and to call `set_state()` while it waits. ### Deprecated @@ -83,6 +92,7 @@ Don't forget to remove deprecated code on each major release! - Removed deprecated function `module_from_template`. - Removed deprecated exception type `reactpy.core.serve.Stop`. - Removed deprecated component `reactpy.widgets.hotswap`. +- Removed the `event-to-object` package. The client no longer serializes an event when it dispatches one, so nothing was producing that snapshot any more (see the live event proxy above). - Removed `reactpy.sample` module. - Removed `reactpy.svg` module. Contents previously within `reactpy.svg.*` can now be accessed via `reactpy.html.svg.*`. - Removed `reactpy.html._` function. Use `reactpy.html(...)` or `reactpy.html.fragment(...)` instead. @@ -107,6 +117,9 @@ Don't forget to remove deprecated code on each major release! - Fixed a bug where the `key` property provided within server-side ReactPy code was failing to propagate to the front-end JavaScript components. - Fixed a bug where `RuntimeError("Hook stack is in an invalid state")` errors could be generated when using a webserver that reuses threads. - Fixed a bug where events on controlled inputs (e.g. `html.input({"onChange": ...})`) could be lost during rapid actions. +- Fixed a bug where a render which happened while an event handler was still running could write a stale value back over a controlled input, because the event which prompted it had been acknowledged before the handler was out of the way. +- Fixed a bug where `event-proxy-response` messages were dropped by `reactpy.executors.asgi.ReactPyMiddleware` rather than being routed to the layout which asked for them, leaving every live event read to hang until it timed out. +- Fixed a bug where a component could stop re-rendering for the rest of its life after a scheduled render was cancelled, since the internal 'a render is already scheduled' latch was never released. - Allow for ReactPy and ReactJS components to be arbitrarily inserted onto the page with any possible hierarchy. ## [1.1.0] - 2024-11-24 diff --git a/pyproject.toml b/pyproject.toml index 765e5aede..a6e99b6f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -190,7 +190,6 @@ detached = true check = [ 'hatch run javascript:build', 'bun run --cwd "{root}/src/js" lint', - 'bun run --cwd "{root}/src/js/packages/event-to-object" checkTypes', 'bun run --cwd "{root}/src/js/packages/@reactpy/client" checkTypes', 'bun run --cwd "{root}/src/js/packages/@reactpy/app" checkTypes', ] @@ -198,30 +197,20 @@ fix = [ 'bun install --cwd "{root}/src/js"', 'bun run --cwd "{root}/src/js" format', ] -test = ['hatch run javascript:build_event_to_object --dev', 'bun test'] +test = ['bun test'] build = [ 'hatch run "{root}/src/build_scripts/clean_js_dir.py"', 'bun install --cwd "{root}/src/js"', - 'hatch run javascript:build_event_to_object {args}', 'hatch run javascript:build_client {args}', 'hatch run javascript:build_app {args}', 'hatch --env default run "{root}/src/build_scripts/copy_dir.py" "{root}/src/js/node_modules/@pyscript/core/dist" "{root}/src/reactpy/static/pyscript"', 'hatch --env default run "{root}/src/build_scripts/copy_dir.py" "{root}/src/js/node_modules/morphdom/dist" "{root}/src/reactpy/static/morphdom"', ] -build_event_to_object = [ - 'hatch run "{root}/src/build_scripts/build_js_event_to_object.py" {args}', -] build_client = [ 'hatch run "{root}/src/build_scripts/build_js_client.py" {args}', ] build_app = ['hatch run "{root}/src/build_scripts/build_js_app.py" {args}'] -publish_event_to_object = [ - 'hatch run javascript:build_event_to_object', - # FIXME: This is a temporary workaround. We are using `bun pm pack`->`npm publish` to fix missing "Trusted Publishing" support in `bun publish` - # See the following ticket https://github.com/oven-sh/bun/issues/15601 - 'cd "{root}/src/js/packages/event-to-object" && bun pm pack --filename "{root}/src/js/packages/event-to-object/dist.tgz" && bunx npm@11.8.0 publish "{root}/src/js/packages/event-to-object/dist.tgz" --provenance --access public', -] publish_client = [ 'hatch run javascript:build_client', # FIXME: This is a temporary workaround. We are using `bun pm pack`->`npm publish` to fix missing "Trusted Publishing" support in `bun publish` @@ -338,6 +327,12 @@ known-third-party = ["js"] [tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports "**/tests/**/*" = ["PLR2004", "S101", "TID252"] +"src/reactpy/_event_proxy.py" = [ + # Live browser object members keep their original camelCase JavaScript names + "N815", + # These exception names describe the failure rather than ending in "Error" + "N818", +] "docs/**/*.py" = [ # Examples require some extra setup before import "E402", diff --git a/src/build_scripts/build_js_event_to_object.py b/src/build_scripts/build_js_event_to_object.py deleted file mode 100644 index af8532bc0..000000000 --- a/src/build_scripts/build_js_event_to_object.py +++ /dev/null @@ -1,36 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [] -# /// -import pathlib -import shutil -import subprocess -import sys - -dev_mode = "--dev" in sys.argv -root_dir = pathlib.Path(__file__).parent.parent.parent - -# Copy LICENSE file -shutil.copyfile( - root_dir / "LICENSE", root_dir / "src/js/packages/event-to-object/LICENSE" -) - -build_commands = [ - [ - "bun", - "install", - "--cwd", - "src/js/packages/event-to-object", - ], - [ - "bun", - "run", - "--cwd", - "src/js/packages/event-to-object", - "build", - ], -] - -for command in build_commands: - print(f"Running command: '{command}'...") # noqa: T201 - subprocess.run(command, check=True, cwd=root_dir) # noqa: S603 diff --git a/src/js/bun.lockb b/src/js/bun.lockb old mode 100644 new mode 100755 index 1d91089a3..84a2123a5 Binary files a/src/js/bun.lockb and b/src/js/bun.lockb differ diff --git a/src/js/package.json b/src/js/package.json index bd735764f..851db2186 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -1,6 +1,5 @@ { "workspaces": [ - "packages/event-to-object", "packages/@reactpy/app", "packages/@reactpy/client" ], @@ -11,8 +10,7 @@ "typescript": "^5.9.3", "json-pointer": "^0.6.2", "@types/json-pointer": "^1.0.34", - "@reactpy/client": "file:./packages/@reactpy/client", - "event-to-object": "2.0.0" + "@reactpy/client": "file:./packages/@reactpy/client" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/js/packages/@reactpy/app/package.json b/src/js/packages/@reactpy/app/package.json index 55c11ec50..a290790ed 100644 --- a/src/js/packages/@reactpy/app/package.json +++ b/src/js/packages/@reactpy/app/package.json @@ -1,7 +1,6 @@ { "dependencies": { "@reactpy/client": "catalog:", - "event-to-object": "catalog:", "preact": "catalog:" }, "description": "ReactPy's client-side entry point. This is strictly for internal use and is not designed to be distributed.", diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index ea75e91cf..72be6b606 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -5,8 +5,7 @@ ], "dependencies": { "json-pointer": "catalog:", - "preact": "catalog:", - "event-to-object": "catalog:" + "preact": "catalog:" }, "description": "A client for ReactPy implemented in React", "files": [ diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts index bc0a4897d..a0b7786ff 100644 --- a/src/js/packages/@reactpy/client/src/client.ts +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -1,4 +1,5 @@ import logger from "./logger"; +import { EVENT_PROXY_REQUEST_TYPE, EventProxyRegistry } from "./event-proxy"; import type { ReactPyClientInterface, ReactPyModule, @@ -12,9 +13,18 @@ export abstract class BaseReactPyClient implements ReactPyClientInterface { protected readonly ready: Promise; private resolveReady: (value: undefined) => void; + /** + * Keeps recently dispatched objects around so the server can read properties from, + * and call methods on, whatever actually triggered an event. + */ + readonly eventProxy: EventProxyRegistry; + constructor() { this.resolveReady = () => {}; this.ready = new Promise((resolve) => (this.resolveReady = resolve)); + this.eventProxy = new EventProxyRegistry((message) => + this.sendMessage(message), + ); } onMessage(type: string, handler: (message: any) => void): () => void { @@ -41,6 +51,13 @@ export abstract class BaseReactPyClient implements ReactPyClientInterface { return; } + // Live event access is answered by every client, and must not wait on anybody + // having registered a handler for it. + if (message.type === EVENT_PROXY_REQUEST_TYPE) { + this.eventProxy.handleMessage(message); + return; + } + const messageHandlers: ((m: any) => void)[] | undefined = this.handlers[message.type]; if (!messageHandlers) { diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index fe6004b62..0f3a9c00a 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -219,14 +219,12 @@ function StandardElement({ model }: { model: ReactPyVdom }) { function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { const client = useContext(ClientContext); const props = createAttributes(model, client); - // ``_reactpy_ack_seq`` is set by the server to the highest sequence - // number it has received from this element's event handlers. We use - // it (instead of a time-based debounce) to decide whether the server - // has caught up to the user's keystrokes. - const serverAckSeq = - typeof model.attributes?.["_reactpy_ack_seq"] === "number" - ? (model.attributes["_reactpy_ack_seq"] as number) - : -1; + // The server reports, per event name, the highest sequence number it has + // finished handling for that event's target. Together with ``sentSeqByEvent`` + // below this decides whether the value carried by a render may be written over + // what the user has typed. + const ackSeqByEvent: Record = + (model.attributes?.["_reactpy_ack_seq"] as Record) ?? {}; // Strip the internal key from props so it never reaches the DOM. delete (props as Record)["_reactpy_ack_seq"]; @@ -239,47 +237,35 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { const inputRef = useRef< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null >(null); - // Per-element (NOT per-handler) monotonic counter for outgoing - // events. The wrapper below installs this counter onto every - // handler via ``_reactpy_set_seq`` so the counter survives - // handler recreation on every server re-render. Each handler - // has its own per-handler ``outgoingSeq`` (used as the default - // when no wrapper is installed), but we override it here so the - // element owns a single monotonic counter across handlers. + // Per-element monotonic allocator for outgoing sequence numbers. The wrapper + // below hands each handler the next number through ``_reactpy_set_seq``, so one + // counter owned by the element numbers every event it emits and survives the + // handlers being recreated on each server re-render. It allocates for every user + // event - including ones a debounce later coalesces away - so it is a counter, + // not a record of what the server has actually been given. const sharedOutgoingSeq = useRef(0); - // Highest sequence number actually sent. The server's - // ``_reactpy_ack_seq`` will catch up to this. ``sharedOutgoingSeq`` - // is incremented optimistically in the wrapper; ``lastSentSeq`` - // is the high-water mark. - const lastSentSeq = useRef(-1); + // Per event name, the highest sequence number actually written to the wire. + // Only the send path can know that, so it reports back through + // ``_reactpy_mark_sent``. Comparing the server's ack against this, rather than + // against how many times a handler fired, is what stops the element from + // mistaking a snapshot of the previous keystroke for an up to date one. + const sentSeqByEvent = useRef>({}); // honor changes to value from the client via props useEffect(() => { - // The sequence number is the single source of truth for whether - // to apply the server's value. Time-based heuristics (debounce - // windows, submit-event detection) are not used here because they - // cannot reliably distinguish a server snapshot from before some - // keystrokes were processed from a snapshot taken after all - // keystrokes were processed. The sequence number can, - // deterministically. - // - // If the server has acknowledged every event the user has sent - // (``serverAckSeq >= lastSentSeq.current``), the server's value - // is the authoritative one and is applied directly. This - // includes clears (Enter handlers that reset the input), - // normalizations, and same-value confirmations. - // If the server is behind, its value is necessarily a stale - // snapshot and is ignored; the next layout-update will be - // applied once the server catches up. - // - // We additionally compare against the DOM's actual current - // value via ``inputRef`` — when the server is supposedly - // caught up but its snapshot is shorter than what the user - // has in the DOM (a stale snapshot racing with the user's - // most recent keystroke), skip applying so we don't clobber - // the user's text. This handles the realistic case where the - // user types faster than the server can ack. - if (serverAckSeq < lastSentSeq.current) { + // The sequence numbers are the single source of truth for whether the + // server's value may be written over what the user has typed. Time based + // heuristics cannot tell a snapshot taken before some keystrokes were handled + // apart from one taken after all of them were; the sequence numbers can. The + // server acknowledges each event of this element separately, so the element is + // caught up only once every event it has sent has been acknowledged. That + // lets a deliberate change through - an Enter handler resetting the field, a + // normalization, a same value confirmation - while a snapshot which predates + // the newest keystroke is ignored and retried by the next layout update. + const isCaughtUp = Object.entries(sentSeqByEvent.current).every( + ([event, sentSeq]) => (ackSeqByEvent[event] ?? -1) >= sentSeq, + ); + if (!isCaughtUp) { return; } // Apply server's value to the DOM directly via the ref, NOT @@ -310,7 +296,7 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { inputRef.current.value = props.value; } setValue(props.value); - }, [props.value, serverAckSeq]); + }, [props.value, model.attributes?.["_reactpy_ack_seq"]]); for (const [name, prop] of Object.entries(props)) { if (typeof prop !== "function") { @@ -341,38 +327,29 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { wrapped = debounceHandler(wrapped, handlerDebounce as number); } - props[name] = (event: TargetedEvent) => { - // Use a per-element (shared across all handlers on this - // element) monotonic counter for outgoing events. We - // overwrite the handler's own ``outgoingSeq`` with this - // counter so the wire-format seq number reflects the - // element-wide sequence. The handler closure's own - // ``outgoingSeq`` is unused for sequencing purposes now - // (it still exists as a default for non-wrapped callers). - // - // Critically, we increment the seq counter and update - // ``lastSentSeq`` on every *user* event, not on every - // server dispatch. If the handler is wrapped in a - // debounce that coalesces several events into one server - // dispatch, ``lastSentSeq`` will be higher than the seq - // actually sent to the server, which is fine — the seq - // we sent is the LATEST user-typed value, which is the - // one the server actually processed. - const seq = sharedOutgoingSeq.current++; - if (seq > lastSentSeq.current) { - lastSentSeq.current = seq; - } - const taggedHandler = givenHandler as TaggedEventHandler & { - _reactpy_set_seq?: (n: number) => void; - }; - if (typeof taggedHandler._reactpy_set_seq === "function") { - // Push the handler's internal counter past our seq so - // the next (possibly debounced) dispatch uses a seq - // number that matches what the user just typed, not - // whatever stale value the handler closure happened - // to have left over. - taggedHandler._reactpy_set_seq(seq + 1); + // Only the send path knows which sequence number genuinely reaches the + // server, so let it report back under this event's name. Handlers are + // recreated on every re-render, but they always close over this same ref. + givenHandler._reactpy_mark_sent = (seq: number) => { + if (seq > (sentSeqByEvent.current[name] ?? -1)) { + sentSeqByEvent.current[name] = seq; } + }; + + props[name] = (event: TargetedEvent) => { + // Allocate from the counter owned by the element rather than the one owned + // by the handler, so numbering survives handlers being recreated on each + // server re-render and stays monotonic across every event of this element - + // ``onChange`` and ``onKeyDown`` included. Handing the number to the handler + // through ``_reactpy_set_seq`` makes the number written to the wire exactly + // the number allocated here, so what the server echoes back per event name + // and what ``_reactpy_mark_sent`` recorded live on one number line. This is + // the invariant the reconcile effect depends on: comparing a per-element + // allocation against a per-handler send left the two one apart, and the + // element then took a snapshot of the previous keystroke for the current one + // and typed over the user's newest character. + const seq = ++sharedOutgoingSeq.current; + givenHandler._reactpy_set_seq?.(seq); // ``onKeyPress`` fires before the DOM has been updated with // the new keystroke — ``event.target.value`` is the value @@ -396,12 +373,13 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { // is a known DOM property), racing with the browser's own // mutations of the DOM value during fast typing and silently // dropping keystrokes. We instead update the DOM value via the - // ``inputRef`` in the reconcile effect above, only when the - // server has caught up and the proposed value is not shorter - // than what the user has already typed. + // ``inputRef`` in the reconcile effect above, and only once the + // server has acknowledged every event sent from this element. + // ``_reactpy_ack_seq`` goes the same way: it is a protocol field, not + // something the DOM should ever carry. const controlledProps: Record = {}; for (const key of Object.keys(props as Record)) { - if (key !== "value") { + if (key !== "value" && key !== "_reactpy_ack_seq") { controlledProps[key] = (props as Record)[key]; } } diff --git a/src/js/packages/@reactpy/client/src/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts new file mode 100644 index 000000000..8b0b22208 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -0,0 +1,945 @@ +/** + * Live ("event proxy") access to the objects which triggered a ReactPy event. + * + * Nothing about the object which triggered an event is sent to the server when the event + * is dispatched. The client keeps the object alive here and puts only its id - plus the + * event ``type``, the one plain value cheap enough to volunteer - into the + * ``layout-event``: + * + * ```js + * { type: "click", __reactpy_event__: { id: "..." } } + * ``` + * + * Everything else is read on demand. Each read is a round trip, so the server asks for + * whatever it is missing as an ``event-proxy-request``: + * + * ```js + * { type: "event-proxy-request", requestId: "...", eventId: "...", + * path: ["target", "value"], call: false, args: [], reply: true, maxDepth: 10 } + * ``` + * + * Values which cannot be turned into JSON come back as a reference which the server + * turns into the next proxy: + * + * ```js + * { __reactpy_ref__: { eventId: "...", path: ["target"], callable?: boolean } } + * ``` + * + * Requests which are outstanding at the same moment travel together as one message + * carrying a ``requests`` list, and are answered by one message carrying the matching + * ``responses`` list. A lone request keeps its unwrapped shape in both directions. + * + * Since there is no snapshot to fall back on, what keeps this safe is the blocklist + * below: anything which could be used to break out of the page (window, document, + * prototype, eval, ...) is refused on every read, whether it is asked for one property + * at a time or handed over as part of a subtree. + */ + +export const EVENT_PROXY_REQUEST_TYPE = "event-proxy-request"; +export const EVENT_PROXY_RESPONSE_TYPE = "event-proxy-response"; +export const EVENT_MARKER_KEY = "__reactpy_event__"; +export const EVENT_REFERENCE_KEY = "__reactpy_ref__"; + +/** + * How many dispatched objects are kept around at most. + * + * This is a correctness limit rather than a memory one. There is no snapshot left to + * fall back on, so an object which has been evicted cannot be read again: every later + * request for it is answered with a hard "evicted" error. The cap therefore has to + * leave room for every event a single page can have in flight - a handler awaiting + * several events, or a burst of keystrokes still being read - rather than merely being + * small enough to be harmless. + * + * What goes when the cap is reached is the object the server has gone longest without + * reading, not the one which happened to be dispatched first - see + * {@link EventProxyRegistry.prune}. + */ +export const MAX_LIVE_OBJECTS = 512; +/** + * How long an object the server never asked about may sit unused before it is forgotten. + * + * This is the rule for things nobody wanted: dispatched, never read once, and therefore + * only taking up room. An object the server *has* read is exempt from it, because the + * handler which read it may just be slow - {@link EventProxyRegistry.prune} says why, and + * {@link MAX_LIVE_OBJECTS} is what actually bounds what the page holds. + */ +export const LIVE_OBJECT_TTL_MS = 60_000; +/** How many members of one object, list, or iterable are handed over at most */ +export const MAX_PROPERTIES = 100; +/** + * The deepest a request may ask the client to go, however it asks. + * + * ``maxDepth`` arrives from the server. A value which is negative, not a number at all, + * or simply enormous would otherwise turn the bound which is there to protect the page + * into the way past it, so it is read as a suggestion with a ceiling of its own. + */ +export const MAX_ALLOWED_DEPTH = 128; +/** + * How many requests one batched message may carry out. + * + * The server queues whatever it wants to know during a pass round its loop, which is a + * bounded number of things. A message carrying more than that did not come from a server + * which means it, and carrying it out would be the page's one turn spent on nothing else. + */ +export const MAX_REQUESTS_PER_MESSAGE = 256; + +/** + * Read the depth a request asked for as a sane one, whatever it actually said + * + * ``maxDepth`` arrives from the server. A value which is negative, not a number at all, + * or simply enormous would otherwise turn the bound which is there to protect the page + * into the way past it, so it is read as a suggestion with a ceiling of its own. + * + * A request which did not say at all is answered with the shallowest thing there is: the + * object as a single reference. Guessing a depth here would mean a malformed message + * being handed more of the page than it asked for, whereas a reference costs the server + * one more round trip and nothing else. The server in this repo always says. + */ +function allowedDepth(requested: unknown): number { + if (typeof requested !== "number" || !Number.isFinite(requested)) return 0; + return Math.min(Math.max(Math.floor(requested), 0), MAX_ALLOWED_DEPTH); +} + +/** + * Answer a request which is not going to be carried out + * + * Whatever wanted an answer gets one, because leaving it unanswered would have the + * server sit there until its own timeout and count a client which is perfectly awake + * against it. + */ +function refuse(request: EventProxyRequest, error: string): EventProxyResponse { + return { + type: EVENT_PROXY_RESPONSE_TYPE, + requestId: typeof request.requestId === "string" ? request.requestId : "", + ok: false, + error, + code: "access", + }; +} + +export type EventProxyRequest = { + type: typeof EVENT_PROXY_REQUEST_TYPE; + requestId: string; + eventId: string; + path: string[]; + call: boolean; + args: any[]; + reply: boolean; + maxDepth?: number; +}; + +export type EventProxyResponse = { + type: typeof EVENT_PROXY_RESPONSE_TYPE; + requestId: string; + ok: boolean; + value?: any; + error?: string | null; + code?: "not-found" | "evicted" | "access" | null; +}; + +/** + * Several requests sent as a single message, each shaped exactly like the one above. + * + * The server queues whatever it wants to know during a pass round its loop and sends it + * all at once, so that a handler which reads a dozen properties pays for one round trip + * instead of a dozen. Each entry keeps its own ``requestId``, and keeps the ``type`` of + * the message it would have arrived in on its own. + */ +export type EventProxyRequestBatch = { + type: typeof EVENT_PROXY_REQUEST_TYPE; + requests: EventProxyRequest[]; +}; + +/** The single message which answers a {@link EventProxyRequestBatch} */ +export type EventProxyResponseBatch = { + type: typeof EVENT_PROXY_RESPONSE_TYPE; + responses: EventProxyResponse[]; +}; + +export type EventMarker = { + id: string; +}; + +/** Thrown when the object a request refers to is gone */ +export class ProxyNotFoundError extends Error {} +/** Thrown when the requested property or method cannot be used */ +export class ProxyAccessError extends Error {} +/** + * Thrown when the object a request refers to was dropped to make room for another one + * + * Answers the same way {@link ProxyNotFoundError} does, but says something different about + * why: the server had been handed this object and was still working with it when the page + * ran out of room, rather than the object having gone stale on its own. + */ +export class ProxyEvictedError extends ProxyNotFoundError {} + +/** + * Properties which must never leave the page. Anything which could be used to reach + * the wider document, walk a prototype chain, or evaluate code is refused. Ordinary + * data properties (``value``, ``checked``, ``key``, ``className``, ...) are fine - they + * are what a handler reads out of an event, one property per round trip. + * + * This is a security control, not a size control: with nothing sent up front, it is the + * only thing standing between a request and the rest of the browser. + */ +const BLOCKED_MEMBERS = new Set([ + // reaching the rest of the document, or the browser itself + "ownerDocument", + "document", + "defaultView", + "parentWindow", + "ownerWindow", + "window", + "globalThis", + "self", + "top", + "frames", + "contentWindow", + "contentDocument", + "navigator", + "location", + "localStorage", + "sessionStorage", + "cookie", + // reaching style sheets (which can carry same origin URLs) + "ownerNode", + "sheets", + "styleSheet", + // tree traversal - walk "composedPath()" or the snapshot instead + "parentNode", + "parentElement", + "childNodes", + "firstChild", + "lastChild", + "nextSibling", + "previousSibling", + "firstElementChild", + "lastElementChild", + "nextElementSibling", + "previousElementSibling", + "children", + "assignedSlot", + "shadowRoot", + "host", + // prototype and realm access + "__proto__", + "constructor", + "prototype", + "eval", + "execScript", + "Function", + "setTimeout", + "setInterval", + "import", +]); + +/** Returns true when ``name`` must not be read from a live object */ +export function isBlockedMember(name: string): boolean { + if (BLOCKED_MEMBERS.has(name)) return true; + if (EVENT_MARKER_KEY === name || EVENT_REFERENCE_KEY === name) return true; + // event handler attributes (``onclick``, ``onerror``, ...) are functions, and the + // double underscore namespace belongs to whoever built the page + if (/^on[a-z]/i.test(name)) return true; + if (name.startsWith("__") && name.endsWith("__")) return true; + return false; +} + +export type Reference = { + eventId: string; + path: string[]; + callable?: boolean; +}; + +export type SerializeOptions = { + /** + * How deep to nest before handing back a reference instead of the value itself. + * + * Every caller states this. The registry takes it from the request which asked, via + * :func:`allowedDepth`, and a caller with no request in front of it has to say for + * itself - there is deliberately no depth to fall back on, since a bound nobody + * chose is the same as a bound nobody meant. + */ + maxDepth: number; + maxProperties?: number; +}; + +/** Build the marker the server turns back into a proxy */ +export function reference( + eventId: string, + path: string[], + callable = false, +): Record { + const value: Record = { eventId, path }; + if (callable) value.callable = true; + return { [EVENT_REFERENCE_KEY]: value }; +} + +/** Read a reference handed to us by the server, if the given value is one */ +export function readReference(value: unknown): Reference | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const raw = (value as Record)[EVENT_REFERENCE_KEY]; + if (!raw || typeof raw !== "object" || typeof raw.eventId !== "string") + return null; + return { + eventId: raw.eventId, + path: Array.isArray(raw.path) ? raw.path.map(String) : [], + callable: Boolean(raw.callable), + }; +} + +/** + * Collect the readable, non-function properties of ``value``. + * + * Own properties are not enough for DOM objects - nearly everything interesting lives + * on the prototype - so the chain is walked the same way a property read on the page + * would resolve it. Blocked names simply never make it into the result, and methods do + * not either: those are called by an explicit path, not handed over as data. + */ +export function collectProperties( + value: object, + max: number = MAX_PROPERTIES, +): Record { + const collected: Record = {}; + let count = 0; + let prototype: any = value; + while (prototype && prototype !== Object.prototype && count < max) { + for (const name of Object.getOwnPropertyNames(prototype)) { + if (Object.prototype.hasOwnProperty.call(collected, name)) continue; + if (isBlockedMember(name)) continue; + let property: unknown; + try { + property = (value as Record)[name]; + } catch { + continue; // getters which throw (detached nodes, revoked proxies, ...) + } + if (typeof property === "function") continue; // methods are called, not sent + collected[name] = property; + if (++count >= max) break; + } + prototype = Object.getPrototypeOf(prototype); + } + return collected; +} + +/** + * Turn a live browser value into the answer to a single on-demand read. + * + * Anything which is too deep, too large, recursive, or simply not data (a function) is + * replaced with a :data:`Reference` so the server can ask about it separately - which is + * exactly what a handler which reaches further down does. + * + * A list, or anything which iterates, hands over as many members as one object's + * properties would and ends with a reference to itself when there were more than that, + * so a handler can tell "that is all of it" from "there is more over there". + */ +export function serializeValue( + value: unknown, + eventId: string, + path: string[] = [], + options: SerializeOptions, + depth = 0, + seen: Set = new Set(), +): any { + if (value === null || value === undefined) return null; + + const kind = typeof value; + if (kind === "string" || kind === "boolean") return value; + if (kind === "number") return Number.isFinite(value) ? value : null; + if (kind === "bigint") { + const asNumber = Number(value); + return Number.isSafeInteger(asNumber) ? asNumber : String(value); + } + if (kind === "symbol") return String(value); + if (kind === "function") return reference(eventId, path, true); + + if (seen.has(value)) return reference(eventId, path); + if (depth >= options.maxDepth) return reference(eventId, path); + + // A single "seen" set covers the whole answer rather than being copied per node. One + // read reaches at most a bounded subtree of an object the server is already holding, + // so anything met twice - a cycle, or a node shared between two properties - is better + // handed back as a reference than expanded again. + seen.add(value); + const next = (item: unknown, segment: string, at: number) => + serializeValue(item, eventId, [...path, segment], options, at, seen); + + // One bound covers every kind of member collection - properties, list items, and the + // items of anything which iterates - so that "how much arrives at once" is one + // question with one answer. + const maxMembers = options.maxProperties ?? MAX_PROPERTIES; + + if (value instanceof Date) return value.toISOString(); + if (value instanceof RegExp) return String(value); + if (value instanceof Error) + return { + name: value.name, + message: value.message, + stack: value.stack ?? null, + }; + + if (Array.isArray(value)) { + // A list is a collection of members like any other, so it is bounded the same way. + // What did not fit is pointed at rather than dropped without a trace, which leaves + // the server free to walk the rest one index at a time. + const items: any[] = []; + for (let index = 0; index < value.length && index < maxMembers; index++) { + items.push(next(value[index], String(index), depth + 1)); + } + if (items.length < value.length) items.push(reference(eventId, path)); + return items; + } + + if (typeof value === "object" && Symbol.iterator in value) { + // NodeList, FileList, Set, Map, ... all read better as a list + const items: any[] = []; + let index = 0; + for (const item of value as Iterable) { + if (index >= maxMembers) { + items.push(reference(eventId, path)); + break; + } + items.push(next(item, String(index), depth + 1)); + index++; + } + return items; + } + + const properties = collectProperties( + value as object, + options.maxProperties ?? MAX_PROPERTIES, + ); + const result: Record = {}; + for (const name of Object.keys(properties)) { + result[name] = next(properties[name], name, depth + 1); + } + return result; +} + +/** Read a single property of a live object, refusing anything blocked */ +export function readProperty(node: any, name: string): any { + if (isBlockedMember(name)) + throw new ProxyAccessError(`reading "${name}" is not allowed`); + if (node === null || node === undefined) + throw new ProxyNotFoundError(`cannot read "${name}" of nothing`); + if (typeof node === "string") { + if (name === "length") return node.length; + if (/^\d+$/.test(name)) return node[Number(name)] ?? null; + throw new ProxyNotFoundError(`cannot read "${name}" of a string`); + } + if (typeof node !== "object" && typeof node !== "function") + throw new ProxyNotFoundError(`cannot read "${name}" of a ${typeof node}`); + if (!(name in node)) throw new ProxyNotFoundError(`"${name}" does not exist`); + try { + return node[name]; + } catch (error) { + throw new ProxyAccessError( + `reading "${name}" failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +/** + * Follow a full property path, refusing anything blocked along the way. + * + * ``calls`` is what this object has already answered to for a method on it, which is + * what lets a path leading through that method be resolved against the answer rather + * than against the function itself. + */ +export function readPath( + root: any, + path: string[], + calls?: ReadonlyMap, +): any { + let node = root; + for (let index = 0; index < path.length; index++) + node = stepDown(node, path, index, calls); + return node; +} + +function parentAndKey( + root: any, + path: string[], + calls?: ReadonlyMap, +): { parent: any; key: string } { + if (path.length === 0) + throw new ProxyAccessError("an empty path cannot be resolved"); + let node = root; + for (let index = 0; index < path.length - 1; index++) + node = stepDown(node, path, index, calls); + return { parent: node, key: path[path.length - 1] }; +} + +type LiveObject = { + value: any; + /** When this object last mattered - dispatched, or answered a request about */ + added: number; + /** + * Whether the server has ever been answered about this object + * + * It decides which of the two ways an entry can end applies to it. An object which was + * dispatched and never once read has nothing waiting on it, and goes the moment it goes + * idle. An object the server has read belongs to a handler which may be part way through + * awaiting something slow, and is only let go when it is genuinely in the way - see + * {@link EventProxyRegistry.prune}. + */ + asked: boolean; + /** + * What each path called on this object answered with, most recent last. + * + * A reference which came back from inside the result of a call names a place inside + * that result, and the result is the only thing it can be resolved against. Keeping + * it is what makes such a path walkable; replaying the call is not an option, since a + * method may have side effects and may not even be there any more. + */ + calls: Map; +}; + +/** How many call results one object keeps around to walk paths through */ +export const MAX_CALL_RESULTS_PER_OBJECT = 16; + +/** What a recorded call is filed under - its own path, and nothing else's */ +function callKey(path: string[]): string { + return JSON.stringify(path); +} + +/** Keep what a call answered, so that a path which leads through it still resolves */ +function rememberCall(entry: LiveObject, path: string[], value: unknown): void { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") + ) + return; // a primitive has nothing inside it to walk into + entry.calls.set(callKey(path), value); + while (entry.calls.size > MAX_CALL_RESULTS_PER_OBJECT) { + const oldest = entry.calls.keys().next(); + if (oldest.done === true) break; + entry.calls.delete(oldest.value); + } +} + +/** + * Step one segment deeper into a live value. + * + * A path which leads through a method is stepped through what that method answered + * instead, whenever the page has been asked to call it before: the reference the server + * is holding came out of that answer, and asking the method again would be both a second + * side effect and a different answer. + */ +function stepDown( + node: any, + path: string[], + index: number, + calls?: ReadonlyMap, +): any { + if (typeof node === "function" && calls) { + const answer = calls.get(callKey(path.slice(0, index))); + if (answer !== undefined) node = answer; + } + return readProperty(node, path[index]); +} + +/** + * Keeps the objects which recently triggered an event around so the server can walk + * their properties and call their methods. + * + * This is the only place those objects exist as far as the server is concerned: nothing + * about them is sent when the event is dispatched, so forgetting one is not a slowdown + * but a dead end - every later request for it is answered with "not-found", or with + * "evicted" when the server had been reading it and the page ran out of room. Entries are + * bounded in count by {@link MAX_LIVE_OBJECTS}, dropping whatever the server has gone + * longest without reading, and in age by {@link LIVE_OBJECT_TTL_MS} for the objects it + * never asked about at all. + * Whatever a method on one of them answered is kept alongside it, for as long as the + * entry lasts and up to a small number of calls, because a reference which came out of + * such an answer names a place inside it and has nowhere else to be resolved against. + * Every value which leaves this registry goes through ``serializeValue``, so functions + * and anything which could be used to escape the page never do. + */ +/** Why an entry which is no longer here stopped answering */ +type DroppedReason = "expired" | "evicted"; + +export class EventProxyRegistry { + private readonly live = new Map(); + /** + * Why an id which is no longer here stopped answering, for as many as there is room to + * remember + * + * Both reasons mean the same thing to a handler - the object is not coming back - but + * they say different things about why, and a log which cannot tell them apart cannot + * tell "stale anyway" from "the page ran out of room under us". + */ + private readonly dropped = new Map(); + /** + * Which id each object being kept is addressed by + * + * Dispatching the same object twice is one thing happening twice, so the second dispatch + * is answered with the id the server already holds rather than a second copy of the same + * object - which is also what keeps the count bound spent on objects which actually + * differ from each other. A WeakMap so that an object nobody else can reach is not held + * alive by this registry alone. + */ + private identities = new WeakMap(); + private readonly send: (message: any) => void; + private readonly clock: () => number; + private nextId = 0; + + constructor( + send: (message: any) => void, + clock: () => number = () => Date.now(), + ) { + this.send = send; + this.clock = clock; + } + + /** + * Remember ``value`` and return the id the server will address it by + * + * An object which is already being kept is not registered a second time - it is brought + * back to the live end of the queue and given back under the id the server knows. + */ + register(value: any): string { + if (this.keyable(value)) { + const known = this.identities.get(value); + const entry = known === undefined ? undefined : this.live.get(known); + if (known !== undefined && entry !== undefined) { + this.touch(known, entry); + return known; + } + } + const eventId = `${this.clock().toString(36)}-${(this.nextId++).toString(36)}`; + this.live.set(eventId, { + value, + added: this.clock(), + asked: false, + calls: new Map(), + }); + if (this.keyable(value)) this.identities.set(value, eventId); + // This registry's ids only ever count up, so a fresh one cannot be in there - but a + // hand-built one should not inherit somebody else's history either. + this.dropped.delete(eventId); + this.prune(); + return eventId; + } + + /** Whether ``value`` has an identity worth dedupe on (primitives do not) */ + private keyable(value: any): value is object { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") + ); + } + + /** Whether anything is still known about the given id */ + has(eventId: string): boolean { + return this.live.has(eventId); + } + + /** How many objects are currently kept around */ + get size(): number { + return this.live.size; + } + + /** Forget every registered object */ + clear(): void { + this.live.clear(); + this.dropped.clear(); + // A WeakMap can only be replaced, not emptied - and an id left in it would point at + // nothing, which is exactly the difference between a reused object and a stale entry. + this.identities = new WeakMap(); + } + + /** + * Mark an object as used right now + * + * Deleting and re-inserting is what makes the cap a least-recently-used bound rather + * than a queue: a Map hands back its keys in the order they went in, so whatever has + * just been read goes to the back of the line and something the server has gone longest + * without reading is what disappears when the registry is full. Without this an object + * could be dropped while a handler was part way through reading it, purely because it + * happened to have been dispatched early. + */ + private touch(eventId: string, entry: LiveObject): void { + entry.added = this.clock(); + this.live.delete(eventId); + this.live.set(eventId, entry); + } + + /** + * Drop objects which went idle without ever being read, or which no longer fit + * + * Whatever is dropped here is gone for good as far as the server is concerned: the id it + * holds keeps working, and keeps answering *why* - "not-found" for one which went stale + * unread, "evicted" for one the server was still using. ``dropped`` is what remembers + * which of the two it was. + * + * Two rules, each with a single job. Age takes what nobody asked about: dispatched, never + * once read, and only taking up room. It deliberately leaves alone an object the server + * *has* read, because the handler which read it may simply be part way through awaiting + * something slow - sweeping that one at sixty seconds would have the request it makes + * when it gets back answered "not-found", with the page looking like it went away on its + * own when it was the sweep which did it. + * + * Count is what actually bounds the page, and takes whatever has gone longest unread - so + * a handler which keeps reading one object keeps it, however many events arrive behind it. + * + * This runs whenever the registry is used, not only when something is added to it. + * Objects are kept for the server's benefit, so a page which stops dispatching events + * still lets go of the ones which have gone idle rather than holding them for their + * whole idle time. + */ + prune(): void { + const now = this.clock(); + for (const [eventId, entry] of this.live) { + if (!entry.asked && now - entry.added > LIVE_OBJECT_TTL_MS) { + this.live.delete(eventId); + this.note(eventId, "expired"); + } + } + while (this.live.size > MAX_LIVE_OBJECTS) { + const oldest = this.live.keys().next(); + if (oldest.done === true) break; + this.live.delete(oldest.value); + this.note(oldest.value, "evicted"); + } + } + + /** Remember why an id stopped answering, for as long as there is room to */ + private note(eventId: string, reason: DroppedReason): void { + this.dropped.delete(eventId); + this.dropped.set(eventId, reason); + while (this.dropped.size > MAX_LIVE_OBJECTS) { + const oldest = this.dropped.keys().next(); + if (oldest.done === true) break; + this.dropped.delete(oldest.value); + } + } + + /** + * Say why an id is not being answered, in a way the server can tell apart + * + * "evicted" is the one worth a look at the page rather than at the handler: the registry + * ran out of room under a server which was still reading the object. Everything else - + * never registered, or idle and never once read - is the ordinary "not-found". + */ + private missing(eventId: string): { + error: string; + code: "not-found" | "evicted"; + } { + if (this.dropped.get(eventId) === "evicted") + return { + error: + `event ${JSON.stringify(eventId)} no longer fits - the page keeps at most ` + + `${MAX_LIVE_OBJECTS} live event objects, and this one was dropped to make room`, + code: "evicted", + }; + return { + error: `nothing is registered for event ${JSON.stringify(eventId)}`, + code: "not-found", + }; + } + + /** + * Answer an incoming ``event-proxy-request``, replying unless told not to + * + * Requests which were outstanding at the same moment arrive together as one message + * carrying a ``requests`` list. Such a batch is answered by exactly one message whose + * ``responses`` list holds one entry per request which wanted an answer - and by no + * message at all when none of them did. A request which arrived on its own keeps its + * unwrapped shape in both directions, so the common single read stays one small + * message instead of paying for a wrapper around it. + */ + handleMessage(message: EventProxyRequest | EventProxyRequestBatch): void { + if (!message || message.type !== EVENT_PROXY_REQUEST_TYPE) return; + + // The requests are carried out first, and only then is the registry swept. Carrying + // one out is the proof that its object is wanted - it is what refreshes the entry the + // request names - so sweeping beforehand used to delete the very object a message had + // arrived to be asked about, and answer a handler which was still working that the + // page had gone away on its own. + // + // Sweeping here at all is what keeps a quiet page honest: whatever is registered is + // kept for the server's benefit and nobody else is coming to take it away, so the + // moment the server talks to us again is the moment to notice which of them have gone + // stale. Waiting for the next event to be dispatched would hold detached objects for + // their whole idle time. + try { + this.answer(message); + } finally { + this.prune(); + } + } + + /** Carry out the single request, or the batch of them, which arrived in ``message`` */ + private answer(message: EventProxyRequest | EventProxyRequestBatch): void { + const requests = (message as EventProxyRequestBatch).requests; + if (!Array.isArray(requests)) { + const request = message as EventProxyRequest; + // Resolved whether or not an answer is wanted: a request which does not want one + // is usually a call whose only point was to run a method in the page. + const response = this.resolve(request); + if (request.reply !== false) this.send(response); + return; + } + + const responses: EventProxyResponse[] = []; + for (let index = 0; index < requests.length; index++) { + const request = requests[index]; + // Anything which is not a request at all cannot be answered meaningfully, so it + // is passed over. Everything else is carried out; only the answering is optional. + if (!request || typeof request !== "object") continue; + // Past the bound the page stops taking requests apart and says so, rather than + // spending its one turn on an arbitrarily large list of them. + const response = + index >= MAX_REQUESTS_PER_MESSAGE + ? refuse( + request, + `a single message may not ask for more than ${MAX_REQUESTS_PER_MESSAGE} things at once`, + ) + : this.resolve(request); + if (request.reply !== false) responses.push(response); + } + if (responses.length > 0) { + const answered: EventProxyResponseBatch = { + type: EVENT_PROXY_RESPONSE_TYPE, + responses, + }; + this.send(answered); + } + } + + /** Resolve a single request into the response for it */ + resolve(message: EventProxyRequest): EventProxyResponse { + const requestId = + typeof message.requestId === "string" ? message.requestId : ""; + const reply = ( + ok: boolean, + extra: Partial = {}, + ): EventProxyResponse => ({ + type: EVENT_PROXY_RESPONSE_TYPE, + requestId, + ok, + error: null, + code: null, + ...extra, + }); + + const eventId = typeof message.eventId === "string" ? message.eventId : ""; + const entry = this.live.get(eventId); + if (!entry) return reply(false, this.missing(eventId)); + + // Carrying this out is what keeps the object around: it is now the most recently used + // thing the page holds, and the server has demonstrably asked about it - which is also + // what takes it out of the reach of the idle sweep. + entry.asked = true; + this.touch(eventId, entry); + + const path = Array.isArray(message.path) ? message.path.map(String) : []; + const maxDepth = allowedDepth(message.maxDepth); + const options: SerializeOptions = { maxDepth }; + + try { + if (message.call) { + const value = this.call( + eventId, + entry.value, + path, + message.args || [], + maxDepth, + entry.calls, + ); + // Whatever the server was handed may have come out of this value, so it has to + // stay around or the path to it leads nowhere. + rememberCall(entry, path, value); + return reply(true, { + value: serializeValue(value, eventId, path, options), + }); + } + if (path.length === 0) + // the whole object the server is pointing at + return reply(true, { + value: serializeValue(entry.value, eventId, path, options), + }); + + const value = readPath(entry.value, path, entry.calls); + if (typeof value === "function") + return reply(true, { value: reference(eventId, path, true) }); + return reply(true, { + value: serializeValue(value, eventId, path, options), + }); + } catch (error) { + return reply(false, { + error: error instanceof Error ? error.message : String(error), + code: + error instanceof ProxyEvictedError + ? "evicted" + : error instanceof ProxyNotFoundError + ? "not-found" + : "access", + }); + } + } + + private call( + eventId: string, + root: any, + path: string[], + args: any[], + maxDepth: number, + calls?: ReadonlyMap, + ): any { + if (path.length === 0) + throw new ProxyAccessError("the event object itself cannot be called"); + const { parent, key } = parentAndKey(root, path, calls); + const method = readProperty(parent, key); + if (typeof method !== "function") + throw new ProxyAccessError(`${JSON.stringify(key)} is not a function`); + return method.apply( + parent, + args.map((argument) => this.readArgument(argument, maxDepth, 0)), + ); + } + + /** + * Turn one argument of a call into the value it stands for. + * + * The shape of an argument is the server's business, not the page's, so it is held to + * the same bound as a read is: whatever nests deeper than the request itself asked for + * is refused instead of walked into. + */ + private readArgument( + argument: unknown, + maxDepth: number, + depth: number, + ): unknown { + if (depth > maxDepth) + throw new ProxyAccessError( + `an argument was nested more deeply than ${maxDepth} levels`, + ); + const ref = readReference(argument); + if (ref) { + const entry = this.live.get(ref.eventId); + if (!entry) { + const { error, code } = this.missing(ref.eventId); + throw code === "evicted" + ? new ProxyEvictedError(error) + : new ProxyNotFoundError(error); + } + entry.asked = true; + this.touch(ref.eventId, entry); + return readPath(entry.value, ref.path, entry.calls); + } + if (Array.isArray(argument)) + return argument.map((item) => + this.readArgument(item, maxDepth, depth + 1), + ); + if (argument && typeof argument === "object") { + const value: Record = {}; + for (const [key, item] of Object.entries(argument)) + value[key] = this.readArgument(item, maxDepth, depth + 1); + return value; + } + return argument; + } +} diff --git a/src/js/packages/@reactpy/client/src/handler.ts b/src/js/packages/@reactpy/client/src/handler.ts index dc6db59e8..2d5815d0c 100644 --- a/src/js/packages/@reactpy/client/src/handler.ts +++ b/src/js/packages/@reactpy/client/src/handler.ts @@ -14,6 +14,18 @@ export type TaggedEventHandler = ((event: Event) => void) & { [HANDLER_MARKER]: true; [HANDLER_DEBOUNCE]?: number; [HANDLER_THROTTLE]?: number; + /** Current value of this handler's outgoing sequence counter */ + _reactpy_peek_seq?: () => number; + /** Force the counter so the next dispatch carries the given sequence number */ + _reactpy_set_seq?: (seq: number) => void; + /** + * Called by the send path with the sequence number that was actually written + * to the wire. A debounced handler allocates a sequence per keystroke but only + * dispatches the last one, so anything which needs to know what the server has + * genuinely been given has to be told here rather than guessing from how many + * times the handler was called. + */ + _reactpy_mark_sent?: (seq: number) => void; }; /** diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts index d647de735..37dc34432 100644 --- a/src/js/packages/@reactpy/client/src/index.ts +++ b/src/js/packages/@reactpy/client/src/index.ts @@ -1,5 +1,6 @@ export * from "./client"; export * from "./components"; +export * from "./event-proxy"; export * from "./handler"; export * from "./mount"; export * from "./types"; diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index abedb6827..501b71a0b 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -1,4 +1,5 @@ import type { ComponentType } from "preact"; +import type { EventProxyRequest, EventProxyResponse } from "./event-proxy"; // #### CONNECTION TYPES #### @@ -60,6 +61,7 @@ export type ReactPyVdomEventHandler = { target: string; preventDefault?: boolean; stopPropagation?: boolean; + stopImmediatePropagation?: boolean; debounce?: number; throttle?: number; }; @@ -116,8 +118,8 @@ export type LayoutEventMessage = { data: any; }; -export type IncomingMessage = LayoutUpdateMessage; -export type OutgoingMessage = LayoutEventMessage; +export type IncomingMessage = LayoutUpdateMessage | EventProxyRequest; +export type OutgoingMessage = LayoutEventMessage | EventProxyResponse; export type Message = IncomingMessage | OutgoingMessage; // #### INTERFACES #### diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 0a123808f..2f8e8ce5d 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,5 +1,5 @@ -import eventToObject from "event-to-object"; import { Fragment } from "preact"; +import { EVENT_MARKER_KEY } from "./event-proxy"; import { HANDLER_DEBOUNCE, HANDLER_MARKER, @@ -218,6 +218,7 @@ function createEventHandler( target, preventDefault, stopPropagation, + stopImmediatePropagation, debounce, throttle, }: ReactPyVdomEventHandler, @@ -239,19 +240,38 @@ function createEventHandler( if (stopPropagation) { event.stopPropagation(); } + if (stopImmediatePropagation) { + event.stopImmediatePropagation(); + } - // Convert JavaScript objects to plain JSON, if needed - if (typeof event === "object") { - return eventToObject(event); - } else { + // Whatever came out of the page is kept alive by the registry so the server can + // read any property of it, and call methods such as "focus()" on it, one round + // trip at a time. Nothing is serialized up front: the marker carrying the id is + // all the server gets, plus the event "type" - the one plain value too cheap, and + // too useful for telling the handler what kind of event it is holding, to withhold. + if (typeof event !== "object" || event === null) { return event; } + const id = client.eventProxy.register(event); + const payload: Record = { [EVENT_MARKER_KEY]: { id } }; + // Real events always carry a string "type". Odd objects handed to a handler by + // something other than the DOM need not, and a made-up one would only send the + // server looking for an event kind which was never dispatched. + if (typeof event.type === "string") payload["type"] = event.type; + return payload; }); const seq = outgoingSeq++; + // Tell the element which sequence number really reached the wire. The + // element compares this against the server's ack to decide whether it is + // safe to write the server's value over what the user has typed, so it must + // be the number actually sent - not the number of times a handler fired, + // which is higher whenever a debounce coalesces keystrokes away. + eventHandler._reactpy_mark_sent?.(seq); client.sendMessage({ type: "layout-event", data, target, seq }); } as TaggedEventHandler & { _reactpy_peek_seq?: () => number; _reactpy_set_seq?: (n: number) => void; + _reactpy_mark_sent?: (seq: number) => void; }; eventHandler[HANDLER_MARKER] = true; eventHandler._reactpy_peek_seq = (): number => outgoingSeq; diff --git a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts new file mode 100644 index 000000000..cbec65564 --- /dev/null +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -0,0 +1,1349 @@ +// Tests for the live "event proxy" boundary. Nothing about a dispatched object is sent +// to the server any more - it all goes out through these functions, one property at a +// time - so the interesting cases are the ones where a value must *not* be handed over, +// and the ones where the shape of a request or its answer is what the server depends on. +import { test, expect } from "bun:test"; +import { + EVENT_MARKER_KEY, + EVENT_PROXY_REQUEST_TYPE, + EVENT_PROXY_RESPONSE_TYPE, + EVENT_REFERENCE_KEY, + EventProxyRegistry, + type EventProxyRequest, + type EventProxyRequestBatch, + type EventProxyResponse, + LIVE_OBJECT_TTL_MS, + MAX_ALLOWED_DEPTH, + MAX_CALL_RESULTS_PER_OBJECT, + MAX_LIVE_OBJECTS, + MAX_REQUESTS_PER_MESSAGE, + ProxyAccessError, + ProxyEvictedError, + ProxyNotFoundError, + type SerializeOptions, + collectProperties, + isBlockedMember, + readPath, + readProperty, + readReference, + reference, + serializeValue, +} from "../src/event-proxy"; + +test("refuses members which could reach the rest of the page", () => { + for (const name of [ + "window", + "document", + "ownerDocument", + "parentNode", + "shadowRoot", + "__proto__", + "constructor", + "prototype", + "eval", + "Function", + "setTimeout", + "localStorage", + ]) { + expect(isBlockedMember(name)).toBe(true); + } +}); + +test("refuses dunders, event handlers, and our own marker keys", () => { + expect(isBlockedMember("__something__")).toBe(true); + expect(isBlockedMember("onclick")).toBe(true); + expect(isBlockedMember("onPointerDown")).toBe(true); + expect(isBlockedMember(EVENT_MARKER_KEY)).toBe(true); + expect(isBlockedMember(EVENT_REFERENCE_KEY)).toBe(true); +}); + +test("allows ordinary data properties through", () => { + for (const name of [ + "value", + "checked", + "key", + "className", + "tagName", + "type", + "dataset", + "detail", + ]) { + expect(isBlockedMember(name)).toBe(false); + } +}); + +test("the handler pattern over-blocks a few plain names", () => { + // "on..." is reserved for event handler attributes, so anything which merely starts + // with those two letters goes with them. Worth knowing about before adding a prop. + expect(isBlockedMember("only")).toBe(true); + expect(isBlockedMember("one")).toBe(true); + expect(isBlockedMember("once")).toBe(true); +}); + +test("readProperty hands over plain properties", () => { + expect(readProperty({ value: "typed" }, "value")).toBe("typed"); + expect(readProperty({ target: { value: 7 } }, "target").value).toBe(7); +}); + +test("readProperty refuses blocked members before anything else", () => { + expect(() => readProperty({ window: {} }, "window")).toThrow( + ProxyAccessError, + ); + // the block is checked first, so even reading from nothing cannot leak + expect(() => readProperty(null, "__proto__")).toThrow(ProxyAccessError); +}); + +test("readProperty reports missing properties and non-objects", () => { + expect(() => readProperty({}, "value")).toThrow(ProxyNotFoundError); + expect(() => readProperty(null, "value")).toThrow(ProxyNotFoundError); + expect(() => readProperty(7, "value")).toThrow(ProxyNotFoundError); +}); + +test("readProperty reads through strings like an array", () => { + expect(readProperty("typed", "length")).toBe(5); + expect(readProperty("typed", "1")).toBe("y"); + expect(readProperty("typed", "9")).toBe(null); + expect(() => readProperty("typed", "toUpperCase")).toThrow( + ProxyNotFoundError, + ); +}); + +test("readProperty wraps a throwing getter", () => { + const detached = { + get nodeName(): string { + throw new Error("node is detached"); + }, + }; + expect(() => readProperty(detached, "nodeName")).toThrow(ProxyAccessError); +}); + +test("readPath walks every segment and refuses blocked ones", () => { + const event = { target: { form: { action: "/submit" } } }; + expect(readPath(event, ["target", "form", "action"])).toBe("/submit"); + expect(readPath(event, [])).toBe(event); + expect(() => readPath(event, ["target", "form", "ownerDocument"])).toThrow( + ProxyAccessError, + ); +}); + +test("references round-trip", () => { + const marked = reference("evt-1", ["target", "value"], true); + expect(marked).toEqual({ + [EVENT_REFERENCE_KEY]: { + eventId: "evt-1", + path: ["target", "value"], + callable: true, + }, + }); + expect(readReference(marked)).toEqual({ + eventId: "evt-1", + path: ["target", "value"], + callable: true, + }); + expect(reference("evt-2", [])).toEqual({ + [EVENT_REFERENCE_KEY]: { eventId: "evt-2", path: [] }, + }); +}); + +/** + * The depth a caller which is not answering a request has to state for itself + * + * ``serializeValue`` keeps no depth of its own: the registry takes it from the request + * which asked, and a test has to say what it means. + */ +const DEEP: SerializeOptions = { maxDepth: 10 }; + +test("serializeValue keeps plain data as-is", () => { + expect(serializeValue("typed", "e", [], DEEP)).toBe("typed"); + expect(serializeValue(true, "e", [], DEEP)).toBe(true); + expect(serializeValue(7, "e", [], DEEP)).toBe(7); + expect(serializeValue(10n, "e", [], DEEP)).toBe(10); + expect(serializeValue(2n ** 100n, "e", [], DEEP)).toBe(String(2n ** 100n)); + expect(serializeValue(Symbol("tag"), "e", [], DEEP)).toBe("Symbol(tag)"); + expect(serializeValue(null, "e", [], DEEP)).toBe(null); + expect(serializeValue(undefined, "e", [], DEEP)).toBe(null); +}); + +test("serializeValue drops numbers JSON cannot carry", () => { + expect(serializeValue(Number.NaN, "e", [], DEEP)).toBe(null); + expect(serializeValue(Number.POSITIVE_INFINITY, "e", [], DEEP)).toBe(null); +}); + +test("serializeValue renders well-known objects readably", () => { + expect( + serializeValue(new Date("2026-10-09T00:00:00.000Z"), "e", [], DEEP), + ).toBe("2026-10-09T00:00:00.000Z"); + expect(serializeValue(/ab+c/g, "e", [], DEEP)).toBe("/ab+c/g"); + expect(serializeValue(new TypeError("bad"), "e", [], DEEP)).toEqual({ + name: "TypeError", + message: "bad", + stack: expect.any(String), + }); +}); + +test("serializeValue turns functions into callable references", () => { + expect(serializeValue(() => 1, "e", ["preventDefault"], DEEP)).toEqual({ + [EVENT_REFERENCE_KEY]: { + eventId: "e", + path: ["preventDefault"], + callable: true, + }, + }); +}); + +test("serializeValue reads iterables as lists", () => { + expect(serializeValue(new Set(["a", "b"]), "e", [], DEEP)).toEqual([ + "a", + "b", + ]); + expect(serializeValue([1, [2, 3]], "e", [], DEEP)).toEqual([1, [2, 3]]); +}); + +test("serializeValue bounds a list by the same member count as an object", () => { + // A list used to be the one collection with no bound at all, so an event which + // carried a long one handed the whole of it over in a single message - while an + // object with a hundred and one properties did not. + const long = Array.from({ length: 250 }, (_, index) => index); + const serialized = serializeValue(long, "e", [], { + ...DEEP, + maxProperties: 5, + }); + + expect(serialized).toHaveLength(6); + expect(serialized.slice(0, 5)).toEqual([0, 1, 2, 3, 4]); + expect(readReference(serialized[5])).toEqual({ + eventId: "e", + path: [], + callable: false, + }); +}); + +test("serializeValue says when an iterable had more members than it sent", () => { + const things = new Set( + Array.from({ length: 9 }, (_, index) => `item-${index}`), + ); + const serialized = serializeValue(things, "e", ["things"], { + ...DEEP, + maxProperties: 3, + }); + + expect(serialized.slice(0, 3)).toEqual(["item-0", "item-1", "item-2"]); + expect(readReference(serialized[3])?.path).toEqual(["things"]); + + // What fits is just the list, with nothing tacked on the end of it + expect(serializeValue(new Set(["a", "b"]), "e", ["few"], DEEP)).toEqual([ + "a", + "b", + ]); +}); + +test("serializeValue uses references for cycles and excess depth", () => { + const loop: Record = { name: "loop" }; + loop["loopInto"] = loop; + expect(serializeValue(loop, "e", [], DEEP)).toEqual({ + name: "loop", + loopInto: { [EVENT_REFERENCE_KEY]: { eventId: "e", path: ["loopInto"] } }, + }); + + const deep = { a: { b: { c: 1 } } }; + expect(serializeValue(deep, "e", [], { maxDepth: 1 })).toEqual({ + a: { [EVENT_REFERENCE_KEY]: { eventId: "e", path: ["a"] } }, + }); + expect(serializeValue(deep, "e", [], DEEP)).toEqual({ a: { b: { c: 1 } } }); +}); + +test("serializeValue has no depth of its own to fall back on", () => { + // The only sender in this repo always says how deep it wants to go. A caller which + // says nothing gets the shallowest answer there is - the value as one reference - + // rather than a depth somebody guessed on its behalf. + const deep = { a: { b: { c: 1 } } }; + expect(serializeValue(deep, "e", [], { maxDepth: 0 })).toEqual({ + [EVENT_REFERENCE_KEY]: { eventId: "e", path: [] }, + }); +}); + +test("blocked properties are dropped rather than turned into references", () => { + // A reference would be an invitation to ask again, so a name which is not allowed to + // leave the page disappears from the serialized object entirely. + const event = { type: "click", self: { window: {} }, value: 1 }; + expect(serializeValue(event, "e", [], DEEP)).toEqual({ + type: "click", + value: 1, + }); +}); + +test("collectProperties walks the prototype chain", () => { + class Target { + value = "typed"; + get tagName() { + return "INPUT"; + } + get ownerDocument() { + return {}; + } + focus(): void {} + } + expect(collectProperties(new Target())).toEqual({ + value: "typed", + tagName: "INPUT", + }); +}); + +test("collectProperties gives up on throwing getters and respects the cap", () => { + const throwing = { + fine: 1, + get boom(): number { + throw new Error("detached"); + }, + }; + expect(collectProperties(throwing)).toEqual({ fine: 1 }); + expect(Object.keys(collectProperties({ a: 1, b: 2, c: 3 }, 2))).toEqual([ + "a", + "b", + ]); +}); + +test("readReference ignores anything which is not one", () => { + expect(readReference(null)).toBe(null); + expect(readReference("nope")).toBe(null); + expect(readReference(42)).toBe(null); + expect(readReference([])).toBe(null); + expect(readReference({ [EVENT_REFERENCE_KEY]: null })).toBe(null); + expect(readReference({ [EVENT_REFERENCE_KEY]: { path: [] } })).toBe(null); + // a path which is not a list is treated as empty rather than thrown at + expect( + readReference({ [EVENT_REFERENCE_KEY]: { eventId: "e", path: 3 } }), + ).toEqual({ + eventId: "e", + path: [], + callable: false, + }); +}); + +function make_registry(now: { value: number } = { value: 0 }) { + const replies: any[] = []; + const registry = new EventProxyRegistry( + (message) => replies.push(message), + () => now.value, + ); + return { registry, replies, now }; +} + +/** The depth the server asks for by default - ``REACTPY_EVENT_PROXY_MAX_DEPTH`` */ +const ASKED_DEPTH = 10; + +/** Build a request the way the server sends one, filling in the fields a test rarely cares about */ +function request( + fields: Partial & + Pick, +): EventProxyRequest { + return { + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "r1", + call: false, + args: [], + reply: true, + // The server always says how deep it wants to go; a request without it is answered + // with a reference rather than a guess, so a test which wants a subtree asks for it. + maxDepth: ASKED_DEPTH, + ...fields, + }; +} + +/** + * Build the one message the server sends when several reads were outstanding at the end + * of a pass round its loop. Each entry keeps the shape it would have had alone. + */ +function batch(...requests: EventProxyRequest[]): EventProxyRequestBatch { + return { type: EVENT_PROXY_REQUEST_TYPE, requests }; +} + +/** Index the entries of an answered batch by the request each one belongs to */ +function responses_of(message: any): Record { + return Object.fromEntries( + (message.responses as EventProxyResponse[]).map((entry) => [ + entry.requestId, + entry, + ]), + ); +} + +function make_event() { + const calls: string[] = []; + return { + calls, + type: "click", + target: { value: "typed", tagName: "INPUT", ownerDocument: {} }, + preventDefault() { + calls.push("preventDefault"); + }, + }; +} + +test("the registry remembers what it was given", () => { + const { registry } = make_registry(); + const eventId = registry.register(make_event()); + expect(registry.has(eventId)).toBe(true); + expect(registry.size).toBe(1); + registry.clear(); + expect(registry.has(eventId)).toBe(false); + expect(registry.size).toBe(0); +}); + +test("the registry forgets idle objects and those which no longer fit", () => { + const { registry, now } = make_registry(); + const eventId = registry.register(make_event()); + registry.prune(); + expect(registry.has(eventId)).toBe(true); + + now.value = LIVE_OBJECT_TTL_MS + 1; + registry.prune(); + expect(registry.has(eventId)).toBe(false); + + for (let index = 0; index <= MAX_LIVE_OBJECTS; index++) registry.register({}); + expect(registry.size).toBe(MAX_LIVE_OBJECTS); +}); + +test("reading keeps an object from aging out", () => { + const { registry, now } = make_registry(); + const eventId = registry.register(make_event()); + now.value = LIVE_OBJECT_TTL_MS - 1; + registry.resolve( + request({ type: EVENT_PROXY_REQUEST_TYPE, eventId, path: [] }), + ); + now.value = LIVE_OBJECT_TTL_MS + 1; + registry.prune(); + expect(registry.size).toBe(1); +}); + +test("an unknown event id is reported as missing", () => { + const { registry } = make_registry(); + const response = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "r1", + eventId: "nope", + path: [], + }), + ); + expect(response).toEqual({ + type: EVENT_PROXY_RESPONSE_TYPE, + requestId: "r1", + ok: false, + error: 'nothing is registered for event "nope"', + code: "not-found", + }); +}); + +test("an empty path reads the whole registered object", () => { + const { registry } = make_registry(); + const eventId = registry.register(make_event()); + const response = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "r1", + eventId, + path: [], + }), + ); + expect(response.ok).toBe(true); + expect(response.value.type).toBe("click"); + // blocked members are dropped on the way out, not handed back as references + expect(response.value.target.ownerDocument).toBeUndefined(); + expect(Object.keys(response.value.target)).toEqual(["value", "tagName"]); + // methods are absent from the whole-object view - collectProperties only gathers + // data - they surface as callable references when addressed by an explicit path + expect(response.value.preventDefault).toBeUndefined(); + expect(Object.keys(response.value).sort()).toEqual([ + "calls", + "target", + "type", + ]); +}); + +test("a path which lands on a method yields a callable reference", () => { + const { registry } = make_registry(); + const eventId = registry.register(make_event()); + const response = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "r4", + eventId, + path: ["preventDefault"], + }), + ); + expect(response.ok).toBe(true); + expect(response.value).toEqual({ + [EVENT_REFERENCE_KEY]: { + eventId, + path: ["preventDefault"], + callable: true, + }, + }); +}); + +test("a path reads a single property", () => { + const { registry } = make_registry(); + const eventId = registry.register(make_event()); + const response = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "r2", + eventId, + path: ["target", "value"], + }), + ); + expect(response.ok).toBe(true); + expect(response.value).toBe("typed"); +}); + +test("blocked and missing paths fail with the right code", () => { + const { registry } = make_registry(); + const eventId = registry.register(make_event()); + + const blocked = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + eventId, + path: ["target", "ownerDocument", "defaultView"], + }), + ); + expect(blocked.ok).toBe(false); + expect(blocked.code).toBe("access"); + + const missing = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + eventId, + path: ["target", "nope"], + }), + ); + expect(missing.ok).toBe(false); + expect(missing.code).toBe("not-found"); +}); + +test("methods are called with the registered object as their receiver", () => { + const { registry } = make_registry(); + const event = make_event(); + const eventId = registry.register(event); + const response = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "r3", + eventId, + path: ["preventDefault"], + call: true, + args: [], + }), + ); + expect(response.ok).toBe(true); + expect(event.calls).toEqual(["preventDefault"]); +}); + +test("calling refuses the event itself, non-functions, and blocked members", () => { + const { registry } = make_registry(); + const eventId = registry.register(make_event()); + + expect( + registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + eventId, + path: [], + call: true, + }), + ).code, + ).toBe("access"); + + expect( + registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + eventId, + path: ["type"], + call: true, + }), + ).code, + ).toBe("access"); + + expect( + registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + eventId, + path: ["window"], + call: true, + }), + ).code, + ).toBe("access"); +}); + +test("a path which leads through a call result is still walkable", () => { + // What comes back out of a call is named by a path leading through it, and the only + // thing such a path can be resolved against is the answer the page already gave. It + // used to lead to a function with no such property, and answer "not-found" - leaving + // the server holding a reference to something it could never reach. + const { registry } = make_registry(); + const note: Record = { text: "written by the browser" }; + note["about"] = note; // a cycle, which is handed over as a reference + const eventId = registry.register({ + reveal() { + return note; + }, + }); + + const called = registry.resolve( + request({ requestId: "call", eventId, path: ["reveal"], call: true }), + ); + expect(called.ok).toBe(true); + expect(readReference(called.value["about"])).toEqual({ + eventId, + path: ["reveal", "about"], + callable: false, + }); + + const walked = registry.resolve( + request({ requestId: "walk", eventId, path: ["reveal", "about", "text"] }), + ); + expect(walked.ok).toBe(true); + expect(walked.value).toBe("written by the browser"); +}); + +test("walking through a call result does not run the method again", () => { + // The answer is kept rather than the call being made again: a method may have side + // effects, and by now it may not even be there. + const { registry } = make_registry(); + let runs = 0; + const eventId = registry.register({ + make() { + runs++; + return { deep: { value: 42 } }; + }, + }); + + const called = registry.resolve( + request({ + requestId: "call", + eventId, + path: ["make"], + call: true, + maxDepth: 1, + }), + ); + expect(runs).toBe(1); + expect(readReference(called.value["deep"])?.path).toEqual(["make", "deep"]); + + const walked = registry.resolve( + request({ requestId: "walk", eventId, path: ["make", "deep", "value"] }), + ); + expect(walked.ok).toBe(true); + expect(walked.value).toBe(42); + expect(runs).toBe(1); +}); + +test("only the most recent call results are kept to walk through", () => { + // Keeping results is what keeps a page's memory bounded, so the oldest make room for + // the newest rather than being held for as long as the event itself is. + const { registry } = make_registry(); + const methods: Record unknown> = {}; + for (let index = 0; index <= MAX_CALL_RESULTS_PER_OBJECT; index++) + methods[`item${index}`] = () => ({ at: index }); + const eventId = registry.register(methods); + + for (let index = 0; index <= MAX_CALL_RESULTS_PER_OBJECT; index++) + registry.resolve( + request({ + requestId: `call-${index}`, + eventId, + path: [`item${index}`], + call: true, + }), + ); + + const kept = registry.resolve( + request({ + eventId, + path: [`item${MAX_CALL_RESULTS_PER_OBJECT}`, "at"], + }), + ); + expect(kept.ok).toBe(true); + expect(kept.value).toBe(MAX_CALL_RESULTS_PER_OBJECT); + + const forgotten = registry.resolve( + request({ eventId, path: ["item0", "at"] }), + ); + expect(forgotten.ok).toBe(false); + expect(forgotten.code).toBe("not-found"); +}); + +test("arguments which are references are resolved on the page", () => { + const { registry } = make_registry(); + const received: unknown[] = []; + const eventId = registry.register({ + remember(...args: unknown[]) { + received.push(...args); + return args.length; + }, + }); + const otherId = registry.register({ target: { value: "typed" } }); + const response = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + eventId, + path: ["remember"], + call: true, + args: [ + { [EVENT_REFERENCE_KEY]: { eventId: otherId, path: ["target"] } }, + ["plain", { [EVENT_REFERENCE_KEY]: { eventId: otherId, path: [] } }], + 5, + ], + }), + ); + expect(response.value).toBe(3); + expect(received[0]).toEqual({ value: "typed" }); + expect(received[1]).toEqual(["plain", { target: { value: "typed" } }]); + expect(received[2]).toBe(5); +}); + +test("an argument reference to a forgotten object is an error", () => { + const { registry } = make_registry(); + const eventId = registry.register({ + remember(...args: unknown[]) { + return args.length; + }, + }); + const response = registry.resolve( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + eventId, + path: ["remember"], + call: true, + args: [{ [EVENT_REFERENCE_KEY]: { eventId: "gone", path: [] } }], + }), + ); + expect(response.ok).toBe(false); + expect(response.code).toBe("not-found"); +}); + +test("an argument nested past the depth the request asked for is refused", () => { + // An argument is the server's shape rather than the page's. Reading is bounded; an + // argument used to be walked however deep it happened to go. + const { registry } = make_registry(); + const eventId = registry.register({ + remember(...args: unknown[]) { + return args.length; + }, + }); + + const response = registry.resolve( + request({ + eventId, + path: ["remember"], + call: true, + args: [[[[["far"]]]]], + maxDepth: 2, + }), + ); + + expect(response.ok).toBe(false); + expect(response.code).toBe("access"); + expect(response.error).toContain("nested more deeply than 2 levels"); +}); + +test("a request cannot ask the page to recurse further than it is willing to", () => { + // ``maxDepth`` comes from the server, so it is a suggestion rather than an order: a + // number which is negative, not a number, or simply enormous has to leave the page + // standing rather than burying it in a stack it cannot come back out of. + const { registry } = make_registry(); + const eventId = registry.register({ deep: nest(MAX_ALLOWED_DEPTH + 20) }); + + for (const asked of [Number.NaN, -1, 100000, "deep"]) { + const response = registry.resolve( + request({ + eventId, + path: ["deep"], + maxDepth: asked as unknown as number, + }), + ); + expect(response.ok).toBe(true); + + let node: any = response.value; + let depth = 0; + while (node && node[EVENT_REFERENCE_KEY] === undefined && node["next"]) { + node = node["next"]; + depth++; + } + expect(depth).toBeLessThanOrEqual(MAX_ALLOWED_DEPTH); + expect(readReference(node)).not.toBe(null); + } +}); + +test("a message which asks for far too much at once is refused the excess", () => { + // The server asks for what it wants to know during one pass round its loop, which is + // a bounded number of things. Carrying out an arbitrarily long list would spend the + // page's one turn on nothing else, and whatever was refused still gets an answer + // rather than leaving the server to time out on a client which is wide awake. + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + const requests = Array.from( + { length: MAX_REQUESTS_PER_MESSAGE + 3 }, + (_, i) => request({ requestId: `r${i}`, eventId, path: ["type"] }), + ); + + registry.handleMessage(batch(...requests)); + + const answered = replies[0].responses as EventProxyResponse[]; + expect(answered).toHaveLength(MAX_REQUESTS_PER_MESSAGE + 3); + expect(answered[MAX_REQUESTS_PER_MESSAGE - 1].ok).toBe(true); + expect(answered[MAX_REQUESTS_PER_MESSAGE].ok).toBe(false); + expect(answered[MAX_REQUESTS_PER_MESSAGE].code).toBe("access"); + expect(answered[MAX_REQUESTS_PER_MESSAGE].error).toContain("more than"); + expect(answered[MAX_REQUESTS_PER_MESSAGE].requestId).toBe( + `r${MAX_REQUESTS_PER_MESSAGE}`, + ); +}); + +function nest(levels: number): Record { + const root: Record = {}; + let node = root; + for (let index = 0; index < levels; index++) { + node["next"] = {}; + node = node["next"] as Record; + } + return root; +} + +test("handleMessage only answers proxy requests, and only when asked", () => { + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + + registry.handleMessage({ + type: "layout-update", + eventId, + } as unknown as EventProxyRequest); + registry.handleMessage(null as unknown as EventProxyRequest); + // a batch which was never a proxy request is ignored as a whole + registry.handleMessage({ + type: "layout-update", + requests: [request({ eventId, path: ["type"] })], + } as unknown as EventProxyRequestBatch); + expect(replies).toEqual([]); + + registry.handleMessage( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "quiet", + eventId, + path: ["type"], + reply: false, + }), + ); + expect(replies).toEqual([]); + + registry.handleMessage( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "loud", + eventId, + path: ["type"], + }), + ); + expect(replies).toHaveLength(1); + expect(replies[0]).toEqual({ + type: EVENT_PROXY_RESPONSE_TYPE, + requestId: "loud", + ok: true, + error: null, + code: null, + value: "click", + }); +}); + +test("one request is answered by one unbatched response", () => { + // The wrapper is reserved for batches: wrapping the single read which most events + // need would only add bytes to the common case. + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + + registry.handleMessage( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "solo", + eventId, + path: ["target", "value"], + }), + ); + + expect(replies).toHaveLength(1); + expect(replies[0]).toEqual({ + type: EVENT_PROXY_RESPONSE_TYPE, + requestId: "solo", + ok: true, + error: null, + code: null, + value: "typed", + }); + expect(replies[0].responses).toBeUndefined(); +}); + +test("a batch of requests is answered by exactly one message", () => { + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + const otherId = registry.register({ target: { value: "other" } }); + + registry.handleMessage( + batch( + request({ requestId: "a", eventId, path: ["type"] }), + request({ requestId: "b", eventId, path: ["target", "value"] }), + request({ requestId: "c", eventId: otherId, path: ["target", "value"] }), + ), + ); + + expect(replies).toHaveLength(1); + const [message] = replies; + expect(message.type).toBe(EVENT_PROXY_RESPONSE_TYPE); + expect(message.requestId).toBeUndefined(); + expect(message.responses).toHaveLength(3); + + // one entry per request, each carrying its own identity and its own answer + const answered = message.responses as EventProxyResponse[]; + expect(answered.map((entry) => entry.requestId)).toEqual(["a", "b", "c"]); + expect(answered.map((entry) => entry.value)).toEqual([ + "click", + "typed", + "other", + ]); + for (const entry of answered) { + expect(entry.type).toBe(EVENT_PROXY_RESPONSE_TYPE); + expect(entry.ok).toBe(true); + expect(entry.error).toBeNull(); + expect(entry.code).toBeNull(); + } +}); + +test("a batch whose requests all want no answer sends nothing at all", () => { + const { registry, replies } = make_registry(); + const event = make_event(); + const eventId = registry.register(event); + + registry.handleMessage( + batch( + request({ + requestId: "quiet-1", + eventId, + path: ["preventDefault"], + call: true, + reply: false, + }), + request({ + requestId: "quiet-2", + eventId, + path: ["target", "value"], + reply: false, + }), + ), + ); + + expect(replies).toEqual([]); + // they were still carried out - a request which does not want an answer is usually a + // call whose only point was to run a method in the page + expect(event.calls).toEqual(["preventDefault"]); +}); + +test("an empty batch sends nothing", () => { + const { registry, replies } = make_registry(); + registry.register(make_event()); + registry.handleMessage(batch()); + expect(replies).toEqual([]); +}); + +test("only the requests which asked are answered, in the order they came", () => { + const { registry, replies } = make_registry(); + const event = make_event(); + const eventId = registry.register(event); + + registry.handleMessage( + batch( + request({ requestId: "loud-1", eventId, path: ["type"] }), + request({ + requestId: "quiet", + eventId, + path: ["preventDefault"], + call: true, + reply: false, + }), + request({ requestId: "loud-2", eventId, path: ["target", "tagName"] }), + ), + ); + + expect(replies).toHaveLength(1); + const answered = replies[0].responses as EventProxyResponse[]; + expect(answered.map((entry) => entry.requestId)).toEqual([ + "loud-1", + "loud-2", + ]); + expect(responses_of(replies[0])["loud-2"].value).toBe("INPUT"); + expect(event.calls).toEqual(["preventDefault"]); +}); + +test("a batch answers each request on its own, failures included", () => { + // One request going wrong says nothing about the others: every entry carries its own + // ok/error/code, and the server decides which of them to raise on. + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + + registry.handleMessage( + batch( + request({ requestId: "fine", eventId, path: ["type"] }), + request({ requestId: "blocked", eventId, path: ["ownerDocument"] }), + request({ requestId: "missing", eventId, path: ["target", "nope"] }), + request({ + requestId: "gone", + eventId: "never-registered", + path: ["type"], + }), + ), + ); + + expect(replies).toHaveLength(1); + const answered = responses_of(replies[0]); + expect(Object.keys(answered).sort()).toEqual([ + "blocked", + "fine", + "gone", + "missing", + ]); + expect(answered["fine"].ok).toBe(true); + expect(answered["fine"].value).toBe("click"); + expect(answered["fine"].code).toBeNull(); + expect(answered["blocked"].ok).toBe(false); + expect(answered["blocked"].code).toBe("access"); + expect(answered["missing"].ok).toBe(false); + expect(answered["missing"].code).toBe("not-found"); + expect(answered["gone"].ok).toBe(false); + expect(answered["gone"].code).toBe("not-found"); + expect(answered["gone"].error).toContain("never-registered"); +}); + +test("a batch which carries something which is not a request skips it", () => { + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + + registry.handleMessage( + batch( + null as unknown as EventProxyRequest, + "nonsense" as unknown as EventProxyRequest, + request({ requestId: "ok", eventId, path: ["type"] }), + ), + ); + + expect(replies).toHaveLength(1); + const answered = replies[0].responses as EventProxyResponse[]; + expect(answered.map((entry) => entry.requestId)).toEqual(["ok"]); +}); + +test("the blocklist refuses the names which could reach the page", () => { + // Nothing is sent up front any more, so these refusals are the only thing between a + // request and the rest of the browser - whether the read is asked for on its own, + // buried behind an ordinary property, or smuggled in as part of a batch. + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + + for (const name of [ + "ownerDocument", + "__proto__", + "eval", + "parentNode", + "window", + ]) { + const alone = registry.resolve( + request({ requestId: name, eventId, path: [name] }), + ); + expect(alone.ok).toBe(false); + expect(alone.code).toBe("access"); + expect(alone.value).toBeUndefined(); + + const buried = registry.resolve( + request({ requestId: name, eventId, path: ["target", name] }), + ); + expect(buried.ok).toBe(false); + expect(buried.code).toBe("access"); + + registry.handleMessage( + batch(request({ requestId: name, eventId, path: ["target", name] })), + ); + const [entry] = replies[replies.length - 1].responses; + expect(entry.requestId).toBe(name); + expect(entry.ok).toBe(false); + expect(entry.code).toBe("access"); + } + + // ordinary properties still come through, blocked names alongside them included + const plain = registry.resolve( + request({ requestId: "plain", eventId, path: ["target", "value"] }), + ); + expect(plain.ok).toBe(true); + expect(plain.value).toBe("typed"); +}); + +test("an object dropped by the cap says the page ran out of room", () => { + // There is no snapshot left to fall back on, so an event which has been forgotten + // cannot be read at all: the handler is told so rather than handed a stale copy. It is + // told *why* too - this one was dropped to make room while the server was still using + // it, which is a different thing from an object nobody ever read going stale. + const { registry, replies } = make_registry(); + const eventId = registry.register(make_event()); + + for (let index = 0; index <= MAX_LIVE_OBJECTS; index++) registry.register({}); + expect(registry.has(eventId)).toBe(false); + + registry.handleMessage( + batch(request({ requestId: "gone", eventId, path: ["type"] })), + ); + + expect(replies).toHaveLength(1); + const [entry] = replies[0].responses; + expect(entry).toEqual({ + type: EVENT_PROXY_RESPONSE_TYPE, + requestId: "gone", + ok: false, + error: + `event ${JSON.stringify(eventId)} no longer fits - the page keeps at most ` + + `${MAX_LIVE_OBJECTS} live event objects, and this one was dropped to make room`, + code: "evicted", + }); +}); + +test("an object which was simply never read going stale is not reported as evicted", () => { + // "stale anyway" and "you ran out of room" have to be tellable apart by whoever reads + // the log: only the second one says anything was lost which was still being used. + const { registry, replies, now } = make_registry(); + const eventId = registry.register(make_event()); + + now.value = LIVE_OBJECT_TTL_MS + 1; + registry.prune(); + + registry.handleMessage( + request({ + type: EVENT_PROXY_REQUEST_TYPE, + requestId: "late", + eventId, + path: ["target", "value"], + }), + ); + + expect(replies).toHaveLength(1); + expect(replies[0].requestId).toBe("late"); + expect(replies[0].ok).toBe(false); + expect(replies[0].code).toBe("not-found"); + expect(replies[0].error).toBe( + `nothing is registered for event ${JSON.stringify(eventId)}`, + ); +}); + +test("answering a request takes the idle objects out with it", () => { + // Objects are registered for the server's benefit and nothing else is coming to take + // them away, so a page which stops dispatching events used to hold every one of them + // for their whole idle time - a detached node is no less detached for nobody having + // asked about it in the last minute. + const { registry, replies, now } = make_registry(); + const idle = registry.register(make_event()); + + now.value = LIVE_OBJECT_TTL_MS - 1; + const busy = registry.register(make_event()); + + now.value = LIVE_OBJECT_TTL_MS + 5; + registry.handleMessage( + request({ requestId: "ask", eventId: busy, path: [] }), + ); + + expect(replies).toHaveLength(1); + expect(replies[0].ok).toBe(true); + expect(registry.has(idle)).toBe( + false, + "the idle one went while nobody watched", + ); + expect(registry.has(busy)).toBe(true, "the one being read is not idle"); +}); + +test("the cap leaves room for every event a busy page can have in flight", () => { + const { registry } = make_registry(); + const ids: string[] = []; + for (let index = 0; index < 500; index++) + ids.push(registry.register(make_event())); + + // A burst of keystrokes is still being read long after the last one fired, and an + // event evicted too early fails hard - so the cap is a correctness limit rather than + // a memory one, and has to be comfortably larger than what a page keeps outstanding. + expect(registry.size).toBe(500); + expect(ids.every((id) => registry.has(id))).toBe(true); +}); + +test("the cap drops what the server has gone longest without reading", () => { + // A queue rather than a least-recently-used bound takes the object a handler is still + // reading purely because it happened to be dispatched first. With no snapshot to fall + // back on that is the one thing this cap must never do. + const { registry } = make_registry(); + const read = registry.register(make_event()); + const unread: string[] = []; + for (let index = 0; index < MAX_LIVE_OBJECTS - 2; index++) + unread.push(registry.register({ index })); + + expect( + registry.resolve( + request({ requestId: "keep", eventId: read, path: ["type"] }), + ).ok, + ).toBe(true); + + for (let index = 0; index < 4; index++) registry.register({ late: index }); + + expect(registry.size).toBe(MAX_LIVE_OBJECTS); + expect(registry.has(read)).toBe( + true, + "the object being read is the newest thing here", + ); + expect(unread.some((id) => !registry.has(id))).toBe( + true, + "an object nobody read went instead", + ); +}); + +test("an argument which points at a dropped object says the page ran out of room", () => { + const { registry } = make_registry(); + const referenced = registry.register({ target: { value: "typed" } }); + const caller = registry.register({ + remember(...args: unknown[]) { + return args.length; + }, + }); + // The call is what the server is working on, so it is the newest thing on the page ... + expect( + registry.resolve(request({ requestId: "warm", eventId: caller, path: [] })) + .ok, + ).toBe(true); + + // ... which leaves the object that call is about to ask for as the one which goes when + // the page fills up. + while (registry.size < MAX_LIVE_OBJECTS) registry.register({ filler: true }); + registry.register({ last: true }); + expect(registry.has(referenced)).toBe(false); + expect(registry.has(caller)).toBe(true); + + const response = registry.resolve( + request({ + requestId: "call", + eventId: caller, + path: ["remember"], + call: true, + args: [ + { [EVENT_REFERENCE_KEY]: { eventId: referenced, path: ["target"] } }, + ], + }), + ); + + expect(response.ok).toBe(false); + expect(response.code).toBe("evicted"); + expect(response.error).toContain("no longer fits"); + // Whoever only knows how to ask "is it still there?" still catches this one: eviction + // is a reason, not a different kind of failure. + expect(new ProxyEvictedError("gone")).toBeInstanceOf(ProxyNotFoundError); +}); + +test("an object the server is still working with is not aged out mid-handler", () => { + // The failure this is here for: a handler reads one property, awaits something slow for + // longer than the idle window, and comes back for the rest. An idle sweep answered that + // second read "not-found", and the page looked like it had gone away on its own when it + // was the sweep which did it. + const { registry, now } = make_registry(); + const eventId = registry.register(make_event()); + + expect( + registry.resolve(request({ requestId: "first", eventId, path: ["type"] })) + .value, + ).toBe("click"); + + now.value = LIVE_OBJECT_TTL_MS * 10; + // Other events land in the meantime, and every one of them brings the sweep with it. + for (let index = 0; index < 5; index++) registry.register(make_event()); + + const late = registry.resolve( + request({ requestId: "late", eventId, path: ["target", "value"] }), + ); + expect(late.ok).toBe(true); + expect(late.value).toBe("typed"); + expect(registry.has(eventId)).toBe(true); +}); + +test("age takes what nobody asked about and leaves what the server is reading", () => { + // The two bounds each have one job: idle time is for the objects which were dispatched + // and never once read, and nothing else, while the count is what bounds the page. + const { registry, now } = make_registry(); + const unread = registry.register(make_event()); + const read = registry.register(make_event()); + expect( + registry.resolve(request({ requestId: "ask", eventId: read, path: [] })).ok, + ).toBe(true); + + now.value = LIVE_OBJECT_TTL_MS * 10; + registry.prune(); + + expect(registry.has(unread)).toBe( + false, + "nobody ever asked, so it was only taking up room", + ); + expect(registry.has(read)).toBe( + true, + "the server was still working with this one", + ); +}); + +test("a first read which arrives after the idle window is still answered", () => { + // A handler which awaits something before it ever touches its event used to find it + // gone: the sweep ran ahead of the requests it belonged to, and deleted the object a + // moment before the read which proved anybody wanted it. + const { registry, replies, now } = make_registry(); + const eventId = registry.register(make_event()); + + now.value = LIVE_OBJECT_TTL_MS + 1; + registry.handleMessage( + request({ requestId: "late", eventId, path: ["type"] }), + ); + + expect(replies).toHaveLength(1); + expect(replies[0].ok).toBe( + true, + "the read which asks is the proof it is wanted", + ); + expect(replies[0].value).toBe("click"); +}); + +test("dispatching the same object twice is one object, not two", () => { + // A second registration of one object used to be a second copy of it: another id, + // another entry, and room in the cap spent on something the server already had. + const { registry } = make_registry(); + const event = make_event(); + const first = registry.register(event); + + expect(registry.register(event)).toBe(first); + expect(registry.size).toBe(1); + + // Objects which merely look alike are still two events + expect(registry.register(make_event())).not.toBe(first); + expect(registry.size).toBe(2); + + // Once the registry has been emptied the object starts over rather than coming back + // under an id which answers nothing. + registry.clear(); + expect(registry.register(event)).not.toBe(first); + expect(registry.size).toBe(1); + + // ... and the same is true of an entry which was dropped to make room for others. + const crowded = make_registry(); + const shared = make_event(); + const old = crowded.registry.register(shared); + for (let index = 0; index <= MAX_LIVE_OBJECTS; index++) + crowded.registry.register({ index }); + expect(crowded.registry.has(old)).toBe(false); + expect(crowded.registry.register(shared)).not.toBe(old); + expect(crowded.registry.size).toBe(MAX_LIVE_OBJECTS); +}); diff --git a/src/js/packages/@reactpy/client/tsconfig.json b/src/js/packages/@reactpy/client/tsconfig.json index c0f6f27d8..ab3d2775f 100644 --- a/src/js/packages/@reactpy/client/tsconfig.json +++ b/src/js/packages/@reactpy/client/tsconfig.json @@ -6,10 +6,5 @@ "noEmit": false }, "extends": "../../../tsconfig.json", - "include": ["src"], - "references": [ - { - "path": "../../event-to-object" - } - ] + "include": ["src"] } diff --git a/src/js/packages/event-to-object/README.md b/src/js/packages/event-to-object/README.md deleted file mode 100644 index b28f5d3fb..000000000 --- a/src/js/packages/event-to-object/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Event to Object - -Converts a JavaScript events to JSON serializable objects. diff --git a/src/js/packages/event-to-object/bun.lockb b/src/js/packages/event-to-object/bun.lockb deleted file mode 100644 index 1c6a0e669..000000000 Binary files a/src/js/packages/event-to-object/bun.lockb and /dev/null differ diff --git a/src/js/packages/event-to-object/package.json b/src/js/packages/event-to-object/package.json deleted file mode 100644 index 62b828f3b..000000000 --- a/src/js/packages/event-to-object/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "author": "Mark Bakhit", - "contributors": [ - "Ryan Morshead" - ], - "dependencies": { - "json-pointer": "catalog:" - }, - "description": "Converts a JavaScript events to JSON serializable objects.", - "files": [ - "dist", - "src", - "LICENSE" - ], - "devDependencies": { - "happy-dom": "^15.0.0", - "lodash": "^4.17.21", - "typescript": "^5.8.3", - "vitest": "^2.1.8" - }, - "keywords": [ - "event", - "json", - "object", - "convert" - ], - "license": "MIT", - "main": "dist/index.js", - "name": "event-to-object", - "repository": { - "type": "git", - "url": "https://github.com/reactive-python/reactpy" - }, - "scripts": { - "build": "tsc -b", - "checkTypes": "tsc --noEmit" - }, - "type": "module", - "version": "2.0.0" -} diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts deleted file mode 100644 index f6aebe62e..000000000 --- a/src/js/packages/event-to-object/src/index.ts +++ /dev/null @@ -1,323 +0,0 @@ -const maxDepthSignal = { __stop__: true }; - -/** - * Convert any class object (such as `Event`) to a plain object. - */ -export default function convert( - classObject: { [key: string]: any }, - maxDepth: number = 10, -): object { - // Immediately return `classObject` if given an unexpected (non-object) input - if (!classObject || typeof classObject !== "object") { - console.warn( - "eventToObject: Expected an object input, received:", - classObject, - ); - return classObject; - } - - // Begin conversion - const visited = new WeakSet(); - visited.add(classObject); - const convertedObj: { [key: string]: any } = {}; - for (const key in classObject) { - // Skip keys that cannot be converted - try { - if (shouldIgnoreValue(classObject[key], key)) { - continue; - } - // Handle objects (potentially cyclical) - else if (typeof classObject[key] === "object") { - const result = deepCloneClass(classObject[key], maxDepth, visited); - if (result !== maxDepthSignal) { - convertedObj[key] = result; - } - } - // Handle simple types (non-cyclical) - else { - convertedObj[key] = classObject[key]; - } - } catch { - continue; - } - } - - // Special case: Event selection - if ( - typeof window !== "undefined" && - window.Event && - classObject instanceof window.Event - ) { - convertedObj["selection"] = serializeSelection(maxDepth, visited); - } - - return convertedObj; -} - -/** - * Serialize the current window selection. - */ -function serializeSelection( - maxDepth: number, - visited: WeakSet, -): object | null { - if (typeof window === "undefined" || !window.getSelection) { - return null; - } - const selection = window.getSelection(); - if (!selection) { - return null; - } - return { - type: selection.type, - anchorNode: selection.anchorNode - ? deepCloneClass(selection.anchorNode, maxDepth, visited) - : null, - anchorOffset: selection.anchorOffset, - focusNode: selection.focusNode - ? deepCloneClass(selection.focusNode, maxDepth, visited) - : null, - focusOffset: selection.focusOffset, - isCollapsed: selection.isCollapsed, - rangeCount: selection.rangeCount, - selectedText: selection.toString(), - }; -} - -/** - * Recursively convert a class-based object to a plain object. - */ -function deepCloneClass( - x: any, - _maxDepth: number, - visited: WeakSet, -): object { - const maxDepth = _maxDepth - 1; - - // Return an indicator if maxDepth is reached - if (maxDepth <= 0 && typeof x === "object") { - return maxDepthSignal; - } - - // Safety check: WeakSet only accepts objects (and not null) - if (!x || typeof x !== "object") { - return x; - } - - if (visited.has(x)) { - return maxDepthSignal; - } - visited.add(x); - - try { - // Convert array-like class (e.g., NodeList, ClassList, HTMLCollection) - if ( - Array.isArray(x) || - (typeof x?.length === "number" && - typeof x[Symbol.iterator] === "function" && - !Object.prototype.toString.call(x).includes("Map") && - !(x instanceof CSSStyleDeclaration)) - ) { - return classToArray(x, maxDepth, visited); - } - - // Convert mapping-like class (e.g., Node, Map, Set) - return classToObject(x, maxDepth, visited); - } finally { - visited.delete(x); - } -} - -/** - * Convert an array-like class to a plain array. - */ -function classToArray( - x: any, - maxDepth: number, - visited: WeakSet, -): Array { - const result: Array = []; - for (let i = 0; i < x.length; i++) { - // Skip anything that should not be converted - if (shouldIgnoreValue(x[i])) { - continue; - } - // Only push objects as if we haven't reached max depth - else if (typeof x[i] === "object") { - const converted = deepCloneClass(x[i], maxDepth, visited); - if (converted !== maxDepthSignal) { - result.push(converted); - } - } - // Add plain values if not skippable - else { - result.push(x[i]); - } - } - return result; -} - -/** - * Convert a mapping-like class to a plain JSON object. - * We must iterate through it with a for-loop in order to gain - * access to properties from all parent classes. - */ -function classToObject( - x: any, - maxDepth: number, - visited: WeakSet, -): object { - const result: { [key: string]: any } = {}; - for (const key in x) { - try { - // Skip anything that should not be converted - if (shouldIgnoreValue(x[key], key, x)) { - continue; - } - // Add objects as a property if we haven't reached max depth - else if (typeof x[key] === "object") { - const converted = deepCloneClass(x[key], maxDepth, visited); - if (converted !== maxDepthSignal) { - result[key] = converted; - } - } - // Add plain values if not skippable - else { - result[key] = x[key]; - } - } catch { - continue; - } - } - - // Explicitly include dataset if it exists (it might not be enumerable) - if ( - x && - typeof x === "object" && - "dataset" in x && - !Object.prototype.hasOwnProperty.call(result, "dataset") - ) { - const dataset = x["dataset"]; - if (!shouldIgnoreValue(dataset, "dataset", x)) { - const converted = deepCloneClass(dataset, maxDepth, visited); - if (converted !== maxDepthSignal) { - result["dataset"] = converted; - } - } - } - - // Explicitly include common input properties if they exist - const extraProps = ["value", "checked", "files", "type", "name"]; - for (const prop of extraProps) { - if ( - x && - typeof x === "object" && - prop in x && - !Object.prototype.hasOwnProperty.call(result, prop) - ) { - const val = x[prop]; - if (!shouldIgnoreValue(val, prop, x)) { - if (typeof val === "object") { - // Ensure files have enough depth to be serialized - const propDepth = prop === "files" ? Math.max(maxDepth, 3) : maxDepth; - const converted = deepCloneClass(val, propDepth, visited); - if (converted !== maxDepthSignal) { - result[prop] = converted; - } - } else { - result[prop] = val; - } - } - } - } - - // Explicitly include form elements if they exist and are not enumerable - const win = typeof window !== "undefined" ? window : undefined; - // @ts-ignore - const FormClass = win - ? win.HTMLFormElement - : typeof HTMLFormElement !== "undefined" - ? HTMLFormElement - : undefined; - - if (FormClass && x instanceof FormClass && x.elements) { - for (let i = 0; i < x.elements.length; i++) { - const element = x.elements[i] as any; - if ( - element.name && - !Object.prototype.hasOwnProperty.call(result, element.name) && - !shouldIgnoreValue(element, element.name, x) - ) { - if (typeof element === "object") { - const converted = deepCloneClass(element, maxDepth, visited); - if (converted !== maxDepthSignal) { - result[element.name] = converted; - } - } else { - result[element.name] = element; - } - } - } - } - - return result; -} - -/** - * Check if a value is non-convertible or holds minimal value. - */ -function shouldIgnoreValue( - value: any, - keyName: string = "", - parent: any = undefined, -): boolean { - return ( - // Useless data - value === null || - value === undefined || - keyName.startsWith("__") || - (keyName.length > 0 && /^[A-Z_]+$/.test(keyName)) || - // Non-convertible types - typeof value === "function" || - value instanceof CSSStyleSheet || - value instanceof Window || - value instanceof Document || - keyName === "view" || - keyName === "size" || - keyName === "length" || - (parent instanceof CSSStyleDeclaration && value === "") || - // DOM Node Blacklist - (typeof Node !== "undefined" && - parent instanceof Node && - // Recursive properties - (keyName === "parentNode" || - keyName === "parentElement" || - keyName === "ownerDocument" || - keyName === "getRootNode" || - keyName === "childNodes" || - keyName === "children" || - keyName === "firstChild" || - keyName === "lastChild" || - keyName === "previousSibling" || - keyName === "nextSibling" || - keyName === "previousElementSibling" || - keyName === "nextElementSibling" || - // Potentially large data - keyName === "innerHTML" || - keyName === "outerHTML" || - // Reflow triggers - keyName === "offsetParent" || - keyName === "offsetWidth" || - keyName === "offsetHeight" || - keyName === "offsetLeft" || - keyName === "offsetTop" || - keyName === "clientTop" || - keyName === "clientLeft" || - keyName === "clientWidth" || - keyName === "clientHeight" || - keyName === "scrollWidth" || - keyName === "scrollHeight" || - keyName === "scrollTop" || - keyName === "scrollLeft")) - ); -} diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts deleted file mode 100644 index 914beddac..000000000 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ /dev/null @@ -1,679 +0,0 @@ -// @ts-ignore -import { window } from "./tooling/setup"; -import { test, expect } from "bun:test"; -import { Event } from "happy-dom"; -import convert from "../src/index"; -import { checkEventConversion } from "./tooling/check"; -import { mockGamepad, mockTouch, mockTouchObject } from "./tooling/mock"; - -type SimpleTestCase = { - types: string[]; - description: string; - givenEventType: new (type: string) => E; - expectedConversion: any; - initGivenEvent?: (event: E) => void; -}; - -const simpleTestCases: SimpleTestCase[] = [ - { - types: [ - "animationcancel", - "animationend", - "animationiteration", - "animationstart", - ], - description: "animation event", - givenEventType: window.AnimationEvent, - expectedConversion: { - animationName: "", - pseudoElement: "", - elapsedTime: 0, - }, - }, - { - types: ["beforeinput"], - description: "event", - givenEventType: window.InputEvent, - expectedConversion: { - detail: 0, - data: "", - inputType: "", - dataTransfer: null, - isComposing: false, - }, - }, - { - types: ["compositionend", "compositionstart", "compositionupdate"], - description: "composition event", - givenEventType: window.CompositionEvent, - expectedConversion: { - data: undefined, - detail: undefined, - }, - }, - { - types: ["copy", "cut", "paste"], - description: "clipboard event", - givenEventType: window.ClipboardEvent, - expectedConversion: { clipboardData: null }, - }, - { - types: [ - "drag", - "dragend", - "dragenter", - "dragleave", - "dragover", - "dragstart", - "drop", - ], - description: "drag event", - givenEventType: window.DragEvent, - expectedConversion: { - altKey: undefined, - button: undefined, - buttons: undefined, - clientX: undefined, - clientY: undefined, - ctrlKey: undefined, - dataTransfer: null, - metaKey: undefined, - movementX: undefined, - movementY: undefined, - offsetX: undefined, - offsetY: undefined, - pageX: undefined, - pageY: undefined, - relatedTarget: null, - screenX: undefined, - screenY: undefined, - shiftKey: undefined, - x: undefined, - y: undefined, - }, - }, - { - types: ["error"], - description: "event", - givenEventType: window.ErrorEvent, - expectedConversion: { detail: 0 }, - }, - { - types: ["blur", "focus", "focusin", "focusout"], - description: "focus event", - givenEventType: window.FocusEvent, - expectedConversion: { - relatedTarget: null, - detail: 0, - }, - }, - { - types: ["gamepadconnected", "gamepaddisconnected"], - description: "gamepad event", - givenEventType: window.GamepadEvent, - expectedConversion: { gamepad: mockGamepad }, - initGivenEvent: (event) => { - event.gamepad = mockGamepad; - }, - }, - { - types: ["keydown", "keypress", "keyup"], - description: "keyboard event", - givenEventType: window.KeyboardEvent, - expectedConversion: { - altKey: false, - code: "", - ctrlKey: false, - isComposing: false, - key: "", - location: 0, - metaKey: false, - repeat: false, - shiftKey: false, - detail: 0, - }, - }, - { - types: [ - "click", - "auxclick", - "dblclick", - "mousedown", - "mouseenter", - "mouseleave", - "mousemove", - "mouseout", - "mouseover", - "mouseup", - "scroll", - ], - description: "mouse event", - givenEventType: window.MouseEvent, - expectedConversion: { - altKey: false, - button: 0, - buttons: 0, - clientX: 0, - clientY: 0, - ctrlKey: false, - metaKey: false, - movementX: 0, - movementY: 0, - offsetX: 0, - offsetY: 0, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: 0, - screenY: 0, - shiftKey: false, - x: undefined, - y: undefined, - }, - }, - { - types: [ - "auxclick", - "click", - "contextmenu", - "dblclick", - "mousedown", - "mouseenter", - "mouseleave", - "mousemove", - "mouseout", - "mouseover", - "mouseup", - ], - description: "mouse event", - givenEventType: window.MouseEvent, - expectedConversion: { - altKey: false, - button: 0, - buttons: 0, - clientX: 0, - clientY: 0, - ctrlKey: false, - metaKey: false, - movementX: 0, - movementY: 0, - offsetX: 0, - offsetY: 0, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: 0, - screenY: 0, - shiftKey: false, - x: undefined, - y: undefined, - }, - }, - { - types: [ - "gotpointercapture", - "lostpointercapture", - "pointercancel", - "pointerdown", - "pointerenter", - "pointerleave", - "pointerlockchange", - "pointerlockerror", - "pointermove", - "pointerout", - "pointerover", - "pointerup", - ], - description: "pointer event", - givenEventType: window.PointerEvent, - expectedConversion: { - altKey: false, - button: 0, - buttons: 0, - clientX: 0, - clientY: 0, - ctrlKey: false, - metaKey: false, - movementX: 0, - movementY: 0, - offsetX: 0, - offsetY: 0, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: 0, - screenY: 0, - shiftKey: false, - x: undefined, - y: undefined, - pointerId: 0, - pointerType: "", - pressure: 0, - tiltX: 0, - tiltY: 0, - width: 1, - height: 1, - isPrimary: false, - twist: 0, - tangentialPressure: 0, - }, - }, - { - types: ["submit"], - description: "event", - givenEventType: window.Event, - expectedConversion: { submitter: null }, - initGivenEvent: (event) => { - event.submitter = null; - }, - }, - { - types: ["touchcancel", "touchend", "touchmove", "touchstart"], - description: "touch event", - givenEventType: window.TouchEvent, - expectedConversion: { - altKey: undefined, - changedTouches: [mockTouchObject], - ctrlKey: undefined, - metaKey: undefined, - targetTouches: [mockTouchObject], - touches: [mockTouchObject], - detail: undefined, - shiftKey: undefined, - }, - initGivenEvent: (event) => { - event.changedTouches = [mockTouch]; - event.targetTouches = [mockTouch]; - event.touches = [mockTouch]; - }, - }, - { - types: [ - "transitioncancel", - "transitionend", - "transitionrun", - "transitionstart", - ], - description: "transition event", - givenEventType: window.TransitionEvent, - expectedConversion: { - propertyName: undefined, - elapsedTime: undefined, - pseudoElement: undefined, - }, - }, - { - types: ["wheel"], - description: "wheel event", - givenEventType: window.WheelEvent, - expectedConversion: { - altKey: undefined, - button: undefined, - buttons: undefined, - clientX: undefined, - clientY: undefined, - ctrlKey: undefined, - deltaMode: 0, - deltaX: 0, - deltaY: 0, - deltaZ: 0, - metaKey: undefined, - movementX: undefined, - movementY: undefined, - offsetX: undefined, - offsetY: undefined, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: undefined, - screenY: undefined, - shiftKey: undefined, - x: undefined, - y: undefined, - }, - }, -]; - -simpleTestCases.forEach((testCase) => { - testCase.types.forEach((type) => { - test(`converts ${type} ${testCase.description}`, () => { - const event = new testCase.givenEventType(type); - if (testCase.initGivenEvent) { - testCase.initGivenEvent(event); - } - checkEventConversion(event, testCase.expectedConversion); - }); - }); -}); - -test("adds text of current selection", () => { - document.body.innerHTML = ` -
-

START

-

MIDDLE

-

END

-
- `; - const start = document.getElementById("start"); - const end = document.getElementById("end"); - window.getSelection()!.setBaseAndExtent(start! as any, 0, end! as any, 0); - checkEventConversion(new window.Event("fake"), { - type: "fake", - selection: { - type: "Range", - anchorNode: {}, - anchorOffset: 0, - focusNode: {}, - focusOffset: 0, - isCollapsed: false, - rangeCount: 1, - selectedText: "START\n MIDDLE\n ", - }, - eventPhase: undefined, - isTrusted: undefined, - }); -}); - -test("includes data-* attributes in dataset", () => { - const div = document.createElement("div"); - div.setAttribute("data-test-value", "123"); - div.setAttribute("data-other", "foo"); - - const event = new window.Event("click"); - Object.defineProperty(event, "target", { - value: div, - enumerable: true, - writable: true, - }); - Object.defineProperty(event, "currentTarget", { - value: div, - enumerable: true, - writable: true, - }); - - checkEventConversion(event, { - target: { - dataset: { - testValue: "123", - other: "foo", - }, - }, - currentTarget: { - dataset: { - testValue: "123", - other: "foo", - }, - }, - }); -}); - -test("includes value and checked for radio and checkbox inputs", () => { - const radio = document.createElement("input"); - radio.type = "radio"; - radio.checked = true; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = true; - - const radioEvent = new window.Event("change"); - Object.defineProperty(radioEvent, "target", { - value: radio, - enumerable: true, - writable: true, - }); - - checkEventConversion(radioEvent, { - target: { - value: "on", - checked: true, - type: "radio", - }, - }); - - const checkboxEvent = new window.Event("change"); - Object.defineProperty(checkboxEvent, "target", { - value: checkbox, - enumerable: true, - writable: true, - }); - - checkEventConversion(checkboxEvent, { - target: { - value: "on", - checked: true, - type: "checkbox", - }, - }); -}); - -test("excludes 'on' properties when missing", () => { - const div = document.createElement("div"); - div.onclick = () => {}; - // @ts-ignore - div.oncustom = null; - - const event = new window.Event("click"); - Object.defineProperty(event, "target", { - value: div, - enumerable: true, - writable: true, - }); - - const converted: any = convert(event); - expect(converted.target.onclick).toBeUndefined(); - expect(converted.target.oncustom).toBeUndefined(); -}); - -test("includes name property for inputs", () => { - const input = document.createElement("input"); - input.name = "test-input"; - input.value = "test-value"; - - const event = new window.Event("change"); - Object.defineProperty(event, "target", { - value: input, - enumerable: true, - writable: true, - }); - - checkEventConversion(event, { - target: { - name: "test-input", - value: "test-value", - }, - }); -}); - -test("includes checked property for checkboxes", () => { - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - - // Test checked = true - checkbox.checked = true; - let event = new window.Event("change"); - Object.defineProperty(event, "target", { - value: checkbox, - enumerable: true, - writable: true, - }); - - checkEventConversion(event, { - target: { - checked: true, - type: "checkbox", - }, - }); - - // Test checked = false - checkbox.checked = false; - event = new window.Event("change"); - Object.defineProperty(event, "target", { - value: checkbox, - enumerable: true, - writable: true, - }); - - checkEventConversion(event, { - target: { - checked: false, - type: "checkbox", - }, - }); -}); - -test("converts file input with files", () => { - const input = window.document.createElement("input"); - input.type = "file"; - - // Create a mock file - const file = new window.File(["content"], "test.txt", { - type: "text/plain", - lastModified: 1234567890, - }); - - // Mock the files property - const mockFileList = { - 0: file, - length: 1, - item: (index: number) => (index === 0 ? file : null), - [Symbol.iterator]: function* () { - yield file; - }, - }; - - Object.defineProperty(input, "files", { - value: mockFileList, - writable: true, - }); - - const event = new window.Event("change"); - Object.defineProperty(event, "target", { - value: input, - enumerable: true, - writable: true, - }); - - const converted: any = convert(event); - - expect(converted.target.files).toBeDefined(); - expect(converted.target.files.length).toBe(1); - expect(converted.target.files[0].name).toBe("test.txt"); -}); - -test("converts form submission with file input", () => { - const form = window.document.createElement("form"); - const input = window.document.createElement("input"); - input.type = "file"; - input.name = "myFile"; - - // Create a mock file - const file = new window.File(["content"], "test.txt", { - type: "text/plain", - lastModified: 1234567890, - }); - - // Mock the files property - const mockFileList = { - 0: file, - length: 1, - item: (index: number) => (index === 0 ? file : null), - [Symbol.iterator]: function* () { - yield file; - }, - }; - - Object.defineProperty(input, "files", { - value: mockFileList, - writable: true, - }); - - form.appendChild(input); - - const event = new window.Event("submit"); - Object.defineProperty(event, "target", { - value: form, - enumerable: true, - writable: true, - }); - - const converted: any = convert(event); - - expect(converted.target.myFile).toBeDefined(); - expect(converted.target.myFile.files).toBeDefined(); - expect(converted.target.myFile.files.length).toBe(1); - expect(converted.target.myFile.files[0].name).toBe("test.txt"); -}); - -test("handles recursive structures", () => { - // Direct recursion - const recursive: any = { a: 1 }; - recursive.self = recursive; - - const converted: any = convert(recursive); - expect(converted.a).toBe(1); - expect(converted.self).toBeUndefined(); - - // Indirect recursion - const indirect: any = { name: "root" }; - const child: any = { name: "child" }; - indirect.child = child; - child.parent = indirect; - - const convertedIndirect: any = convert(indirect); - expect(convertedIndirect.name).toBe("root"); - expect(convertedIndirect.child.name).toBe("child"); - expect(convertedIndirect.child.parent).toBeUndefined(); -}); - -test("handles shared references without stopping", () => { - const shared = { name: "shared" }; - const root = { - left: { item: shared }, - right: { item: shared }, - }; - - const converted: any = convert(root); - expect(converted.left.item.name).toBe("shared"); - expect(converted.right.item.name).toBe("shared"); - expect(converted.left.item).not.toEqual({ __stop__: true }); - expect(converted.right.item).not.toEqual({ __stop__: true }); -}); - -test("handles recursive HTML node structures", () => { - const parent = window.document.createElement("div"); - const child = window.document.createElement("span"); - parent.appendChild(child); - - // Add explicit circular references to ensure we test recursion - // even if standard DOM properties are not enumerable in this environment. - (parent as any).circular = parent; - (child as any).parentLink = parent; - (parent as any).childLink = child; - - const converted: any = convert(parent); - - // Verify explicit cycle is handled - expect(converted.circular).toBeUndefined(); - - // Verify child link is handled - if (converted.childLink) { - expect(converted.childLink.parentLink).toBeUndefined(); - } - - // If the DOM implementation enumerates parentNode, it should be handled gracefully - if ( - converted.children && - converted.children.length > 0 && - converted.children[0].parentNode - ) { - expect(converted.children[0].parentNode).toBeUndefined(); - } -}); - -test("pass-through on unexpected non-object inputs", () => { - expect(convert(null as any)).toEqual(null); - expect(convert(undefined as any)).toEqual(undefined); - expect(convert(42 as any)).toEqual(42); - expect(convert("test" as any)).toEqual("test"); -}); diff --git a/src/js/packages/event-to-object/tests/tooling/check.ts b/src/js/packages/event-to-object/tests/tooling/check.ts deleted file mode 100644 index 835823ad1..000000000 --- a/src/js/packages/event-to-object/tests/tooling/check.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { expect } from "bun:test"; -import { Event } from "happy-dom"; -// @ts-ignore -import lodash from "lodash"; -import convert from "../../src/index"; - -export function checkEventConversion( - givenEvent: Event, - expectedConversion: any, -): void { - // Patch happy-dom event to make standard properties enumerable and defined - const standardProps = [ - "bubbles", - "cancelable", - "composed", - "currentTarget", - "defaultPrevented", - "eventPhase", - "isTrusted", - "target", - "type", - "srcElement", - "returnValue", - "altKey", - "metaKey", - "ctrlKey", - "shiftKey", - "elapsedTime", - "propertyName", - "pseudoElement", - ]; - - for (const prop of standardProps) { - if (prop in givenEvent) { - try { - Object.defineProperty(givenEvent, prop, { - enumerable: true, - value: (givenEvent as any)[prop], - writable: true, - configurable: true, - }); - } catch { - // ignore - } - } - } - - // timeStamp is special - try { - Object.defineProperty(givenEvent, "timeStamp", { - enumerable: true, - value: givenEvent.timeStamp || Date.now(), - writable: true, - configurable: true, - }); - } catch { - // ignore - } - - // Patch undefined properties that are expected to be 0 or null - const defaults: any = { - offsetX: 0, - offsetY: 0, - layerX: 0, - layerY: 0, - pageX: 0, - pageY: 0, - x: 0, - y: 0, - screenX: 0, - screenY: 0, - movementX: 0, - movementY: 0, - detail: 0, - which: 0, - relatedTarget: null, - }; - - for (const [key, value] of Object.entries(defaults)) { - if ((givenEvent as any)[key] === undefined && key in givenEvent) { - try { - Object.defineProperty(givenEvent, key, { - enumerable: true, - value: value, - writable: true, - configurable: true, - }); - } catch { - // ignore - } - } - } - - const actualSerializedEvent = convert( - // @ts-ignore - givenEvent, - 5, - ); - - if (!actualSerializedEvent) { - expect(actualSerializedEvent).toEqual(expectedConversion); - return; - } - - // too hard to compare - // @ts-ignore - expect(typeof actualSerializedEvent.timeStamp).toBe("number"); - - // Remove nulls from expectedConversionDefaults because convert() strips nulls - const comparisonDefaults = { - bubbles: false, - cancelable: false, - composed: false, - defaultPrevented: false, - eventPhase: 0, - }; - - const expected = lodash.merge( - // @ts-ignore - { timeStamp: actualSerializedEvent.timeStamp, type: givenEvent.type }, - comparisonDefaults, - expectedConversion, - ); - - // Remove keys from expected that are null or undefined, because convert() strips them - for (const key in expected) { - if (expected[key] === null || expected[key] === undefined) { - delete expected[key]; - } - } - - // Use toMatchObject to allow extra properties in actual (like layerX, detail, etc.) - expect(actualSerializedEvent).toMatchObject(expected); - - // verify result is JSON serializable - JSON.stringify(actualSerializedEvent); -} diff --git a/src/js/packages/event-to-object/tests/tooling/mock.ts b/src/js/packages/event-to-object/tests/tooling/mock.ts deleted file mode 100644 index f118003a2..000000000 --- a/src/js/packages/event-to-object/tests/tooling/mock.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const mockBoundingRect = { - left: 0, - top: 0, - right: 0, - bottom: 0, - x: 0, - y: 0, - height: 0, - width: 0, -}; - -export const mockElement = { - tagName: null, - getBoundingClientRect: () => mockBoundingRect, -}; - -export const mockGamepad = { - id: "test", - index: 0, - connected: true, - mapping: "standard", - axes: [], - buttons: [ - { - pressed: false, - touched: false, - value: 0, - }, - ], -}; - -export const mockTouch = { - identifier: 0, - pageX: 0, - pageY: 0, - screenX: 0, - screenY: 0, - clientX: 0, - clientY: 0, - force: 0, - radiusX: 0, - radiusY: 0, - rotationAngle: 0, - target: mockElement, -}; - -export const mockTouchObject = { - ...mockTouch, - target: {}, -}; diff --git a/src/js/packages/event-to-object/tests/tooling/setup.js b/src/js/packages/event-to-object/tests/tooling/setup.js deleted file mode 100644 index 12b99fa41..000000000 --- a/src/js/packages/event-to-object/tests/tooling/setup.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Window } from "happy-dom"; -import { beforeAll, beforeEach } from "bun:test"; - -export const window = new Window(); - -export function setup() { - global.window = window; - global.document = window.document; - global.navigator = window.navigator; - global.getComputedStyle = window.getComputedStyle; - global.requestAnimationFrame = null; - global.CSSStyleSheet = window.CSSStyleSheet; - global.CSSStyleDeclaration = window.CSSStyleDeclaration; - global.Window = window.constructor; - global.Document = window.document.constructor; - global.Node = window.Node; - global.Element = window.Element; - global.HTMLElement = window.HTMLElement; -} - -export function reset() { - window.document.title = ""; - window.document.head.innerHTML = ""; - window.document.body.innerHTML = "
"; - window.getSelection().removeAllRanges(); -} - -beforeAll(setup); -beforeEach(reset); diff --git a/src/js/packages/event-to-object/tsconfig.json b/src/js/packages/event-to-object/tsconfig.json deleted file mode 100644 index 4e3b040c6..000000000 --- a/src/js/packages/event-to-object/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "composite": true, - "noEmit": false - }, - "extends": "../../tsconfig.json", - "include": ["src"] -} diff --git a/src/js/packages/event-to-object/vitest.config.ts b/src/js/packages/event-to-object/vitest.config.ts deleted file mode 100644 index c92f3607e..000000000 --- a/src/js/packages/event-to-object/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.test.ts"], - environment: "happy-dom", - }, -}); diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py new file mode 100644 index 000000000..9bdf12cd1 --- /dev/null +++ b/src/reactpy/_event_proxy.py @@ -0,0 +1,1256 @@ +"""Client-backed ("live") proxies for browser events. + +Nothing about the object behind an event is sent to the server when it is dispatched. +The client keeps the object alive and sends only an id:: + + { + "type": "layout-event", + "target": "key-path:onClick", + "data": [{"__reactpy_event__": {"id": "5b1c..."}, "type": "click"}], + "seq": 3, + } + +ReactPy turns that into an :class:`Event`. Reading any property of it hands back +another :class:`EventProxy`, and *awaiting* that proxy is what asks the browser for the +value: + +.. code-block:: python + + async def on_click(event: Event): + value = await event.target.value # one round trip + await event.target.focus() # one round trip + +This is deliberately the same shape as JavaScript. ``event.target.value`` names the same +property chain in both places; the only difference is that reading it from Python is an +``await``, because the value lives in the browser rather than in this process. Every +proxy is awaitable, always - whether the value happens to be known already or not. + +A read belongs to the moment it is awaited rather than to the event which woke the +handler. ``await event.target.value`` asks the browser what the element holds *now*, +just as the same expression would in JavaScript, so a handler which is still queued +behind another event's work sees whatever that element has become by then - not the +value which triggered it. This is the whole of the difference between reading live and +being handed a snapshot: nothing is remembered about the past, and a handler which must +act on the value as it stood when its own event fired should read it before anything +else is given the chance to change it. + +Waiting costs a round trip, so two things keep the count down. A property which has been +read is cached against its event, so reading it again sends nothing. Properties which are +in flight at the same moment are sent together as one message: + +.. code-block:: python + + value, key = await event.read("target.value", "key") + value, key = await asyncio.gather(event.target.value, event.key) + +Both cost a single round trip. Two ``await`` expressions written one after the other +cannot be batched, because the second is not known until the first has landed. + +Resolution travels over the existing connection with two extra message types: + +.. code-block:: json + + {"type": "event-proxy-request", "requestId": "...", "eventId": "...", + "path": ["target", "value"], "call": false, "args": [], "reply": true, + "maxDepth": 10} + + {"type": "event-proxy-response", "requestId": "...", "ok": true, + "value": "hello", "error": null, "code": null} + +A failed answer says which kind of failure it was in ``code``: ``"not-found"`` for an +object the page has forgotten, ``"evicted"`` for one it dropped to make room for the +events behind it while this side was still reading it, and ``"access"`` for whatever the +page refused outright. + +Several requests travel as one ``{"type": "event-proxy-request", "requests": [...]}`` +and are answered by one ``{"type": "event-proxy-response", "responses": [...]}``. + +Objects which cannot be serialized (DOM nodes, functions, ...) come back as a +reference - ``{"__reactpy_ref__": {"path": ["target"]}}`` - which ReactPy turns into +another proxy, so arbitrarily deep structures can be walked on demand. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +from collections.abc import Awaitable, Callable, Mapping, Sequence +from concurrent.futures import Future +from concurrent.futures import TimeoutError as FuturesTimeoutError +from logging import getLogger +from typing import TYPE_CHECKING, Any, Generic, NoReturn, TypeVar +from uuid import uuid4 + +from reactpy.config import ( + REACTPY_EVENT_PROXY_MAX_DEPTH, + REACTPY_EVENT_PROXY_MAX_TIMEOUTS, + REACTPY_EVENT_PROXY_TIMEOUT, +) + +logger = getLogger(__name__) + +EVENT_PROXY_REQUEST_TYPE = "event-proxy-request" +"""The ``type`` of a server to client live event access message""" + +EVENT_PROXY_RESPONSE_TYPE = "event-proxy-response" +"""The ``type`` of a client to server live event access result message""" + +EVENT_MARKER_KEY = "__reactpy_event__" +"""The key used to mark event data as being backed by a live client side object""" + +EVENT_REFERENCE_KEY = "__reactpy_ref__" +"""The key used to mark a value as a reference to a live client side object""" + +_T = TypeVar("_T") +_MISSING = object() + +CACHE_LIMIT = 2048 +"""How many resolved property values a single connection remembers before starting over +""" + +PENDING_LIMIT = 256 +"""How many live event requests one connection may have waiting on the client at once + +Nobody can read a hundred properties at a time and get anything useful out of it, and a +reader which goes away - a cancelled handler, a component which unmounted - would +otherwise leave its request behind to be answered by nobody forever. +""" + +SendCoroutine = Callable[[dict[str, Any]], Awaitable[None]] +"""Sends a single message to the client which owns a given event""" + + +class EventProxyError(RuntimeError): + """Base class for failures which occur while resolving live event data""" + + +class EventProxyUnavailable(EventProxyError): + """The client which dispatched this event cannot serve live event access""" + + +class EventProxyTimeout(EventProxyError): + """The client did not answer a live event access request in time""" + + +class EventProxyNotFound(EventProxyError): + """The browser object this proxy refers to is no longer available""" + + +class EventProxyEvicted(EventProxyNotFound): + """The browser ran out of room for this object while it was still being read + + The plain :class:`EventProxyNotFound` says the object went away on its own - nobody + read it, and it went stale. This says something different: the page had handed over + this object, was expecting to keep reading it, and dropped it to make room for the + events which arrived behind it. Worth telling apart in a log, because the thing to + look at is the client's own limit rather than the handler. + """ + + +class EventProxyAccessError(EventProxyError): + """The client refused or failed to resolve the requested path""" + + +def _prefer_failure( + current: BaseException | None, candidate: BaseException +) -> BaseException: + """Pick what to raise when several paths of one batch went wrong + + A timeout says nothing about the property which was asked for - only that the + client was not there to answer. Any other failure explains something about the + data, which is the more useful of the two to put in front of a handler. + """ + if current is None: + return candidate + if isinstance(current, EventProxyTimeout) and not isinstance( + candidate, EventProxyTimeout + ): + return candidate + return current + + +class EventProxyBackend: + """Sends live event access requests and routes the answers back to waiters + + One backend is owned by each connected client (a layout). It knows how to talk to + the browser and keeps track of every request which is currently in flight. + """ + + def __init__(self) -> None: + self._send: SendCoroutine | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._loop_thread_id: int | None = None + self._pending: dict[str, Future] = {} + self._send_tasks: set[Any] = set() + self._timeouts = 0 + self._disabled = False + self._closed = False + # Requests are counted against the round trip which carried them - one send of + # a batch, or one answer which came back - rather than against each other. + self._round_trip = 0 + self._batch_of_request: dict[str, int] = {} + self._struck: set[int] = set() + self._cache: dict[tuple[str, tuple[str, ...]], Any] = {} + self._inflight: dict[ + tuple[str, tuple[str, ...]], tuple[str, Future | None] + ] = {} + self._outgoing: list[dict[str, Any]] = [] + self._flush_scheduled = False + + @property + def available(self) -> bool: + """Whether live event access may currently be attempted""" + return self._send is not None and not self._disabled + + @property + def disabled(self) -> bool: + """Whether live event access has been switched off for this connection""" + return self._disabled + + def set_sender(self, send: SendCoroutine) -> None: + """Bind the coroutine which sends a single message to the client + + This must be called from the event loop which runs the associated layout since + live access needs to know both *how* and *from which thread* to talk to the + browser. + + Binding a *different* sender also brings live access back if an earlier one was + switched off. Being switched off is a statement about the connection which was + bound at the time - that the client behind it stopped answering - and it says + nothing about the client which has just connected. That one has yet to be given + the chance to answer for itself. + """ + if self._send is not send: + self._reactivate() + self._send = send + self._loop = asyncio.get_running_loop() + self._loop_thread_id = threading.get_ident() + + def _reactivate(self) -> None: + """Give a newly bound sender the chance to answer for itself + + Without this, being switched off is permanent for the life of the layout, and a + single unanswered message - a page which was reloaded, a socket which was + replaced - leaves every later handler reading nothing but + :class:`EventProxyUnavailable` while a perfectly willing browser sits on the + other end of the connection. + """ + if self._closed or not (self._disabled or self._timeouts): + return + self._disabled = False + self._timeouts = 0 + self._struck.clear() + logger.debug("Live event access re-enabled for a newly bound sender") + + def disable(self, reason: str) -> None: + """Stop attempting live access, for example because the client went away""" + if not self._disabled: + self._disabled = True + logger.warning(f"Live event access disabled - {reason}") + self._abandon_pending(reason) + + def close(self) -> None: + """Abandon all in-flight requests (called when the layout shuts down) + + This one is final. Binding a sender afterwards does not bring live access back, + because there is no layout left for the answers to belong to. + """ + self._disabled = True + self._closed = True + self._abandon_pending("the layout was closed") + + def _abandon_pending(self, reason: str) -> None: + for future in self._pending.values(): + if not future.done(): + future.set_exception(EventProxyUnavailable(reason)) + self._pending.clear() + self._batch_of_request.clear() + self._inflight.clear() + self._outgoing.clear() + self._flush_scheduled = False + + def handle_response(self, message: Mapping[str, Any]) -> bool: + """Route a ``event-proxy-response`` message to whoever is waiting for it + + A message either answers one request or, when several went out together, carries + a ``responses`` list answering each of them in turn. + + Returns :data:`True` if the message was meant for this backend. + """ + if message.get("type") != EVENT_PROXY_RESPONSE_TYPE: + return False + + # Everything which came back in this message travelled as one round trip + self._begin_round_trip() + + responses = message.get("responses") + if isinstance(responses, list): + for entry in responses: + if isinstance(entry, Mapping): + self._route(entry) + return True + + self._route(message) + return True + + def _route(self, message: Mapping[str, Any]) -> None: + """Deliver one answer to the request which asked for it""" + request_id = message.get("requestId") + future = self._take(request_id) + if future is None: + # Either the request timed out or the layout was closed while it was in + # flight. Either way there is nobody left to deliver the answer to. + logger.debug( + f"Ignored live event response to unknown request {request_id!r}" + ) + return + + if not future.done(): + future.set_result( + { + "ok": bool(message.get("ok")), + "value": message.get("value"), + "error": message.get("error"), + "code": message.get("code"), + } + ) + + async def resolve( + self, + event_id: str, + path: Sequence[str | int], + call: bool = False, + args: Sequence[Any] = (), + refresh: bool = False, + ) -> Any: + """Resolve ``path`` against the client side event (awaited from the loop) + + Reads are remembered against the event, so asking for the same property twice + sends one request. A request which is already in flight is shared rather than + duplicated. ``refresh`` asks the browser what it holds now instead, which costs + the round trip again and replaces what was remembered. + """ + key = None if call else self._cache_key(event_id, path) + if key is not None: + if not refresh and key in self._cache: + return self._cache[key] + shared = self._inflight.get(key) + if shared is not None: + # Somebody else has already asked for this, so there is no sense in + # asking twice - whether or not the answer is wanted fresh. The one who + # asked owns the request and releases it; this reader only borrows the + # answer, and walking away early must not take the request with it. + payload = await self._await(*shared, event_id, path, owner=False) + return self._to_value(payload, event_id, path) + + request_id, future = self._enqueue(event_id, path, call, args, reply=True) + if key is not None and future is not None: + self._inflight[key] = (request_id, future) + try: + payload = await self._await(request_id, future, event_id, path) + finally: + # Whatever happened, nobody may keep waiting on a future which is already + # done - on a timeout it has been cancelled, and leaving it here would hand + # that cancellation to the next reader of this path instead of letting it + # ask the browser again. + if key is not None: + self._inflight.pop(key, None) + value = self._to_value(payload, event_id, path) + if key is not None: + self._remember(key, value) + return value + + async def resolve_many( + self, + event_id: str, + paths: Sequence[Sequence[str | int]], + refresh: bool = False, + ) -> list[Any]: + """Resolve several paths of one event in as few messages as possible + + Anything already cached is answered on the spot. Everything else is enqueued + together, so it leaves the process as a single batched request. Pass ``refresh`` + to ask the browser about all of them again, whatever is remembered. + + Whatever the client did answer is kept even when another path of the same + batch went wrong - that round trip has already been paid for. When more than + one path fails, the failure which says something about the data is the one + which reaches the handler rather than the one which only says the client was + slow to answer. + """ + values: list[Any] = [None] * len(paths) + outstanding: list[ + tuple[int, tuple[str, tuple[str, ...]], tuple[str, Future | None], bool] + ] = [] + ours: list[tuple[str, tuple[str, ...]]] = [] + for index, path in enumerate(paths): + key = self._cache_key(event_id, path) + if not refresh and key in self._cache: + values[index] = self._cache[key] + continue + shared = self._inflight.get(key) + if shared is not None: + # Somebody else asked for this one and is waiting on it too; it is + # theirs to release, and this reader walking away must not take it + # away from under them. + outstanding.append((index, key, shared, False)) + continue + entry = self._enqueue(event_id, path, False, (), True) + self._inflight[key] = entry + ours.append(key) + outstanding.append((index, key, entry, True)) + + try: + payloads = await asyncio.gather( + *( + self._await(request_id, future, event_id, paths[index], owner) + for index, _, (request_id, future), owner in outstanding + ), + # One path going wrong must not throw away the answers the client + # already gave to its neighbours in the same batch. + return_exceptions=True, + ) + finally: + for key in ours: + self._inflight.pop(key, None) + + failure: BaseException | None = None + for (index, key, _, _), payload in zip(outstanding, payloads, strict=True): + if isinstance(payload, asyncio.CancelledError): + raise payload # nocov - nothing cancels a request which is in flight + if isinstance(payload, BaseException): + failure = _prefer_failure(failure, payload) + continue + try: + value = self._to_value(payload, event_id, paths[index]) + except BaseException as error: + # Kept rather than raised on the spot, so that the rest of the batch + # still gets as far as the cache before something is re-raised + failure = _prefer_failure(failure, error) + continue + values[index] = value + self._remember(key, value) + + if failure is not None: + raise failure + return values + + def notify( + self, + event_id: str, + path: Sequence[str | int], + args: Sequence[Any] = (), + ) -> None: + """Call a client side method without waiting for its result (best effort)""" + try: + self._enqueue(event_id, path, True, args, reply=False) + except EventProxyError: + logger.debug( + f"Could not schedule live call to {list(path)!r} of {event_id!r}" + ) + return + + # -- internals -------------------------------------------------------- + + @staticmethod + def _cache_key( + event_id: str, path: Sequence[str | int] + ) -> tuple[str, tuple[str, ...]]: + return event_id, tuple(str(segment) for segment in path) + + def _remember(self, key: tuple[str, tuple[str, ...]], value: Any) -> None: + if len(self._cache) >= CACHE_LIMIT: + # Reading is meant to be cheap, not a place to accumulate memory + self._cache.clear() + self._cache[key] = value + + async def _await( + self, + request_id: str, + future: Future | None, + event_id: str, + path: Sequence[str | int], + owner: bool = True, + ) -> dict[str, Any]: + if future is None: # nocov - only reply=False requests have no future + return {"ok": True, "value": None, "error": None, "code": None} + timeout = REACTPY_EVENT_PROXY_TIMEOUT.current + if not owner: + # This is somebody else's request, so nothing here is ours to give up. The + # wait is shielded: walking away must not cancel the answer the reader which + # asked is still waiting to collect, and a wait of our own which ran out + # says so rather than striking a round trip which that reader is still due + # an answer to. + try: + return await asyncio.wait_for( + asyncio.shield(asyncio.wrap_future(future)), timeout + ) + except (TimeoutError, FuturesTimeoutError): + msg = self._timeout_message(event_id, path) + raise EventProxyTimeout(msg) from None + try: + return await asyncio.wait_for(asyncio.wrap_future(future), timeout) + except (TimeoutError, FuturesTimeoutError): + self._handle_timeout(request_id, event_id, path) + finally: + # Whatever is still recorded for this request at this point belongs to + # nobody. Either the answer came in and was routed, or it went unanswered + # and was counted as such - or the reader itself went away, which is the one + # case which would otherwise leave a request waiting forever for a handler + # which is no longer there to be woken by it. + self._take(request_id) + + def _timeout_message(self, event_id: str, path: Sequence[str | int]) -> str: + return ( + f"the client did not answer the request for {list(path)!r} of event " + f"{event_id!r} within {REACTPY_EVENT_PROXY_TIMEOUT.current}s" + ) + + def _enqueue( + self, + event_id: str, + path: Sequence[str | int], + call: bool, + args: Sequence[Any], + reply: bool, + ) -> tuple[str, Future | None]: + """Queue one request to leave with whatever else is waiting, and await it""" + request_id, message, future = self._begin( + event_id, path, call, args, reply=reply + ) + self._outgoing.append(message) + self._schedule_flush() + return request_id, future + + def _schedule_flush(self) -> None: + """Send everything queued, once, at the end of the current pass round the loop""" + loop = self._loop + if loop is None or self._flush_scheduled: + return + self._flush_scheduled = True + if threading.get_ident() == self._loop_thread_id: + loop.call_soon(self._flush) + else: + loop.call_soon_threadsafe(self._flush) + + def _flush(self) -> None: + self._flush_scheduled = False + batch, self._outgoing = self._outgoing, [] + if not batch: + return + self._begin_round_trip() + if self._loop is None or self._send is None: + self._abandon(batch) + return + for entry in batch: + request_id = entry.get("requestId") + if isinstance(request_id, str) and request_id in self._pending: + self._batch_of_request[request_id] = self._round_trip + message = ( + batch[0] + if len(batch) == 1 + else {"type": EVENT_PROXY_REQUEST_TYPE, "requests": batch} + ) + task = self._loop.create_task(self._send_message(message)) + self._send_tasks.add(task) + task.add_done_callback(self._send_tasks.discard) + + def _abandon(self, batch: Sequence[dict[str, Any]]) -> None: + for message in batch: + future = self._take(message.get("requestId")) + if future is not None and not future.done(): + future.set_exception( + EventProxyUnavailable("no sender is bound to this connection") + ) + + def _begin( + self, + event_id: str, + path: Sequence[str | int], + call: bool, + args: Sequence[Any], + reply: bool, + ) -> tuple[str, dict[str, Any], Future | None]: + if not self.available: + msg = f"live access to event {event_id!r} is unavailable" + raise EventProxyUnavailable(msg) + if call and not path: + msg = "the event object itself cannot be called" + raise EventProxyAccessError(msg) + if reply and len(self._pending) >= PENDING_LIMIT: + msg = ( + f"too many live event requests ({PENDING_LIMIT}) are already waiting " + f"for the client to answer, so {list(path)!r} of event {event_id!r} " + f"was not asked for" + ) + raise EventProxyUnavailable(msg) + + request_id = uuid4().hex + message: dict[str, Any] = { + "type": EVENT_PROXY_REQUEST_TYPE, + "requestId": request_id, + "eventId": event_id, + "path": [str(segment) for segment in path], + "call": call, + "args": [serialize_argument(argument) for argument in args], + "reply": reply, + "maxDepth": REACTPY_EVENT_PROXY_MAX_DEPTH.current, + } + future: Future | None = None + if reply: + future = Future() + self._pending[request_id] = future + return request_id, message, future + + async def _send_message(self, message: dict[str, Any]) -> None: + send = self._send + if send is None: # nocov - guarded by _flush + return + try: + await send(message) + except Exception as error: # any send failure kills live access for this client + self.disable(f"failed to send a live event request ({error})") + + def _take(self, request_id: Any) -> Future | None: + """Stop waiting on one request, forgetting which round trip carried it too""" + if not isinstance(request_id, str): + return None + self._batch_of_request.pop(request_id, None) + return self._pending.pop(request_id, None) + + def _begin_round_trip(self) -> None: + """Count one message, in either direction, as a round trip of its own""" + self._round_trip += 1 + self._struck.clear() + + def _handle_timeout( + self, request_id: str, event_id: str, path: Sequence[str | int] + ) -> NoReturn: + self._strike(request_id) + msg = self._timeout_message(event_id, path) + raise EventProxyTimeout(msg) + + def _strike(self, request_id: str) -> None: + """Record one round trip which the client did not answer + + A batch which went silent used to be counted once per property it was asking + for, so a handler which read four properties of one event exhausted a budget of + three all by itself, and live access was switched off for a client which had + merely been asked several things at once. What is being measured is whether the + client answers at all, which is why one unanswered message costs one strike no + matter how many properties it was after. + + Only the messages which are still outstanding can be struck, since starting a + round trip in either direction clears this - so it cannot accumulate. + """ + batch = self._batch_of_request.pop(request_id, None) + self._take(request_id) + if batch is None: + # A timeout the client reported itself belongs to the answer being handled + batch = self._round_trip + if batch in self._struck: + return + self._struck.add(batch) + self._timeouts += 1 + max_timeouts = REACTPY_EVENT_PROXY_MAX_TIMEOUTS.current + if max_timeouts > 0 and self._timeouts >= max_timeouts: + self.disable( + f"{self._timeouts} consecutive live event round trips went unanswered" + ) + + def _to_value( + self, + payload: Mapping[str, Any], + event_id: str, + path: Sequence[str | int], + ) -> Any: + if not payload.get("ok"): + code = payload.get("code") or "access" + error = payload.get("error") or "the client did not provide a reason" + message = f"could not resolve {list(path)!r} of event {event_id!r}: {error}" + if code == "evicted": + raise EventProxyEvicted(message) + if code == "not-found": + raise EventProxyNotFound(message) + if code == "timeout": + self._handle_timeout("", event_id, path) + raise EventProxyAccessError(message) + + self._timeouts = 0 + return deserialize_value(payload.get("value"), event_id, self) + + +_NO_PROXY_BACKEND = EventProxyBackend() +"""A backend which is never bound to a connection, standing in for an unbacked event + +Since it has no sender, ``available`` is :data:`False` and any attempt to resolve +something through it raises :class:`EventProxyUnavailable`. +""" + + +async def _immediate(value: Any) -> Any: + """Answer an await without asking the browser anything + + Used by a proxy whose value the client already gave us - the event ``type`` - so + that awaiting any proxy behaves the same whether or not it is already known. + """ + return value + + +def serialize_argument(value: Any, depth: int = 0) -> Any: + """Prepare a Python value to be handed to a client side method + + Nesting is bounded the way reading is, so that a value which goes deeper than the + connection can carry is refused in a way a handler can read, instead of overflowing + the stack on its way out. + """ + if depth > REACTPY_EVENT_PROXY_MAX_DEPTH.current: + msg = ( + f"arguments for a live event call are nested more deeply than " + f"{REACTPY_EVENT_PROXY_MAX_DEPTH.current} levels" + ) + raise EventProxyAccessError(msg) + + if isinstance(value, EventProxy): + return { + EVENT_REFERENCE_KEY: { + "eventId": value.event_id, + "path": [str(segment) for segment in value.path], + } + } + if value is None or isinstance(value, bool | int | float | str): + return value + if isinstance(value, Mapping): + return { + str(key): serialize_argument(item, depth + 1) for key, item in value.items() + } + if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray): + return [serialize_argument(item, depth + 1) for item in value] + + try: + json.dumps(value) + except (TypeError, ValueError) as error: + msg = f"cannot pass {value!r} to the client: {error}" + raise EventProxyAccessError(msg) from error + return value # nocov - json.dumps only accepts types already handled above + + +def deserialize_value( + value: Any, + event_id: str, + backend: EventProxyBackend, + depth: int = 0, +) -> Any: + """Turn a client supplied value into data, replacing references with proxies""" + if depth > REACTPY_EVENT_PROXY_MAX_DEPTH.current: + msg = ( + f"live event data for event {event_id!r} is nested more deeply than " + f"{REACTPY_EVENT_PROXY_MAX_DEPTH.current} levels" + ) + raise EventProxyAccessError(msg) + + if isinstance(value, Mapping): + reference = value.get(EVENT_REFERENCE_KEY) + if isinstance(reference, Mapping): + child = EventProxy( + backend=backend, + event_id=str(reference.get("eventId", event_id)), + path=tuple(reference.get("path", ())), + ) + child._reactpy_callable = bool(reference.get("callable")) + return child + return { + key: deserialize_value(item, event_id, backend, depth + 1) + for key, item in value.items() + } + + if isinstance(value, list): + return [deserialize_value(item, event_id, backend, depth + 1) for item in value] + + return value + + +class EventProxy(Generic[_T]): + """A live, lazily resolved view onto a browser object + + Nothing about the object is known until it is asked for. Reading any property hands + back another proxy, and *awaiting* that proxy is what sends the request:: + + async def on_input(event: Event): + value = await event.target.value + await event.target.focus() + + This is the same property chain the browser sees. The one difference is that reading + it from Python is an ``await``, because the value lives over the connection. Every + proxy is awaitable, whether or not its value happens to be known already - there is + no second, non-awaitable kind of value to trip over. + + A property which has been read is cached against its event, and properties which are + in flight at the same moment travel together as one message:: + + value, key = await event.read("target.value", "key") + + The cache is keyed on the event and the path, so a property is asked for at most + once and a second read hands back what the first one found. That is what makes a + handler which reads the same property twice free rather than expensive - and it is + also why a property the page may have changed in the meantime is read with + ``refresh``:: + + (latest,) = await event.read("target.value", refresh=True) + + A property the browser will not give - a blocked member, an object it has forgotten, + a path which is not there - raises a subclass of :class:`EventProxyError`. Nothing is + ever quietly answered with :data:`None`. + """ + + __slots__ = ( + "_reactpy_backend", + "_reactpy_callable", + "_reactpy_debounce", + "_reactpy_event_id", + "_reactpy_flags", + "_reactpy_has_value", + "_reactpy_known", + "_reactpy_path", + "_reactpy_value", + ) + + def __init__( + self, + *, + backend: EventProxyBackend | None = None, + event_id: str = "", + path: Sequence[str | int] = (), + flags: dict[str, bool] | None = None, + value: Any = _MISSING, + known: Mapping[str, Any] | None = None, + ) -> None: + self._reactpy_backend = backend if backend is not None else _NO_PROXY_BACKEND + self._reactpy_event_id = event_id + self._reactpy_path: tuple[str | int, ...] = tuple(path) + self._reactpy_flags = {} if flags is None else flags + self._reactpy_debounce: int | None = None + self._reactpy_callable = False + self._reactpy_has_value = value is not _MISSING + self._reactpy_value = None if value is _MISSING else value + self._reactpy_known = {} if known is None else dict(known) + + # -- identity --------------------------------------------------------- + + @property + def event_id(self) -> str: + """The client assigned identifier of the event this proxy belongs to""" + return self._reactpy_event_id + + @property + def path(self) -> tuple[str | int, ...]: + """The property path, relative to the event object, this proxy points at""" + return self._reactpy_path + + @property + def backend(self) -> EventProxyBackend: + """The connection which serves this proxy""" + return self._reactpy_backend + + @property + def is_known(self) -> bool: + """Whether the value is already held here, so awaiting sends nothing""" + return self._reactpy_has_value + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(event_id={self._reactpy_event_id!r}, " + f"path={list(self._reactpy_path)!r})" + ) + + def __dir__(self) -> list[str]: + return sorted({*dir(type(self)), *self._reactpy_known_members()}) + + def _reactpy_known_members(self) -> list[str]: + """Properties the client told us about when it dispatched the event""" + return list(self._reactpy_known) + + # -- child access ----------------------------------------------------- + + def _reactpy_make_child( + self, segment: str | int, value: Any = _MISSING + ) -> EventProxy[Any]: + """A proxy one segment deeper, awaiting which reads it from the browser + + The value comes free in one case only: the client volunteered it when it + dispatched the event (the event ``type``, which is small and needed to describe + the event even after the object behind it has been forgotten). The child is + awaitable either way - a known value just means awaiting it sends nothing. + """ + if value is _MISSING and self._reactpy_known: + value = self._reactpy_known.get(str(segment), _MISSING) + return EventProxy( + backend=self._reactpy_backend, + event_id=self._reactpy_event_id, + path=(*self._reactpy_path, segment), + flags=self._reactpy_flags, + value=value, + ) + + @staticmethod + def _reactpy_normalize_path(path: str | Sequence[Any]) -> list[str]: + if isinstance(path, str): + return [segment for segment in path.split(".") if segment] + return [str(segment) for segment in path] + + def _reactpy_invoke( + self, name: str, args: Sequence[Any] = (), wait: bool = False + ) -> Any: + """Call the client side method ``name`` on the object this proxy points at""" + path = (*self._reactpy_path, name) + backend = self._reactpy_backend + if wait: + return backend.resolve(self._reactpy_event_id, path, call=True, args=args) + backend.notify(self._reactpy_event_id, path, args) + return None + + # -- event level conveniences ----------------------------------------- + + def preventDefault(self) -> None: + """Prevent the default action of the underlying DOM event + + The body of this method is empty on purpose. ReactPy reads the bytecode of your + handler as it is created, and finding this call anywhere within it sets + :attr:`~reactpy.types.BaseEventHandler.prevent_default` to :data:`True`. The + browser is then asked to call ``preventDefault()`` itself, before the event is + ever sent to the server. Writing ``@event(prevent_default=True)`` does exactly + the same thing, but says so out loud. + + If the object behind this proxy is still alive, the JavaScript call is also + scheduled on the client without being waited for. The browser has usually moved + on by then, so depend on the handler flag when the action truly must not happen. + """ + self._reactpy_flags["preventDefault"] = True + self._reactpy_invoke("preventDefault") + + def stopPropagation(self) -> None: + """Stop the underlying DOM event from propagating further up the tree + + Like :meth:`preventDefault`, calling this is detected in the bytecode of your + handler and turned into + :attr:`~reactpy.types.BaseEventHandler.stop_propagation` - the browser stops + the event before it reaches the server. The call is additionally forwarded to a + still-live event object, but is not waited for. + """ + self._reactpy_flags["stopPropagation"] = True + self._reactpy_invoke("stopPropagation") + + def stopImmediatePropagation(self) -> None: + """Also stop any remaining listeners of the underlying DOM event + + Detected in the bytecode of your handler just like :meth:`stopPropagation`, and + additionally forwarded to a still-live event object. + """ + self._reactpy_flags["stopPropagation"] = True + self._reactpy_flags["stopImmediatePropagation"] = True + self._reactpy_invoke("stopImmediatePropagation") + + @property + def defaultPrevented(self) -> bool: + """Whether :meth:`preventDefault` has been asked for on this proxy + + This answers synchronously, and with what Python has asked the browser to do - + not with what the browser settled on. What the browser holds is a property of + the live event, so it is read the way any other browser property is:: + + async def on_submit(event: Event): + (prevented,) = await event.read("defaultPrevented") + + That round trip is worth paying for when other scripts on the page may have + prevented the action as well, or when this handler never called + :meth:`preventDefault` at all. + """ + return bool(self._reactpy_flags.get("preventDefault")) + + @property + def propagationStopped(self) -> bool: + """Whether :meth:`stopPropagation` has been asked for on this proxy""" + return bool(self._reactpy_flags.get("stopPropagation")) + + @property + def debounce(self) -> int | None: + """Rate limit (in milliseconds) applied to server driven value updates""" + return self._reactpy_debounce + + @debounce.setter + def debounce(self, value: int | None) -> None: + self._reactpy_debounce = value + + # -- property access ---------------------------------------------------- + + def __getattr__(self, name: str) -> Any: + if name.startswith("_reactpy") or ( + name.startswith("__") and name.endswith("__") + ): + # Never resolve internals or interpreter lookups against the browser + raise AttributeError(name) + return self._reactpy_make_child(name) + + # Deliberately no __getitem__, __len__, __iter__ or __contains__: a proxy is not a + # container, and there is nothing to enumerate about an object which still lives in + # the browser. Define __getitem__ without __iter__ and ``x in event`` would fall + # back to trying event[0], event[1], ... forever, since no index is ever missing. + + # -- resolution ------------------------------------------------------- + + def _reactpy_resolve_path( + self, call: bool, args: Sequence[Any], refresh: bool = False + ) -> Any: + return self._reactpy_backend.resolve( + self._reactpy_event_id, + self._reactpy_path, + call=call, + args=args, + refresh=refresh, + ) + + def _reactpy_call_method(self, name: str, args: Sequence[Any]) -> Any: + path = (*self._reactpy_path, name) + return self._reactpy_backend.resolve( + self._reactpy_event_id, path, call=True, args=args + ) + + def composedPath(self) -> Awaitable[list[Any]]: + """Return the event's path, read from the browser + + Await the result:: + + async def on_click(event: Event): + print(await event.composedPath()) + """ + return self._reactpy_call_method("composedPath", ()) + + def resolve(self, *, refresh: bool = False) -> Awaitable[_T]: + """Fetch the value this proxy currently points at from the browser + + Awaiting the proxy itself does the same thing; this exists for the times when + spelling out the round trip reads better. + + A value which has been read before is handed back from what was read then, which + is what makes reading one property twice free. Pass ``refresh=True`` to ask the + browser what it holds by now instead. + """ + return self._reactpy_resolve_path(False, (), refresh) + + async def read( + self, *paths: str | Sequence[Any], refresh: bool = False + ) -> tuple[Any, ...]: + """Read one or more properties in as few round trips as possible + + Each argument is a dotted path, or a sequence of segments, relative to this + proxy; the result is a tuple of the same length. Whatever is already known is + answered on the spot and the rest goes to the browser as one message:: + + async def on_input(event: Event): + value, key = await event.read("target.value", "key") + + Awaiting the proxies directly reads just as well and shares the same cache:: + + value, key = await asyncio.gather(event.target.value, event.key) + + Values are fetched when the read is made rather than when the event fired, so a + read made after other work has intervened reports what the browser holds at that + moment. Whatever was read is remembered against the event and is not asked for + again, which is what makes two reads of one property cost one round trip. Pass + ``refresh=True`` to take the browser's answer afresh:: + + (latest,) = await event.read("target.value", refresh=True) + """ + if not paths: + return () + segments = [self._reactpy_normalize_path(path) for path in paths] + values: list[Any] = [None] * len(segments) + wanted: list[list[str | int]] = [] + wanted_at: list[int] = [] + for index, item in enumerate(segments): + if not refresh and len(item) == 1 and item[0] in self._reactpy_known: + # The client sent this with the event, so asking costs nothing + values[index] = self._reactpy_known[item[0]] + else: + wanted.append([*self._reactpy_path, *item]) + wanted_at.append(index) + if wanted: + resolved = await self._reactpy_backend.resolve_many( + self._reactpy_event_id, wanted, refresh + ) + for index, value in zip(wanted_at, resolved, strict=True): + values[index] = value + return tuple(values) + + def __await__(self) -> Any: + if self._reactpy_has_value: + return _immediate(self._reactpy_value).__await__() + return self._reactpy_resolve_path(False, ()).__await__() + + def __call__(self, *args: Any) -> Awaitable[Any]: + """Call the client side method this proxy currently points at""" + return self._reactpy_resolve_path(True, args) + + def __bool__(self) -> bool: + return True + + +if TYPE_CHECKING: + _ElementMembers = dict[str, Any] + + +class Event(EventProxy[_T]): + """The event object handed to event handler functions + + Reading any property of it hands back a proxy, and awaiting that proxy asks the + browser for the value:: + + async def on_key_down(event: Event): + if await event.key == "Enter": + await event.target.focus() + print(await event.target.value) + + This is the property chain the browser itself uses. The only difference is that a + read from Python is an ``await``, because the value lives in the browser. There is + no snapshot to be surprised by, and no value which is sometimes awaitable and + sometimes not - every proxy is awaitable, always. + + Each read is a round trip, so reads which are in flight together travel together:: + + value, key = await event.read("target.value", "key") + + What was read is remembered against the event, so reading one property twice costs + one round trip - and the second read hands back the first answer. Ask the browser + what it holds by now with ``refresh``:: + + (value,) = await event.read("target.value", refresh=True) + + A property the browser will not give - one which is blocked, or an object it has + already forgotten - raises a subclass of :class:`EventProxyError`. + + Every member below is a best-effort hint for editors and type checkers. At runtime + any property of any browser object can be read - unlisted ones simply resolve to + :class:`EventProxy`. + """ + + if TYPE_CHECKING: + # -- identity ----------------------------------------------------- + type: str + target: EventProxy[Any] + currentTarget: EventProxy[Any] + srcElement: EventProxy[Any] + relatedTarget: EventProxy[Any] + view: EventProxy[Any] + # -- flags -------------------------------------------------------- + # ``defaultPrevented`` and ``propagationStopped`` are typed by the base class. + bubbles: bool + cancelable: bool + composed: bool + cancelBubble: bool + isTrusted: bool + eventPhase: int + timeStamp: float + returnValue: Any + detail: Any + # -- mouse -------------------------------------------------------- + button: int + buttons: int + clientX: float + clientY: float + movementX: float + movementY: float + offsetX: float + offsetY: float + pageX: float + pageY: float + screenX: float + screenY: float + layerX: float + layerY: float + x: float + y: float + # -- keyboard ----------------------------------------------------- + key: str + code: str + char: str + charCode: int + keyCode: int + location: int + repeat: bool + isComposing: bool + altKey: bool + ctrlKey: bool + metaKey: bool + shiftKey: bool + # -- input / clipboard / wheel ------------------------------------ + data: str + inputType: str + dataTransfer: EventProxy[Any] + clipboardData: EventProxy[Any] + touches: list[Any] + targetTouches: list[Any] + changedTouches: list[Any] + deltaX: float + deltaY: float + deltaZ: float + deltaMode: int + + # -- methods ------------------------------------------------------ + # ``preventDefault()``, ``stopPropagation()``, ``stopImmediatePropagation()``, + # ``composedPath()``, ``resolve()`` and ``read()`` are all typed by + # :class:`EventProxy`, and every one of them is awaited. + + +def event_marker_id(item: Any) -> str | None: + """Return the live event identifier attached to event ``data``, if any""" + if not isinstance(item, Mapping): + return None + marker = item.get(EVENT_MARKER_KEY) + if isinstance(marker, str): + return marker + if isinstance(marker, Mapping): + event_id = marker.get("id") + return event_id if isinstance(event_id, str) else None + return None + + +def build_event_args( + data: Sequence[Any], + backend: EventProxyBackend | None, +) -> list[Any]: + """Turn the ``data`` of a ``layout-event`` message into handler arguments + + Items which the client marked as live become :class:`Event` objects which read from + and call into the browser. Nothing about them is filled in here beyond what the + client chose to send alongside the id - plain scalars only, which in practice means + the event ``type``. Anything that was never an object is handed over as it arrived. + """ + args: list[Any] = [] + for item in data: + event_id = event_marker_id(item) if backend is not None else None + if event_id is None: + args.append(item) + continue + known = { + key: value + for key, value in item.items() + if key != EVENT_MARKER_KEY and isinstance(value, str | int | float | bool) + } + args.append(Event(backend=backend, event_id=event_id, known=known)) + return args + + +class EventArguments(list): + """The positional arguments a :class:`~reactpy.types.BaseEventHandler` is called with + + Every handler is a coroutine, so there is a single flavour of event data and these + arguments can be handed straight to whichever handlers the event is routed to. + """ + + def __init__( + self, + data: Sequence[Any], + backend: EventProxyBackend | None = None, + ) -> None: + super().__init__(build_event_args(data, backend)) diff --git a/src/reactpy/config.py b/src/reactpy/config.py index d9d4f760c..07d380bd7 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -138,3 +138,34 @@ def boolean(value: str | bool | int) -> bool: validator=int, ) """The maximum size for internal queues used by ReactPy""" + +REACTPY_EVENT_PROXY_TIMEOUT = Option( + "REACTPY_EVENT_PROXY_TIMEOUT", + default=5.0, + mutable=True, + validator=float, +) +"""How long (in seconds) to wait for the client to answer a live event access""" + +REACTPY_EVENT_PROXY_MAX_DEPTH = Option( + "REACTPY_EVENT_PROXY_MAX_DEPTH", + default=10, + mutable=True, + validator=int, +) +"""How many nested object references a single live event resolution may contain""" + +REACTPY_EVENT_PROXY_MAX_TIMEOUTS = Option( + "REACTPY_EVENT_PROXY_MAX_TIMEOUTS", + default=3, + mutable=True, + validator=int, +) +"""How many consecutive unanswered round trips are tolerated before access is disabled + +Live event access requires a round trip to the client. If a client stops answering +(for example because it navigated away) ReactPy stops attempting live access after +this many consecutive round trips went unanswered so that event handlers do not stall. +What counts is the message, not the properties inside it: a batch which asked for four +properties and got no answer at all costs one of this budget, not four. +""" diff --git a/src/reactpy/core/_event_inspect.py b/src/reactpy/core/_event_inspect.py index e9adc8cd7..864651247 100644 --- a/src/reactpy/core/_event_inspect.py +++ b/src/reactpy/core/_event_inspect.py @@ -1,6 +1,8 @@ """ -In order to retain a JavaScript-like event handler API, ReactPy allows users to configure event handlers with `event.debounce = `. -Implementing this syntax necessitates inspecting the bytecode of event handler functions. +In order to retain a JavaScript-like event handler API, ReactPy allows users to +configure event handlers with `event.debounce = `, `event.preventDefault()` +and friends. Implementing this syntax necessitates inspecting the bytecode of event +handler functions. """ from __future__ import annotations @@ -19,47 +21,92 @@ _STORE_ATTR_VALUE_OFFSET = 2 -def inspect_event_handler(func: Callable) -> tuple[bool, bool, int | None]: +def _is_plain_int(value: Any) -> bool: + """Whether ``value`` is an ``int`` which is not a ``bool``""" + return isinstance(value, int) and not isinstance(value, bool) + + +def _handler_code(func: Callable) -> CodeType | None: + """The bytecode of the callable a user handed us + + A handler is normally a function, but an object whose ``__call__`` is a coroutine + is a handler too, and its bytecode lives on the class. + """ + code = getattr(func, "__code__", None) + if code is not None: + return code + # a callable object's own ``__call__`` is defined on its class + try: + call = type(func).__call__ + except AttributeError: # nocov - only a metaclass can hide this + return None + return getattr(call, "__code__", None) + + +def _event_arg_name(func: Callable, code: CodeType | None) -> str | None: + """What the given handler calls the event it is handed, if it takes one""" + if code is None: + return None + # the first parameter of a callable object's ``__call__`` is the object itself + offset = 0 if hasattr(func, "__code__") else 1 + if code.co_argcount <= offset: + return None + return code.co_varnames[offset] + + +def inspect_event_handler( + func: Callable, +) -> tuple[bool, bool | str, int | None]: """Detect ``preventDefault``/``stopPropagation``/``debounce`` usage. Walks the bytecode of ``func`` looking for the patterns: - ``event.preventDefault()`` -> ``prevent_default = True`` - ``event.stopPropagation()`` -> ``stop_propagation = True`` + - ``event.stopImmediatePropagation()`` -> ``stop_propagation = "immediate"`` - ``event.debounce = `` -> ``debounce = `` - The ``debounce`` value can come from any of the following: + The value behind ``debounce`` can come from any of the following: - - An integer literal (``LOAD_CONST`` / ``LOAD_SMALL_INT``) - - A function argument with a numeric default - - A closure variable whose captured value is numeric + - A literal (``LOAD_CONST`` / ``LOAD_SMALL_INT``) + - A function argument with a matching default + - A closure variable whose captured value matches - An integer constant in ``code.co_consts`` (best-effort, covers certain compile-time-folded module-level names) Module-level globals looked up via ``LOAD_GLOBAL``/``LOAD_NAME`` cannot - be resolved from the function alone, so users must pass - ``debounce=...`` explicitly in that case. + be resolved from the function alone, so users must pass ``debounce=...`` + explicitly in that case. - Returns ``(prevent_default, stop_propagation, debounce)``. + Returns ``(prevent_default, stop_propagation, debounce)``, where + ``stop_propagation`` is the string ``"immediate"`` if the handler asked to stop + *other listeners* as well. """ - code = func.__code__ - prevent_default = False stop_propagation = False + stop_immediate_propagation = False debounce: int | None = None - if code.co_argcount <= 0: + code = _handler_code(func) + event_arg_name = _event_arg_name(func, code) + if code is None or event_arg_name is None: + # Nothing to walk, or nothing to walk from return prevent_default, stop_propagation, debounce names = code.co_names check_prevent_default = "preventDefault" in names check_stop_propagation = "stopPropagation" in names + check_stop_immediate_propagation = "stopImmediatePropagation" in names check_debounce = "debounce" in names - if not (check_prevent_default or check_stop_propagation or check_debounce): + if not ( + check_prevent_default + or check_stop_propagation + or check_stop_immediate_propagation + or check_debounce + ): return prevent_default, stop_propagation, debounce - event_arg_name = code.co_varnames[0] instructions = list(dis.get_instructions(code)) arg_defaults = _function_arg_defaults(func) closure_by_name = _closure_lookup(func) @@ -105,12 +152,19 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool, int | None]: elif check_stop_propagation and instr.argval == "stopPropagation": stop_propagation = True check_stop_propagation = False + elif ( + check_stop_immediate_propagation + and instr.argval == "stopImmediatePropagation" + ): + stop_immediate_propagation = True + stop_propagation = True + check_stop_immediate_propagation = False elif ( check_debounce and instr.opname == "STORE_ATTR" and instr.argval == "debounce" ): - candidate = _resolve_debounce_value( + candidate = _resolve_stored_value( instructions, index, event_arg_name, @@ -131,22 +185,37 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool, int | None]: debounce = None check_debounce = False - if not (check_prevent_default or check_stop_propagation or check_debounce): + if not ( + check_prevent_default + or check_stop_propagation + or check_stop_immediate_propagation + or check_debounce + ): break last_was_event = False - return prevent_default, stop_propagation, debounce + return ( + prevent_default, + "immediate" if stop_immediate_propagation else stop_propagation, + debounce, + ) -def _function_arg_defaults(func: Callable) -> dict[str, int]: - """Return ``{arg_name: default}`` for numeric defaults of ``func``. +def _function_arg_defaults( + func: Callable, + is_value: Callable[[Any], bool] = _is_plain_int, +) -> dict[str, Any]: + """Return ``{arg_name: default}`` for the defaults of ``func`` which ``is_value`` + accepts - plain integers unless another shape is asked for. Includes positional defaults (``__defaults__``) and keyword-only defaults (``__kwdefaults__``). """ - code = func.__code__ - result: dict[str, int] = {} + code = _handler_code(func) + result: dict[str, Any] = {} + if code is None: + return result positional_defaults = getattr(func, "__defaults__", None) if positional_defaults: @@ -154,26 +223,32 @@ def _function_arg_defaults(func: Callable) -> dict[str, int]: n_defaults = len(positional_defaults) first_default_idx = argcount - n_defaults for offset, value in enumerate(positional_defaults): - if not (isinstance(value, int) and not isinstance(value, bool)): + if not is_value(value): continue name = code.co_varnames[first_default_idx + offset] result[name] = value kwdefaults = getattr(func, "__kwdefaults__", None) or {} for name, value in kwdefaults.items(): - if isinstance(value, int) and not isinstance(value, bool): + if is_value(value): result[name] = value return result -def _closure_lookup(func: Callable) -> dict[str, int]: - """Return ``{name: captured_value}`` for numeric closure variables on ``func``.""" +def _closure_lookup( + func: Callable, + is_value: Callable[[Any], bool] = _is_plain_int, +) -> dict[str, Any]: + """Return ``{name: captured_value}`` for the closure variables of ``func`` which + ``is_value`` accepts - plain integers unless another shape is asked for. + """ closure = getattr(func, "__closure__", None) if not closure: return {} - freevars = getattr(func.__code__, "co_freevars", ()) - result: dict[str, int] = {} + code = _handler_code(func) + freevars = getattr(code, "co_freevars", ()) if code is not None else () + result: dict[str, Any] = {} for idx, cell in enumerate(closure): if idx >= len(freevars): break @@ -181,7 +256,7 @@ def _closure_lookup(func: Callable) -> dict[str, int]: value = cell.cell_contents except ValueError: continue - if isinstance(value, int) and not isinstance(value, bool): + if is_value(value): result[freevars[idx]] = value return result @@ -198,17 +273,19 @@ def _constant_ints_by_name(code: CodeType) -> dict[str, int]: return {} -def _resolve_debounce_value( +def _resolve_stored_value( instructions: list[dis.Instruction], store_index: int, event_arg_name: str, - arg_defaults: dict[str, int], - closure_by_name: dict[str, int], - constant_ints_by_name: dict[str, int], + values_by_name: dict[str, Any], + closure_by_name: dict[str, Any], + constant_ints_by_name: dict[str, Any], + is_value: Callable[[Any], bool] = _is_plain_int, ) -> Any: - """Inspect the bytecode immediately before ``STORE_ATTR debounce`` to find - the value being stored. Returns the resolved value, ``None`` when the - value cannot be determined. + """Inspect the bytecode immediately before ``STORE_ATTR`` to find the value being + stored on the event, where ``values_by_name`` and ``closure_by_name`` hold the + names whose values ``is_value`` accepts. Returns the resolved value, ``None`` when + the value cannot be determined. """ if store_index < 1: return None @@ -225,7 +302,7 @@ def _resolve_debounce_value( if isinstance(names, Iterable) and not isinstance(names, str): names = list(names) if len(names) == _LOAD_FAST_BORROW_PAIR_SIZE and names[1] == event_arg_name: - return arg_defaults.get(names[0]) + return values_by_name.get(names[0]) return None # Otherwise the value is loaded by the instruction two slots back, with @@ -241,9 +318,9 @@ def _resolve_debounce_value( "LOAD_FAST_BORROW", # CPython 3.13+ may fuse two ``LOAD_FAST`` instructions into # ``LOAD_FAST_LOAD_FAST`` whose argval is ``(value_name, target_name)``. - # The detector above already covers this case for debounce values, - # but the standalone form can also appear if the value isn't a - # default and the event is loaded separately. + # The detector above already covers this case whenever the value comes + # from a default, but the standalone form can also appear if the value + # isn't a default and the event is loaded separately. "LOAD_FAST_LOAD_FAST", ): return None @@ -252,12 +329,12 @@ def _resolve_debounce_value( if val_instr.opname in ("LOAD_CONST", "LOAD_SMALL_INT"): value = val_instr.argval - if isinstance(value, int) and not isinstance(value, bool): + if is_value(value): return value return None if val_instr.opname in ("LOAD_FAST", "LOAD_FAST_BORROW"): - return arg_defaults.get(val_instr.argval) + return values_by_name.get(val_instr.argval) if val_instr.opname == "LOAD_DEREF": return closure_by_name.get(val_instr.argval) diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index cf77742ea..78a15ae3e 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -166,13 +166,34 @@ def __init__( def schedule_render(self) -> None: if self._scheduled_render: return None + # Raise the latch *before* handing off to the scheduler, not afterwards. + # + # A state change can reach this method from the thread a synchronous event + # handler ran on, while the layout's own thread is midway through a render + # and is about to clear this latch. Setting it after the hand-off let that + # write land *after* the clear, which left the latch permanently raised since + # nothing was ever going to render again to reset it. Every later state + # change was then silently deduplicated away and the component stalled on a + # stale model for the rest of its life. Setting first means the clear of + # any render we could have scheduled is guaranteed to follow our write. + self._scheduled_render = True try: self._schedule_render_callback() except Exception: + self._scheduled_render = False msg = f"Failed to schedule render via {self._schedule_render_callback}" logger.exception(msg) - else: - self._scheduled_render = True + + def unschedule_render(self) -> None: + """Undo :meth:`schedule_render` when its pending render is cancelled + + The latch set by :meth:`schedule_render` is cleared only once a render + actually begins. If the scheduled render is cancelled before it ever + starts, nothing else resets that latch - so every later state change + would be silently deduplicated away, leaving the component stuck on a + stale model indefinitely. + """ + self._scheduled_render = False def use_state(self, function: Callable[[], T]) -> T: """Add state to this hook diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index 3828e2a0f..027d6f35e 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -1,7 +1,7 @@ from __future__ import annotations import inspect -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from functools import partial from typing import Any, Literal, cast, overload @@ -11,11 +11,59 @@ from reactpy.types import BaseEventHandler, EventHandlerFunc +def _unwrap_handler(function: Any) -> Any: + """Get at the callable a user actually wrote, through wrappers and partials""" + target = function + while isinstance(target, partial): + target = target.func + return inspect.unwrap(target) + + +def _is_handler_coroutine(function: Any) -> bool: + """Whether calling this object gives something awaitable + + :func:`inspect.iscoroutinefunction` says no to an object whose ``__call__`` is a + coroutine, so that case is looked at directly. + """ + target = _unwrap_handler(function) + if inspect.iscoroutinefunction(target): + return True + call = type(target).__dict__.get("__call__", None) + return call is not None and inspect.iscoroutinefunction(call) + + +def check_is_async_handler(function: Any) -> None: + """Raise a :exc:`TypeError` unless ``function`` can be awaited as a handler + + Event handlers are always coroutines. There is no blocking flavour of event data, + which is what makes it safe for a handler to call ``set_state()`` and keeps the + layout's event loop free to render while a live value is fetched from the browser. + """ + if _is_handler_coroutine(function): + return + + name = getattr(function, "__qualname__", None) or repr(function) + msg = ( + f"{name} cannot be used as an event handler because it is not a coroutine. " + "Event handlers must be declared with 'async def':\n\n" + " async def on_click(event):\n ...\n\n" + "An event object reads from the browser as needed, and only a coroutine can " + "await that round trip without stalling the event loop." + ) + if name.startswith("_CurrentState.__init__") or name.endswith(".dispatch"): + msg += ( + "\n\nA state setter is not an event handler - call it from one instead:\n\n" + " async def on_click(event):\n set_value(...)" + ) + raise TypeError(msg) + + @overload def event( - function: Callable[..., Any], + function: Callable[..., Awaitable[Any]], *, stop_propagation: bool = ..., + stop_immediate_propagation: bool = ..., prevent_default: bool = ..., debounce: int | None = ..., throttle: int | None = ..., @@ -27,20 +75,22 @@ def event( function: Literal[None] = ..., *, stop_propagation: bool = ..., + stop_immediate_propagation: bool = ..., prevent_default: bool = ..., debounce: int | None = ..., throttle: int | None = ..., -) -> Callable[[Callable[..., Any]], EventHandler]: ... +) -> Callable[[Callable[..., Awaitable[Any]]], EventHandler]: ... def event( - function: Callable[..., Any] | None = None, + function: Callable[..., Awaitable[Any]] | None = None, *, stop_propagation: bool = False, + stop_immediate_propagation: bool = False, prevent_default: bool = False, debounce: int | None = None, throttle: int | None = None, -) -> EventHandler | Callable[[Callable[..., Any]], EventHandler]: +) -> EventHandler | Callable[[Callable[..., Awaitable[Any]]], EventHandler]: """A decorator for constructing an :class:`EventHandler`. While you're always free to add callbacks by assigning them to an element's attributes @@ -56,14 +106,18 @@ def event( .. code-block:: python @event(stop_propagation=True, prevent_default=True) - def my_callback(*data): ... + async def my_callback(event): ... element = reactpy.html.button({"onClick": my_callback}) + The handler must be a coroutine. Anything which is not an ``async def`` raises a + :exc:`TypeError`, because reading the object an event fired from means asking the + browser for it, and only a coroutine can await that without stalling the layout. + Parameters: function: - A function or coroutine responsible for handling the event. + A coroutine responsible for handling the event. stop_propagation: Block the event from propagating further up the DOM. prevent_default: @@ -74,26 +128,31 @@ def my_callback(*data): ... throttle: Rate-limit client-→server outgoing events for any element. See :attr:`BaseEventHandler.throttle`. + stop_immediate_propagation: + Also block any other listener of the same event in the browser. """ - def setup(function: Callable[..., Any]) -> EventHandler: + def setup(function: Callable[..., Awaitable[Any]]) -> EventHandler: return EventHandler( to_event_handler_function(function, positional_args=True), stop_propagation, prevent_default, debounce=debounce, throttle=throttle, + stop_immediate_propagation=stop_immediate_propagation, ) return setup(function) if function is not None else setup class EventHandler(BaseEventHandler): - """Turn a function or coroutine into an event handler + """Turn a coroutine into an event handler Parameters: function: - The function or coroutine which handles the event. + The coroutine which handles the event. A plain ``def`` raises a + :exc:`TypeError`, since only a coroutine can await the browser for the + object an event fired from. See :func:`event`. stop_propagation: Block the event from propagating further up the DOM. prevent_default: @@ -106,110 +165,98 @@ class EventHandler(BaseEventHandler): throttle: Client-→server rate limit (ms) for outgoing events. See :attr:`BaseEventHandler.throttle`. + stop_immediate_propagation: + Also block any other listener of the same event in the browser. """ def __init__( self, - function: EventHandlerFunc, + function: Callable[..., Awaitable[Any]], stop_propagation: bool = False, prevent_default: bool = False, target: str | None = None, debounce: int | None = None, throttle: int | None = None, + stop_immediate_propagation: bool = False, ) -> None: + check_is_async_handler(function) self.function = to_event_handler_function(function, positional_args=False) self.prevent_default = prevent_default self.stop_propagation = stop_propagation + self.stop_immediate_propagation = stop_immediate_propagation self.debounce = debounce self.throttle = throttle self.target = target - # Check if our `preventDefault` or `stopPropagation` methods were called - # by inspecting the function's bytecode - func_to_inspect = cast(Any, function) - while hasattr(func_to_inspect, "__wrapped__"): - func_to_inspect = func_to_inspect.__wrapped__ - - if isinstance(func_to_inspect, partial): - func_to_inspect = func_to_inspect.func - - found_prevent_default, found_stop_propagation, found_debounce = ( - inspect_event_handler(func_to_inspect) - ) + # Check what the handler says about the event it is handed by inspecting the + # bytecode of the function the user actually wrote + ( + found_prevent_default, + found_stop_propagation, + found_debounce, + ) = inspect_event_handler(_unwrap_handler(function)) if found_prevent_default: self.prevent_default = True if found_stop_propagation: self.stop_propagation = True + if found_stop_propagation == "immediate": + self.stop_immediate_propagation = True if found_debounce is not None: self.debounce = found_debounce __hash__ = None # type: ignore + _PUBLIC_ATTRS = ( + "function", + "prevent_default", + "stop_propagation", + "stop_immediate_propagation", + "debounce", + "target", + "throttle", + ) + def __eq__(self, other: object) -> bool: undefined = object() return not any( not attr.startswith("_") and not getattr(other, attr, undefined) == getattr(self, attr) - for attr in ( - "function", - "prevent_default", - "stop_propagation", - "debounce", - "target", - "throttle", - ) + for attr in self._PUBLIC_ATTRS ) def __repr__(self) -> str: - public_names = ( - "function", - "prevent_default", - "stop_propagation", - "debounce", - "target", - "throttle", - ) - items = ", ".join([f"{n}={getattr(self, n)!r}" for n in public_names]) + items = ", ".join([f"{n}={getattr(self, n)!r}" for n in self._PUBLIC_ATTRS]) return f"{type(self).__name__}({items})" def to_event_handler_function( - function: Callable[..., Any], + function: Callable[..., Awaitable[Any]], positional_args: bool = True, ) -> EventHandlerFunc: - """Make a :data:`~reactpy.core.proto.EventHandlerFunc` from a function or coroutine + """Make a :data:`~reactpy.types.EventHandlerFunc` from an event handler coroutine Parameters: function: - A function or coroutine accepting a number of positional arguments. + A coroutine accepting a number of positional arguments. positional_args: - Whether to pass the event parameters a positional args or as a list. - """ - if positional_args: - if inspect.iscoroutinefunction(function): + Whether to pass the event parameters as positional args or as a list. - async def wrapper(data: Sequence[Any]) -> None: - await function(*data) - - cast(Any, wrapper).__wrapped__ = function - - else: - - async def wrapper(data: Sequence[Any]) -> None: - function(*data) + Handlers are always coroutines, and the :class:`~reactpy.types.Event` handed to them + reaches back into the browser by being awaited. Nothing here ever blocks, which is + what allows a handler to call ``set_state()`` from the loop it renders on. + """ + check_is_async_handler(function) - cast(Any, wrapper).__wrapped__ = function - return wrapper - elif not inspect.iscoroutinefunction(function): + if positional_args: async def wrapper(data: Sequence[Any]) -> None: - function(data) + await function(*data) cast(Any, wrapper).__wrapped__ = function return wrapper - else: - return function + + return cast(EventHandlerFunc, function) def merge_event_handlers( @@ -220,7 +267,7 @@ def merge_event_handlers( Raises a ValueError if any handlers have conflicting :attr:`~reactpy.core.proto.EventHandlerType.stop_propagation`, :attr:`~reactpy.core.proto.EventHandlerType.prevent_default`, - :attr:`~reactpy.core.proto.EventHandlerType.debounce`, or + :attr:`~reactpy.core.proto.EventHandlerType.debounce`, :attr:`~reactpy.core.proto.EventHandlerType.throttle` attributes. """ if not event_handlers: @@ -236,7 +283,9 @@ def merge_event_handlers( debounce = first_handler.debounce throttle = first_handler.throttle target = first_handler.target - + stop_immediate_propagation = getattr( + first_handler, "stop_immediate_propagation", False + ) for handler in event_handlers: if ( handler.stop_propagation != stop_propagation @@ -244,10 +293,13 @@ def merge_event_handlers( or handler.debounce != debounce or handler.throttle != throttle or handler.target != target + or getattr(handler, "stop_immediate_propagation", False) + != stop_immediate_propagation ): msg = ( "Cannot merge handlers - 'stop_propagation', 'prevent_default', " - "'debounce', 'throttle' or 'target' mismatch." + "'debounce', 'throttle', 'target', or " + "'stop_immediate_propagation' mismatch." ) raise ValueError(msg) @@ -258,13 +310,18 @@ def merge_event_handlers( target, debounce, throttle, + stop_immediate_propagation=stop_immediate_propagation, ) def merge_event_handler_funcs( functions: Sequence[EventHandlerFunc], ) -> EventHandlerFunc: - """Make one event handler function from many""" + """Make one event handler function from many + + Every handler is handed the very same event data. There is a single flavour of it, + and each handler awaits whatever it needs from the browser in its own task. + """ if not functions: msg = "No event handler functions to merge" raise ValueError(msg) diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 18253aab2..cef2fc3b5 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -12,7 +12,7 @@ wait, ) from collections import Counter -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Mapping, Sequence from contextlib import AsyncExitStack, suppress from logging import getLogger from types import TracebackType @@ -30,6 +30,11 @@ from anyio import Semaphore +from reactpy._event_proxy import ( + EVENT_PROXY_RESPONSE_TYPE, + EventArguments, + EventProxyBackend, +) from reactpy.config import ( REACTPY_ASYNC_RENDERING, REACTPY_CHECK_VDOM_SPEC, @@ -43,7 +48,6 @@ Component, Context, ContextProvider, - Event, EventHandlerDict, Key, LayoutEventMessage, @@ -63,6 +67,10 @@ def __init__(self, root: Component | Context[Any] | ContextProvider[Any]) -> Non msg = f"Expected a ReactPy component, not {type(root)!r}." raise TypeError(msg) self.root = root + # Bound by reactpy.core.serve.serve_layout, possibly before we are entered. + self._event_proxy_sender: Callable[[dict[str, Any]], Awaitable[None]] | None = ( + None + ) async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager @@ -82,6 +90,12 @@ async def __aenter__(self) -> Layout: Task[LayoutUpdateMessage], _LifeCycleStateId ] = {} self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() + # Flipped as teardown begins. A render asked for after that point has nowhere + # to go: everything it would render with is on its way out, and a task created + # in the meantime would outlive the layout which made it and reach for state + # which teardown has already let go of. Unlike the rest it is left in place on + # the way out, since asking for a render is legitimate at any time. + self._has_closed = False # Per-target event sequence tracking. Each incoming layout-event # may carry an optional ``seq`` field (assigned by the client) # which the server records here so it can be echoed back to the @@ -90,6 +104,24 @@ async def __aenter__(self) -> Layout: # processed all keystrokes without relying on a time-based # debounce window. self._last_event_seq_by_target: dict[str, int] = {} + # An acknowledgement only reaches the client inside the model of the + # element it describes, which means it travels with a render - and an + # event whose handler changes no state schedules no render of its own. + # These track, per handler target, the acknowledgement a render has + # already carried and the one a render has yet to carry, so that a + # newly handled event can prompt a render while a redundant request - + # one the client already holds, or which a render already queued will + # deliver - is dropped rather than spending another render on it. + self._pending_ack_by_target: dict[str, int] = {} + self._delivered_ack_by_target: dict[str, int] = {} + # The component to re-render in order to carry a given target's + # acknowledgement, recorded as that target's handler is rendered. + self._ack_owner_id_by_target: dict[str, _LifeCycleStateId] = {} + # Routes live ("event proxy") property reads and method calls made by event + # handlers back to the client which dispatched them. + self._event_proxy_backend = EventProxyBackend() + if self._event_proxy_sender is not None: + self._event_proxy_backend.set_sender(self._event_proxy_sender) root_model_state = _new_root_model_state(self.root, self._schedule_render_task) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} @@ -100,6 +132,13 @@ async def __aenter__(self) -> Layout: async def __aexit__( self, exc_type: type[Exception], exc_value: Exception, traceback: TracebackType ) -> None: + # Anything asked to render from here on is told there is nothing left to + # render. Cancelling the tasks below hands control back to the event loop, and + # a handler finishing its business during that window would otherwise create a + # render task which survives the loop over the tasks it knows about, only to + # reach, once teardown is over, for state which is no longer there. + self._has_closed = True + root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] @@ -115,9 +154,11 @@ async def __aexit__( await self._unmount_model_states([root_model_state]) await self._rendering_queue.close() + self._event_proxy_backend.close() # delete attributes here to avoid access after exiting context manager del self._event_handlers + del self._event_proxy_backend del self._event_queues del self._event_processing_tasks del self._rendering_queue @@ -126,9 +167,18 @@ async def __aexit__( del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id del self._last_event_seq_by_target + del self._pending_ack_by_target + del self._delivered_ack_by_target + del self._ack_owner_id_by_target async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: """Dispatch an event to the targeted handler""" + if event.get("type") == EVENT_PROXY_RESPONSE_TYPE: + # Not an event at all, but the answer to a live property read or method + # call made by a handler which is (or was) running. + self.handle_event_proxy_response(event) + return + # 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 @@ -149,22 +199,60 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: if REACTPY_DEBUG.current: await sleep(0) + def set_event_proxy_sender( + self, send: Callable[[dict[str, Any]], Awaitable[None]] + ) -> EventProxyBackend | None: + """Tell the layout how to send live event requests to the client + + Returns the :class:`~reactpy._event_proxy.EventProxyBackend` which answers the + reads and calls the layout's handlers make, so that callers can route replies + back to it. This is :data:`None` when the layout is not currently running, in + which case the sender is remembered and used once it starts. + + This must be called from the event loop running the layout, and is what makes + it possible for event handlers to read properties from, and call methods on, + the browser objects which triggered them. + + Binding a sender which is not the one already in place - which is what a + reconnect does - also brings live access back if an earlier client was switched + off for going quiet. A client which has just connected has not failed yet. + """ + self._event_proxy_sender = send + backend = getattr(self, "_event_proxy_backend", None) + if backend is not None: + backend.set_sender(send) + return backend + + def handle_event_proxy_response(self, message: Mapping[str, Any]) -> bool: + """Deliver the answer to a live event access request + + Returns :data:`False` if the message was not meant for this layout, which + includes the case where the layout is not running and so has no backend to + hand the answer to. + """ + backend = getattr(self, "_event_proxy_backend", None) + if backend is None: + return False + return backend.handle_response(message) + + def _make_event_arguments(self, data: Sequence[Any]) -> EventArguments: + """Wrap raw event data for a handler + + Every handler is handed the same thing. Whatever the client marked as an object + it is still holding becomes a live proxy which reads from and calls into the + browser; everything else arrives exactly as it did. A client which has gone away + cannot answer for that mark either way, because a backend without a sender + reports itself unavailable and awaiting anything through it raises + :class:`~reactpy._event_proxy.EventProxyUnavailable`. + """ + return EventArguments(data, self._event_proxy_backend) + async def _process_event_queue( self, target: str, queue: Queue[LayoutEventMessage | dict[str, Any]] ) -> None: while True: event = await queue.get() - # Record the client-assigned event sequence number (if any) - # so we can echo it back as ``ackSeq`` on the originating - # element. The client uses this to decide whether the - # server has caught up to the latest keystrokes. - seq = event.get("seq") - if isinstance(seq, int) and seq >= 0: - prev = self._last_event_seq_by_target.get(target, -1) - if seq > prev: - self._last_event_seq_by_target[target] = seq - # Retry a few times to handle potential re-render race conditions where # the handler is temporarily removed and then re-added. handler = self._event_handlers.get(target) @@ -175,19 +263,40 @@ async def _process_event_queue( if handler is not None: break - if handler is not None: - try: - data = [ - Event(d) if isinstance(d, dict) else d for d in event["data"] - ] - await handler.function(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" - ) + try: + if handler is not None: + try: + await handler.function( + self._make_event_arguments(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" + ) + finally: + # The loop is on its way out once teardown has started, so there is no + # client left to acknowledge to and nothing left to record into. + if not self._has_closed: + # Record the client-assigned event sequence number (if any) so it can + # be echoed back on the originating element, once - and only once - + # the handler which consumed the event is out of the way. Recording it + # up front would let a render made while the handler is still running + # claim that the event has been handled, even though the value that + # render carries was read before the handler could update it. A client + # which then wrote that value over what the user has typed since would + # throw those keystrokes away. It is recorded even when the handler + # failed or was never found, because no further state change can come + # from that event and withholding the acknowledgement would leave the + # client waiting for one forever. + seq = event.get("seq") + if isinstance(seq, int) and seq >= 0: + prev = self._last_event_seq_by_target.get(target, -1) + if seq > prev: + self._last_event_seq_by_target[target] = seq + self._schedule_ack_delivery(target, seq) async def render(self) -> LayoutUpdateMessage: if REACTPY_ASYNC_RENDERING.current: @@ -412,6 +521,12 @@ def _render_model_attributes( for old_event in set(old_state.targets_by_event).difference(handlers_by_event): old_target = old_state.targets_by_event[old_event] del self._event_handlers[old_target] + # Nothing renders this target's acknowledgement any more, and a + # leftover record here would suppress the request to deliver one + # if the same target were to come back. + self._pending_ack_by_target.pop(old_target, None) + self._delivered_ack_by_target.pop(old_target, None) + self._ack_owner_id_by_target.pop(old_target, None) if not handlers_by_event: self._inject_event_ack_seq(new_state, raw_model.get("tagName")) @@ -434,36 +549,106 @@ def _render_model_attributes( def _inject_event_ack_seq( self, new_state: _ModelState, tag_name: str | None ) -> None: - """Attach ``ackSeq`` to user-input element attributes so the client - can determine deterministically whether the server has processed + """Attach ``_reactpy_ack_seq`` to user-input element attributes so the + client can determine deterministically whether the server has processed the latest keystrokes. - For each event handler bound to this element, we look up the - highest client-assigned sequence number the server has received - for that target and, if any was recorded, attach the maximum - across handlers as the element's ``ackSeq``. + The value is a mapping of event name to the highest client-assigned + sequence number the server has finished handling for that event's + target. The client compares this, per event name, against the highest + sequence number it actually wrote to the wire for that event. A single + number across all events cannot do that: it lets a render which + predates the newest keystroke of one event look up to date because a + different event happened to be further along. """ if tag_name not in {"input", "select", "textarea"}: + # Only these are rendered by the client's user-input element, and + # only that element holds anything up until an acknowledgement + # arrives. Prompting a render for an ordinary button or link would + # spend a round trip on a figure nobody is waiting for, and would + # break the rule that setting state to an equal value re-renders + # nothing at all. return - targets = list(new_state.targets_by_event.values()) - if not targets: - return - max_ack: int | None = None - for target in targets: - ack = self._last_event_seq_by_target.get(target) - if ack is None: - continue - if max_ack is None or ack > max_ack: - max_ack = ack - if max_ack is None: + + # Remember which component has to be rendered for this element's + # acknowledgement to get out, as that is what prompting a render + # on an event's behalf costs. + owner_id = self._owner_component_id(new_state) + if owner_id is not None: + for target in new_state.targets_by_event.values(): + self._ack_owner_id_by_target[target] = owner_id + + ack_seq_by_event = { + event: seq + for event, target in new_state.targets_by_event.items() + if (seq := self._last_event_seq_by_target.get(target)) is not None + } + if not ack_seq_by_event: return attrs = new_state.model.current.get("attributes") if not isinstance(attrs, dict): return - # Never let ackSeq leak into the user's attributes (it's not a - # real DOM attribute). Store it in a dedicated key the client - # recognizes. - attrs.setdefault("_reactpy_ack_seq", max_ack) # type: ignore[reportArgumentType] + # Never let this leak into the user's attributes (it's not a real DOM + # attribute). Store it in a dedicated key the client recognizes. It has + # to be assigned, not defaulted, because the value advances as the + # server catches up with the keystrokes the client has sent. + attrs["_reactpy_ack_seq"] = ack_seq_by_event # type: ignore[reportGeneralTypeIssues] + for event, seq in ack_seq_by_event.items(): + # This render is what carries the acknowledgement, so a later + # request to deliver the same one has nothing left to ask for. + target = new_state.targets_by_event[event] + self._delivered_ack_by_target[target] = seq + if self._pending_ack_by_target.get(target, -1) <= seq: + self._pending_ack_by_target.pop(target, None) + + def _schedule_ack_delivery(self, target: str, seq: int) -> None: + """Prompt a render of the component owning ``target`` so that the + acknowledgement of the event it has just handled reaches the client. + + An element waits on an acknowledgement before it will write the + server's value over what the user has typed, and that figure travels + inside the element's own model. A handler which changes no state + schedules no render of its own, so without prompting one here the newer + acknowledgement would never be sent and the element would wait forever. + A request is dropped when the client already holds that acknowledgement, + or when a render already queued will carry it, since a render costs a + round trip and the second time around buys nothing. + """ + if self._has_closed: + # Teardown has begun: no render will carry this acknowledgement, and the + # bookkeeping below is about to be deleted. + return None + if self._delivered_ack_by_target.get(target, -1) >= seq: + return None + owner_id = self._ack_owner_id_by_target.get(target) + if owner_id is None: + # Either the event came from an element which does not wait on an + # acknowledgement, or the element is not rendered by anything right + # now. In both cases there is nothing to prompt: in the first no + # render is owed, in the second the model carries the acknowledgement + # the moment the element is rendered again. + return None + if self._pending_ack_by_target.get(target, -1) >= seq: + return None + self._pending_ack_by_target[target] = seq + self._schedule_render_task(owner_id) + return None + + @staticmethod + def _owner_component_id(model_state: _ModelState) -> _LifeCycleStateId | None: + """The ID of the component which renders ``model_state`` + + Walks up from an element to the component nearest above it, since that + is the one whose re-render rewrites the element's model. + """ + state = model_state + while not state.is_component_state: + try: + state = state.parent + except RuntimeError: + # Nothing above it remains mounted to render it. + return None + return state.life_cycle_state.id def _render_model_event_handlers_without_old_state( self, @@ -494,16 +679,23 @@ def _serialize_event_handler(handler: Any, target: str) -> dict[str, Any]: where it has an effect (user-input elements). ``throttle`` is forwarded when set and applies to every element. Both are omitted from the payload when ``None`` so the wire format stays minimal. + + ``eventProxy`` is forwarded unless this handler has declined live access: an + absent key tells the client not to hold on to an event it knows nobody will ask + about, which is the whole cost of an event nobody reads past. """ entry: dict[str, Any] = { "target": target, "preventDefault": handler.prevent_default, "stopPropagation": handler.stop_propagation, } + if getattr(handler, "stop_immediate_propagation", False): + entry["stopImmediatePropagation"] = True if handler.debounce is not None: entry["debounce"] = handler.debounce if handler.throttle is not None: entry["throttle"] = handler.throttle + return entry async def _render_model_children( @@ -569,6 +761,12 @@ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: for target in model_state.targets_by_event.values(): del self._event_handlers[target] + # As above: nothing renders these acknowledgements any more and + # a leftover record would suppress the request to deliver one + # were the same target to be rendered again. + self._pending_ack_by_target.pop(target, None) + self._delivered_ack_by_target.pop(target, None) + self._ack_owner_id_by_target.pop(target, None) if model_state.is_component_state: life_cycle_state = model_state.life_cycle_state @@ -578,6 +776,11 @@ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount.extend(model_state.children_by_key.values()) def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None: + if self._has_closed: + # Teardown has begun, so there is nothing left for this render to produce. + # A task made now would not run until after everything it renders with - + # the handlers, the model states, the queue - has been deleted. + return None if not REACTPY_ASYNC_RENDERING.current: self._rendering_queue.put(lcs_id) return None diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 622d8b1ac..9db50d303 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -31,12 +31,29 @@ async def serve_layout( recv: RecvCoroutine, ) -> None: """Run a dispatch loop for a single view instance""" + # Let the layout reach back into the browser for the properties and methods of the + # events its handlers are working on. Older layouts simply do not have this hook. + set_event_proxy_sender = getattr(layout, "set_event_proxy_sender", None) + if callable(set_event_proxy_sender): + set_event_proxy_sender(_send_through(send)) + async with layout: async with create_task_group() as task_group: task_group.start_soon(_single_outgoing_loop, layout, send) task_group.start_soon(_single_incoming_loop, task_group, layout, recv) +def _send_through(send: SendCoroutine) -> SendCoroutine: + """Adapt the layout's outgoing message sender for live event requests""" + + async def send_event_proxy_request( + message: LayoutUpdateMessage | dict[str, Any], + ) -> None: + await send(message) + + return send_event_proxy_request + + async def _single_outgoing_loop( layout: BaseLayout[ LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 9d8ba4147..c45da3cb5 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -22,6 +22,7 @@ CustomVdomConstructor, EllipsisRepr, EventHandlerDict, + EventHandlerFunc, ImportSourceDict, InlineJavaScript, InlineJavaScriptDict, @@ -72,6 +73,7 @@ "target": {"type": "string"}, "preventDefault": {"type": "boolean"}, "stopPropagation": {"type": "boolean"}, + "stopImmediatePropagation": {"type": "boolean"}, "debounce": {"type": "integer", "minimum": 0}, "throttle": {"type": "integer", "minimum": 0}, }, @@ -234,7 +236,12 @@ def separate_attributes_handlers_and_inline_javascript( for k, v in attributes.items(): if callable(v): - _event_handlers[k] = EventHandler(to_event_handler_function(v)) + # any callable attribute is taken to be an event handler - the handler + # check inside `to_event_handler_function` rejects plain `def` functions + handler_function = cast(EventHandlerFunc, v) + _event_handlers[k] = EventHandler( + to_event_handler_function(handler_function) + ) elif isinstance(v, BaseEventHandler): _event_handlers[k] = v elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index d6e81a324..9a45dd22a 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -174,7 +174,7 @@ async def __call__( msg: dict[str, str] = orjson.loads(event["text"]) if msg.get("type") == "layout-event": await ws.rendering_queue.put(msg) - else: # nocov + elif not ws.handle_event_proxy_response(msg): # nocov await asyncio.to_thread( _logger.warning, f"Unknown message type: {msg.get('type')}" ) @@ -199,6 +199,9 @@ def __init__( config.REACTPY_MAX_QUEUE_SIZE.current ) self.dispatcher: asyncio.Task[Any] | None = None + # Set once the rendering loop has a layout, so that answers to live event + # access requests can be handed back to the handler which asked for them. + self.layout: Layout | None = None async def __aenter__(self) -> ReactPyWebsocket: self.dispatcher = asyncio.create_task(self.run_dispatcher()) @@ -238,8 +241,9 @@ async def run_dispatcher(self) -> None: ) # Start the ReactPy component rendering loop + self.layout = Layout(ConnectionContext(component(), value=connection)) await serve_layout( - Layout(ConnectionContext(component(), value=connection)), + self.layout, self.send_json, self.rendering_queue.get, ) @@ -248,6 +252,18 @@ async def run_dispatcher(self) -> None: except Exception as error: await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") + def handle_event_proxy_response(self, message: dict[str, Any]) -> bool: + """Hand the answer to a live event access request to the waiting handler + + This runs on the same event loop as the layout, which is the loop the request + was made from and the only one allowed to resolve what is waiting on it. + + Returns :data:`False` if there is no layout to take the message yet, or the + layout does not recognise it as an answer to one of its own requests. + """ + layout = self.layout + return layout is not None and layout.handle_event_proxy_response(message) + async def send_json(self, data: Any) -> None: return await self._send( {"type": "websocket.send", "text": orjson.dumps(data).decode()} diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index 52350d04e..c7f3f780f 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -171,7 +171,11 @@ class StaticEventHandler: @reactpy.component def MyComponent(): state, set_state = reactpy.hooks.use_state(0) - handler = static_handler.use(lambda event: set_state(state + 1)) + + async def on_click(event): + set_state(state + 1) + + handler = static_handler.use(on_click) return reactpy.html.button({"onClick": handler}, "Click me!") # gives the target ID for onClick where from the last render of MyComponent @@ -194,7 +198,11 @@ def Parent(): @reactpy.component def Child(key): state, set_state = reactpy.hooks.use_state(0) - handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1)) + + async def on_click(event): + set_state(state + 1) + + handler = static_handlers_by_key[key].use(on_click) return reactpy.html.button({"onClick": handler}, "Click me!") # grab the individual targets for each instance above @@ -207,7 +215,7 @@ def __init__(self) -> None: def use( self, - function: Callable[..., Any], + function: Callable[..., Awaitable[Any]], stop_propagation: bool = False, prevent_default: bool = False, ) -> EventHandler: diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index f1896c831..465139c94 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -2,10 +2,14 @@ from typing import Any, cast -from reactpy.core.events import EventHandler, to_event_handler_function +from reactpy.core.events import EventHandler from reactpy.types import VdomAttributes, VdomDict +async def _ignore_event_data(*_args: Any) -> None: + """A handler which exists only to carry ``prevent_default`` on an anchor click""" + + def attributes_to_reactjs(attributes: VdomAttributes): """Convert HTML attribute names to their ReactJS equivalents.""" attrs = cast(VdomAttributes, attributes.items()) @@ -125,7 +129,7 @@ def intercept_link_clicks(self, vdom: VdomDict) -> None: vdom.setdefault("eventHandlers", {}) if "eventHandlers" in vdom and isinstance(vdom["eventHandlers"], dict): vdom["eventHandlers"]["onClick"] = EventHandler( - to_event_handler_function(lambda *_args, **_kwargs: None), + _ignore_event_data, prevent_default=True, ) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 48203ab09..10b5394b2 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -20,6 +20,8 @@ overload, ) +from reactpy._event_proxy import Event, EventProxy + CarrierType = TypeVar("CarrierType") _Type = TypeVar("_Type") @@ -84,6 +86,7 @@ class BaseLayout(Protocol[_Render_co, _Event_contra]): __slots__: tuple[str, ...] = ( "__weakref__", "_event_handlers", + "_event_proxy_backend", "_model_states_by_life_cycle_state_id", "_render_tasks", "_render_tasks_ready", @@ -915,6 +918,7 @@ class JsonEventTarget(TypedDict): target: str preventDefault: bool stopPropagation: bool + stopImmediatePropagation: NotRequired[bool] debounce: NotRequired[int] throttle: NotRequired[int] @@ -944,6 +948,7 @@ class BaseEventHandler: "debounce", "function", "prevent_default", + "stop_immediate_propagation", "stop_propagation", "target", "throttle", @@ -958,6 +963,13 @@ class BaseEventHandler: stop_propagation: bool """Stops the default action associate with the event from taking place.""" + stop_immediate_propagation: bool + """Whether to also block other listeners of the same event in the browser + + This is detected automatically from calls to + ``event.stopImmediatePropagation()`` inside the handler. + """ + debounce: int | None """Server-→client debounce window in milliseconds. @@ -1132,6 +1144,9 @@ class ReactPyConfig(TypedDict, total=False): debug: bool max_queue_size: int tests_default_timeout: int + event_proxy_timeout: float + event_proxy_max_depth: int + event_proxy_max_timeouts: int class PyScriptOptions(TypedDict, total=False): @@ -1154,25 +1169,15 @@ def __repr__(self) -> str: return "..." -class Event(dict): - """ - A light `dict` wrapper for event data passed to event handler functions. - """ - - debounce: int | None - - def __getattr__(self, name: str) -> Any: - value = self.get(name) - return Event(value) if isinstance(value, dict) else value - - def preventDefault(self) -> None: - """Prevent the default action of the event.""" +SourceType = NewType("SourceType", str) - def stopPropagation(self) -> None: - """Stop the event from propagating.""" +EventObject: TypeAlias = Event[Any] | EventProxy[Any] +"""Anything which may be handed to an event handler as event data -SourceType = NewType("SourceType", str) +Either the event object itself, or something lazily resolved out of it. Both are +awaitable, and both are documented on :class:`~reactpy._event_proxy.EventProxy`. +""" @dataclass(frozen=True) diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index ef9c6efaf..dd2a41e37 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -6,7 +6,7 @@ import reactpy from reactpy._html import html -from reactpy.types import VdomAttributes, VdomDict +from reactpy.types import Event, VdomAttributes, VdomDict def image( @@ -59,8 +59,8 @@ def use_linked_inputs( """ value, set_value = reactpy.hooks.use_state(initial_value) - def sync_inputs(event: dict[str, Any]) -> None: - new_value = event["target"]["value"] + async def sync_inputs(event: Event) -> None: + new_value = await event.target.value set_value(new_value) if not new_value and ignore_empty: return None diff --git a/tests/test_asgi/pyscript_components/load_second.py b/tests/test_asgi/pyscript_components/load_second.py index c640209a5..06c732b92 100644 --- a/tests/test_asgi/pyscript_components/load_second.py +++ b/tests/test_asgi/pyscript_components/load_second.py @@ -5,7 +5,7 @@ def child(): count, set_count = hooks.use_state(0) - def increment(event): + async def increment(event): set_count(count + 1) return html.div( diff --git a/tests/test_asgi/pyscript_components/root.py b/tests/test_asgi/pyscript_components/root.py index caa9a7c9d..53d2ac96f 100644 --- a/tests/test_asgi/pyscript_components/root.py +++ b/tests/test_asgi/pyscript_components/root.py @@ -5,7 +5,7 @@ def root(): count, set_count = hooks.use_state(0) - def increment(event): + async def increment(event): set_count(count + 1) return html.div( diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 2ea1e8649..994604a57 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -34,10 +34,14 @@ async def test_display_simple_click_counter(display: DisplayFixture): @reactpy.component def Counter(): count, set_count = reactpy.hooks.use_state(0) + + async def on_click(event): + set_count(lambda old_count: old_count + 1) + return reactpy.html.button( { "id": "counter", - "onClick": lambda event: set_count(lambda old_count: old_count + 1), + "onClick": on_click, }, f"Count: {count}", ) diff --git a/tests/test_client.py b/tests/test_client.py index 3dc3c6095..8603aec08 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,11 +12,13 @@ async def test_automatic_reconnect(display: DisplayFixture, server: BackendFixtu @reactpy.component def SomeComponent(): count, incr_count = use_counter(0) + + async def on_click(event): + incr_count() + return reactpy.html( reactpy.html.p({"data-count": count, "id": "count"}, "count", count), - reactpy.html.button( - {"onClick": lambda e: incr_count(), "id": "incr"}, "incr" - ), + reactpy.html.button({"onClick": on_click, "id": "incr"}, "incr"), ) async def get_count(): @@ -66,10 +68,14 @@ async def test_style_can_be_changed(display: DisplayFixture): def ButtonWithChangingColor(): color_toggle, set_color_toggle = reactpy.hooks.use_state(True) color = "red" if color_toggle else "blue" + + async def on_click(event): + set_color_toggle(not color_toggle) + return reactpy.html.button( { "id": "my-button", - "onClick": lambda event: set_color_toggle(not color_toggle), + "onClick": on_click, "style": {"backgroundColor": color, "color": "white"}, }, f"color: {color}", @@ -110,7 +116,7 @@ def SomeComponent(): async def handle_change(event): await asyncio.sleep(delay) - set_value(event["target"]["value"]) + set_value(await event.target.value) return reactpy.html.input({"onChange": handle_change, "id": "test-input"}) diff --git a/tests/test_core/test_event_inspect.py b/tests/test_core/test_event_inspect.py index 904c7dba8..6815ba09d 100644 --- a/tests/test_core/test_event_inspect.py +++ b/tests/test_core/test_event_inspect.py @@ -16,7 +16,7 @@ _closure_lookup, _constant_ints_by_name, _function_arg_defaults, - _resolve_debounce_value, + _resolve_stored_value, inspect_event_handler, ) @@ -29,7 +29,7 @@ def test_non_int_positional_default_is_ignored(): """A positional default that isn't an int must be skipped.""" - def handler(event, ms="not-an-int"): + async def handler(event, ms="not-an-int"): event.debounce = ms # Public API confirms the int default was filtered out and the closure @@ -43,7 +43,7 @@ def handler(event, ms="not-an-int"): def test_non_int_load_const_debounce_returns_none(): """``LOAD_CONST`` with a non-int value should resolve to ``None``.""" - def handler(event): + async def handler(event): event.debounce = "not-an-int" from reactpy.core.events import EventHandler @@ -55,7 +55,7 @@ def handler(event): def test_mixed_default_with_string_and_int_uses_int(): """Defaults that aren't int are ignored, leaving int ones intact.""" - def handler(event, label="x", ms=275): + async def handler(event, label="x", ms=275): event.debounce = ms from reactpy.core.events import EventHandler @@ -73,7 +73,7 @@ def test_target_loaded_from_global_returns_none(): GLOBAL_EVENT = object() - def handler(event): + async def handler(event): GLOBAL_EVENT.debounce = 100 # Sanity: confirm the target is loaded by something other than @@ -94,7 +94,7 @@ def test_target_local_differs_from_event_arg_returns_none(): from reactpy.core.events import EventHandler - def handler(other): + async def handler(other): event = other event.debounce = 100 @@ -107,7 +107,7 @@ def test_closure_lookup_handles_extra_cells(): in pathological bytecode but shouldn't happen in pure Python), the helper must defensively stop iterating.""" - def handler(event): + async def handler(event): pass # Simulate the defensive branch by calling the helper directly with a @@ -127,7 +127,7 @@ def test_closure_lookup_skips_uninitialized_cells(): def make(): captured = 100 - def handler(event): + async def handler(event): return captured return handler @@ -184,7 +184,7 @@ class _FakeCode: def test_function_arg_defaults_ignores_non_int_positional(): - def handler(event, label="x", ms=275): + async def handler(event, label="x", ms=275): pass defaults = _function_arg_defaults(handler) @@ -193,43 +193,43 @@ def handler(event, label="x", ms=275): def test_function_arg_defaults_returns_empty_when_only_string_defaults(): - def handler(event, label="x"): + async def handler(event, label="x"): pass assert _function_arg_defaults(handler) == {} def test_closure_lookup_returns_empty_when_no_closure(): - def handler(event): + async def handler(event): pass assert _closure_lookup(handler) == {} def test_constant_ints_by_name_returns_empty(): - def handler(event): + async def handler(event): pass assert _constant_ints_by_name(handler.__code__) == {} -def test_resolve_debounce_value_returns_none_for_empty_store_index(): +def test_resolve_stored_value_returns_none_for_empty_store_index(): # With an empty instruction list and a store index of 0, the defensive - # guard at the top of ``_resolve_debounce_value`` triggers. - assert _resolve_debounce_value([], 0, "event", {}, {}, {}) is None + # guard at the top of ``_resolve_stored_value`` triggers. + assert _resolve_stored_value([], 0, "event", {}, {}, {}) is None -def test_resolve_debounce_value_returns_none_when_store_index_below_offset(): +def test_resolve_stored_value_returns_none_when_store_index_below_offset(): # The defensive ``store_index < _STORE_ATTR_VALUE_OFFSET`` guard. # With a single preceding instruction and a store index of 1, the guard # at the bottom of the function triggers. instructions = _build_fake_instructions([("LOAD_FAST", "event")]) # The single LOAD_FAST sits at index 0; index 1 corresponds to "STORE_ATTR" # which is below the offset of 2. - assert _resolve_debounce_value(instructions, 1, "event", {}, {}, {}) is None + assert _resolve_stored_value(instructions, 1, "event", {}, {}, {}) is None -def test_resolve_debounce_value_superinstruction_with_non_event_target_returns_none(): +def test_resolve_stored_value_superinstruction_with_non_event_target_returns_none(): """When the previous instruction IS a ``LOAD_FAST_LOAD_FAST``/``LOAD_FAST_BORROW_LOAD_FAST_BORROW`` superinstruction but the fused target name does not match @@ -246,12 +246,10 @@ def test_resolve_debounce_value_superinstruction_with_non_event_target_returns_n instructions = [val, prev] # ``store_index`` of 2 represents the (hypothetical) ``STORE_ATTR`` slot # after the two preceding instructions. - assert ( - _resolve_debounce_value(instructions, 2, "event", {"ms": 800}, {}, {}) is None - ) + assert _resolve_stored_value(instructions, 2, "event", {"ms": 800}, {}, {}) is None -def test_resolve_debounce_value_target_opname_not_allowed_returns_none(): +def test_resolve_stored_value_target_opname_not_allowed_returns_none(): """When the target instruction's ``opname`` is not one of the allowed ``LOAD_FAST`` family members, the resolver must ``return None``. @@ -263,12 +261,10 @@ def test_resolve_debounce_value_target_opname_not_allowed_returns_none(): val = types.SimpleNamespace(opname="LOAD_FAST_BORROW", argval="ms") prev = types.SimpleNamespace(opname="LOAD_GLOBAL", argval="event") instructions = [val, prev] - assert ( - _resolve_debounce_value(instructions, 2, "event", {"ms": 800}, {}, {}) is None - ) + assert _resolve_stored_value(instructions, 2, "event", {"ms": 800}, {}, {}) is None -def test_resolve_debounce_value_target_argval_mismatch_returns_none(): +def test_resolve_stored_value_target_argval_mismatch_returns_none(): """When the target ``LOAD_FAST_BORROW`` references a local that isn't the event argument, the resolver must ``return None``. @@ -279,12 +275,10 @@ def test_resolve_debounce_value_target_argval_mismatch_returns_none(): val = types.SimpleNamespace(opname="LOAD_FAST_BORROW", argval="ms") prev = types.SimpleNamespace(opname="LOAD_FAST_BORROW", argval="other") instructions = [val, prev] - assert ( - _resolve_debounce_value(instructions, 2, "event", {"ms": 800}, {}, {}) is None - ) + assert _resolve_stored_value(instructions, 2, "event", {"ms": 800}, {}, {}) is None -def test_resolve_debounce_value_val_instr_load_fast_borrow_returns_default(): +def test_resolve_stored_value_val_instr_load_fast_borrow_returns_default(): """When the value being stored is loaded via ``LOAD_FAST``/ ``LOAD_FAST_BORROW`` (a local variable that's an argument), the resolver looks up that local in ``arg_defaults`` and returns the @@ -300,11 +294,11 @@ def test_resolve_debounce_value_val_instr_load_fast_borrow_returns_default(): prev = types.SimpleNamespace(opname="LOAD_FAST_BORROW", argval="event") instructions = [val, prev] assert ( - _resolve_debounce_value(instructions, 2, "event", {"delay": 350}, {}, {}) == 350 + _resolve_stored_value(instructions, 2, "event", {"delay": 350}, {}, {}) == 350 ) -def test_resolve_debounce_value_superinstruction_match_returns_default(): +def test_resolve_stored_value_superinstruction_match_returns_default(): """When the previous instruction IS a ``LOAD_FAST_LOAD_FAST`` / ``LOAD_FAST_BORROW_LOAD_FAST_BORROW`` superinstruction whose fused ``(value_name, target_name)`` tuple is @@ -322,7 +316,7 @@ def test_resolve_debounce_value_superinstruction_match_returns_default(): ) val = types.SimpleNamespace(opname="LOAD_CONST", argval=1) # not used here instructions = [val, prev] - assert _resolve_debounce_value(instructions, 2, "event", {"ms": 800}, {}, {}) == 800 + assert _resolve_stored_value(instructions, 2, "event", {"ms": 800}, {}, {}) == 800 def test_inspect_event_handler_handles_fused_superinstruction(): @@ -338,7 +332,7 @@ def test_inspect_event_handler_handles_fused_superinstruction(): import dis from unittest.mock import patch - def handler(event, ms=400): + async def handler(event, ms=400): event.debounce = ms fake_prev = types.SimpleNamespace( @@ -378,7 +372,7 @@ def test_inspect_event_handler_with_borrow_superinstruction(): from reactpy.core.events import EventHandler - def handler(event, ms=225): + async def handler(event, ms=225): event.debounce = ms # Sanity: confirm the superinstruction is actually emitted on this @@ -397,9 +391,9 @@ def handler(event, ms=225): not _PYTHON_314_OR_NEWER, reason="LOAD_FAST_BORROW_LOAD_FAST_BORROW only exists in CPython 3.14+", ) -def test_resolve_debounce_value_with_borrow_superinstruction_non_event_target(): +def test_resolve_stored_value_with_borrow_superinstruction_non_event_target(): """When the fused superinstruction's target name does not match the event - argument, ``_resolve_debounce_value`` falls through to ``return None``.""" + argument, ``_resolve_stored_value`` falls through to ``return None``.""" # We can't easily hand-craft bytecode on 3.14+, so test via the public # API by using a handler that loads two unrelated locals before the @@ -407,7 +401,7 @@ def test_resolve_debounce_value_with_borrow_superinstruction_non_event_target(): # followed by ``event``. from reactpy.core.events import EventHandler - def handler(event, other, ms=175): + async def handler(event, other, ms=175): # Force two LOAD_FAST ops before STORE_ATTR. ``other`` and ``event`` # are emitted as the fused superinstruction; if the event detector # picks up ``other`` instead, debounce detection should still work @@ -460,3 +454,28 @@ def _build_fake_instructions(items): ) result.append(instr) return result + + +def test_a_callable_whose_call_carries_no_bytecode_reports_nothing(): + """An object made callable by a built-in ``__call__`` has no code to walk + + Detection reads the bytecode of the handler, and a built-in keeps none, so the + inspector has to settle for having looked at all. + """ + + class Talkative: + __call__ = print # a built-in, which carries no bytecode of its own + + handler = Talkative() + + assert inspect_event_handler(handler) == (False, False, None) + assert _function_arg_defaults(handler) == {} + + +def test_no_detect_debounce_from_a_bool(): + """The reverse holds too: a bool is not a debounce window""" + + async def handler(event): + event.debounce = False + + assert inspect_event_handler(handler) == (False, False, None) diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py new file mode 100644 index 000000000..1de97171a --- /dev/null +++ b/tests/test_core/test_event_proxy.py @@ -0,0 +1,1732 @@ +"""Tests for the live, client backed event objects in :mod:`reactpy._event_proxy`. + +Nothing about the object behind an event is sent to the server when it is dispatched. +The client keeps it alive and sends only an id, so every property a handler reads is a +round trip, and most of what follows counts those round trips. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from time import monotonic +from typing import Any + +import pytest + +import reactpy +from reactpy._event_proxy import ( + EVENT_MARKER_KEY, + EVENT_PROXY_REQUEST_TYPE, + EVENT_PROXY_RESPONSE_TYPE, + EVENT_REFERENCE_KEY, + Event, + EventProxy, + EventProxyAccessError, + EventProxyBackend, + EventProxyError, + EventProxyEvicted, + EventProxyNotFound, + EventProxyTimeout, + EventProxyUnavailable, + build_event_args, + deserialize_value, + serialize_argument, +) +from reactpy.config import ( + REACTPY_EVENT_PROXY_MAX_DEPTH, + REACTPY_EVENT_PROXY_MAX_TIMEOUTS, + REACTPY_EVENT_PROXY_TIMEOUT, +) +from reactpy.core.component import component +from reactpy.core.events import EventHandler, merge_event_handlers +from reactpy.core.layout import Layout +from tests.tooling.common import event_message + + +def make_event( + backend: EventProxyBackend, + event_id: str, + known: dict[str, Any] | None = None, + path: Sequence[str | int] = (), +) -> Event: + """Build an event the way the layout hands one to a handler""" + return Event(backend=backend, event_id=event_id, path=path, known=known) + + +def make_proxy( + backend: EventProxyBackend, + event_id: str, + path: Sequence[str | int] = (), + known: dict[str, Any] | None = None, +) -> EventProxy: + """Build a member proxy of the kind an event hands out""" + return EventProxy(backend=backend, event_id=event_id, path=path, known=known) + + +class Method: + """A callable which records how it was called""" + + def __init__(self, result: Any = None) -> None: + self.result = result + self.calls: list[tuple[Any, ...]] = [] + + def __call__(self, *args: Any) -> Any: + self.calls.append(args) + return self.result + + +class Reference: + """Data the fake client hands over as a reference rather than inline""" + + def __init__(self, value: Any) -> None: + self.value = value + + +class FakeClient: + """Answers live event requests out of a plain Python object tree""" + + def __init__( + self, + backend: EventProxyBackend, + objects: dict[str, Any], + *, + respond: bool = True, + delay: float = 0.0, + ) -> None: + self.backend = backend + self.objects = objects + self.respond = respond + self.delay = delay + self.sent: list[dict[str, Any]] = [] + + @property + def requests(self) -> list[dict[str, Any]]: + """Every individual request which went out, batched together or not""" + found: list[dict[str, Any]] = [] + for message in self.messages: + batch = message.get("requests") + if isinstance(batch, list): + found.extend(batch) + else: + found.append(message) + return found + + @property + def messages(self) -> list[dict[str, Any]]: + """The request messages exactly as they left the process""" + return [m for m in self.sent if m["type"] == EVENT_PROXY_REQUEST_TYPE] + + async def send(self, message: dict[str, Any]) -> None: + self.sent.append(message) + if message["type"] != EVENT_PROXY_REQUEST_TYPE or not self.respond: + return + loop = asyncio.get_running_loop() + batch = message.get("requests") + work = ( + [dict(entry) for entry in batch] + if isinstance(batch, list) + else [dict(message)] + ) + if self.delay > 0: + loop.call_later(self.delay, self._answer, work) + else: + # Carried out within this same turn of the loop. A zero-delay timer is + # due "now" but is only picked up on a later turn, and a loop with a + # coarse clock (Windows) can let a test read ahead of that turn. + loop.call_soon(self._answer, work) + + def _answer(self, messages: list[dict[str, Any]]) -> None: + """Reply to a whole batch at once, the way the browser answers one request + + Requests which did not ask for a reply go unanswered, so a batch of nothing but + fire-and-forget calls sends no message back at all. + """ + replies = [ + reply + for reply in (self._reply_for(message) for message in messages) + if reply is not None + ] + if not replies: + return + if len(replies) == 1: + self.backend.handle_response(replies[0]) + return + self.backend.handle_response( + {"type": EVENT_PROXY_RESPONSE_TYPE, "responses": replies}, + ) + + def _reply_for(self, message: dict[str, Any]) -> dict[str, Any] | None: + # A real client carries out the request either way - it only answers when the + # server asked for a reply. + should_reply = bool(message.get("reply")) + path = list(message["path"]) + response: dict[str, Any] = { + "type": EVENT_PROXY_RESPONSE_TYPE, + "requestId": message["requestId"], + "ok": True, + "value": None, + "error": None, + "code": None, + } + root = self.objects.get(message["eventId"]) + if root is None: + response.update(ok=False, error="not registered", code="not-found") + return response if should_reply else None + + try: + if message["call"]: + name = path[-1] if path else "" + parent = self._walk(root, path[:-1]) + method = parent.get(name) if isinstance(parent, dict) else None + if not isinstance(method, Method): + msg = f"{name!r} is not a function" + raise LookupError(msg) + value = method(*[self._read_argument(a) for a in message["args"]]) + else: + value = self._walk(root, path) + except LookupError as error: + response.update(ok=False, error=str(error), code="not-found") + else: + response["value"] = self._serialize(value, path) + return response if should_reply else None + + def _walk(self, node: Any, path: list[str]) -> Any: + for segment in path: + if isinstance(node, Reference): + node = node.value + if isinstance(node, dict): + if segment not in node: + msg = f"{segment!r} does not exist" + raise LookupError(msg) + node = node[segment] + elif isinstance(node, list): + try: + node = node[int(segment)] + except (ValueError, IndexError) as error: + msg = f"{segment!r} does not exist" + raise LookupError(msg) from error + else: + msg = f"cannot read {segment!r} of a {type(node).__name__}" + raise LookupError(msg) + return node + + def _read_argument(self, argument: Any) -> Any: + if isinstance(argument, dict) and EVENT_REFERENCE_KEY in argument: + reference = argument[EVENT_REFERENCE_KEY] + return self._walk( + self.objects[reference["eventId"]], list(reference["path"]) + ) + if isinstance(argument, list): + return [self._read_argument(item) for item in argument] + if isinstance(argument, dict): + return {key: self._read_argument(item) for key, item in argument.items()} + return argument + + def _serialize(self, value: Any, path: list[str]) -> Any: + if isinstance(value, Method): + return {EVENT_REFERENCE_KEY: {"path": path, "callable": True}} + if isinstance(value, Reference): + return {EVENT_REFERENCE_KEY: {"path": path}} + if isinstance(value, dict): + return { + key: self._serialize(item, [*path, key]) for key, item in value.items() + } + if isinstance(value, list): + return [ + self._serialize(item, [*path, str(index)]) + for index, item in enumerate(value) + ] + return value + + +def make_client( + objects: dict[str, Any], *, respond: bool = True, delay: float = 0.0 +) -> tuple[EventProxyBackend, FakeClient]: + """Build a backend wired up to a fake client serving the given objects""" + backend = EventProxyBackend() + client = FakeClient(backend, objects, respond=respond, delay=delay) + backend.set_sender(client.send) + return backend, client + + +def requested_paths(client: FakeClient) -> list[list[str]]: + return [request["path"] for request in client.requests] + + +async def poll(predicate, timeout: float = 5.0) -> None: + """Wait until ``predicate()`` becomes truthy""" + deadline = monotonic() + timeout + while not predicate(): + if monotonic() > deadline: + msg = "Timed out waiting for condition to become true" + raise TimeoutError(msg) + await asyncio.sleep(0.005) + + +def connect(layout: Layout, client: FakeClient) -> None: + """Pipe a layout's live event requests through ``client`` and back again""" + client.backend = layout.set_event_proxy_sender(client.send) + + +def make_objects() -> dict[str, Any]: + """A stand-in for the object tree a browser would hand back""" + return { + "evt": { + "type": "click", + "target": { + "tagName": "INPUT", + "value": "typed", + "focus": Method("focused"), + }, + "deep": Reference({"a": {"b": {"c": 42}}}), + "composedPath": Method(["window", "input"]), + "preventDefault": Method(), + "stopPropagation": Method(), + "log": Method(None), + "touches": [{"pageX": 1}, {"pageX": 2}], + } + } + + +class CannedClient: + """Answers every live request with one fixed response payload""" + + def __init__( + self, + backend: EventProxyBackend, + *, + ok: bool, + value: Any = None, + error: str | None = None, + code: str | None = None, + ) -> None: + self.backend = backend + self.ok = ok + self.value = value + self.error = error + self.code = code + self.sent: list[dict[str, Any]] = [] + + @property + def requests(self) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + for message in self.sent: + if message["type"] != EVENT_PROXY_REQUEST_TYPE: + continue + batch = message.get("requests") + found.extend(batch if isinstance(batch, list) else [message]) + return found + + async def send(self, message: dict[str, Any]) -> None: + self.sent.append(message) + if message["type"] != EVENT_PROXY_REQUEST_TYPE: + return + batch = message.get("requests") + asking = [ + entry + for entry in (batch if isinstance(batch, list) else [message]) + if entry.get("reply") + ] + if not asking: + return + answers = [ + { + "requestId": entry["requestId"], + "ok": self.ok, + "value": self.value, + "error": self.error, + "code": self.code, + } + for entry in asking + ] + if len(answers) == 1: + self.backend.handle_response( + {"type": EVENT_PROXY_RESPONSE_TYPE, **answers[0]}, + ) + return + self.backend.handle_response( + {"type": EVENT_PROXY_RESPONSE_TYPE, "responses": answers}, + ) + + +def make_canned_client(**response: Any) -> tuple[EventProxyBackend, CannedClient]: + """Build a backend whose client always answers with the same payload""" + backend = EventProxyBackend() + client = CannedClient(backend, **response) + backend.set_sender(client.send) + return backend, client + + +# -- every proxy is awaitable, whether or not its value is already known ----- + + +async def read_it_the_way_a_handler_does(event_object: Event) -> tuple[Any, Any]: + """The very expressions a handler writes, run against either kind of event + + Under the snapshot model this could only be written for one of them: awaiting + ``event.type`` raised ``TypeError: object str can't be used in 'await' + expression`` whenever the client had happened to include the type with the event, + because the snapshot handed back the bare string. Both events below are read with + the same awaits and answer the same; only what they cost differs. + """ + return await event_object.type, await event_object.target.value + + +async def test_every_proxy_is_awaitable_whether_the_value_is_known_or_not(): + backend, client = make_client(make_objects()) + nothing_is_known = make_event(backend, "evt") + + assert type(nothing_is_known) is Event + assert await read_it_the_way_a_handler_does(nothing_is_known) == ("click", "typed") + assert requested_paths(client) == [["type"], ["target", "value"]] + + backend, client = make_client(make_objects()) + the_type_is_known = make_event(backend, "evt", known={"type": "click"}) + + assert type(the_type_is_known) is Event + assert await the_type_is_known.type == "click" + assert client.messages == [], "a value the client volunteered is never asked for" + assert await read_it_the_way_a_handler_does(the_type_is_known) == ( + "click", + "typed", + ) + assert requested_paths(client) == [ + ["target", "value"] + ], "one round trip for the one property which was not known" + + # a known value is still a proxy rather than the bare value, so awaiting it is + # neither optional nor forbidden - it simply costs nothing + assert isinstance(the_type_is_known.type, EventProxy) + assert the_type_is_known.type.is_known is True + assert nothing_is_known.target.is_known is False + + +async def test_a_known_member_costs_no_round_trip(): + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt", known={"type": "click"}) + + assert await event_object.type == "click" + assert (await event_object.read("type")) == ("click",) + assert requested_paths(client) == [] + assert "type" in dir(event_object), "a known member should still be discoverable" + + # a value handed over up front is awaitable at the path it belongs to as well + handed_over = EventProxy( + backend=backend, event_id="evt", path=("target", "value"), value="typed" + ) + assert await handed_over == "typed" + assert requested_paths(client) == [] + + +async def test_a_property_is_read_from_the_browser_only_once(): + """A resolved property is cached against its event, however it is spelled""" + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + assert await event_object.target.value == "typed" + assert (await event_object.read("target.value")) == ("typed",) + assert await event_object.target.value == "typed" + + assert requested_paths(client) == [ + ["target", "value"] + ], "the browser was asked about one property exactly once" + assert len(client.messages) == 1 + + +async def test_a_property_can_be_asked_of_the_browser_again(): + """What is remembered is the default; asking again is the caller's call + + A resolved property is remembered against its event, which is what makes a handler + which reads one twice cost nothing - and it also means the second read hands back the + first answer however much the page has moved on since. Refreshing reads the way the + browser holds it by now, and pays for that round trip again. + """ + objects = make_objects() + backend, client = make_client(objects) + event_object = make_event(backend, "evt", known={"type": "click"}) + + assert await event_object.target.value == "typed" + assert await event_object.target.value == "typed" + assert requested_paths(client) == [ + ["target", "value"] + ], "two reads of one property cost one round trip" + + objects["evt"]["target"]["value"] = "retyped while the handler thought" + + assert await event_object.target.value == "typed" + assert await event_object.read("target.value") == ("typed",) + assert requested_paths(client) == [ + ["target", "value"] + ], "what is remembered costs nothing at all" + + assert await event_object.target.value.resolve(refresh=True) == ( + "retyped while the handler thought" + ) + refreshed, = await event_object.read("target.value", refresh=True) + assert refreshed == "retyped while the handler thought" + assert requested_paths(client) == [ + ["target", "value"], + ["target", "value"], + ["target", "value"], + ], "each refresh paid for itself" + + # What the client volunteered with the event is remembered too, and refreshing goes + # to the browser for it like anything else + assert await event_object.read("type") == ("click",) + assert requested_paths(client)[-1] == ["target", "value"] + assert await event_object.read("type", refresh=True) == ("click",) + assert requested_paths(client)[-1] == ["type"], "even what came free is asked again" + + +async def test_reads_which_are_in_flight_together_travel_as_one_message(): + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + value, tag_name = await event_object.read("target.value", "target.tagName") + + assert (value, tag_name) == ("typed", "INPUT"), "answers arrive in the asked order" + assert len(client.messages) == 1, "both reads left the process together" + message = client.messages[0] + assert [entry["path"] for entry in message["requests"]] == [ + ["target", "value"], + ["target", "tagName"], + ] + assert requested_paths(client) == [["target", "value"], ["target", "tagName"]] + + +async def test_gathered_reads_are_batched(): + """``asyncio.gather`` of several reads costs one round trip, not several""" + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + value, tag_name = await asyncio.gather( + event_object.target.value, event_object.target.tagName + ) + + assert (value, tag_name) == ("typed", "INPUT") + assert len(client.messages) == 1 + assert len(client.requests) == 2 + + +async def test_sequential_reads_are_not_batched(): + """The honest limitation, so nobody optimises it into a lie + + Two ``await`` expressions written one after the other cannot travel together: the + second is not even thought of until the first has landed. + """ + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + assert await event_object.target.value == "typed" + assert await event_object.target.tagName == "INPUT" + + assert len(client.messages) == 2 + assert requested_paths(client) == [["target", "value"], ["target", "tagName"]] + + +async def test_a_read_already_in_flight_is_shared_rather_than_duplicated(): + """Two readers of one property send one request + + The second does not ask the browser again and does not collect an answer of its + own; it waits on the request which is already on its way. Sharing has to outlast + the queueing of a request, so the read is remembered as in flight until the answer + has been delivered - which is also what stops a timed out request from being + joined by anyone later. + """ + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + value, again = await asyncio.gather( + event_object.target.value, event_object.target.value + ) + + assert value == again == "typed" + assert requested_paths(client) == [ + ["target", "value"] + ], "the browser was asked once for what two readers wanted" + assert len(client.messages) == 1 + + +async def test_a_batch_adopts_a_read_which_is_already_in_flight(): + """A batch does not re-ask for what a lone read is already waiting on""" + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + lone = asyncio.ensure_future(event_object.target.value) + await asyncio.sleep(0) # let it set off, and register itself as in flight + + value, tag_name = await event_object.read("target.value", "target.tagName") + + assert await lone == value == "typed", "both readers got the one answer" + assert tag_name == "INPUT" + assert requested_paths(client) == [ + ["target", "value"], + ["target", "tagName"], + ], "the batch asked only for the property nobody had asked about yet" + + +async def test_read_of_no_paths_returns_an_empty_tuple(): + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + assert await event_object.read() == () + assert requested_paths(client) == [] + + +async def test_a_proxy_is_not_a_container(): + """A proxy is not a mapping, and must never be made to look like one + + ``EventProxy`` deliberately has no ``__getitem__``, ``__len__``, ``__iter__`` or + ``__contains__``. The trap which hung the test suite is defining ``__getitem__`` + without ``__iter__``: ``x in proxy`` then falls back to trying ``proxy[0]``, + ``proxy[1]``, ... until an index raises ``IndexError``, and for a proxy no index is + ever missing - every one of them hands back another proxy - so the membership test + spun forever. There is nothing to enumerate about an object which is still sitting + in the browser anyway. + """ + backend, _client = make_client(make_objects()) + event_object = make_event(backend, "evt", known={"type": "click"}) + + for name in ("__getitem__", "__len__", "__iter__", "__contains__"): + assert not hasattr(EventProxy, name), f"{name} would make a proxy enumerable" + + with pytest.raises(TypeError): + event_object["target"] + with pytest.raises(TypeError): + assert "type" in event_object + with pytest.raises(TypeError): + len(event_object) + with pytest.raises(TypeError): + list(event_object) + + +async def test_a_proxy_is_always_truthy(): + """A proxy stands for its browser object even before that object is known + + ``bool`` answers True without leaving the process, so ``if event.target:`` cannot + quietly drop an element whose value turns out to be an empty string, a zero or + :data:`None`. What is unknown is not the same as absent. + """ + backend, _client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + assert bool(event_object) is True + assert bool(event_object.target) is True + + +async def test_the_debounce_a_handler_sets_can_be_read_back(): + """``debounce`` is a setting on the handler rather than a trip to the browser""" + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + assert event_object.debounce is None + + event_object.debounce = 150 + + assert event_object.debounce == 150 + assert client.messages == [], "a setting the server keeps costs nothing" + + +# -- what a proxy says about itself ----------------------------------------- + + +async def test_proxies_describe_themselves(): + backend, _client = make_client(make_objects()) + event_object = make_event(backend, "evt", known={"type": "click"}) + target = event_object.target + + assert event_object.backend is backend + assert event_object.event_id == "evt" + assert event_object.path == () + assert repr(event_object) == "Event(event_id='evt', path=[])" + # what the client volunteered shows up as a member, and nothing else does + assert "type" in dir(event_object) + assert "target" not in dir(event_object) + + assert target.backend is backend + assert target.path == ("target",) + assert repr(target) == "EventProxy(event_id='evt', path=['target'])" + + # there is no positional snapshot argument to hand over any more + with pytest.raises(TypeError): + EventProxy(backend, "evt") + + +async def test_internal_attributes_are_never_resolved_against_the_browser(): + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + for name in ("_reactpy_nothing", "__wrapped__", "__deepcopy__"): + with pytest.raises(AttributeError, match=f"^{name}$"): + getattr(event_object, name) + + assert client.requests == [], "internals are not looked up in the browser" + + +async def test_a_proxy_with_nothing_behind_it_cannot_resolve(): + base = make_proxy(EventProxyBackend(), "evt", ["target"]) + + assert repr(base) == "EventProxy(event_id='evt', path=['target'])" + assert base.is_known is False + with pytest.raises(EventProxyUnavailable, match="live access"): + await base + + +# -- reading and calling through a proxy ------------------------------------- + + +async def test_read_walks_the_whole_dotted_path_at_once(): + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + assert (await event_object.read("target.value")) == ("typed",) + assert (await event_object.read("target.tagName")) == ("INPUT",) + # a chain is asked for as one path, however deep it goes ... + assert (await event_object.read("deep.a.b.c")) == (42,) + # ... and may be handed over as a sequence of segments too, indices included + assert (await event_object.read(["touches", 0, "pageX"])) == (1,) + assert (await event_object.read("touches.1.pageX")) == (2,) + + assert requested_paths(client) == [ + ["target", "value"], + ["target", "tagName"], + ["deep", "a", "b", "c"], + ["touches", "0", "pageX"], + ["touches", "1", "pageX"], + ] + + +async def test_reads_use_what_is_known_and_ask_for_everything_else(): + objects = make_objects() + objects["evt"]["detail"] = 7 + backend, client = make_client(objects) + event_object = make_event(backend, "evt", known={"type": "click"}) + + cached = await event_object.read("type") + typed = await event_object.read("target.value") + detail = await event_object.read("detail") + resolved = await make_proxy(backend, "evt", ["target"]).resolve() + + assert cached == ("click",) + assert typed == ("typed",) + assert detail == (7,) + assert type(resolved) is dict, "a plain object comes back as a plain object" + assert resolved["value"] == "typed" + assert requested_paths(client) == [ + ["target", "value"], + ["detail"], + ["target"], + ] + + # a path the browser does not have is raised, never quietly answered with None + with pytest.raises(EventProxyNotFound): + await event_object.read("nope") + assert requested_paths(client)[-1] == ["nope"] + + +async def test_chained_attributes_are_asked_for_in_one_round_trip(): + def chain(answer: int) -> Reference: + return Reference({"next": Reference({"next": Reference({"answer": answer})})}) + + objects = {"evt": {"chain": chain(5), "trail": chain(7)}} + backend, client = make_client(objects) + event_object = make_event(backend, "evt") + + # read() asks for the whole path in a single round trip ... + assert (await event_object.read("chain.next.next.answer")) == (5,) + assert requested_paths(client) == [["chain", "next", "next", "answer"]] + + # ... and so does walking attributes, since nothing is asked of the browser until + # the value behind the last name in the chain is actually awaited. A chain four + # names deep costs one request here, not one request per name. + assert await event_object.trail.next.next.answer == 7 + assert requested_paths(client) == [ + ["chain", "next", "next", "answer"], + ["trail", "next", "next", "answer"], + ] + + # either of them again sends nothing at all, spelled whichever way + assert await event_object.trail.next.next.answer == 7 + assert (await event_object.read("chain.next.next.answer")) == (5,) + assert len(client.messages) == 2 + + +async def test_async_event_walks_references(): + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + deep = await event_object.deep.resolve() + assert isinstance(deep, EventProxy) + assert deep.path == ("deep",) + assert await event_object.deep.a.b.c == 42 + # the same path spelled the other way is answered out of the cache the backend + # keeps against (event id, path), so it costs the browser nothing at all + assert (await event_object.read("deep.a.b.c")) == (42,) + assert requested_paths(client) == [["deep"], ["deep", "a", "b", "c"]] + + +async def test_async_event_calls_client_side_methods(): + objects = make_objects() + backend, _client = make_client(objects) + event_object = make_event(backend, "evt") + + assert await event_object.target.focus() == "focused" + assert await event_object.composedPath() == ["window", "input"] + assert objects["evt"]["target"]["focus"].calls == [()] + + await event_object.log(event_object.target, {"a": [1, 2]}) + logged_node, payload = objects["evt"]["log"].calls[-1] + assert logged_node["tagName"] == "INPUT" + assert logged_node["value"] == "typed" + assert payload == {"a": [1, 2]} + + +async def test_missing_objects_and_paths_are_reported(): + backend, _client = make_client(make_objects()) + missing = make_event(backend, "not-registered") + with pytest.raises(EventProxyNotFound): + await missing.resolve() + + event_object = make_event(backend, "evt") + with pytest.raises(EventProxyNotFound): + await event_object.nope.resolve() + + +async def test_an_object_the_page_ran_out_of_room_for_says_so(): + """What went stale and what was dropped are reported differently + + Both mean the object is not coming back, but only one of them says it was lost while + still being used: the page keeps a bounded number of dispatched objects, and an + object it pushes out to make room for the events behind it arrives as "evicted". A + handler, or the log a traceback ends up in, can tell "stale anyway" from "you ran + out of room" - and still catches the ordinary :class:`EventProxyNotFound`. + """ + backend, client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + + dropped = asyncio.create_task(event_object.read("target")) + stale = asyncio.create_task(event_object.read("type")) + await poll(lambda: len(client.requests) == 2) + dropped_id, stale_id = (request["requestId"] for request in client.requests) + + def refuse(request_id: str, code: str, error: str) -> None: + backend.handle_response( + { + "type": EVENT_PROXY_RESPONSE_TYPE, + "requestId": request_id, + "ok": False, + "value": None, + "error": error, + "code": code, + }, + ) + + refuse( + dropped_id, + "evicted", + "event 'evt' no longer fits - the page keeps at most 512 live event objects, " + "and this one was dropped to make room", + ) + refuse(stale_id, "not-found", "nothing is registered for event 'evt'") + + with pytest.raises(EventProxyEvicted, match="no longer fits") as lost: + await dropped + assert isinstance(lost.value, EventProxyNotFound), "the ordinary catch still works" + + with pytest.raises(EventProxyNotFound) as forgotten: + await stale + assert not isinstance(forgotten.value, EventProxyEvicted), "stale is not eviction" + assert backend._pending == {} + + +async def test_the_event_object_itself_cannot_be_called(): + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + with pytest.raises(EventProxyAccessError, match="cannot be called"): + await event_object() + with pytest.raises(EventProxyAccessError, match="cannot be called"): + await make_proxy(backend, "evt")() + + assert client.requests == [] + + +async def test_client_side_calls_can_be_waited_for(): + objects = make_objects() + backend, client = make_client(objects) + target = make_proxy(backend, "evt", ["target"]) + + # ``preventDefault()`` and friends do not wait; asking to wait hands back + # whatever the browser's method returned once it has actually run + assert await target._reactpy_invoke("focus", (), wait=True) == "focused" + + assert objects["evt"]["target"]["focus"].calls == [()] + assert requested_paths(client) == [["target", "focus"]] + assert client.requests[0]["reply"] is True + + +async def test_fires_and_forget_calls_never_raise_at_the_handler(): + # nothing is bound to this backend, so there is nobody to tell + event_object = make_event(EventProxyBackend(), "evt") + + assert event_object.preventDefault() is None + assert event_object.stopPropagation() is None + assert event_object.stopImmediatePropagation() is None + + # what we asked for is still recorded, only the browser side call is dropped + assert event_object.defaultPrevented is True + assert event_object.propagationStopped is True + + +async def test_prevent_default_is_scheduled_but_not_awaited(): + objects = make_objects() + backend, client = make_client(objects) + event_object = make_event(backend, "evt") + + event_object.preventDefault() + event_object.stopPropagation() + assert event_object.defaultPrevented + assert event_object.propagationStopped + + # Wait on the calls themselves rather than on how many requests went out: the + # fake client carries a request out a turn after it records sending it, and a + # loop with a coarser clock (Windows) lets this test read ahead of that turn. + prevented, stopped = ( + objects["evt"]["preventDefault"], + objects["evt"]["stopPropagation"], + ) + await poll(lambda: len(prevented.calls) == 1 and len(stopped.calls) == 1) + assert requested_paths(client) == [["preventDefault"], ["stopPropagation"]] + assert all(not request["reply"] for request in client.requests) + assert prevented.calls == [()] + assert stopped.calls == [()] + + +async def test_stop_immediate_propagation_is_forwarded_to_the_client(): + objects = make_objects() + objects["evt"]["stopImmediatePropagation"] = Method() + backend, client = make_client(objects) + event_object = make_event(backend, "evt") + + event_object.stopImmediatePropagation() + + stop_immediately = objects["evt"]["stopImmediatePropagation"] + await poll(lambda: len(stop_immediately.calls) == 1) + assert requested_paths(client) == [["stopImmediatePropagation"]] + assert client.requests[0]["reply"] is False + assert stop_immediately.calls == [()] + assert event_object.propagationStopped is True + assert event_object.defaultPrevented is False + + +async def test_what_the_browser_prevented_is_read_rather_than_awaited(): + """Two different questions, answered two different ways + + ``event.defaultPrevented`` answers synchronously with what this side asked the + browser to do, so awaiting it was always going to be a ``TypeError``. What the + browser itself settled on - which other scripts on the page may have had a say in - + is a property of the live event, and is read like any other. + """ + objects = make_objects() + objects["evt"]["defaultPrevented"] = True + backend, client = make_client(objects) + event_object = make_event(backend, "evt") + + assert event_object.defaultPrevented is False + with pytest.raises(TypeError): + await event_object.defaultPrevented + + prevented, = await event_object.read("defaultPrevented") + assert prevented is True + assert requested_paths(client) == [["defaultPrevented"]] + + event_object.preventDefault() + assert event_object.defaultPrevented is True + prevented_method = objects["evt"]["preventDefault"] + await poll(lambda: len(prevented_method.calls) == 1) + + +# -- what may cross the connection in either direction ----------------------- + + +def test_arguments_are_serialized_or_refused(): + backend = EventProxyBackend() + proxy = make_proxy(backend, "evt", ["target"]) + assert serialize_argument(proxy) == { + EVENT_REFERENCE_KEY: {"eventId": "evt", "path": ["target"]}, + } + assert serialize_argument({"a": [1, "two", None]}) == {"a": [1, "two", None]} + with pytest.raises(EventProxyAccessError): + serialize_argument(object()) + + +def test_arguments_the_client_could_never_use_are_refused(): + assert serialize_argument(("a", "b")) == ["a", "b"] + for value in (b"bytes", bytearray(b"raw"), {1, 2}, object()): + with pytest.raises(EventProxyAccessError, match="cannot pass"): + serialize_argument(value) + + +def test_excessively_nested_references_are_refused(): + backend = EventProxyBackend() + value: Any = {EVENT_REFERENCE_KEY: {"path": ["a"]}} + for _ in range(20): + value = {"nested": value} + + with pytest.raises(EventProxyAccessError): + deserialize_value(value, "evt", backend) + + +def test_arguments_nested_too_deeply_are_refused_without_overflowing(): + """A deep argument is refused rather than overflowing the stack + + Arguments were handed to the client by recursing through them with no bound at all, + so a value which went deep enough raised ``RecursionError`` from inside the call - + which is not a failure a handler has much hope of reading, and not one it can + recover from by asking for something shallower. + """ + value: Any = "leaf" + for _ in range(2000): + value = {"nested": value} + + with pytest.raises(EventProxyAccessError, match="nested more deeply than"): + serialize_argument(value) + + +def test_arguments_as_deep_as_the_client_accepts_still_travel(monkeypatch): + """The bound is the one reading already uses, and it is not off by one""" + monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_DEPTH, "current", 3) + + assert serialize_argument([[["leaf"]]]) == [[["leaf"]]] + + with pytest.raises(EventProxyAccessError, match="nested more deeply than"): + serialize_argument([[[[["leaf"]]]]]) + + +# -- when there is nobody on the other end to answer ------------------------- + + +def test_backend_without_a_sender_is_unavailable(): + backend = EventProxyBackend() + assert not backend.available + + data = [{EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, 5] + args = build_event_args(data, backend) + assert isinstance(args[0], Event) + assert args[1] == 5 + + +async def test_a_request_whose_sender_went_away_is_abandoned(): + """Losing the connection between queueing and sending strands nobody + + Requests leave together at the end of a pass round the loop, so there is a moment + when a request is committed but the browser has not been asked. If the way to the + client disappears in that moment the waiter is told plainly, rather than being + left to sit on a request which will never be sent. + """ + backend, _client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + pending = asyncio.ensure_future(event_object.target.value) + await asyncio.sleep(0) # the request is queued, waiting for the flush to carry it + + backend._send = None # the connection went away before the queue was drained + backend._flush() + + with pytest.raises(EventProxyUnavailable): + await pending + + +def test_a_response_before_the_layout_runs_has_nobody_to_wake(): + """An answer arriving outside the layout's life has no request to satisfy + + The backend belongs to the running layout, so before it has started - or after it + has stopped - there is nothing to hand the answer to. It is refused rather than + raising, because the connection has no way to know the difference and should not + be blamed for the timing. + """ + + @component + def MyComponent(): + return reactpy.html.div() + + layout = Layout(MyComponent()) + + assert ( + layout.handle_event_proxy_response( + {"type": EVENT_PROXY_RESPONSE_TYPE, "requestId": "gone", "ok": True} + ) + is False + ) + + +def test_a_response_named_by_something_other_than_a_string_is_ignored(): + """A requestId is only ever something this side handed out + + A client which is confused, or worse, can answer a request which was never sent + with an id which is not even a string. Nothing may be looked up for it then, and + nothing may be remembered about it either - a list cannot so much as be a key. + """ + backend = EventProxyBackend() + + for request_id in (42, None, ["a"], {"a": 1}, b"bytes"): + assert ( + backend.handle_response( + { + "type": EVENT_PROXY_RESPONSE_TYPE, + "requestId": request_id, + "ok": True, + }, + ) + is True + ) + assert ( + backend.handle_response( + { + "type": EVENT_PROXY_RESPONSE_TYPE, + "responses": [{"requestId": request_id, "ok": True}], + }, + ) + is True + ) + + +async def test_unanswered_requests_eventually_disable_live_access(monkeypatch): + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 2) + backend, _client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + + # Each attempt asks for a different property. A path which has just timed out is + # still remembered as being in flight, so asking for it again would join the dead + # request rather than make a new one - see the note in the test report. + for path in ("target", "type"): + with pytest.raises(EventProxyTimeout): + await event_object.read(path) + + assert backend.disabled + assert not backend.available + with pytest.raises(EventProxyUnavailable): + await event_object.read("key") + + +async def test_client_reported_timeouts_count_against_the_same_budget(monkeypatch): + monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 1) + backend, _client = make_canned_client( + ok=False, code="timeout", error="browser hung" + ) + event_object = make_event(backend, "evt") + + with pytest.raises(EventProxyTimeout, match="did not answer"): + await event_object.read("target") + + assert backend.disabled, "a timeout the client reports is still a timeout" + + +async def test_one_silent_batch_costs_one_timeout(monkeypatch): + """A batch which went silent is one dead round trip, not one per property + + Each property of a silent batch used to raise its own timeout, and every one of + those was counted. A handler which read four properties of an event at once + therefore exhausted a budget of three all by itself, and switched live access off + for a client which had merely been asked several things in one message. + """ + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 3) + backend, client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + properties = ("target.tagName", "target.value", "type", "deep.a") + + for attempt in (1, 2): + with pytest.raises(EventProxyTimeout): + await event_object.read(*properties) + assert len(client.messages) == attempt, "the batch left as a single message" + assert not backend.disabled, f"round trip {attempt} of three is not the last" + + with pytest.raises(EventProxyTimeout): + await event_object.read(*properties) + + assert backend.disabled, "three dead round trips is a dead connection" + + +async def test_a_client_which_connected_again_can_be_read_again(monkeypatch): + """A reconnect is a new client, not the one which stopped answering + + Being switched off used to last for the life of the layout, so one unanswered + message - a page which was reloaded, a socket which was quietly replaced - left + every handler which came after it reading nothing but ``EventProxyUnavailable``, + while a perfectly willing browser sat on the other end of the connection. + """ + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 1) + + async def silence(message: dict[str, Any]) -> None: + return None + + backend = EventProxyBackend() + backend.set_sender(silence) + event_object = make_event(backend, "evt") + + with pytest.raises(EventProxyTimeout): + await event_object.read("target") + + assert backend.disabled + backend.set_sender(silence) + assert backend.disabled, "the very same silent sender is not a second chance" + + alive = CannedClient(backend, ok=True, value="typed") + backend.set_sender(alive.send) + assert backend.available, "a client which has just connected has not failed yet" + assert await event_object.read("target.value") == ("typed",) + + +async def test_a_closed_layout_does_not_come_back_when_a_sender_appears(): + """Closing is the layout going away, which no sender can undo""" + backend = EventProxyBackend() + backend.disable("the client went away") + backend.close() + + async def send(message: dict[str, Any]) -> None: + return None + + backend.set_sender(send) + assert backend.disabled + assert not backend.available + + +async def test_failures_the_client_cannot_explain_are_still_reported(): + backend, client = make_canned_client(ok=False) + event_object = make_event(backend, "evt") + + with pytest.raises(EventProxyAccessError) as info: + await event_object.read("target") + + assert "could not resolve ['target'] of event 'evt'" in str(info.value) + assert "the client did not provide a reason" in str(info.value) + assert not backend.disabled, "one refusal is not a broken connection" + assert len(client.requests) == 1 + + +class ScriptedClient(FakeClient): + """Answers like a real client, except on the paths it has been told to misbehave on""" + + def __init__( + self, + backend: EventProxyBackend, + objects: dict[str, Any], + hung: set[tuple[str, ...]] | None = None, + silent: set[tuple[str, ...]] | None = None, + ) -> None: + super().__init__(backend, objects) + self.hung = set() if hung is None else hung + self.silent = set() if silent is None else silent + + def _reply_for(self, message: dict[str, Any]) -> dict[str, Any] | None: + if tuple(message["path"]) in self.silent: + # The browser never gets round to this one + return None + reply = super()._reply_for(message) + if reply is not None and tuple(message["path"]) in self.hung: + # The browser hung while working this one out, and said as much + reply.update(ok=False, error="browser hung", code="timeout") + return reply + + +def make_scripted_client( + objects: dict[str, Any], + *, + hung: set[tuple[str, ...]] | None = None, + silent: set[tuple[str, ...]] | None = None, +) -> tuple[EventProxyBackend, ScriptedClient]: + """Build a backend whose client hangs on, or stays quiet about, named paths""" + backend = EventProxyBackend() + client = ScriptedClient(backend, objects, hung, silent) + backend.set_sender(client.send) + return backend, client + + +async def test_a_batch_keeps_the_answers_it_already_paid_for(monkeypatch): + """One path going wrong must not throw away the answers beside it + + A batch used to give up as soon as one of its paths raised: the client had already + answered the rest, and all of it was discarded anyway. Reading those properties + again then paid for a round trip the browser had already carried out. + """ + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + backend, client = make_scripted_client(make_objects(), silent={("gone",)}) + event_object = make_event(backend, "evt") + + with pytest.raises(EventProxyTimeout): + await event_object.read("gone", "target.value") + + assert len(client.messages) == 1, "both paths travelled as one message" + + asked_for_before = len(client.requests) + assert await event_object.read("target.value") == ("typed",) + assert len(client.requests) == asked_for_before, "the answer is not asked for twice" + + +async def test_a_batch_reports_the_failure_which_explains_something(): + """A timeout must not bury the reason another property was refused + + Both failures below come back in the same message, and the timeout is the one which + is listed first. Raising the first failure to exist reported a client which never + answered, when in fact it had answered - and had explained that the other property + simply does not exist. + """ + backend, _client = make_scripted_client(make_objects(), hung={("target", "value")}) + event_object = make_event(backend, "evt") + + with pytest.raises(EventProxyNotFound, match="does not exist") as info: + await event_object.read("target.value", "does_not_exist") + + assert "does_not_exist" in str(info.value) + assert not backend.disabled, "one hung request is not yet a dead connection" + + +async def test_a_failed_send_switches_live_access_off(): + backend = EventProxyBackend() + sent: list[dict[str, Any]] = [] + + async def send(message: dict[str, Any]) -> None: + sent.append(message) + msg = "socket is closed" + raise ConnectionResetError(msg) + + backend.set_sender(send) + event_object = make_event(backend, "evt") + + with pytest.raises(EventProxyUnavailable, match="failed to send"): + await event_object.read("target") + + assert len(sent) == 1, "the request was handed to the client once" + assert backend.disabled + assert not backend.available + + +async def test_a_call_made_from_another_thread_is_sent_by_the_loop(): + """A handler is free to think on a worker thread; the loop still does the sending + + Nothing about live access is thread safe - the connection to the browser belongs + to the loop which accepted it - so a request conceived elsewhere is handed over + rather than sent from the thread which thought of it. + """ + backend, client = make_canned_client(ok=True) + + def call_from_a_worker() -> None: + backend.notify("evt", ("log",), ["written"]) + + await asyncio.to_thread(call_from_a_worker) + await poll(lambda: client.sent) + + request = client.sent[0] + assert request["type"] == EVENT_PROXY_REQUEST_TYPE + assert request["path"] == ["log"] + assert request["call"] is True + assert request["args"] == ["written"] + assert request["reply"] is False + + +async def test_a_reader_which_went_away_releases_its_request(): + """A cancelled read must not leave a request waiting for nobody + + A handler which is stopped while it is reading - a re-render which replaced it, a + component which unmounted - abandons whatever it was waiting on. The request stayed + recorded as though somebody still cared, and the answer to it had nowhere to go. + Nothing reclaimed that either, so a page which re-renders quickly kept one alive for + every read it ever cancelled. + """ + backend, client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + + task = asyncio.create_task(event_object.read("target")) + await poll(lambda: len(client.requests) == 1) + assert len(backend._pending) == 1, "the request is waiting on the client" + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert backend._pending == {}, "nothing waits on behalf of an absent reader" + assert backend._inflight == {}, "and the path is free to be asked about again" + + # The very same property may be read again, and it is asked for afresh + again = asyncio.create_task(event_object.read("target")) + await poll(lambda: len(client.requests) == 2) + again.cancel() + with pytest.raises(asyncio.CancelledError): + await again + + +async def test_a_reader_which_only_borrowed_an_answer_releases_nothing(): + """Two readers of one property share the request, and only one of them owns it + + The second handler to want a property borrows the answer of the first rather than + asking the client twice. When that second reader goes away it is not its request to + release: the handler which asked is still there, still waiting, and still expects the + answer to find it. + """ + backend, client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + + first = asyncio.create_task(event_object.read("target")) + await poll(lambda: len(client.requests) == 1) + request_id = next(iter(backend._pending)) + + # The borrower wants one property which is being asked for and one which is not, so + # the request it sends itself is proof that it joined rather than asked again. + borrower = asyncio.create_task(event_object.read("target", "type")) + await poll(lambda: len(client.requests) == 2) + assert requested_paths(client)[1] == ["type"], "only what it lacks goes out" + + borrower.cancel() + with pytest.raises(asyncio.CancelledError): + await borrower + + assert request_id in backend._pending, "the one still waiting keeps its request" + + backend.handle_response( + { + "type": EVENT_PROXY_RESPONSE_TYPE, + "requestId": request_id, + "ok": True, + "value": "typed", + "error": None, + "code": None, + }, + ) + assert await first == ("typed",) + assert backend._pending == {} + + +async def test_a_reader_which_borrowed_an_answer_gives_up_on_its_own_clock( + monkeypatch, +): + """A borrower runs out of patience on its own, without costing anyone else + + Waiting on somebody else's request is held to the same deadline as waiting on one's + own, so a client which has stopped answering cannot strand a handler which merely + borrowed an answer. Giving up is the borrower's own affair though: the request stays + where the reader which asked left it, and it is that reader whose round trip is + counted as having gone unanswered. + """ + backend, client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + + # Each reader is held to the clock which was in force when its own wait began, so + # the borrower gives up while the reader which asked is still waiting. + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 30) + owner = asyncio.create_task(event_object.read("target")) + await poll(lambda: len(client.requests) == 1) + request_id = next(iter(backend._pending)) + + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + borrower = asyncio.create_task(event_object.read("target", "type")) + await poll(lambda: len(client.requests) == 2) + + with pytest.raises(EventProxyTimeout): + await borrower + + assert not owner.done(), "the reader which asked is still waiting for its answer" + assert request_id in backend._pending, "its request stays where it was left" + assert backend._timeouts == 1, "only the round trip which was the borrower's own" + + backend.handle_response( + { + "type": EVENT_PROXY_RESPONSE_TYPE, + "requestId": request_id, + "ok": True, + "value": "typed", + "error": None, + "code": None, + }, + ) + assert await owner == ("typed",) + + +async def test_requests_may_not_pile_up_on_a_client_which_is_not_answering(monkeypatch): + """There is a limit to how much a client can be asked at once + + A client which is not answering does not stop being asked. Whatever is still waiting + on it stays recorded in case the answer turns up late, and nothing bounded how many + of those could be waiting - so a handler which read a great many properties, or one + which kept reading while the browser had gone, accumulated them without end. + """ + monkeypatch.setattr("reactpy._event_proxy.PENDING_LIMIT", 2) + backend, _client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + + waiting = [ + asyncio.create_task(event_object.read("target.tagName")), + asyncio.create_task(event_object.read("target.value")), + ] + await poll(lambda: len(backend._pending) == 2) + + with pytest.raises(EventProxyUnavailable, match="too many live event requests"): + await event_object.read("type") + + for task in waiting: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_closing_the_backend_fails_everything_still_in_flight(): + backend, client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + pending = [ + asyncio.create_task(event_object.read("target")), + asyncio.create_task(event_object.read("type")), + ] + await poll(lambda: len(client.requests) == 2) + + backend.close() + + for task in pending: + with pytest.raises(EventProxyUnavailable, match="layout was closed"): + await task + # the answers of a client which turns up late are simply dropped + assert client.backend.handle_response( + { + "type": EVENT_PROXY_RESPONSE_TYPE, + "requestId": client.requests[0]["requestId"], + "ok": True, + "value": "too late", + } + ) + + +async def test_requests_made_just_after_the_loop_is_gone_fail_fast(monkeypatch): + """A request which loses its loop on the way out is failed, not left waiting + + The connection to the browser belongs to the loop which accepted it. Once that + loop has gone there is nothing left to send with, so whoever is waiting is failed + rather than left holding a future nobody is ever going to answer. + """ + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + + pending = asyncio.create_task(event_object.read("target")) + # exactly one turn of the loop: the request is now queued, and the flush which + # would carry it out has been scheduled behind this test rather than before it + await asyncio.sleep(0) + assert len(backend._outgoing) == 1 + monkeypatch.setattr(backend, "_loop", None) + + with pytest.raises(EventProxyUnavailable, match="no sender is bound"): + await pending + + assert client.sent == [], "nothing was handed over a loop which no longer runs" + + +async def test_a_request_queued_while_the_loop_is_already_gone_is_never_sent( + monkeypatch, +): + """Nothing crosses the wire when there is no loop left to send it with + + The request is not handed to a loop which is gone, and the handler is not left + waiting on a future forever either: the wait is bounded and ends as an + ``EventProxyError``, whichever way the failure is reported. + """ + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + backend, client = make_client(make_objects()) + event_object = make_event(backend, "evt") + monkeypatch.setattr(backend, "_loop", None) + + with pytest.raises(EventProxyError): + await event_object.read("target") + + assert client.sent == [] + + +# -- how event data is recognised and handed out ----------------------------- + + +async def test_a_plain_string_marker_names_a_live_event_too(): + backend, client = make_client(make_objects()) + + args = build_event_args( + [{EVENT_MARKER_KEY: "evt", "type": "click"}, {"type": "click"}, "extra"], + backend, + ) + + assert isinstance(args[0], Event) + assert args[0].event_id == "evt" + assert await args[0].type == "click" + # data the client never marked was never a browser object, so it is handed over + # exactly as it arrived rather than dressed up as a proxy nobody can resolve + assert type(args[1]) is dict + assert args[1] == {"type": "click"} + assert args[2] == "extra" + assert requested_paths(client) == [], "the type came free with the event" + + marked = build_event_args([{EVENT_MARKER_KEY: {"id": "evt"}}], backend) + assert isinstance(marked[0], Event) + assert marked[0].event_id == "evt" + + +async def test_only_what_the_client_volunteered_comes_free(): + """Only plain scalars travel with the event; everything else is read on demand""" + backend, client = make_client(make_objects()) + (event_object,) = build_event_args( + [ + { + EVENT_MARKER_KEY: {"id": "evt"}, + "type": "click", + "count": 3, + "repeat": True, + "target": {"value": "typed"}, + "nothing": None, + "list": [1], + } + ], + backend, + ) + + assert (await event_object.read("type", "count", "repeat")) == ("click", 3, True) + assert requested_paths(client) == [] + for name in ("type", "count", "repeat"): + assert name in dir(event_object) + # an object which arrived alongside the id is not a value we may answer with + assert "target" not in dir(event_object) + assert await event_object.target.value == "typed" + assert requested_paths(client) == [["target", "value"]] + + +def event_handlers_of(model: dict[str, Any]) -> dict[str, Any]: + """Find the event handlers of the (single) element a test component renders""" + if "eventHandlers" in model: + return model["eventHandlers"] + for child in model.get("children", []): + if isinstance(child, dict): + try: + return event_handlers_of(child) + except KeyError: + continue + msg = "no event handlers in model" + raise KeyError(msg) + + +async def test_layout_hands_data_which_is_not_a_browser_object_as_is(): + received: dict[str, Any] = {} + + async def on_click(event): + received["event"] = event + + @component + def MyComponent(): + return reactpy.html.div({"onClick": on_click}) + + _backend, client = make_client(make_objects()) + + async with Layout(MyComponent()) as layout: + connect(layout, client) + model = (await layout.render())["model"] + handlers = event_handlers_of(model) + + target = handlers["onClick"]["target"] + data = {"type": "click", "detail": 3} + await layout.deliver(event_message(target, data)) + await poll(lambda: "event" in received) + + # nothing marked this data as a live browser object, so it is passed straight on + assert type(received["event"]) is dict + assert received["event"] == {"type": "click", "detail": 3} + assert EVENT_MARKER_KEY not in received["event"] + assert client.requests == [], "nothing to reach into, so nothing was asked" + + +async def test_merged_handlers_all_share_the_same_event(): + """Merged handlers are handed the very same data, which each reads as it likes + + There is a single flavour of event data, so a merged handler never needs arguments + dressed up differently from those of its siblings. + """ + received: dict[str, Any] = {} + + async def first_handler(args): + received["first"] = args[0] + + async def second_handler(args): + received["second"] = args[0] + + merged = merge_event_handlers( + [EventHandler(first_handler), EventHandler(second_handler)] + ) + + @component + def MyComponent(): + return reactpy.html.div({"onClick": merged}) + + _backend, client = make_client(make_objects()) + + async with Layout(MyComponent()) as layout: + connect(layout, client) + model = (await layout.render())["model"] + target = event_handlers_of(model)["onClick"]["target"] + await layout.deliver( + event_message(target, {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}) + ) + await poll(lambda: len(received) == 2) + + assert isinstance(received["first"], Event) + assert isinstance(received["second"], Event) + assert received["first"] is received["second"] + assert (await received["first"].read("type")) == ("click",) + assert client.requests == [], "the type was already known to both of them" + + +async def test_proxy_responses_are_routed_not_delivered(): + _backend, client = make_client(make_objects()) + + @component + def MyComponent(): + return reactpy.html.div() + + async with Layout(MyComponent()) as layout: + connect(layout, client) + assert layout.handle_event_proxy_response( + {"type": EVENT_PROXY_RESPONSE_TYPE, "requestId": "unknown", "ok": True} + ) + assert not layout.handle_event_proxy_response({"type": "layout-update"}) + + +async def test_stop_immediate_propagation_is_detected_and_forwarded(): + async def on_click(event): + event.stopImmediatePropagation() + + @component + def MyComponent(): + return reactpy.html.div({"onClick": on_click}) + + async with Layout(MyComponent()) as layout: + model = (await layout.render())["model"] + handler = event_handlers_of(model)["onClick"] + assert handler["stopPropagation"] is True + assert handler["stopImmediatePropagation"] is True + + +async def test_a_timed_out_read_does_not_poison_that_path(monkeypatch): + """A request which timed out must not be handed to whoever asks next + + The in-flight record used to be left behind when a read timed out, and the future + inside it had already been cancelled by the wait. Reading the very same property + again then took the "already in flight" branch and raised ``CancelledError`` at the + handler rather than an :class:`EventProxyError` - and because that branch never + reaches the timeout bookkeeping, the consecutive-timeout budget stopped counting, + so a client stuck on one property could never trip the guard which switches live + access off. + """ + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 3) + backend, _client = make_client(make_objects(), respond=False) + event_object = make_event(backend, "evt") + + for _ in range(3): + with pytest.raises(EventProxyTimeout): + await event_object.target.value + + # counting really happened, so the connection is now considered dead + assert backend.disabled + assert not backend.available diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 21c03b3da..dd70f67f8 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -1,10 +1,11 @@ import asyncio +import json from functools import partial import pytest import reactpy -from reactpy import component, html, use_state +from reactpy import component, html, use_effect, use_state from reactpy.core.events import ( EventHandler, event, @@ -13,56 +14,66 @@ to_event_handler_function, ) from reactpy.core.layout import Layout -from reactpy.testing import DEFAULT_TYPE_DELAY, DisplayFixture, poll +from reactpy.testing import ( + DEFAULT_TYPE_DELAY, + DisplayFixture, + StaticEventHandler, + poll, +) from reactpy.types import Event +from tests.tooling.common import TYPE_DELAYS, event_message + + +async def _noop_handler(*args: Event) -> None: + """A handler which does nothing, for tests which only care about the wrapper""" def test_event_handler_repr(): - handler = EventHandler(lambda: None) + handler = EventHandler(_noop_handler) assert repr(handler) == ( f"EventHandler(function={handler.function}, prevent_default=False, " - f"stop_propagation=False, debounce=None, target={handler.target!r}, " - f"throttle=None)" + f"stop_propagation=False, stop_immediate_propagation=False, debounce=None, " + f"target={handler.target!r}, throttle=None)" ) def test_event_handler_props(): - handler_0 = EventHandler(lambda data: None) + handler_0 = EventHandler(_noop_handler) assert handler_0.stop_propagation is False assert handler_0.prevent_default is False assert handler_0.debounce is None assert handler_0.throttle is None assert handler_0.target is None - handler_1 = EventHandler(lambda data: None, prevent_default=True) + handler_1 = EventHandler(_noop_handler, prevent_default=True) assert handler_1.stop_propagation is False assert handler_1.prevent_default is True assert handler_1.debounce is None assert handler_1.throttle is None assert handler_1.target is None - handler_2 = EventHandler(lambda data: None, stop_propagation=True) + handler_2 = EventHandler(_noop_handler, stop_propagation=True) assert handler_2.stop_propagation is True assert handler_2.prevent_default is False assert handler_2.debounce is None assert handler_2.throttle is None assert handler_2.target is None - handler_3 = EventHandler(lambda data: None, target="123") + handler_3 = EventHandler(_noop_handler, target="123") assert handler_3.stop_propagation is False assert handler_3.prevent_default is False assert handler_3.debounce is None assert handler_3.throttle is None assert handler_3.target == "123" - handler_4 = EventHandler(lambda data: None, debounce=250) + handler_4 = EventHandler(_noop_handler, debounce=250) assert handler_4.stop_propagation is False assert handler_4.prevent_default is False assert handler_4.debounce == 250 assert handler_4.throttle is None assert handler_4.target is None - handler_5 = EventHandler(lambda data: None, throttle=150) + handler_5 = EventHandler(_noop_handler, throttle=150) assert handler_5.stop_propagation is False assert handler_5.prevent_default is False assert handler_5.debounce is None @@ -74,9 +85,12 @@ def test_event_handler_equivalence(): async def func(data): return None + async def other_func(data): + return None + assert EventHandler(func) == EventHandler(func) - assert EventHandler(lambda data: None) != EventHandler(lambda data: None) + assert EventHandler(func) != EventHandler(other_func) assert EventHandler(func, stop_propagation=True) != EventHandler( func, stop_propagation=False @@ -99,20 +113,39 @@ async def test_to_event_handler_function(): async def coro(*args): call_args.current = args - def func(*args): - call_args.current = args - await to_event_handler_function(coro, positional_args=True)([1, 2, 3]) assert call_args.current == (1, 2, 3) - await to_event_handler_function(func, positional_args=True)([1, 2, 3]) - assert call_args.current == (1, 2, 3) - await to_event_handler_function(coro, positional_args=False)([1, 2, 3]) assert call_args.current == ([1, 2, 3],) - await to_event_handler_function(func, positional_args=False)([1, 2, 3]) - assert call_args.current == ([1, 2, 3],) + +def test_event_handlers_must_be_coroutines(): + """A plain ``def`` would have to block for the browser, so it is refused + + Reading the object an event fired from is a round trip, and only a coroutine can + await one. A blocking handler would have to be run off the event loop, where it + could not safely update the very state it renders from. + """ + + def sync_handler(data): + return None + + for make_handler in (EventHandler, event, to_event_handler_function): + with pytest.raises(TypeError, match="must be declared with 'async def'"): + make_handler(sync_handler) + + async def async_handler(data): + return None + + # partials and objects with a coroutine __call__ are handlers too + assert isinstance(EventHandler(partial(async_handler)), EventHandler) + + class CallableHandler: + async def __call__(self, data): + return None + + assert isinstance(EventHandler(CallableHandler()), EventHandler) async def test_merge_event_handler_empty_list(): @@ -131,7 +164,7 @@ async def test_merge_event_handler_empty_list(): ], ) async def test_merge_event_handlers_raises_on_mismatch(kwargs_1, kwargs_2): - def func(data): + async def func(data): return None with pytest.raises(ValueError, match=r"Cannot merge handlers"): @@ -144,14 +177,21 @@ def func(data): async def test_merge_event_handlers(): - handler = EventHandler(lambda data: None) + handler = EventHandler(_noop_handler) assert merge_event_handlers([handler]) is handler calls = [] + + async def first(data): + calls.append("first") + + async def second(data): + calls.append("second") + merged_handler = merge_event_handlers( [ - EventHandler(lambda data: calls.append("first")), - EventHandler(lambda data: calls.append("second")), + EventHandler(first), + EventHandler(second), ] ) await merged_handler.function({}) @@ -224,10 +264,10 @@ async def test_can_stop_event_propagation(display: DisplayFixture): @reactpy.component def DivInDiv(): @reactpy.event(stop_propagation=True) - def inner_click_no_op(event): + async def inner_click_no_op(event): clicked.current = True - def outer_click_is_not_triggered(event): + async def outer_click_is_not_triggered(event): raise AssertionError outer = reactpy.html.div( @@ -310,12 +350,16 @@ async def test_javascript_event_after_state_update(display: DisplayFixture): @reactpy.component def App(): click_count, set_click_count = reactpy.hooks.use_state(0) + + async def on_click(event): + set_click_count(click_count + 1) + return reactpy.html.div( {"id": "the-parent"}, reactpy.html.button( { "id": "button-with-reactpy-event", - "onClick": lambda _: set_click_count(click_count + 1), + "onClick": on_click, }, "Click Me", ), @@ -353,7 +397,7 @@ def App(): def test_detect_prevent_default(): - def handler(event: Event): + async def handler(event: Event): event.preventDefault() eh = EventHandler(handler) @@ -361,7 +405,7 @@ def handler(event: Event): def test_detect_stop_propagation(): - def handler(event: Event): + async def handler(event: Event): event.stopPropagation() eh = EventHandler(handler) @@ -369,7 +413,7 @@ def handler(event: Event): def test_detect_debounce(): - def handler(event: Event): + async def handler(event: Event): event.debounce = 200 eh = EventHandler(handler) @@ -384,7 +428,7 @@ def test_computed_debounce_value_is_not_detected(): def make_handler(): computed_debounce = 200 - def handler(event: Event): + async def handler(event: Event): event.debounce = computed_debounce return handler @@ -400,7 +444,7 @@ def handler(event: Event): def get_debounce(): return 200 - def handler(event: Event): + async def handler(event: Event): event.debounce = get_debounce() eh2 = EventHandler(handler) @@ -408,7 +452,7 @@ def handler(event: Event): def test_detect_both(): - def handler(event: Event): + async def handler(event: Event): event.preventDefault() event.stopPropagation() @@ -418,7 +462,7 @@ def handler(event: Event): def test_detect_both_when_handler_is_partial(): - def handler(event: Event, *, extra_param): + async def handler(event: Event, *, extra_param): event.preventDefault() event.stopPropagation() @@ -428,7 +472,7 @@ def handler(event: Event, *, extra_param): def test_detect_debounce_when_handler_is_partial(): - def handler(event: Event, *, extra_param): + async def handler(event: Event, *, extra_param): event.debounce = 125 eh = EventHandler(partial(handler, extra_param=125)) @@ -436,7 +480,7 @@ def handler(event: Event, *, extra_param): def test_detect_debounce_from_positional_default(): - def handler(event: Event, ms=375): + async def handler(event: Event, ms=375): event.debounce = ms eh = EventHandler(handler) @@ -444,7 +488,7 @@ def handler(event: Event, ms=375): def test_detect_debounce_from_keyword_only_default(): - def handler(event: Event, *, ms=400): + async def handler(event: Event, *, ms=400): event.debounce = ms eh = EventHandler(handler) @@ -453,7 +497,7 @@ def handler(event: Event, *, ms=400): def test_detect_debounce_from_closure(): def make(ms): - def handler(event: Event): + async def handler(event: Event): event.debounce = ms return handler @@ -464,7 +508,7 @@ def handler(event: Event): def test_detect_debounce_from_closure_among_multiple(): def make(unused, used): - def handler(event: Event): + async def handler(event: Event): event.debounce = used return handler @@ -474,7 +518,7 @@ def handler(event: Event): def test_no_detect_debounce_when_local_var_used(): - def handler(event: Event): + async def handler(event: Event): ms = 500 event.debounce = ms @@ -485,7 +529,7 @@ def handler(event: Event): def test_no_detect_debounce_when_fallback_literal_used(): # Branch uses a local var (unresolvable) plus a literal fallback; we # refuse to pick either because runtime behaviour is ambiguous. - def handler(event: Event): + async def handler(event: Event): val = 50 if val: event.debounce = val @@ -497,7 +541,7 @@ def handler(event: Event): def test_detect_debounce_from_nested_default(): - def handler(event: Event, a=100, b=200): + async def handler(event: Event, a=100, b=200): event.debounce = b eh = EventHandler(handler) @@ -505,7 +549,7 @@ def handler(event: Event, a=100, b=200): def test_detect_debounce_zero_literal(): - def handler(event: Event): + async def handler(event: Event): event.debounce = 0 eh = EventHandler(handler) @@ -513,7 +557,7 @@ def handler(event: Event): def test_no_detect(): - def handler(event: Event): + async def handler(event: Event): pass eh = EventHandler(handler) @@ -524,7 +568,7 @@ def handler(event: Event): def test_event_decorator_accepts_throttle(): @event(throttle=200) - def handler(event: Event): + async def handler(event: Event): pass assert handler.throttle == 200 @@ -533,7 +577,7 @@ def handler(event: Event): def test_event_decorator_accepts_debounce_and_throttle(): @event(debounce=300, throttle=100) - def handler(event: Event): + async def handler(event: Event): pass assert handler.debounce == 300 @@ -541,23 +585,14 @@ def handler(event: Event): def test_event_handler_throttle_default_is_none(): - eh = EventHandler(lambda data: None) + eh = EventHandler(_noop_handler) assert eh.throttle is None -def test_event_wrapper(): - data = {"a": 1, "b": {"c": 2}} - event = Event(data) - assert event.a == 1 - assert event.b.c == 2 - assert event["a"] == 1 - assert event["b"]["c"] == 2 - - async def test_vdom_has_prevent_default(): @component def MyComponent(): - def handler(event: Event): + async def handler(event: Event): event.preventDefault() return html.button({"onClick": handler}) @@ -573,7 +608,7 @@ def handler(event: Event): async def test_vdom_has_debounce(): @component def MyComponent(): - def handler(event: Event): + async def handler(event: Event): event.debounce = 200 return html.input({"onChange": handler}) @@ -591,7 +626,7 @@ def test_event_export(): def test_detect_false_positive(): - def handler(event: Event): + async def handler(event: Event): # This should not trigger detection other = Event() other.preventDefault() @@ -605,7 +640,7 @@ def handler(event: Event): def test_detect_renamed_argument(): - def handler(e: Event): + async def handler(e: Event): e.preventDefault() e.stopPropagation() e.debounce = 200 @@ -626,7 +661,8 @@ def SequentialEvents(): async def handle_click(event): # Simulate slow processing await asyncio.sleep(0.1) - events_processed.append(event["target"]) + (event_type,) = await event.read("type") + events_processed.append(event_type) return html.button({"id": "btn", "onClick": handle_click}, "Click me") @@ -673,7 +709,7 @@ async def handle_click(event): def ListContainer(): items, set_items = use_state(["B", "C"]) - def add_top(event): + async def add_top(event): set_items(["A", *items]) return html.div( @@ -784,64 +820,209 @@ async def add_top(event): assert clicked_items == ["B"] -async def test_controlled_input_rapid_typing(display: DisplayFixture): - """ - Test that a controlled input updates correctly even with rapid typing. - This validates that user inputs are properly debounced by the client. - """ +MARKER = "|" - @reactpy.component - def ControlledInput(): - value, set_value = use_state("") - def on_change(event): - set_value(event["target"]["value"]) +@reactpy.component +def _ControlledInputWithMirror(): + """A controlled input whose model is also rendered somewhere un-clobberable""" + value, set_value = use_state("") - return reactpy.html.div( - reactpy.html.input( - { - "value": value, - "onChange": on_change, - "id": "controlled-input", - }, - ), - reactpy.html.pre({"id": "server-value"}, value), - ) + async def on_change(event): + set_value(await event.target.value) - await display.show(ControlledInput) + async def append_marker(event): + set_value(lambda old: old + MARKER) + + return reactpy.html.div( + reactpy.html.input( + { + "value": value, + "onChange": on_change, + "id": "controlled-input", + }, + ), + reactpy.html.button({"onClick": append_marker, "id": "append"}, "append"), + # A
 is never rewritten behind the user's back the way a focused
+        # input can be, so this reflects the model as the server holds it.
+        reactpy.html.pre({"id": "server-value"}, value),
+    )
+
+
+@pytest.mark.parametrize("delay", TYPE_DELAYS)
+async def test_controlled_input_rapid_typing(display: DisplayFixture, delay: int):
+    """Typing into a controlled input keeps the component live end to end
+
+    This is the browser level counterpart of the latch tests in
+    ``tests/test_core/test_hooks.py``, which pin the ``_scheduled_render`` race
+    directly. That race window is far too narrow to hit reliably through a real
+    browser, so what is checked here is the property a stranded component violates
+    from the outside: after a burst of keystrokes it must still converge with what
+    is on screen, and must still respond to a later state change. Which characters
+    end up on screen is a separate concern covered by
+    :func:`test_controlled_input_rapid_typing_preserves_every_character`.
+    """
+    await display.show(_ControlledInputWithMirror)
 
     inp = await display.page.wait_for_selector("#controlled-input")
+    await inp.type("hello world this is a test", delay=delay)
+
+    async def values():
+        return (
+            await inp.evaluate("node => node.value"),
+            await display.page.locator("#server-value").text_content(),
+        )
+
+    # A keystroke is handled by a server side handler which has to round trip to the
+    # browser for the value it reads, so the server may trail what is on screen for a
+    # moment. Wait until it has caught up rather than settling for a fixed delay, which
+    # only ever passes when the machine happens to be fast enough. A component stranded
+    # by the latch bug would be pinned to a stale prefix forever, timing this out.
+    await poll(values).until(
+        lambda client_and_server: client_and_server[0] == client_and_server[1],
+        timeout=30,
+        description="the server to catch up with what was typed",
+    )
+
+    # Converging once is not sufficient: the latch only stranded a component *after*
+    # the burst, once no further render was ever going to be scheduled. Prove the
+    # component is still live by driving a fresh state change and requiring the
+    # rendered model to follow it.
+    value_after_typing = await display.page.locator("#server-value").text_content()
+    await display.page.locator("#append").click()
+
+    await poll(values).until(
+        lambda client_and_server: client_and_server[1] == value_after_typing + MARKER,
+        timeout=20,
+        description="the state change after the burst to reach the rendered model",
+    )
+
 
-    # Use a moderate per-character delay so the browser's native input
-    # event system has time to update ``event.target.value`` between
-    # keystrokes. With ``delay=0`` or very low delays adjacent
-    # keystrokes get coalesced by the browser, causing onChange events
-    # to carry incorrect values. This is a well-known Playwright
-    # limitation — the per-handler ``debounce=N`` kwarg remains
-    # available for server-side flood control on real high-speed
-    # inputs.
+@pytest.mark.parametrize("delay", TYPE_DELAYS)
+async def test_controlled_input_rapid_typing_preserves_every_character(
+    display: DisplayFixture,
+    delay: int,
+):
+    """Every keystroke of a burst survives into both the model and the DOM
+
+    The client may only write the server's value over what the user has typed once
+    the server has acknowledged every event sent from that element. The numbers the
+    two sides compare have to be the same number line, and the acknowledgement has
+    to describe events the rendered value actually reflects; when the client counted
+    events one way and the server acknowledged them another, the element took a
+    snapshot of the previous keystroke for the current one and deleted the newest
+    character from the middle of the text.
+    """
     target_text = "hello world this is a test"
-    await inp.type(target_text, delay=25)
 
-    # Wait a bit for all events to settle
-    await asyncio.sleep(0.5)
+    await display.show(_ControlledInputWithMirror)
 
-    # Ensure all characters stayed within the client, even if server updates were in-flight
-    assert (await inp.evaluate("node => node.value")) == target_text
+    inp = await display.page.wait_for_selector("#controlled-input")
+    await inp.type(target_text, delay=delay)
 
-    # Ensure the server and client are in sync
-    server_value = await display.page.locator("#server-value").text_content()
+    async def values():
+        return (
+            await inp.evaluate("node => node.value"),
+            await display.page.locator("#server-value").text_content(),
+        )
+
+    await poll(values).until(
+        lambda client_and_server: client_and_server[0] == client_and_server[1],
+        timeout=30,
+        description="the server to catch up with what was typed",
+    )
+
+    client_value, server_value = await values()
+    assert client_value == target_text
     assert server_value == target_text
 
 
+@reactpy.component
+def _ControlledInputWhichClearsOnKeyDown():
+    """A controlled input which also handles ``onKeyDown``, to clear the field
+
+    An element which binds more than one event is the case a single ack figure for
+    the whole element cannot serve. The keystrokes arrive through ``onChange`` while
+    the deliberate clear arrives through ``onKeyDown``, so acknowledging the element
+    as a whole either trusts the server with keystrokes it has not seen yet (taking
+    the highest) or refuses an update it has already handled (taking the lowest).
+    """
+    value, set_value = use_state("")
+
+    async def on_change(event):
+        set_value(await event.target.value)
+
+    async def on_key_down(event):
+        set_value("")
+
+    return reactpy.html.div(
+        reactpy.html.input(
+            {
+                "value": value,
+                "onChange": on_change,
+                "onKeyDown": on_key_down,
+                "id": "controlled-input",
+            }
+        ),
+        reactpy.html.pre({"id": "server-value"}, value),
+    )
+
+
+@pytest.mark.parametrize("delay", TYPE_DELAYS)
+async def test_controlled_input_clear_from_second_handler_reaches_the_dom(
+    display: DisplayFixture,
+    delay: int,
+):
+    """A deliberate clear arriving on another event still reaches the input
+
+    The clear lands on an element whose value the server has no reason to change
+    again, so nothing else would ever carry the acknowledgement the client is
+    waiting for. Without it the input keeps displaying text the server has moved
+    past.
+    """
+    await display.show(_ControlledInputWhichClearsOnKeyDown)
+
+    inp = await display.page.wait_for_selector("#controlled-input")
+    await inp.click()
+    await inp.type("abc", delay=delay)
+
+    async def field_value():
+        return await inp.evaluate("node => node.value")
+
+    async def server_value():
+        return await display.page.locator("#server-value").text_content()
+
+    # Let every keystroke be handled and acknowledged before the clear is sent. A
+    # live read sees the field as it is when the handler gets round to it, not as it
+    # was when the keystroke fired, so a keystroke still in flight would write back
+    # whatever the field happens to hold at that moment - quite possibly after the
+    # clear. That is a property of reading live rather than of the acknowledgement,
+    # which is what this test is about; see
+    # :func:`test_a_late_live_read_may_write_the_field_as_it_now_stands`.
+    await poll(server_value).until(
+        lambda value: value == "abc",
+        timeout=10,
+        description="the keystrokes to reach the model",
+    )
+
+    await inp.press("Enter")
+
+    await poll(field_value).until(
+        lambda value: value == "",
+        timeout=10,
+        description="the server's clear to reach the input",
+    )
+    assert await server_value() == ""
+
+
 async def test_controlled_input_respects_custom_debounce(display: DisplayFixture):
     @reactpy.component
     def ControlledInput():
         value, set_value = use_state("")
 
-        def on_change(event: Event):
+        async def on_change(event: Event):
             event.debounce = 0
-            set_value(event.target.value.upper())
+            set_value((await event.target.value).upper())
 
         return reactpy.html.input(
             {
@@ -874,9 +1055,9 @@ async def test_controlled_input_default_debounce_reconciles_server_value(
     def ControlledInput():
         value, set_value = use_state("")
 
-        def on_change(event: Event):
+        async def on_change(event: Event):
             event.debounce = 200
-            set_value(event.target.value.upper())
+            set_value((await event.target.value).upper())
 
         return reactpy.html.div(
             reactpy.html.input(
@@ -918,3 +1099,324 @@ def on_change(event: Event):
     )
     assert (await inp.evaluate("node => node.value")) == "A"
     assert await display.page.locator("#server-value").text_content() == "A"
+
+
+# ===========================================================================
+# The acknowledgement which says the server has caught up with the keyboard
+# ===========================================================================
+
+
+def _ack_seq_of(update):
+    """Read the acknowledgement off the input rendered by a single-input layout"""
+    model = update["model"]["children"][0]
+    return model["attributes"].get("_reactpy_ack_seq")
+
+
+async def test_ack_seq_is_a_map_keyed_by_event():
+    """What the server sends back is per event, and is the client's own number
+
+    An element may only write the server's value over what the user has typed once
+    the server has acknowledged every event sent from it. To decide that the two
+    sides have to be counting on one number line: the client assigns a sequence
+    number to each event as it writes it to the wire, and the server echoes back
+    the highest one it has finished handling - per event, because an element with
+    more than one handler is catching up on each of them separately. One figure
+    across all handlers lets a render which predates the newest keystroke of one
+    event look up to date because a different event happened to be further along,
+    and a figure the server counts for itself, rather than repeating the client's,
+    is a number the client can never be satisfied by.
+    """
+    change = StaticEventHandler()
+    key_down = StaticEventHandler()
+    handled = asyncio.Event()
+
+    async def note_handled(event):
+        handled.set()
+
+    @component
+    def Root():
+        value, _ = use_state("text")
+        return html.input(
+            {
+                "value": value,
+                "onChange": EventHandler(note_handled, target=change.target),
+                "onKeyDown": EventHandler(note_handled, target=key_down.target),
+            }
+        )
+
+    async with Layout(Root()) as layout:
+        await layout.render()
+
+        await layout.deliver({**event_message(change.target), "seq": 4})
+        await handled.wait()
+        assert _ack_seq_of(await layout.render()) == {"onChange": 4}
+
+        # The other event is on a number line of its own, and the first
+        # acknowledgement has to survive alongside it rather than be displaced.
+        handled.clear()
+        await layout.deliver({**event_message(key_down.target), "seq": 9})
+        await handled.wait()
+        assert _ack_seq_of(await layout.render()) == {"onChange": 4, "onKeyDown": 9}
+
+
+async def test_ack_never_goes_to_the_wire(display: DisplayFixture):
+    """The acknowledgement travels to the client, never back from it
+
+    The number belongs to the client, which assigns it as it sends; all the server
+    is entitled to know is the number it was given. Were the acknowledgement to be
+    sent up with the event rather than sent down inside the model, the server would
+    be acknowledging whatever the client happened to last report instead of what it
+    actually finished handling, and an element waiting on that figure would be
+    waiting on a number which tells it nothing about whether the value about to be
+    written over its own is any good.
+    """
+    await display.show(_ControlledInputWithMirror)
+
+    inp = await display.page.wait_for_selector("#controlled-input")
+
+    # Anything already in flight is not of interest; what matters is what the
+    # client puts on the wire in answer to the keystrokes which follow.
+    await display.page.evaluate(
+        """
+        () => {
+            window.__sent = [];
+            const send = WebSocket.prototype.send;
+            WebSocket.prototype.send = function (data) {
+                window.__sent.push(String(data));
+                return send.call(this, data);
+            };
+        }
+        """
+    )
+
+    async def server_value():
+        return await display.page.locator("#server-value").text_content()
+
+    await inp.type("abc", delay=0)
+
+    await poll(server_value).until(
+        lambda text: text == "abc",
+        timeout=5,
+        description="the server to catch up with what was typed",
+    )
+
+    sent = await display.page.evaluate("() => window.__sent")
+    events = [json.loads(msg) for msg in sent]
+    events = [msg for msg in events if msg.get("type") == "layout-event"]
+    assert events, f"the client never told the server anything: {sent}"
+    assert all("_reactpy_ack_seq" not in msg for msg in events), (
+        f"the acknowledgement leaked into an event: {events}"
+    )
+
+
+async def test_ack_never_reaches_the_dom(display: DisplayFixture):
+    """The acknowledgement is a protocol field, not something to put on screen
+
+    It arrives inside the element's attributes, and whatever is left there once the
+    element has read it would otherwise be written onto the element as one more
+    attribute for the user to find in their markup.
+    """
+    await display.show(_ControlledInputWithMirror)
+
+    inp = await display.page.wait_for_selector("#controlled-input")
+
+    async def server_value():
+        return await display.page.locator("#server-value").text_content()
+
+    await inp.type("abc", delay=0)
+
+    # Wait until the server has caught up, otherwise there would be nothing to
+    # catch leaking even if something were.
+    await poll(server_value).until(
+        lambda text: text == "abc",
+        timeout=5,
+        description="the server to catch up with what was typed",
+    )
+
+    markup = await inp.evaluate("node => node.outerHTML")
+    assert "_reactpy_ack_seq" not in markup
+
+
+async def test_a_clear_is_not_written_back_over_by_what_came_before_it(
+    display: DisplayFixture,
+):
+    """A render too old to describe the field cannot resurrect what it held
+
+    Renders carrying the text typed into the field are queued up behind the clear,
+    and a render is entitled to write over what the user has in the field only once
+    its acknowledgement covers the events the field has already moved past; a render
+    which cannot claim that, and writes anyway, arrives after the clear and puts the
+    text from before it back on screen.
+
+    The keystrokes are given leave to finish before the clear is sent. A live read
+    belongs to the handler rather than to the event, so a keystroke whose handler has
+    not run yet may legitimately write the field as it finds it later on - which is
+    the behaviour of reading live, covered by
+    :func:`test_a_late_live_read_may_write_the_field_as_it_now_stands`, and not a
+    render which the acknowledgement should have refused.
+    """
+    await display.show(_ControlledInputWhichClearsOnKeyDown)
+
+    inp = await display.page.wait_for_selector("#controlled-input")
+    await inp.click()
+
+    async def field_value():
+        return await inp.evaluate("node => node.value")
+
+    async def server_value():
+        return await display.page.locator("#server-value").text_content()
+
+    await inp.type("hello", delay=0)
+
+    # Every keystroke handled, so what is left in flight is renders rather than
+    # handlers still to be run.
+    await poll(server_value).until(
+        lambda value: value == "hello",
+        timeout=20,
+        description="the keystrokes to reach the model",
+    )
+
+    await inp.press("Enter")
+
+    await poll(field_value).until(
+        lambda value: value == "",
+        timeout=20,
+        description="the clear to reach the field",
+    )
+    await poll(server_value).until(
+        lambda value: value == "",
+        timeout=20,
+        description="the clear to reach the model",
+    )
+
+    # Whatever is still in flight, none of it may put the earlier text back.
+    for _ in range(20):
+        await asyncio.sleep(0.05)
+        assert await field_value() == "", (
+            "a render carrying the text from before the clear overwrote it"
+        )
+
+
+async def test_a_late_live_read_may_write_the_field_as_it_now_stands(
+    display: DisplayFixture,
+):
+    """A handler which reads late writes the field as it finds it, not as it was
+
+    A live read belongs to the moment it is awaited rather than to the event which
+    woke the handler, so a keystroke whose handler is still queued behind the work of
+    another event sees whatever the field holds by then and writes that back. What
+    such a write carries is not a snapshot the acknowledgement ought to have refused:
+    it is the newest state the server holds, and the two sides are still expected to
+    agree on it. A handler which must act on the text as it stood when its own event
+    fired should take it from the event it was handed rather than read the element
+    later, or be ordered ahead of anything which goes on to change it.
+    """
+    await display.show(_ControlledInputWhichClearsOnKeyDown)
+
+    inp = await display.page.wait_for_selector("#controlled-input")
+    await inp.click()
+
+    async def field_value():
+        return await inp.evaluate("node => node.value")
+
+    async def server_value():
+        return await display.page.locator("#server-value").text_content()
+
+    async def both_values():
+        return await field_value(), await server_value()
+
+    # Fill the field and empty it without pausing, so the clear can overtake the
+    # keystrokes instead of following them.
+    await inp.type("hello", delay=0)
+    await inp.press("Enter")
+
+    # Whichever of the two wins the race, the field and the model have to end up
+    # agreeing: a live read may put the text back, but it may not leave the two
+    # halves of the application telling different stories about the field.
+    await poll(both_values).until(
+        lambda pair: pair[0] == pair[1],
+        timeout=20,
+        description="the field and the model to agree",
+    )
+    field, model = await both_values()
+    assert field in ("", "hello"), f"unexpected field value {field!r}"
+    assert model == field
+
+
+@reactpy.component
+def _HeavyPage():
+    """A page which re-renders constantly while the user is typing at it"""
+    value, set_value = use_state("")
+    ticks, set_ticks = use_state(0)
+
+    async def on_change(event):
+        set_value(await event.target.value)
+
+    def keep_rendering():
+        """Re-render on a timer, so renders are always in flight behind the typing"""
+        loop = asyncio.get_running_loop()
+        left = [300]
+
+        def tick():
+            set_ticks(lambda old: old + 1)
+            left[0] -= 1
+            if left[0]:
+                handle[0] = loop.call_later(0.01, tick)
+
+        handle = [loop.call_later(0.01, tick)]
+        # Cancelled through the cell, not captured directly, because every tick
+        # replaces the handle with a fresh one.
+
+        def stop():
+            handle[0].cancel()
+
+        return stop
+
+    use_effect(keep_rendering)
+
+    return reactpy.html.div(
+        reactpy.html.input(
+            {"value": value, "onChange": on_change, "id": "controlled-input"}
+        ),
+        reactpy.html.pre({"id": "server-value"}, value),
+        reactpy.html.pre({"id": "ticks"}, str(ticks)),
+    )
+
+
+async def test_controlled_input_drops_no_character_under_a_heavy_page(
+    display: DisplayFixture,
+):
+    """A page churning while the user types still ends up with what they typed
+
+    Every re-render is a chance to write the server's value over the field, and the
+    renders which follow the last keystroke carry a value read before it. Only the
+    acknowledgement tells the element which of them are too old to write with, so
+    under a load which multiplies the renders in flight that property is the only
+    thing standing between the user and a vanished character.
+    """
+    target_text = "the quick brown fox jumps over the lazy dog"
+
+    await display.show(_HeavyPage)
+
+    inp = await display.page.wait_for_selector("#controlled-input")
+
+    await inp.type(target_text, delay=0)
+
+    async def values():
+        return (
+            await inp.evaluate("node => node.value"),
+            await display.page.locator("#server-value").text_content(),
+            # counted along with the rest, so a timeout says whether renders were
+            # reaching the page at all while the field and the model disagreed
+            await display.page.locator("#ticks").text_content(),
+        )
+
+    await poll(values).until(
+        lambda field_model_ticks: field_model_ticks[:2] == (target_text, target_text),
+        timeout=30,
+        description="the field and the model to hold everything which was typed",
+    )
+
+    assert int(await display.page.locator("#ticks").text_content()) > 1, (
+        "the page never re-rendered, so this exercised nothing"
+    )
diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py
index 6856ae187..57b4e62e0 100644
--- a/tests/test_core/test_hooks.py
+++ b/tests/test_core/test_hooks.py
@@ -177,9 +177,9 @@ async def test_set_state_checks_equality_not_identity(display: DisplayFixture):
     event_count = reactpy.Ref(0)
 
     def event_count_tracker(function):
-        def tracker(*args, **kwargs):
+        async def tracker(event):
             event_count.current += 1
-            return function(*args, **kwargs)
+            return await function(event)
 
         return tracker
 
@@ -187,19 +187,25 @@ def tracker(*args, **kwargs):
     def TestComponent():
         state, set_state = reactpy.hooks.use_state(r_1)
 
+        async def set_state_to_r_1(event):
+            set_state(r_1)
+
+        async def set_state_to_r_2(event):
+            set_state(r_2)
+
         render_count.current += 1
         return reactpy.html.div(
             reactpy.html.button(
                 {
                     "id": "r_1",
-                    "onClick": event_count_tracker(lambda event: set_state(r_1)),
+                    "onClick": event_count_tracker(set_state_to_r_1),
                 },
                 "r_1",
             ),
             reactpy.html.button(
                 {
                     "id": "r_2",
-                    "onClick": event_count_tracker(lambda event: set_state(r_2)),
+                    "onClick": event_count_tracker(set_state_to_r_2),
                 },
                 "r_2",
             ),
@@ -242,8 +248,9 @@ def Input(message=None):
         message_ref.current = message
 
         async def on_change(event):
-            if event["target"]["value"] == "this is a test":
-                set_message(event["target"]["value"])
+            value = await event.target.value
+            if value == "this is a test":
+                set_message(value)
 
         if message is None:
             return reactpy.html.input({"id": "input", "onChange": on_change})
@@ -265,7 +272,7 @@ def SomeComponent():
         state_1, set_state_1 = reactpy.hooks.use_state(0)
         state_2, set_state_2 = reactpy.hooks.use_state(0)
 
-        def double_set_state(event):
+        async def double_set_state(event):
             set_state_1(state_1 + 1)
             set_state_2(state_2 + 1)
 
@@ -1472,3 +1479,70 @@ async def effect():
 
         # Verify the previous effect was cancelled
         await asyncio.wait_for(effect_was_cancelled.wait(), 1)
+
+
+def test_a_render_is_marked_before_the_scheduler_is_handed_it():
+    """The request must be raised before the scheduler is given the work
+
+    The state change which gets us here can arrive from the thread a synchronous
+    event handler ran on, while the thread which owns the layout is midway through a
+    render and about to clear this very flag.  Raising it only after the hand-off
+    allowed the write to land *after* that clear, and since nothing was then ever
+    going to render again to reset it, every later state change was silently
+    deduplicated away and the component was stranded on a stale model for the rest
+    of its life.
+    """
+    seen_while_handing_off: list[bool] = []
+    hook = LifeCycleHook(lambda: seen_while_handing_off.append(hook._scheduled_render))
+
+    hook.schedule_render()
+
+    assert seen_while_handing_off == [True]
+
+
+def test_a_failed_schedule_leaves_the_flag_down():
+    """A request which never reached the scheduler must not count as pending"""
+    attempts = 0
+
+    def explode():
+        nonlocal attempts
+        attempts += 1
+        raise RuntimeError("no scheduler available")
+
+    hook = LifeCycleHook(explode)
+
+    with assert_reactpy_did_log(
+        match_message="Failed to schedule render", error_type=RuntimeError
+    ):
+        hook.schedule_render()
+
+        assert hook._scheduled_render is False
+
+        # the flag went back down, so the next change is not deduplicated away
+        hook.schedule_render()
+
+    assert attempts == 2
+
+
+def test_a_cancelled_render_does_not_swallow_later_state_changes():
+    """Cancelling a pending render has to give back the right to render
+
+    The layout cancels a component's pending render whenever it renders that
+    component as part of updating its parent.  Once that happens nothing is left
+    which will ever clear the flag raised by ``schedule_render()``, so the layout
+    must reset it itself - otherwise this render, and every one after it, is lost.
+    """
+    scheduled: list[int] = []
+    hook = LifeCycleHook(lambda: scheduled.append(1))
+
+    hook.schedule_render()
+    assert scheduled == [1]
+
+    # a second request is deduplicated while the first is still pending
+    hook.schedule_render()
+    assert scheduled == [1]
+
+    hook.unschedule_render()
+
+    hook.schedule_render()
+    assert scheduled == [1, 1]
diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py
index fbf6ce651..df7ced7e3 100644
--- a/tests/test_core/test_layout.py
+++ b/tests/test_core/test_layout.py
@@ -20,7 +20,7 @@
 from reactpy.core.component import component
 from reactpy.core.events import EventHandler
 from reactpy.core.hooks import use_async_effect, use_effect, use_state
-from reactpy.core.layout import Layout, _ThreadSafeQueue
+from reactpy.core.layout import Layout, _ModelState, _ThreadSafeQueue
 from reactpy.testing import (
     HookCatcher,
     StaticEventHandler,
@@ -35,7 +35,11 @@
 from tests.tooling.common import event_message, update_message
 from tests.tooling.hooks import use_force_render, use_toggle
 from tests.tooling.layout import layout_runner
-from tests.tooling.select import element_exists, find_element
+from tests.tooling.select import element_exists, find_element, find_elements
+
+
+async def _noop_handler(*args):
+    """A handler which does nothing, for tests which only need something to bind"""
 
 
 @pytest.fixture(autouse=True, params=[True, False])
@@ -539,12 +543,12 @@ def MyComponent():
         reverse_children, set_reverse_children = use_toggle()
 
         @good_handler.use
-        def good_trigger():
+        async def good_trigger():
             called_good_trigger.current = True
             set_reverse_children()
 
         @bad_handler.use
-        def bad_trigger():
+        async def bad_trigger():
             msg = "Called bad trigger"
             raise ValueError(msg)
 
@@ -601,14 +605,14 @@ def Trigger(set_reverse_children, name):
         if name == "good":
 
             @good_handler.use
-            def callback():
+            async def callback():
                 called_good_trigger.current = True
                 set_reverse_children()
 
         else:
 
             @bad_handler.use
-            def callback():
+            async def callback():
                 msg = "Called bad trigger"
                 raise ValueError(msg)
 
@@ -693,8 +697,11 @@ async def test_event_handler_at_component_root_is_garbage_collected():
     def HasEventHandlerAtRoot():
         value, set_value = reactpy.hooks.use_state(False)
         set_value(not value)  # trigger renders forever
-        event_handler.current = weakref(set_value)
-        button = reactpy.html.button({"onClick": set_value}, "state is: ", value)
+
+        async def on_click(event):
+            set_value(event)
+
+        button = reactpy.html.button({"onClick": on_click}, "state is: ", value)
         event_handler.current = weakref(button["eventHandlers"]["onClick"].function)
         return button
 
@@ -715,8 +722,11 @@ async def test_event_handler_deep_in_component_layout_is_garbage_collected():
     def HasNestedEventHandler():
         value, set_value = reactpy.hooks.use_state(False)
         set_value(not value)  # trigger renders forever
-        event_handler.current = weakref(set_value)
-        button = reactpy.html.button({"onClick": set_value}, "state is: ", value)
+
+        async def on_click(event):
+            set_value(event)
+
+        button = reactpy.html.button({"onClick": on_click}, "state is: ", value)
         event_handler.current = weakref(button["eventHandlers"]["onClick"].function)
         return reactpy.html.div(reactpy.html.div(button))
 
@@ -795,7 +805,7 @@ async def test_log_error_on_bad_event_handler():
     @reactpy.component
     def ComponentWithBadEventHandler():
         @bad_handler.use
-        def raise_error():
+        async def raise_error():
             msg = "bad event handler"
             raise Exception(msg)
 
@@ -897,7 +907,7 @@ def SomeComponent():
             [
                 reactpy.html.div(
                     {"key": i},
-                    reactpy.html.input({"onChange": lambda event: None}),
+                    reactpy.html.input({"onChange": _noop_handler}),
                 )
                 for i in items
             ]
@@ -953,7 +963,7 @@ async def test_switching_node_type_with_event_handlers():
     @reactpy.component
     def Root():
         toggle, toggle_type.current = use_toggle(True)
-        handler = element_static_handler.use(lambda: None)
+        handler = element_static_handler.use(_noop_handler)
         if toggle:
             return html.div(html.button({"onEvent": handler}))
         else:
@@ -961,7 +971,7 @@ def Root():
 
     @reactpy.component
     def SomeComponent():
-        handler = component_static_handler.use(lambda: None)
+        handler = component_static_handler.use(_noop_handler)
         return html.button({"onAnotherEvent": handler})
 
     async with Layout(Root()) as layout:
@@ -1043,9 +1053,13 @@ async def test_element_keys_inside_components_do_not_reset_state_of_component():
     @component
     def Parent():
         state, set_state = use_state(0)
+
+        async def on_click():
+            set_state(state + 1)
+
         return html.div(
             html.button(
-                {"onClick": set_child_key_num.use(lambda: set_state(state + 1))},
+                {"onClick": set_child_key_num.use(on_click)},
                 "click me",
             ),
             Child("some-key"),
@@ -1115,9 +1129,11 @@ async def test_changing_event_handlers_in_the_next_render():
     @component
     def Root():
         event_name, set_event_name.current = use_state("first")
-        return html.button(
-            {event_name: event_handler.use(lambda: did_trigger.set_current(True))}
-        )
+
+        async def on_event():
+            did_trigger.set_current(True)
+
+        return html.button({event_name: event_handler.use(on_event)})
 
     async with Layout(Root()) as layout:
         await layout.render()
@@ -1136,9 +1152,7 @@ async def test_no_warn_when_debounce_on_non_input_element():
     """``debounce`` is forwarded unconditionally to the client. The client
     only applies it where it has an effect; the layout does not warn."""
     static = StaticEventHandler()
-    debounced_handler = EventHandler(
-        lambda data: None, target=static.target, debounce=150
-    )
+    debounced_handler = EventHandler(_noop_handler, target=static.target, debounce=150)
 
     @component
     def Root():
@@ -1152,9 +1166,7 @@ def Root():
 
 async def test_no_warn_when_debounce_on_input_element():
     static = StaticEventHandler()
-    debounced_handler = EventHandler(
-        lambda data: None, target=static.target, debounce=150
-    )
+    debounced_handler = EventHandler(_noop_handler, target=static.target, debounce=150)
 
     @component
     def Root():
@@ -1169,9 +1181,7 @@ def Root():
 async def test_throttle_is_stored_on_handler():
     """``throttle`` should be preserved on the EventHandler after render."""
     static = StaticEventHandler()
-    throttled_handler = EventHandler(
-        lambda data: None, target=static.target, throttle=120
-    )
+    throttled_handler = EventHandler(_noop_handler, target=static.target, throttle=120)
 
     @component
     def Root():
@@ -1192,7 +1202,7 @@ async def test_debounce_and_throttle_both_stored():
     """Both ``debounce`` and ``throttle`` are preserved on the handler."""
     static = StaticEventHandler()
     handler = EventHandler(
-        lambda data: None,
+        _noop_handler,
         target=static.target,
         debounce=250,
         throttle=120,
@@ -1328,10 +1338,10 @@ async def test_ensure_model_path_udpates():
     def Item(item: str, all_items: State[list[str]]):
         color = use_state(None)
 
-        def deleteme(event):
+        async def deleteme(event):
             all_items.set_value([i for i in all_items.value if (i != item)])
 
-        def colorize(event):
+        async def colorize(event):
             color.set_value("blue" if not color.value else None)
 
         return html.div(
@@ -1663,7 +1673,7 @@ async def test_inject_ack_seq_on_input_without_attributes():
     """An ```` with event handlers and a recorded ack seq but no
     ``"attributes"`` key should not error when injecting the ack sequence."""
     static = StaticEventHandler()
-    handler = EventHandler(lambda e: None, target=static.target)
+    handler = EventHandler(_noop_handler, target=static.target)
     set_state = Ref()
 
     @component
@@ -1692,6 +1702,139 @@ def Root():
             await layout.render()
 
 
+def _ack_seq_of(update):
+    """The per-event ``_reactpy_ack_seq`` carried by the rendered input, if any"""
+    matches = [
+        element["attributes"]["_reactpy_ack_seq"]
+        for element, _ in find_elements(
+            update["model"],
+            lambda element, info: (
+                "_reactpy_ack_seq" in (element.get("attributes") or {})
+            ),
+        )
+    ]
+    assert len(matches) <= 1, f"expected at most one ack sequence, found {matches}"
+    return matches[0] if matches else None
+
+
+async def test_ack_seq_waits_for_the_handler_to_catch_up():
+    """A render made while a handler is still running does not advertise that handler's
+    event as having been handled.
+
+    The value carried by such a render was read before the event was handled, so a
+    client which applied it on top of what the user has typed since then would throw
+    away those keystrokes. The sequence number is only recorded once the handler which
+    read the event has actually returned.
+    """
+    handler_has_started = Event()
+    handler_may_finish = Event()
+    handler_has_finished = Event()
+    set_state = Ref()
+    static = StaticEventHandler()
+
+    async def on_change(event):
+        handler_has_started.set()
+        await handler_may_finish.wait()
+        handler_has_finished.set()
+
+    handler = EventHandler(on_change, target=static.target)
+
+    @component
+    def Root():
+        value, set_state.current = use_state("first")
+        return html.input({"value": value, "onChange": handler})
+
+    async with Layout(Root()) as layout:
+        await layout.render()
+
+        await layout.deliver({**event_message(static.target), "seq": 5})
+        await handler_has_started.wait()
+
+        # The handler is still running, so this render carries a value which predates
+        # the event it would be acknowledging.
+        set_state.current("second")
+        assert _ack_seq_of(await layout.render()) is None
+
+        handler_may_finish.set()
+        await handler_has_finished.wait()
+        await poll(lambda: layout._last_event_seq_by_target).until(
+            lambda seq_by_target: seq_by_target.get(static.target) == 5,
+            description="the sequence number of the handled event",
+        )
+
+        set_state.current("third")
+        assert _ack_seq_of(await layout.render()) == {"onChange": 5}
+
+
+async def test_ack_reaches_the_client_even_when_the_handler_changed_nothing():
+    """An event whose handler leaves the model alone is still acknowledged
+
+    The ack sequence travels inside the model of the element it describes, and the
+    element waits on it before it will write the server's value over what the user
+    has typed. A handler which changes no state schedules no render of its own, so
+    were it not for the render prompted on the event's own behalf the newer ack
+    would never be sent, and the element would wait forever on a figure only that
+    render could carry.
+    """
+    handler_has_run = Event()
+    static = StaticEventHandler()
+
+    async def on_change(event):
+        handler_has_run.set()
+
+    handler = EventHandler(on_change, target=static.target)
+
+    @component
+    def Root():
+        value, _ = use_state("text")
+        return html.input({"value": value, "onChange": handler})
+
+    async with Layout(Root()) as layout:
+        await layout.render()
+
+        await layout.deliver({**event_message(static.target), "seq": 3})
+        await handler_has_run.wait()
+
+        # Nothing about the model differs from the last render, but the ack for the
+        # event has to reach the client regardless.
+        assert _ack_seq_of(await layout.render()) == {"onChange": 3}
+
+
+async def test_redundant_ack_delivery_requests_are_dropped(monkeypatch):
+    """Only one render is spent carrying a given ack to the client
+
+    Rendering the component is how an ack gets out, so a request to deliver an ack
+    the client already holds, or one which a render already queued will carry, must
+    not queue another.
+    """
+    static = StaticEventHandler()
+    handler = EventHandler(_noop_handler, target=static.target)
+
+    @component
+    def Root():
+        return html.input({"value": "text", "onChange": handler})
+
+    async with Layout(Root()) as layout:
+        await layout.render()
+
+        scheduled = []
+        monkeypatch.setattr(layout, "_schedule_render_task", scheduled.append)
+
+        # The ack has already been written into a rendered model.
+        layout._delivered_ack_by_target[static.target] = 7
+        layout._schedule_ack_delivery(static.target, 7)
+        assert scheduled == []
+        assert layout._pending_ack_by_target == {}
+
+        # Nothing has carried this one yet, so the first request queues a render and
+        # a second request for the same ack is folded into it.
+        del layout._delivered_ack_by_target[static.target]
+        layout._schedule_ack_delivery(static.target, 8)
+        assert layout._pending_ack_by_target[static.target] == 8
+        layout._schedule_ack_delivery(static.target, 8)
+        assert len(scheduled) == 1
+
+
 async def test_event_handler_retry_logic():
     # Setup
     @component
@@ -1732,3 +1875,135 @@ async def handler_func(data):
             await asyncio.wait_for(event_handled.wait(), timeout=1.0)
         except TimeoutError:
             pytest.fail("Event handler was not called after retry")
+
+
+async def test_a_render_asked_for_during_teardown_is_dropped(monkeypatch):
+    """A handler which finishes as the layout closes must not outlive it
+
+    What carries an acknowledgement to the client is a render, so a handler which
+    completes while the layout is being torn down asks for one - and by then the
+    layout has begun letting go of everything a render is made of. Cancelling the
+    tasks on the way out hands control back to the event loop, which is precisely
+    when such a handler gets its turn, so the request has to be turned down rather
+    than left to run against a layout which no longer exists.
+    """
+    inside_handler = asyncio.Event()
+    let_handler_finish = asyncio.Event()
+    has_exited = Ref(False)
+    renders_after_exit = []
+
+    @component
+    def Root():
+        value, set_value = use_state("")
+
+        async def on_change(event):
+            inside_handler.set()
+            await let_handler_finish.wait()
+            set_value(await event.target.value)
+
+        return html.input({"value": value, "onChange": on_change})
+
+    original_create_layout_update = Layout._create_layout_update
+
+    def note_late_render(self, model_state):
+        if has_exited.current:
+            renders_after_exit.append(model_state)
+        return original_create_layout_update(self, model_state)
+
+    monkeypatch.setattr(Layout, "_create_layout_update", note_late_render)
+
+    layout = Layout(Root())
+
+    async with layout:
+        model = (await layout.render())["model"]
+        target = model["children"][0]["eventHandlers"]["onChange"]["target"]
+
+        await layout.deliver(
+            {
+                "type": "layout-event",
+                "target": target,
+                "data": [{"target": {"value": "typed"}}],
+                "seq": 1,
+            }
+        )
+        await inside_handler.wait()
+
+        # Teardown starts while the handler is still parked, so releasing it here
+        # leaves it to finish in the window the cancellations open on the way out -
+        # which is where the acknowledgement is recorded, and a render asked for.
+        let_handler_finish.set()
+
+    has_exited.current = True
+
+    # A render which outlived the layout was created during teardown and has only
+    # the event loop's next turn to run in, so it is given one before the verdict.
+    await asyncio.sleep(0.05)
+
+    assert renders_after_exit == [], (
+        "a render went ahead after the layout had closed, on a layout which has "
+        "already let go of what it renders with"
+    )
+
+
+async def test_a_closed_layout_owes_neither_an_ack_delivery_nor_a_render():
+    """Nothing is promised on behalf of a layout which has closed
+
+    An acknowledgement reaches the client inside a render of the element it
+    describes, and a render is a task belonging to the layout's loop. Once the
+    layout has closed there is no element left, no task to create, and no client
+    to receive any of it - only the memory of the tables which used to hold all
+    that, and reaching for those would raise where nothing is left to catch it.
+    """
+    static = StaticEventHandler()
+    handler = EventHandler(_noop_handler, target=static.target)
+
+    @component
+    def Root():
+        return html.input({"value": "text", "onChange": handler})
+
+    layout = Layout(Root())
+
+    async with layout:
+        await layout.render()
+
+    layout._schedule_ack_delivery(static.target, 4)
+    layout._schedule_render_task("a-component-which-is-no-longer-here")
+
+    # Neither request was allowed to reach for what teardown has released: the
+    # acknowledgement tables are gone and no task was created to replace them
+    assert getattr(layout, "_pending_ack_by_target", None) is None
+    assert layout._render_tasks == set()
+
+
+def test_an_element_with_nothing_above_it_prompts_no_render():
+    """The component which would carry an acknowledgement is the one above
+
+    Walking up from an element finds the component nearest above it, because that
+    is the one whose re-render rewrites the element's model. An element with
+    nothing left above it has no component to prompt - its model will carry the
+    acknowledgement the moment it is rendered again, which is all anyone can ask.
+    """
+    parent = _ModelState(
+        parent=None,
+        index=-1,
+        key=None,
+        model=Ref({"tagName": "", "children": []}),
+        patch_path="",
+        children_by_key={},
+        targets_by_event={},
+    )
+    element = _ModelState(
+        parent=parent,
+        index=0,
+        key=None,
+        model=Ref({"tagName": "input", "attributes": {"value": "text"}}),
+        patch_path="/children/0",
+        children_by_key={},
+        targets_by_event={"onChange": "/0:onChange"},
+    )
+
+    # Whatever was above the element is gone, so there is no component to prompt
+    del parent
+    gc.collect()
+
+    assert Layout._owner_component_id(element) is None
diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py
index 20ec0d8d5..cd6e08707 100644
--- a/tests/test_core/test_serve.py
+++ b/tests/test_core/test_serve.py
@@ -7,6 +7,11 @@
 from jsonpointer import set_pointer
 
 import reactpy
+from reactpy._event_proxy import (
+    EVENT_MARKER_KEY,
+    EVENT_PROXY_REQUEST_TYPE,
+    EVENT_PROXY_RESPONSE_TYPE,
+)
 from reactpy.config import REACTPY_MAX_QUEUE_SIZE
 from reactpy.core.hooks import use_effect
 from reactpy.core.layout import Layout
@@ -89,7 +94,11 @@ def Counter():
         (lambda old_count, diff: old_count + diff),
         initial_value=0,
     )
-    handler = STATIC_EVENT_HANDLER.use(lambda: change_count(1))
+
+    async def on_event():
+        change_count(1)
+
+    handler = STATIC_EVENT_HANDLER.use(on_event)
     return reactpy.html.div({EVENT_NAME: handler, "count": count})
 
 
@@ -150,3 +159,59 @@ def set_did_render():
         await second_event_did_execute.wait()
     finally:
         task.cancel()
+
+
+async def test_serve_round_trips_live_event_requests():
+    """A handler which reads past the snapshot sent with its event reaches the browser
+    through ``send`` and the answer finds its way back through ``recv``"""
+    seen = {}
+    did_read = asyncio.Event()
+    read_handler = StaticEventHandler()
+
+    # what the browser still holds for the object the event fired from - the snapshot
+    # which arrives with the event deliberately leaves ``target`` out of it
+    browser_objects = {"evt": {"type": "input", "target": {"value": "filled in later"}}}
+
+    @reactpy.component
+    def Reader():
+        @read_handler.use
+        async def read_late_value(event):
+            (seen["value"],) = await event.read("target.value")
+            did_read.set()
+
+        return reactpy.html.div({"onInput": read_late_value})
+
+    requests = []
+    inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
+    inbox.put_nowait(
+        event_message(read_handler.target, {EVENT_MARKER_KEY: {"id": "evt"}})
+    )
+
+    async def send(message):
+        if message.get("type") != EVENT_PROXY_REQUEST_TYPE:
+            return
+        requests.append(message)
+        # answer the way a browser would, by walking the object it kept for the event
+        value = browser_objects[message["eventId"]]
+        for segment in message["path"]:
+            value = value[segment]
+        await inbox.put(
+            {
+                "type": EVENT_PROXY_RESPONSE_TYPE,
+                "requestId": message["requestId"],
+                "ok": True,
+                "value": value,
+            }
+        )
+
+    async def recv():
+        return await inbox.get()
+
+    task = asyncio.create_task(serve_layout(Layout(Reader()), send, recv))
+    try:
+        await asyncio.wait_for(did_read.wait(), 5)
+    finally:
+        task.cancel()
+
+    assert seen["value"] == "filled in later"
+    assert [request["path"] for request in requests] == [["target", "value"]]
diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py
index b00ca7a34..ca829746b 100644
--- a/tests/test_core/test_vdom.py
+++ b/tests/test_core/test_vdom.py
@@ -9,7 +9,12 @@
 from reactpy.core.vdom import Vdom, is_vdom, validate_vdom_json
 from reactpy.types import VdomDict, VdomTypeDict
 
-FAKE_EVENT_HANDLER = EventHandler(lambda data: None)
+
+async def _noop_handler(*args):
+    """A handler which does nothing, for tests which only need something to bind"""
+
+
+FAKE_EVENT_HANDLER = EventHandler(_noop_handler)
 FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER}
 
 
@@ -91,9 +96,10 @@ def test_simple_node_construction(actual, expected):
 async def test_callable_attributes_are_cast_to_event_handlers():
     params_from_calls = []
 
-    node = reactpy.Vdom("div")(
-        {"onEvent": lambda *args: params_from_calls.append(args)}
-    )
+    async def on_event(*args):
+        params_from_calls.append(args)
+
+    node = reactpy.Vdom("div")({"onEvent": on_event})
 
     event_handlers = node.pop("eventHandlers")
     assert node == {"tagName": "div"}
diff --git a/tests/test_html.py b/tests/test_html.py
index 97189a4ad..aaa8da6d4 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -12,7 +12,7 @@ async def test_script_re_run_on_content_change(display: DisplayFixture):
     def HasScript():
         count, set_count = hooks.use_state(0)
 
-        def on_click(event):
+        async def on_click(event):
             set_count(count + 1)
 
         return html.div(
@@ -98,8 +98,10 @@ def test_child_of_script_must_be_string():
 
 
 def test_script_has_no_event_handlers():
+    async def on_event(event): ...
+
     with pytest.raises(ValueError, match=r"do not support event handlers"):
-        html.script({"onEvent": lambda: None})
+        html.script({"onEvent": on_event})
 
 
 def test_simple_fragment():
diff --git a/tests/test_pyscript/pyscript_components/custom_root_name.py b/tests/test_pyscript/pyscript_components/custom_root_name.py
index f2609c80c..0918efa76 100644
--- a/tests/test_pyscript/pyscript_components/custom_root_name.py
+++ b/tests/test_pyscript/pyscript_components/custom_root_name.py
@@ -5,7 +5,7 @@
 def custom():
     count, set_count = hooks.use_state(0)
 
-    def increment(event):
+    async def increment(event):
         set_count(count + 1)
 
     return html.div(
diff --git a/tests/test_pyscript/pyscript_components/root.py b/tests/test_pyscript/pyscript_components/root.py
index caa9a7c9d..53d2ac96f 100644
--- a/tests/test_pyscript/pyscript_components/root.py
+++ b/tests/test_pyscript/pyscript_components/root.py
@@ -5,7 +5,7 @@
 def root():
     count, set_count = hooks.use_state(0)
 
-    def increment(event):
+    async def increment(event):
         set_count(count + 1)
 
     return html.div(
diff --git a/tests/test_pyscript/pyscript_components/root_error.py b/tests/test_pyscript/pyscript_components/root_error.py
index 52c990b67..b438599e5 100644
--- a/tests/test_pyscript/pyscript_components/root_error.py
+++ b/tests/test_pyscript/pyscript_components/root_error.py
@@ -5,7 +5,7 @@
 def root():
     count, set_count = hooks.use_state(0)
 
-    def increment(event):
+    async def increment(event):
         set_count(count + 1)
 
     # crash on purpose after a few clicks, same as the real bug report
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f15277a9d..a49044e95 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -6,6 +6,10 @@
 from reactpy import component, html, utils
 
 
+async def _noop_handler(*args):
+    """A handler which does nothing, for tests which only need something to bind"""
+
+
 def test_basic_ref_behavior():
     r = reactpy.Ref(1)
     assert r.current == 1
@@ -343,7 +347,7 @@ def example_none_return():
             '
helloexampleworld
', ), ( - html.button({"onClick": lambda event: None}), + html.button({"onClick": _noop_handler}), "", ), ( diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 9b5cb87d9..b40138ee8 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -99,9 +99,10 @@ async def test_module_from_file(display: DisplayFixture): @reactpy.component def ShowSimpleButton(): - return SimpleButton( - {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} - ) + async def on_click(event): + is_clicked.set_current(True) + + return SimpleButton({"id": "my-button", "onClick": on_click}) await display.show(ShowSimpleButton) @@ -480,9 +481,10 @@ async def test_component_from_file(display: DisplayFixture): @reactpy.component def ShowSimpleButton(): - return SimpleButton( - {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} - ) + async def on_click(event): + is_clicked.set_current(True) + + return SimpleButton({"id": "my-button", "onClick": on_click}) await display.show(ShowSimpleButton) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 9841fc231..a38cb3305 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,8 +1,11 @@ from base64 import b64encode from pathlib import Path +import pytest + import reactpy from reactpy.testing import DEFAULT_TYPE_DELAY, DisplayFixture, poll +from tests.tooling.common import TYPE_DELAYS HERE = Path(__file__).parent @@ -29,7 +32,8 @@ async def test_image_from_bytes(display: DisplayFixture): assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) -async def test_use_linked_inputs(display: DisplayFixture): +@pytest.mark.parametrize("delay", TYPE_DELAYS) +async def test_use_linked_inputs(display: DisplayFixture, delay: int): @reactpy.component def SomeComponent(): i_1, i_2 = reactpy.widgets.use_linked_inputs([{"id": "i_1"}, {"id": "i_2"}]) @@ -40,16 +44,24 @@ def SomeComponent(): input_1 = await display.page.wait_for_selector("#i_1") input_2 = await display.page.wait_for_selector("#i_2") - await input_1.type("hello", delay=DEFAULT_TYPE_DELAY) + async def values(): + return ( + await input_1.evaluate("e => e.value"), + await input_2.evaluate("e => e.value"), + ) - assert (await input_1.evaluate("e => e.value")) == "hello" - assert (await input_2.evaluate("e => e.value")) == "hello" + await input_1.type("hello", delay=delay) + + # Only the server mirrors one input into the other, so both values depend on a + # round trip which has no fixed duration. Reading them the instant typing ends + # is what made this test flaky; waiting for the pair to agree tests the thing + # which is actually promised. + await poll(values).until_equals(("hello", "hello"), timeout=10) await input_2.focus() - await input_2.type(" world", delay=DEFAULT_TYPE_DELAY) + await input_2.type(" world", delay=delay) - assert (await input_1.evaluate("e => e.value")) == "hello world" - assert (await input_2.evaluate("e => e.value")) == "hello world" + await poll(values).until_equals(("hello world", "hello world"), timeout=10) async def test_use_linked_inputs_on_change(display: DisplayFixture): diff --git a/tests/tooling/common.py b/tests/tooling/common.py index 48ac8122b..736225a69 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,7 +1,18 @@ from typing import Any +from reactpy.testing import DEFAULT_TYPE_DELAY from reactpy.types import LayoutEventMessage, LayoutUpdateMessage +# Per-character delays to type at when a test needs keystrokes to arrive faster than +# the server can answer them. ``0`` leaves the client running well ahead of the +# server. A few milliseconds is the awkward zone where the server lands exactly one +# keystroke behind, which is precisely where a snapshot of the previous keystroke +# can be mistaken for a current one and written back over what the user typed. +# ``DEFAULT_TYPE_DELAY`` is slower than the server and is what the rest of the suite +# uses. Typing has to work at every one of them - a test which only passes at the +# slowest value is timing its way past a real bug. +TYPE_DELAYS = list(dict.fromkeys([0, 5, 10, DEFAULT_TYPE_DELAY])) + def event_message(target: str, *data: Any) -> LayoutEventMessage: return {"type": "layout-event", "target": target, "data": data} diff --git a/tests/tooling/select.py b/tests/tooling/select.py index 7e93f7c4b..0d7a5d502 100644 --- a/tests/tooling/select.py +++ b/tests/tooling/select.py @@ -13,9 +13,8 @@ def id_equals(id: str) -> Selector: def class_equals(class_name: str) -> Selector: - return ( - lambda element, _: class_name - in element.get("attributes", {}).get("class", "").split() + return lambda element, _: ( + class_name in element.get("attributes", {}).get("class", "").split() )