From 117747c7e0bc915a6ee6eb66746c83f57606be33 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Fri, 9 Oct 2026 21:57:38 -0700 Subject: [PATCH 01/36] Add live event proxy for accessing browser event data --- CHANGELOG.md | 3 + pyproject.toml | 6 + src/js/packages/@reactpy/client/src/client.ts | 17 + .../@reactpy/client/src/event-proxy.ts | 480 +++++++ src/js/packages/@reactpy/client/src/index.ts | 1 + src/js/packages/@reactpy/client/src/types.ts | 12 +- src/js/packages/@reactpy/client/src/vdom.tsx | 25 +- .../@reactpy/client/tests/event-proxy.test.ts | 577 ++++++++ src/reactpy/_event_proxy.py | 1225 +++++++++++++++++ src/reactpy/config.py | 45 + src/reactpy/core/_event_inspect.py | 36 +- src/reactpy/core/events.py | 131 +- src/reactpy/core/layout.py | 91 +- src/reactpy/core/serve.py | 17 + src/reactpy/core/vdom.py | 2 + src/reactpy/types.py | 78 +- tests/test_core/test_event_proxy.py | 619 +++++++++ tests/test_core/test_events.py | 4 +- tests/test_core/test_serve.py | 1 + 19 files changed, 3320 insertions(+), 50 deletions(-) create mode 100644 src/js/packages/@reactpy/client/src/event-proxy.ts create mode 100644 src/js/packages/@reactpy/client/tests/event-proxy.test.ts create mode 100644 src/reactpy/_event_proxy.py create mode 100644 tests/test_core/test_event_proxy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c7bac0f..3581cb9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,9 @@ 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 rather than being limited to the snapshot sent along with the event. Handlers can `await event.target.value` or `await event.target.focus()` when asynchronous, or simply use `event.target.value` and `event.target.focus()` when synchronous. The new `reactpy.types.AsyncEvent` and `reactpy.types.SyncEvent` types describe these objects for editors and type checkers. + - This can be disabled entirely via `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. + - 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`. ### Changed diff --git a/pyproject.toml b/pyproject.toml index 765e5aede..21e5f89fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -338,6 +338,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/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/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts new file mode 100644 index 000000000..55c71df72 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -0,0 +1,480 @@ +/** + * Live ("event proxy") access to the objects which triggered a ReactPy event. + * + * When a VDOM event handler is flagged with ``eventProxy``, the client keeps a short + * lived reference to the object which was dispatched (usually a JavaScript ``Event``) + * and tags the snapshot it sends to the server with an identifier: + * + * ```js + * { type: "click", target: { tagName: "INPUT" }, __reactpy_event__: { id: "..." } } + * ``` + * + * The server can then walk that object property by property, and call its methods, by + * sending ``event-proxy-request`` messages: + * + * ```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 } } + * ``` + * + * Only what is needed is serialized, which removes the fixed recursion depth that the + * snapshot alone imposes, while keeping anything which could be used to break out of + * the page (window, document, prototype, eval, ...) firmly out of reach. + */ + +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 */ +export const MAX_LIVE_OBJECTS = 100; +/** How long an object may sit unused before it is forgotten (ms) */ +export const LIVE_OBJECT_TTL_MS = 60_000; +/** How many properties of a single object are handed over at most */ +export const MAX_PROPERTIES = 100; +/** How deep a single resolution may nest before references are used */ +export const DEFAULT_MAX_DEPTH = 10; + +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" | "access" | null; +}; + +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 {} + +/** + * 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 - the + * snapshot the client sends alongside already contains those. + */ +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 = { + 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 the snapshot serializer does. + */ +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 something JSON can carry. + * + * 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 for it later. + */ +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 ?? DEFAULT_MAX_DEPTH)) + return reference(eventId, path); + + const nested = new Set(seen); + nested.add(value); + const next = (item: unknown, segment: string, at: number) => + serializeValue(item, eventId, [...path, segment], options, at, nested); + + 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)) + return value.map((item, index) => next(item, String(index), depth + 1)); + + 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 >= MAX_PROPERTIES) 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 */ +export function readPath(root: any, path: string[]): any { + let node = root; + for (const segment of path) node = readProperty(node, segment); + return node; +} + +function parentAndKey(root: any, path: string[]): { parent: any; key: string } { + if (path.length === 0) + throw new ProxyAccessError("an empty path cannot be resolved"); + let node = root; + for (const segment of path.slice(0, -1)) node = readProperty(node, segment); + return { parent: node, key: path[path.length - 1] }; +} + +type LiveObject = { value: any; added: number }; + +/** + * Keeps the objects which recently triggered an event around so the server can walk + * their properties and call their methods. + * + * Entries are bounded in count and in age: an object which nobody has asked about for + * a while, or which does not fit alongside the most recent ones, is forgotten. Every + * value which leaves this registry goes through ``serializeValue``, so functions and + * anything which could be used to escape the page never do. + */ +export class EventProxyRegistry { + private readonly live = new Map(); + 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 */ + register(value: any): string { + const eventId = `${this.clock().toString(36)}-${(this.nextId++).toString(36)}`; + this.live.set(eventId, { value, added: this.clock() }); + this.prune(); + return eventId; + } + + /** 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(); + } + + /** Drop objects which went unused for too long, or which no longer fit */ + prune(): void { + const now = this.clock(); + for (const [eventId, entry] of this.live) { + if (now - entry.added > LIVE_OBJECT_TTL_MS) this.live.delete(eventId); + } + while (this.live.size > MAX_LIVE_OBJECTS) { + const oldest = this.live.keys().next(); + if (oldest.done === true) break; + this.live.delete(oldest.value); + } + } + + /** Answer an incoming ``event-proxy-request``, replying unless told not to */ + handleMessage(message: EventProxyRequest): void { + if (!message || message.type !== EVENT_PROXY_REQUEST_TYPE) return; + const response = this.resolve(message); + if (message.reply !== false) this.send(response); + } + + /** 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, { + error: `nothing is registered for event ${JSON.stringify(eventId)}`, + code: "not-found", + }); + + // keep actively used objects around + entry.added = this.clock(); + + const path = Array.isArray(message.path) ? message.path.map(String) : []; + const options: SerializeOptions = { maxDepth: message.maxDepth }; + + try { + if (message.call) { + const value = this.call(eventId, entry.value, path, message.args || []); + 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 { parent, key } = parentAndKey(entry.value, path); + const value = readProperty(parent, key); + 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 ProxyNotFoundError ? "not-found" : "access", + }); + } + } + + private call(eventId: string, root: any, path: string[], args: any[]): any { + if (path.length === 0) + throw new ProxyAccessError("the event object itself cannot be called"); + const { parent, key } = parentAndKey(root, path); + 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)), + ); + } + + private readArgument(argument: unknown): unknown { + const ref = readReference(argument); + if (ref) { + const entry = this.live.get(ref.eventId); + if (!entry) + throw new ProxyNotFoundError( + `nothing is registered for event ${JSON.stringify(ref.eventId)}`, + ); + entry.added = this.clock(); + return readPath(entry.value, ref.path); + } + if (Array.isArray(argument)) + return argument.map((item) => this.readArgument(item)); + if (argument && typeof argument === "object") { + const value: Record = {}; + for (const [key, item] of Object.entries(argument)) + value[key] = this.readArgument(item); + return value; + } + return argument; + } +} 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..1deec1595 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,13 @@ export type ReactPyVdomEventHandler = { target: string; preventDefault?: boolean; stopPropagation?: boolean; + stopImmediatePropagation?: boolean; + /** + * Keep the dispatched object around so the server can read properties from, and call + * methods on, whatever is actually in the page. Sent by the server when one of its + * handlers is able to make use of it. + */ + eventProxy?: boolean; debounce?: number; throttle?: number; }; @@ -116,8 +124,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..c00e84ff4 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,5 +1,6 @@ import eventToObject from "event-to-object"; import { Fragment } from "preact"; +import { EVENT_MARKER_KEY } from "./event-proxy"; import { HANDLER_DEBOUNCE, HANDLER_MARKER, @@ -218,8 +219,10 @@ function createEventHandler( target, preventDefault, stopPropagation, + stopImmediatePropagation, debounce, throttle, + eventProxy, }: ReactPyVdomEventHandler, ): [string, TaggedEventHandler] { // Sequence number for the next outgoing event on this handler. @@ -239,10 +242,28 @@ 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); + if (typeof event === "object" && event !== null) { + const snapshot = eventToObject(event); + if ( + eventProxy && + snapshot && + typeof snapshot === "object" && + !(EVENT_MARKER_KEY in snapshot) + ) { + // Hand the server a handle on the real object, not just on what could be + // squeezed into a snapshot, so it can read whatever it needs and call + // methods such as "focus()" on it. + return { + ...snapshot, + [EVENT_MARKER_KEY]: { id: client.eventProxy.register(event) }, + }; + } + return snapshot; } else { return event; } 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..5c695ce42 --- /dev/null +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -0,0 +1,577 @@ +// Tests for the live "event proxy" boundary. Everything which leaves the page goes +// through one of these functions, so the interesting cases are the ones where a value +// must *not* be handed over. +import { test, expect } from "bun:test"; +import { + DEFAULT_MAX_DEPTH, + EVENT_MARKER_KEY, + EVENT_PROXY_REQUEST_TYPE, + EVENT_PROXY_RESPONSE_TYPE, + EVENT_REFERENCE_KEY, + EventProxyRegistry, + type EventProxyRequest, + LIVE_OBJECT_TTL_MS, + MAX_LIVE_OBJECTS, + ProxyAccessError, + ProxyNotFoundError, + 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: [] }, + }); +}); + +test("serializeValue keeps plain data as-is", () => { + expect(serializeValue("typed", "e")).toBe("typed"); + expect(serializeValue(true, "e")).toBe(true); + expect(serializeValue(7, "e")).toBe(7); + expect(serializeValue(10n, "e")).toBe(10); + expect(serializeValue(2n ** 100n, "e")).toBe(String(2n ** 100n)); + expect(serializeValue(Symbol("tag"), "e")).toBe("Symbol(tag)"); + expect(serializeValue(null, "e")).toBe(null); + expect(serializeValue(undefined, "e")).toBe(null); +}); + +test("serializeValue drops numbers JSON cannot carry", () => { + expect(serializeValue(Number.NaN, "e")).toBe(null); + expect(serializeValue(Number.POSITIVE_INFINITY, "e")).toBe(null); +}); + +test("serializeValue renders well-known objects readably", () => { + expect(serializeValue(new Date("2026-10-09T00:00:00.000Z"), "e")).toBe( + "2026-10-09T00:00:00.000Z", + ); + expect(serializeValue(/ab+c/g, "e")).toBe("/ab+c/g"); + expect(serializeValue(new TypeError("bad"), "e")).toEqual({ + name: "TypeError", + message: "bad", + stack: expect.any(String), + }); +}); + +test("serializeValue turns functions into callable references", () => { + expect(serializeValue(() => 1, "e", ["preventDefault"])).toEqual({ + [EVENT_REFERENCE_KEY]: { + eventId: "e", + path: ["preventDefault"], + callable: true, + }, + }); +}); + +test("serializeValue reads iterables as lists", () => { + expect(serializeValue(new Set(["a", "b"]), "e")).toEqual(["a", "b"]); + expect(serializeValue([1, [2, 3]], "e")).toEqual([1, [2, 3]]); +}); + +test("serializeValue uses references for cycles and excess depth", () => { + const loop: Record = { name: "loop" }; + loop["loopInto"] = loop; + expect(serializeValue(loop, "e")).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(DEFAULT_MAX_DEPTH).toBeGreaterThan(1); + expect(serializeValue(deep, "e")).toEqual({ a: { b: { c: 1 } } }); +}); + +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")).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 }; +} + +/** 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, + ...fields, + }; +} + +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("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("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); + 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", + }); +}); diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py new file mode 100644 index 000000000..8de8d8307 --- /dev/null +++ b/src/reactpy/_event_proxy.py @@ -0,0 +1,1225 @@ +"""Client-backed ("live") proxies for browser events. + +Historically an event handler was handed a plain :class:`reactpy.types.Event` - a +snapshot of the JavaScript ``Event`` object built by the ``event-to-object`` package. +Snapshots are cheap and never block, but they are frozen at the moment the event was +dispatched: methods such as ``focus()`` or ``composedPath()`` cannot be called from +Python, and any property that the snapshot left out is simply gone. + +This module implements the machinery which lets a handler reach *live* into the browser +instead. When the client supports it, each item of a ``layout-event`` message carries a +small marker next to the usual snapshot:: + + { + "type": "layout-event", + "target": "key-path:onClick", + "data": [ + { + "type": "click", + "target": {"tagName": "INPUT", "value": "hello"}, + "__reactpy_event__": {"id": "5b1c..."}, + } + ], + } + +On receipt, ReactPy swaps that dictionary for a :class:`SyncEvent` or +:class:`AsyncEvent` - both are ``dict`` subclasses which *also* hold the snapshot. +Reading an attribute returns a lazily resolved :class:`EventProxy` whose value is +fetched from the browser only when it is actually needed: + +.. code-block:: python + + async def on_click(event: AsyncEvent): + # one round trip: read "target.value" from the live DOM node + value = await event.target.value + # one round trip: call "focus()" on the live DOM node + await event.target.focus() + # nothing is sent for values which the snapshot already contains + print(event["type"]) + +Which flavour a handler receives is decided by the handler itself: ``async def`` +handlers get :class:`AsyncEvent` (resolve with ``await`` or ``.resolve()``), plain +``def`` handlers get :class:`SyncEvent` whose accessors block - transparently running +the whole handler in a worker thread when it needs the value of a live property. + +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} + +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() + +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 EventProxyAccessError(EventProxyError): + """The client refused or failed to resolve the requested path""" + + +class EventProxyBlocked(EventProxyError): + """Synchronous live event access was attempted from the event loop thread + + A synchronous event handler which needs a live value is run in a worker thread by + ReactPy. Seeing this error means that a blocking access was attempted somewhere + which ReactPy could not run off of the event loop - typically from an ``async def`` + handler using a :class:`SyncEvent`. Prefer ``await`` on an :class:`AsyncEvent`. + """ + + +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 + + @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. + """ + self._send = send + self._loop = asyncio.get_running_loop() + self._loop_thread_id = threading.get_ident() + + 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)""" + self._disabled = 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() + + def handle_response(self, message: Mapping[str, Any]) -> bool: + """Route a ``event-proxy-response`` message to whoever is waiting for it + + Returns :data:`True` if the message was meant for this backend. + """ + if message.get("type") != EVENT_PROXY_RESPONSE_TYPE: + return False + + request_id = message.get("requestId") + future = ( + self._pending.pop(request_id, None) if isinstance(request_id, str) else None + ) + 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 True + + if not future.done(): + future.set_result( + { + "ok": bool(message.get("ok")), + "value": message.get("value"), + "error": message.get("error"), + "code": message.get("code"), + } + ) + return True + + async def resolve( + self, + event_id: str, + path: Sequence[str | int], + call: bool = False, + args: Sequence[Any] = (), + sync: bool = False, + ) -> Any: + """Resolve ``path`` against the client side event (awaited from the loop)""" + request_id, message, future = self._begin( + event_id, path, call, args, reply=True + ) + self._submit(message, future, request_id) + if future is None: # nocov - reply=True always yields a future + return None + try: + payload = await asyncio.wait_for( + asyncio.wrap_future(future), REACTPY_EVENT_PROXY_TIMEOUT.current + ) + except (TimeoutError, FuturesTimeoutError): + self._handle_timeout(request_id, event_id, path) + return self._to_value(payload, event_id, path, sync) + + def resolve_blocking( + self, + event_id: str, + path: Sequence[str | int], + call: bool = False, + args: Sequence[Any] = (), + ) -> Any: + """Resolve ``path`` by blocking the calling (non-loop) thread until answered""" + if ( + self._loop_thread_id is not None + and threading.get_ident() == self._loop_thread_id + ): + msg = ( + f"cannot synchronously resolve {list(path)!r} of event {event_id!r} from " + "the event loop thread - access live values from a synchronous event " + "handler, or await them from an asynchronous one" + ) + raise EventProxyBlocked(msg) + + request_id, message, future = self._begin( + event_id, path, call, args, reply=True + ) + if future is None: # nocov - reply=True always yields a future + return None + self._submit(message, future, request_id) + try: + payload = future.result(timeout=REACTPY_EVENT_PROXY_TIMEOUT.current) + except (TimeoutError, FuturesTimeoutError): + self._handle_timeout(request_id, event_id, path) + return self._to_value(payload, event_id, path, sync=True) + + 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: + request_id, message, future = self._begin( + 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 + self._submit(message, future, request_id) + + # -- internals -------------------------------------------------------- + + 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) + + 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 + + def _submit( + self, + message: dict[str, Any], + future: Future | None, + request_id: str, + ) -> None: + loop, send = self._loop, self._send + if loop is None or send is None: + self._discard(request_id) + if future is not None and not future.done(): + future.set_exception( + EventProxyUnavailable("no sender is bound to this connection") + ) + return + + if threading.get_ident() == self._loop_thread_id: + task = loop.create_task(self._send_message(message, future)) + self._send_tasks.add(task) + task.add_done_callback(self._send_tasks.discard) + else: + asyncio.run_coroutine_threadsafe(self._send_message(message, future), loop) + + async def _send_message( + self, message: dict[str, Any], future: Future | None + ) -> None: + send = self._send + if send is None: # nocov - guarded by _submit + 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 _discard(self, request_id: str) -> None: + self._pending.pop(request_id, None) + + def _handle_timeout( + self, request_id: str, event_id: str, path: Sequence[str | int] + ) -> NoReturn: + self._discard(request_id) + 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 requests went unanswered" + ) + msg = ( + 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" + ) + raise EventProxyTimeout(msg) + + def _to_value( + self, + payload: Mapping[str, Any], + event_id: str, + path: Sequence[str | int], + sync: bool, + ) -> 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 == "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, sync) + + +def serialize_argument(value: Any) -> Any: + """Prepare a Python value to be handed to a client side method""" + 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) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray): + return [serialize_argument(item) 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 + + +def deserialize_value( + value: Any, + event_id: str, + backend: EventProxyBackend, + sync: bool, + 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_class = SyncEventProxy if sync else AsyncEventProxy + child = child_class( + backend, + str(reference.get("eventId", event_id)), + tuple(reference.get("path", ())), + ) + child._reactpy_callable = bool(reference.get("callable")) + return child + return { + key: deserialize_value(item, event_id, backend, sync, depth + 1) + for key, item in value.items() + } + + if isinstance(value, list): + return [ + deserialize_value(item, event_id, backend, sync, depth + 1) + for item in value + ] + + return value + + +class _EventProxyBase(dict, Generic[_T]): + """A live, lazily resolved view onto a browser object + + Instances are ``dict`` subclasses which hold on to whatever the client chose to + serialize when the event was dispatched (the "snapshot"). Attribute access returns + further proxies for anything the snapshot does not already answer with a plain + value, so a chain such as ``event.target.value`` costs a single round trip - and + only when the value is genuinely needed. + """ + + _reactpy_sync: bool = False + """Whether resolving this proxy blocks (sync) or must be awaited (async)""" + + def __init__( + self, + backend: EventProxyBackend, + event_id: str, + path: Sequence[str | int] = (), + snapshot: Mapping[str, Any] | None = None, + flags: dict[str, bool] | None = None, + ) -> None: + dict.__init__(self, snapshot or {}) + self._reactpy_backend = 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 + + # -- 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 has_snapshot(self) -> bool: + """Whether the client already gave us the value of this proxy""" + return bool(dict.__len__(self)) + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(event_id={self._reactpy_event_id!r}, " + f"path={list(self._reactpy_path)!r}, snapshot={dict.__repr__(self)})" + ) + + def __dir__(self) -> list[str]: + return sorted( + { + *dir(type(self)), + *(key for key in dict.keys(self) if isinstance(key, str)), + } + ) + + # -- child access ----------------------------------------------------- + + def _reactpy_snapshot_value(self, segment: str | int) -> Any: + try: + return dict.__getitem__(self, segment) + except KeyError: + return _MISSING + + def _reactpy_make_child( + self, segment: str | int, snapshot: Mapping[str, Any] | None = None + ) -> _EventProxyBase[Any]: + child_class: type[_EventProxyBase[Any]] = ( + SyncEventProxy if self._reactpy_sync else AsyncEventProxy + ) + child = child_class( + self._reactpy_backend, + self._reactpy_event_id, + (*self._reactpy_path, segment), + flags=self._reactpy_flags, + ) + if snapshot is None: + value = self._reactpy_snapshot_value(segment) + snapshot = value if isinstance(value, Mapping) else {} + reference = ( + snapshot.get(EVENT_REFERENCE_KEY) if isinstance(snapshot, Mapping) else None + ) + if isinstance(reference, Mapping): + # The snapshot only points at what the browser still holds - hand back a + # real proxy for it rather than a dict containing the marker. + path = tuple(reference.get("path", ())) or (*self._reactpy_path, segment) + child = child_class( + self._reactpy_backend, + str(reference.get("eventId", self._reactpy_event_id)), + path, + flags=self._reactpy_flags, + ) + child._reactpy_callable = bool(reference.get("callable")) + return child + dict.update(child, snapshot) + return child + + def _reactpy_wrap_sequence( + self, segment: str | int, value: Sequence[Any] + ) -> list[Any]: + """Copy a list of snapshot data, promoting object entries to child proxies""" + parent = self._reactpy_make_child(segment) + items: list[Any] = [] + for index, item in enumerate(value): + if isinstance(item, Mapping): + items.append(parent._reactpy_make_child(str(index), item)) + elif isinstance(item, list | tuple): + items.append(parent._reactpy_wrap_sequence(str(index), item)) + else: + items.append(item) + return items + + def _reactpy_child_or_value(self, segment: str | int) -> Any: + """Return the snapshot value, or a proxy for whatever the browser holds""" + value = self._reactpy_snapshot_value(segment) + if value is _MISSING: + return self._reactpy_resolve_segment(segment) + if isinstance(value, Mapping): + return self._reactpy_make_child(segment) + if isinstance(value, list | tuple): + return self._reactpy_wrap_sequence(segment, value) + return value + + def _reactpy_resolve_segment(self, segment: str | int) -> Any: + """Read something the snapshot does not know about + + The asynchronous flavour stays lazy here; the synchronous one asks the browser + right away so that handlers always deal with real values. + """ + return self._reactpy_make_child(segment) + + def _reactpy_walk_snapshot(self, segments: Sequence[str]) -> Any: + """Follow ``segments`` through the snapshot, or return ``_MISSING``""" + node: Any = self + for segment in segments: + if isinstance(node, dict): + if not dict.__contains__(node, segment): + return _MISSING + # bypass proxy __getitem__ so nested data stays plain JSON + node = dict.__getitem__(node, segment) + elif isinstance(node, Mapping): + if segment not in node: + return _MISSING + node = node[segment] + elif isinstance(node, Sequence) and not isinstance(node, str | bytes): + try: + node = node[int(segment)] + except (ValueError, IndexError): + return _MISSING + else: + return _MISSING + return node + + @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, sync=False + ) + 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 matching JavaScript call is scheduled on the client but is *not* waited + for. Use :func:`reactpy.event`'s ``prevent_default=True`` if the action must be + cancelled before the browser carries on. + """ + self._reactpy_flags["preventDefault"] = True + self._reactpy_invoke("preventDefault") + + def stopPropagation(self) -> None: + """Stop the underlying DOM event from propagating further up the tree""" + self._reactpy_flags["stopPropagation"] = True + self._reactpy_invoke("stopPropagation") + + def stopImmediatePropagation(self) -> None: + """Also stop any remaining listeners of the underlying DOM event""" + self._reactpy_flags["stopPropagation"] = True + self._reactpy_flags["stopImmediatePropagation"] = True + self._reactpy_invoke("stopImmediatePropagation") + + @property + def defaultPrevented(self) -> bool: + """Whether :meth:`preventDefault` has been called for this event""" + return bool( + self._reactpy_flags.get("preventDefault") + or dict.get(self, "defaultPrevented", False), + ) + + @property + def propagationStopped(self) -> bool: + """Whether :meth:`stopPropagation` has been called for this event""" + return bool( + self._reactpy_flags.get("stopPropagation") + or dict.get(self, "propagationStopped", False), + ) + + @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 + + # -- dict like behaviour ---------------------------------------------- + + 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_child_or_value(name) + + def __getitem__(self, key: Any) -> Any: + return self._reactpy_child_or_value(key) + + def __bool__(self) -> bool: + return bool(dict.__len__(self)) or bool(self._reactpy_path) + + # -- resolution (implemented by the sync and async flavours) ---------- + + def _reactpy_resolve_path(self, call: bool, args: Sequence[Any]) -> Any: + msg = f"{type(self).__name__} cannot resolve live event data" + raise NotImplementedError(msg) + + def _reactpy_call_method(self, name: str, args: Sequence[Any]) -> Any: + path = (*self._reactpy_path, name) + backend = self._reactpy_backend + if self._reactpy_sync: + return backend.resolve_blocking( + self._reactpy_event_id, path, call=True, args=args + ) + return backend.resolve( + self._reactpy_event_id, path, call=True, args=args, sync=False + ) + + def composedPath(self) -> Any: # the JavaScript method name is kept as-is + """Return the event's path, resolving on the client if needed + + This blocks for a :class:`SyncEventProxy` and must be awaited for an + :class:`AsyncEventProxy`. + """ + return self._reactpy_call_method("composedPath", ()) + + def _reactpy_description(self) -> str: + return ( + f"{type(self).__name__} for {list(self._reactpy_path)!r} of event " + f"{self._reactpy_event_id!r}" + ) + + def _reactpy_require_snapshot(self, action: str) -> None: + """Complain about an operation which needs a value we do not have yet""" + if self._reactpy_backend.available: + msg = ( + f"cannot {action} {self._reactpy_description()} because nothing is " + "known about it yet - await the proxy (or call .resolve() on it) first" + ) + raise EventProxyAccessError(msg) + + +class AsyncEventProxy(_EventProxyBase[_T]): + """The asynchronous flavour of a live event object + + Values are pulled from the browser with ``await``:: + + async def on_input(event: AsyncEvent): + value = await event.target.value + await event.target.focus() + """ + + _reactpy_sync = False + + def _reactpy_resolve_path(self, call: bool, args: Sequence[Any]) -> Any: + return self._reactpy_backend.resolve( + self._reactpy_event_id, + self._reactpy_path, + call=call, + args=args, + sync=False, + ) + + def resolve(self) -> Awaitable[_T]: + """Fetch the value this proxy currently points at from the browser""" + return self._reactpy_resolve_path(False, ()) + + async def read(self, path: str | Sequence[Any], default: Any = _MISSING) -> Any: + """Return the value at ``path`` whether or not the snapshot knows it + + Unlike awaiting an attribute, this always works - including for values which + the snapshot already answered with a plain ``str``, ``bool`` or ``None``:: + + async def on_input(event: AsyncEvent): + value = await event.read("target.value") + """ + segments = self._reactpy_normalize_path(path) + value = self._reactpy_walk_snapshot(segments) + if value is not _MISSING: + return value + try: + return await self._reactpy_backend.resolve( + self._reactpy_event_id, + (*self._reactpy_path, *segments), + call=False, + args=(), + sync=False, + ) + except EventProxyError: + if default is _MISSING: + raise + return default + + def __await__(self) -> Any: + 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 __len__(self) -> int: + if not dict.__len__(self): + self._reactpy_require_snapshot("measure") + return dict.__len__(self) + + def __iter__(self) -> Any: + if not dict.__len__(self): + self._reactpy_require_snapshot("iterate over") + return dict.__iter__(self) + + def __contains__(self, key: Any) -> bool: + if dict.__contains__(self, key): + return True + if not dict.__len__(self): + self._reactpy_require_snapshot("test membership of") + return False + + +class SyncEventProxy(_EventProxyBase[_T]): + """The synchronous flavour of a live event object + + Accessing something the snapshot does not already know about blocks until the + browser answers. ReactPy runs synchronous event handlers in a worker thread for + exactly this reason, so the event loop is never stalled. + """ + + _reactpy_sync = True + + def _reactpy_resolve_segment(self, segment: str | int) -> Any: + """Read a value the snapshot does not have, blocking until the browser answers""" + try: + value = self._reactpy_backend.resolve_blocking( + self._reactpy_event_id, + (*self._reactpy_path, segment), + call=False, + args=(), + ) + except ( + EventProxyTimeout, + EventProxyNotFound, + EventProxyAccessError, + EventProxyUnavailable, + ): + # The browser could not tell us - hand back an (empty) proxy so that + # repr(), logging and "in" checks keep working. + logger.debug( + f"Falling back to snapshot data for {list(self._reactpy_path)!r}" + f" + {segment!r}", + exc_info=True, + ) + return self._reactpy_make_child(segment) + + if isinstance(value, _EventProxyBase): + return value + if isinstance(value, Mapping): + return self._reactpy_make_child(segment, value) + if isinstance(value, list | tuple): + return self._reactpy_wrap_sequence(segment, value) + return value + + def _reactpy_resolve_path(self, call: bool, args: Sequence[Any]) -> Any: + return self._reactpy_backend.resolve_blocking( + self._reactpy_event_id, self._reactpy_path, call=call, args=args + ) + + def resolve(self) -> _T: + """Fetch the value this proxy currently points at from the browser""" + return self._reactpy_resolve_path(False, ()) + + def read(self, path: str | Sequence[Any], default: Any = _MISSING) -> Any: + """Return the value at ``path`` whether or not the snapshot knows it + + Blocking, like every other accessor on this class:: + + def on_input(event: SyncEvent): + value = event.read("target.value") + """ + segments = self._reactpy_normalize_path(path) + value = self._reactpy_walk_snapshot(segments) + if value is not _MISSING: + return value + try: + return self._reactpy_backend.resolve_blocking( + self._reactpy_event_id, + (*self._reactpy_path, *segments), + call=False, + args=(), + ) + except EventProxyError: + if default is _MISSING: + raise + return default + + def __call__(self, *args: Any) -> Any: + """Call the client side method this proxy currently points at""" + return self._reactpy_resolve_path(True, args) + + def _reactpy_resolved_or_self(self) -> Any: + """The live value if we do not have a snapshot for it, else this proxy""" + if dict.__len__(self) or not self._reactpy_backend.available: + return self + try: + value = self._reactpy_resolve_path(False, ()) + except EventProxyError: + logger.debug( + f"Could not resolve {self._reactpy_description()}", exc_info=True + ) + return self + # a reference to something else (an object or a function) says nothing about + # how this proxy should be printed or compared + return self if isinstance(value, _EventProxyBase) else value + + def __eq__(self, other: Any) -> Any: + if isinstance(other, _EventProxyBase): + if (self._reactpy_event_id, self._reactpy_path) == ( + other._reactpy_event_id, + other._reactpy_path, + ): + return True + return dict.__eq__(self, other) + value = self._reactpy_resolved_or_self() + if value is self: + return dict.__eq__(self, other) + return bool(value == other) + + def __ne__(self, other: Any) -> Any: + result = self.__eq__(other) + if result is NotImplemented: + return result + return not result + + __hash__ = None # type: ignore[assignment] + + def __str__(self) -> str: + value = self._reactpy_resolved_or_self() + return dict.__repr__(self) if value is self else str(value) + + def __format__(self, format_spec: str) -> str: + value = self._reactpy_resolved_or_self() + if value is self: + return dict.__repr__(self) + return format(value, format_spec) + + def __len__(self) -> int: + if dict.__len__(self): + return dict.__len__(self) + return len(self._reactpy_resolved_or_self()) + + def __iter__(self) -> Any: + if dict.__len__(self): + return dict.__iter__(self) + value = self._reactpy_resolved_or_self() + return dict.__iter__(self) if value is self else iter(value) + + def __contains__(self, key: Any) -> bool: + if dict.__contains__(self, key): + return True + if dict.__len__(self) or not self._reactpy_backend.available: + return False + try: + return bool(key in self._reactpy_resolve_path(False, ())) + except EventProxyError: + return False + + +EventProxy = _EventProxyBase +"""The base class of every lazily resolved live event object""" + + +if TYPE_CHECKING: + _ElementMembers = dict[str, Any] + + +class AsyncEvent(AsyncEventProxy[_T]): + """The event object handed to ``async def`` event handlers + + A ``dict`` subclass which holds the snapshot the client took when the event was + dispatched *and* knows how to reach back into the browser for anything missing:: + + async def on_key_down(event: AsyncEvent): + if await event.read("key") == "Enter": + await event.target.focus() + print(await event.target.value) + + 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:`AsyncEventProxy`. + """ + + if TYPE_CHECKING: + # -- identity ----------------------------------------------------- + type: str + target: AsyncEventProxy[Any] + currentTarget: AsyncEventProxy[Any] + srcElement: AsyncEventProxy[Any] + relatedTarget: AsyncEventProxy[Any] + view: AsyncEventProxy[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: AsyncEventProxy[Any] + clipboardData: AsyncEventProxy[Any] + touches: list[Any] + targetTouches: list[Any] + changedTouches: list[Any] + deltaX: float + deltaY: float + deltaZ: float + deltaMode: int + + # -- methods ------------------------------------------------------ + # ``preventDefault()``, ``stopPropagation()``, ``stopImmediatePropagation()`` + # and ``resolve()`` are typed by the base classes. Only ``composedPath()`` + # differs, since it must be awaited here but not for a synchronous event. + def composedPath(self) -> Awaitable[list[Any]]: ... + + +class SyncEvent(SyncEventProxy[_T]): + """The event object handed to plain ``def`` event handlers + + Behaves like an :class:`AsyncEvent` except that reading something the snapshot does + not already know about blocks until the browser answers. ReactPy runs these + handlers in a worker thread so that blocking never stalls the event loop:: + + def on_key_down(event: SyncEvent): + if event.key == "Enter": + event.target.focus() + """ + + if TYPE_CHECKING: + # -- identity ----------------------------------------------------- + type: str + target: SyncEventProxy[Any] + currentTarget: SyncEventProxy[Any] + srcElement: SyncEventProxy[Any] + relatedTarget: SyncEventProxy[Any] + view: SyncEventProxy[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: SyncEventProxy[Any] + clipboardData: SyncEventProxy[Any] + touches: list[Any] + targetTouches: list[Any] + changedTouches: list[Any] + deltaX: float + deltaY: float + deltaZ: float + deltaMode: int + + # -- methods ------------------------------------------------------ + # ``preventDefault()``, ``stopPropagation()``, ``stopImmediatePropagation()`` + # and ``resolve()`` are typed by the base classes. Only ``composedPath()`` + # differs, since it blocks here rather than returning an awaitable. + def composedPath(self) -> list[Any]: ... + + +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, + prefer_async: bool = True, +) -> tuple[list[Any], bool | None]: + """Turn the ``data`` of a ``layout-event`` message into handler arguments + + Items which the client marked as live become :class:`AsyncEvent` or + :class:`SyncEvent` objects, everything else stays exactly as it was. The second + element of the result is the flavour which was used, or :data:`None` when no live + event was present. + """ + from reactpy.types import Event + + args: list[Any] = [] + flavor: bool | None = None + for item in data: + event_id = event_marker_id(item) + if event_id is not None and backend is not None and backend.available: + snapshot = { + key: value for key, value in item.items() if key != EVENT_MARKER_KEY + } + event_class = AsyncEvent if prefer_async else SyncEvent + args.append(event_class(backend, event_id, snapshot=snapshot)) + flavor = prefer_async + elif isinstance(item, dict): + args.append( + Event( + { + key: value + for key, value in item.items() + if key != EVENT_MARKER_KEY + } + ) + ) + else: + args.append(item) + return args, flavor + + +class EventArguments(list): + """The positional arguments a :class:`~reactpy.types.BaseEventHandler` is called with + + Whether data should be handed out in blocking or awaitable form is only known once + the handler itself runs, and merged handlers may mix both. This list therefore + remembers the raw event data so that :meth:`rebuild` can hand each handler + arguments of the flavour it needs. + """ + + def __init__( + self, + data: Sequence[Any], + backend: EventProxyBackend | None = None, + prefer_async: bool = True, + ) -> None: + built, flavor = build_event_args(data, backend, prefer_async) + super().__init__(built) + self._reactpy_data = data + self._reactpy_backend = backend + self._reactpy_flavor = flavor + + def rebuild(self, prefer_async: bool) -> EventArguments: + """Return arguments of the requested flavour (possibly this very list)""" + if self._reactpy_flavor is None or self._reactpy_flavor is prefer_async: + return self + return EventArguments(self._reactpy_data, self._reactpy_backend, prefer_async) + + +def uses_sync_event_proxy(args: Sequence[Any]) -> bool: + """Whether any of the given handler arguments blocks on the client""" + return any(isinstance(arg, SyncEventProxy) for arg in args) diff --git a/src/reactpy/config.py b/src/reactpy/config.py index d9d4f760c..47bee7d9c 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -138,3 +138,48 @@ def boolean(value: str | bool | int) -> bool: validator=int, ) """The maximum size for internal queues used by ReactPy""" + +REACTPY_ENABLE_EVENT_PROXY = Option( + "REACTPY_ENABLE_EVENT_PROXY", + default=True, + mutable=True, + validator=boolean, +) +"""Whether event handlers receive live client-backed event objects. + +When enabled (the default) the client keeps a reference to the dispatched JavaScript +``Event`` around and lets the server read properties from, and call methods on, that +object. Event handlers then receive a :class:`reactpy.types.SyncEvent` or +:class:`reactpy.types.AsyncEvent` (both are ``dict`` subclasses holding the usual +snapshot). When disabled, or when the client does not support live access, handlers +receive the plain :class:`reactpy.types.Event` snapshot as they always have. +""" + +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 live event timeouts 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 timeouts so that event handlers do not stall. +""" diff --git a/src/reactpy/core/_event_inspect.py b/src/reactpy/core/_event_inspect.py index e9adc8cd7..f30853e8a 100644 --- a/src/reactpy/core/_event_inspect.py +++ b/src/reactpy/core/_event_inspect.py @@ -19,13 +19,14 @@ _STORE_ATTR_VALUE_OFFSET = 2 -def inspect_event_handler(func: Callable) -> tuple[bool, bool, int | None]: +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: @@ -40,12 +41,15 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool, int | None]: 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: @@ -54,9 +58,15 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool, int | None]: 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] @@ -105,6 +115,13 @@ 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" @@ -131,12 +148,21 @@ 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]: diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index 3828e2a0f..cc36a6bf6 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -5,8 +5,9 @@ from functools import partial from typing import Any, Literal, cast, overload -from anyio import create_task_group +from anyio import create_task_group, to_thread +from reactpy._event_proxy import uses_sync_event_proxy from reactpy.core._event_inspect import inspect_event_handler from reactpy.types import BaseEventHandler, EventHandlerFunc @@ -16,9 +17,11 @@ def event( function: Callable[..., Any], *, stop_propagation: bool = ..., + stop_immediate_propagation: bool = ..., prevent_default: bool = ..., debounce: int | None = ..., throttle: int | None = ..., + event_proxy: bool | None = ..., ) -> EventHandler: ... @@ -27,9 +30,11 @@ def event( function: Literal[None] = ..., *, stop_propagation: bool = ..., + stop_immediate_propagation: bool = ..., prevent_default: bool = ..., debounce: int | None = ..., throttle: int | None = ..., + event_proxy: bool | None = ..., ) -> Callable[[Callable[..., Any]], EventHandler]: ... @@ -37,9 +42,11 @@ def event( function: Callable[..., Any] | None = None, *, stop_propagation: bool = False, + stop_immediate_propagation: bool = False, prevent_default: bool = False, debounce: int | None = None, throttle: int | None = None, + event_proxy: bool | None = None, ) -> EventHandler | Callable[[Callable[..., Any]], EventHandler]: """A decorator for constructing an :class:`EventHandler`. @@ -74,6 +81,12 @@ 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. + event_proxy: + Whether this handler receives a live, client backed event object. Defaults + to :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. Pass ``False`` for + handlers which never reach back into the browser. """ def setup(function: Callable[..., Any]) -> EventHandler: @@ -83,6 +96,8 @@ def setup(function: Callable[..., Any]) -> EventHandler: prevent_default, debounce=debounce, throttle=throttle, + stop_immediate_propagation=stop_immediate_propagation, + event_proxy=event_proxy, ) return setup(function) if function is not None else setup @@ -106,6 +121,11 @@ 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. + event_proxy: + Whether this handler is handed a live, client backed event object. See + :attr:`BaseEventHandler.event_proxy`. """ def __init__( @@ -116,12 +136,16 @@ def __init__( target: str | None = None, debounce: int | None = None, throttle: int | None = None, + stop_immediate_propagation: bool = False, + event_proxy: bool | None = None, ) -> None: 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.event_proxy = event_proxy self.target = target # Check if our `preventDefault` or `stopPropagation` methods were called @@ -141,36 +165,34 @@ def __init__( 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", + "event_proxy", + ) + 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})" @@ -185,28 +207,49 @@ def to_event_handler_function( A function or coroutine accepting a number of positional arguments. positional_args: Whether to pass the event parameters a positional args or as a list. + + Synchronous handlers are handed :class:`~reactpy.types.SyncEvent` objects which + block while reading from the browser. To keep that from stalling the event loop, + such handlers are run in their own thread. """ + is_async = inspect.iscoroutinefunction(function) + + def prepare(data: Sequence[Any]) -> Any: + """Hand the function the flavour of event data it knows how to handle""" + rebuild = getattr(data, "rebuild", None) + return data if rebuild is None else rebuild(is_async) + if positional_args: - if inspect.iscoroutinefunction(function): + if is_async: async def wrapper(data: Sequence[Any]) -> None: - await function(*data) - - cast(Any, wrapper).__wrapped__ = function + await function(*prepare(data)) else: async def wrapper(data: Sequence[Any]) -> None: - function(*data) + args = prepare(data) + if uses_sync_event_proxy(args): + # Live event access blocks on a round trip to the client - run the + # handler somewhere it is allowed to block. + await to_thread.run_sync(partial(function, *args)) + else: + function(*args) cast(Any, wrapper).__wrapped__ = function + cast(Any, wrapper).__reactpy_is_async_handler__ = is_async return wrapper - elif not inspect.iscoroutinefunction(function): + elif not is_async: async def wrapper(data: Sequence[Any]) -> None: - function(data) + args = prepare(data) + if uses_sync_event_proxy(args): + await to_thread.run_sync(function, args) + else: + function(args) cast(Any, wrapper).__wrapped__ = function + cast(Any, wrapper).__reactpy_is_async_handler__ = False return wrapper else: return function @@ -220,8 +263,9 @@ 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.throttle` attributes. + :attr:`~reactpy.core.proto.EventHandlerType.debounce`, + :attr:`~reactpy.core.proto.EventHandlerType.throttle`, or + :attr:`~reactpy.types.BaseEventHandler.event_proxy` attributes. """ if not event_handlers: msg = "No event handlers to merge" @@ -236,6 +280,10 @@ 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 + ) + event_proxy = getattr(first_handler, "event_proxy", None) for handler in event_handlers: if ( @@ -244,10 +292,14 @@ 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 + or getattr(handler, "event_proxy", None) != event_proxy ): msg = ( "Cannot merge handlers - 'stop_propagation', 'prevent_default', " - "'debounce', 'throttle' or 'target' mismatch." + "'debounce', 'throttle', 'target', 'stop_immediate_propagation' or " + "'event_proxy' mismatch." ) raise ValueError(msg) @@ -258,22 +310,39 @@ def merge_event_handlers( target, debounce, throttle, + stop_immediate_propagation=stop_immediate_propagation, + event_proxy=event_proxy, ) def merge_event_handler_funcs( functions: Sequence[EventHandlerFunc], ) -> EventHandlerFunc: - """Make one event handler function from many""" + """Make one event handler function from many + + Each of the given functions is handed event data of the flavour it expects, which + matters when synchronous (blocking) and asynchronous (awaitable) handlers are + merged together. + """ if not functions: msg = "No event handler functions to merge" raise ValueError(msg) elif len(functions) == 1: return functions[0] + is_async_by_func = [ + bool( + getattr( + func, "__reactpy_is_async_handler__", inspect.iscoroutinefunction(func) + ) + ) + for func in functions + ] + async def await_all_event_handlers(data: Sequence[Any]) -> None: async with create_task_group() as group: - for func in functions: - group.start_soon(func, data) + for func, is_async in zip(functions, is_async_by_func, strict=True): + rebuild = getattr(data, "rebuild", None) + group.start_soon(func, data if rebuild is None else rebuild(is_async)) return await_all_event_handlers diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 18253aab2..9b1ac880b 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,10 +30,16 @@ 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, REACTPY_DEBUG, + REACTPY_ENABLE_EVENT_PROXY, REACTPY_MAX_QUEUE_SIZE, ) from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook @@ -43,7 +49,6 @@ Component, Context, ContextProvider, - Event, EventHandlerDict, Key, LayoutEventMessage, @@ -63,6 +68,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 @@ -90,6 +99,11 @@ 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] = {} + # 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} @@ -115,9 +129,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 @@ -129,6 +145,12 @@ async def __aexit__( 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,6 +171,51 @@ 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. + """ + 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. + """ + return self._event_proxy_backend.handle_response(message) + + def _make_event_arguments( + self, handler: Any, data: Sequence[Any] + ) -> EventArguments: + """Wrap raw event data for the given handler + + Handlers which ask for it (or are given it by default) receive live, + client-backed event objects. Everything else gets the plain snapshot. + """ + want_proxy = getattr(handler, "event_proxy", None) + if want_proxy is None: + want_proxy = REACTPY_ENABLE_EVENT_PROXY.current + if not want_proxy: + from reactpy._event_proxy import build_event_args + + args, _ = build_event_args(data, None, prefer_async=True) + return EventArguments(args) + return EventArguments(data, self._event_proxy_backend) + async def _process_event_queue( self, target: str, queue: Queue[LayoutEventMessage | dict[str, Any]] ) -> None: @@ -177,10 +244,9 @@ async def _process_event_queue( if handler is not None: try: - data = [ - Event(d) if isinstance(d, dict) else d for d in event["data"] - ] - await handler.function(data) + await handler.function( + self._make_event_arguments(handler, event["data"]) + ) except Exception: logger.exception(f"Failed to execute event handler {handler}") else: @@ -494,16 +560,29 @@ 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 only forwarded when live event access is wanted for this + handler: an absent key means "this server cannot do live access" which keeps + the client from holding on to events nobody will ask about. """ 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 + + want_proxy = getattr(handler, "event_proxy", None) + if want_proxy is None: + want_proxy = REACTPY_ENABLE_EVENT_PROXY.current + if want_proxy: + entry["eventProxy"] = True + return entry async def _render_model_children( 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..8fa9fb656 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -72,6 +72,8 @@ "target": {"type": "string"}, "preventDefault": {"type": "boolean"}, "stopPropagation": {"type": "boolean"}, + "stopImmediatePropagation": {"type": "boolean"}, + "eventProxy": {"type": "boolean"}, "debounce": {"type": "integer", "minimum": 0}, "throttle": {"type": "integer", "minimum": 0}, }, diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 48203ab09..ba85dad7b 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -20,6 +20,14 @@ overload, ) +from reactpy._event_proxy import ( + AsyncEvent, + AsyncEventProxy, + EventProxy, + SyncEvent, + SyncEventProxy, +) + CarrierType = TypeVar("CarrierType") _Type = TypeVar("_Type") @@ -84,6 +92,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 +924,17 @@ class JsonEventTarget(TypedDict): target: str preventDefault: bool stopPropagation: bool + stopImmediatePropagation: NotRequired[bool] + eventProxy: NotRequired[bool] + """Whether the client should keep the event around for live access + + When present and ``true`` the client registers the dispatched JavaScript ``Event`` + with its live object registry and tags it with + :data:`~reactpy._event_proxy.EVENT_MARKER_KEY` in the ``layout-event`` message. + This lets the corresponding handler read properties from, and call methods on, the + real browser object. When absent ReactPy is talking to a client which cannot do + this and only the snapshot is sent. + """ debounce: NotRequired[int] throttle: NotRequired[int] @@ -942,8 +962,10 @@ class BaseEventHandler: __slots__ = ( "__weakref__", "debounce", + "event_proxy", "function", "prevent_default", + "stop_immediate_propagation", "stop_propagation", "target", "throttle", @@ -958,6 +980,22 @@ 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. + """ + + event_proxy: bool | None + """Whether this handler is handed a live client backed event object + + :data:`None` (the default) defers to :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. + Set this to ``False`` for handlers which only ever look at the data the client + already sent along, since live access requires a round trip to the browser for + anything the snapshot is missing. + """ + debounce: int | None """Server-→client debounce window in milliseconds. @@ -1132,6 +1170,10 @@ class ReactPyConfig(TypedDict, total=False): debug: bool max_queue_size: int tests_default_timeout: int + enable_event_proxy: bool + event_proxy_timeout: float + event_proxy_max_depth: int + event_proxy_max_timeouts: int class PyScriptOptions(TypedDict, total=False): @@ -1157,6 +1199,11 @@ def __repr__(self) -> str: class Event(dict): """ A light `dict` wrapper for event data passed to event handler functions. + + This is what handlers receive when the client cannot (or is not asked to) keep the + original JavaScript ``Event`` around. See :class:`SyncEvent` and + :class:`AsyncEvent` for the live, client backed variants which additionally allow + reading arbitrary properties from and calling methods on the browser object. """ debounce: int | None @@ -1166,15 +1213,42 @@ def __getattr__(self, name: str) -> Any: return Event(value) if isinstance(value, dict) else value def preventDefault(self) -> None: - """Prevent the default action of the event.""" + """Prevent the default action of the event. + + This is a no-op on a plain snapshot - use :func:`reactpy.event`'s + ``prevent_default=True``, or a live event + (:data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`) to actually call it. + """ def stopPropagation(self) -> None: - """Stop the event from propagating.""" + """Stop the event from propagating. + + This is a no-op on a plain snapshot - use :func:`reactpy.event`'s + ``stop_propagation=True``, or a live event + (:data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`) to actually call it. + """ SourceType = NewType("SourceType", str) +EventObject: TypeAlias = Event | EventProxy[Any] +"""Anything which may be handed to an event handler as event data + +Either the plain :class:`Event` snapshot, or a live :class:`~reactpy._event_proxy.EventProxy`. +""" + +LiveEvent: TypeAlias = SyncEvent[Any] | AsyncEvent[Any] +"""The live event objects ReactPy hands to event handlers + +A :class:`~reactpy._event_proxy.SyncEvent` for synchronous handlers and an +:class:`~reactpy._event_proxy.AsyncEvent` for asynchronous ones. +""" + +LiveEventMember: TypeAlias = SyncEventProxy[Any] | AsyncEventProxy[Any] +"""A lazily resolved member of a :data:`LiveEvent`""" + + @dataclass(frozen=True) class JavaScriptModule: source: str diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py new file mode 100644 index 000000000..eea7238a2 --- /dev/null +++ b/tests/test_core/test_event_proxy.py @@ -0,0 +1,619 @@ +"""Tests for the live, client backed event objects in :mod:`reactpy._event_proxy`.""" + +from __future__ import annotations + +import asyncio +import threading +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, + AsyncEvent, + AsyncEventProxy, + EventArguments, + EventProxyAccessError, + EventProxyBackend, + EventProxyBlocked, + EventProxyNotFound, + EventProxyTimeout, + EventProxyUnavailable, + SyncEvent, + SyncEventProxy, + build_event_args, + deserialize_value, + serialize_argument, +) +from reactpy.config import ( + 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 reactpy.types import Event +from reactpy.utils import Ref +from tests.tooling.common import event_message + + +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]]: + 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 + asyncio.get_running_loop().call_later(self.delay, self._answer, dict(message)) + + def _answer(self, message: 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") + if should_reply: + self.backend.handle_response(response) + return + + 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) + if should_reply: + self.backend.handle_response(response) + + 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}], + } + } + + +async def test_async_event_prefers_the_snapshot(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent( + backend, + "evt", + snapshot={"type": "click", "target": {"value": "cached"}}, + ) + + assert event_object["type"] == "click" + assert event_object["target"]["value"] == "cached" + assert event_object.target.value == "cached" + assert isinstance(event_object["target"]["nope"], AsyncEventProxy) + assert requested_paths(client) == [] + + +async def test_async_event_resolves_what_the_snapshot_lacks(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent(backend, "evt", snapshot={"type": "click"}) + + assert await event_object.target.tagName == "INPUT" + assert await event_object.read("target.value") == "typed" + assert await event_object.read("nope", default="fallback") == "fallback" + assert requested_paths(client) == [ + ["target", "tagName"], + ["target", "value"], + ["nope"], + ] + + +async def test_async_event_walks_references(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent(backend, "evt", snapshot={}) + + deep = await event_object.deep.resolve() + assert isinstance(deep, AsyncEventProxy) + assert deep.path == ("deep",) + assert await event_object.deep.a.b.c == 42 + assert await event_object.read("deep.a.b.c") == 42 + assert requested_paths(client) == [ + ["deep"], + ["deep", "a", "b", "c"], + ["deep", "a", "b", "c"], + ] + + +async def test_async_event_calls_client_side_methods(): + objects = make_objects() + backend, _client = make_client(objects) + event_object = AsyncEvent(backend, "evt", snapshot={}) + + 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_prevent_default_is_scheduled_but_not_awaited(): + objects = make_objects() + backend, client = make_client(objects) + event_object = AsyncEvent(backend, "evt", snapshot={}) + + event_object.preventDefault() + event_object.stopPropagation() + assert event_object.defaultPrevented + assert event_object.propagationStopped + + await asyncio.sleep(0.01) + assert requested_paths(client) == [["preventDefault"], ["stopPropagation"]] + assert all(not request["reply"] for request in client.requests) + assert objects["evt"]["preventDefault"].calls == [()] + + +def test_snapshot_data_is_plain_json(): + import json + + backend = EventProxyBackend() + event_object = AsyncEvent( + backend, "evt", snapshot={"type": "click", "target": {"value": "x"}} + ) + + assert json.loads(json.dumps(event_object)) == { + "type": "click", + "target": {"value": "x"}, + } + assert list(event_object) == ["type", "target"] + assert bool(event_object) is True + assert event_object.debounce is None + event_object.debounce = 120 + assert event_object.debounce == 120 + + +async def test_sync_event_blocks_until_the_client_answers(): + objects = make_objects() + backend, _client = make_client(objects, delay=0.01) + event_object = SyncEvent(backend, "evt", snapshot={"type": "click"}) + outcome: Ref[tuple[Any, ...]] = Ref(None) + + def handler() -> None: + outcome.current = ( + event_object.target.value, + str(event_object.deep.a.b.c), + event_object.target.focus(), + event_object.composedPath(), + [touch["pageX"] for touch in event_object.touches], + ) + + thread = threading.Thread(target=handler) + thread.start() + await asyncio.to_thread(thread.join, 10) + assert not thread.is_alive() + assert outcome.current == ( + "typed", + "42", + "focused", + ["window", "input"], + [1, 2], + ) + + +async def test_sync_access_from_the_event_loop_is_refused(): + backend, _client = make_client(make_objects()) + event_object = SyncEvent(backend, "evt", snapshot={}) + + with pytest.raises(EventProxyBlocked): + value = event_object.target.value + assert value + + +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 = AsyncEvent(backend, "evt", snapshot={}) + + for _ in range(2): + with pytest.raises(EventProxyTimeout): + await event_object.read("target") + + assert backend.disabled + assert not backend.available + with pytest.raises(EventProxyUnavailable): + await event_object.read("target") + + +async def test_missing_objects_and_paths_are_reported(): + backend, _client = make_client(make_objects()) + missing = AsyncEvent(backend, "not-registered", snapshot={}) + with pytest.raises(EventProxyNotFound): + await missing.resolve() + + event_object = AsyncEvent(backend, "evt", snapshot={}) + with pytest.raises(EventProxyNotFound): + await event_object.nope.resolve() + + +def test_arguments_are_serialized_or_refused(): + backend = EventProxyBackend() + proxy = SyncEventProxy(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_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, sync=False) + + +def test_backend_without_a_sender_is_unavailable(): + backend = EventProxyBackend() + assert not backend.available + + data = [{EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, 5] + args, flavor = build_event_args(data, backend) + assert isinstance(args[0], Event) + assert args[1] == 5 + assert flavor is None + + +async def test_event_arguments_are_rebuilt_for_each_handler(): + backend, _client = make_client(make_objects()) + data = [{EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}] + + arguments = EventArguments(data, backend) + assert isinstance(arguments[0], AsyncEvent) + assert EVENT_MARKER_KEY not in arguments[0] + + sync_arguments = arguments.rebuild(False) + assert isinstance(sync_arguments[0], SyncEvent) + assert arguments.rebuild(True) is arguments + + plain = EventArguments([1, "two"]) + assert plain.rebuild(False) is plain + + +# -- layout integration ----------------------------------------------------- + + +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_handlers_events_of_their_own_flavour(): + main_thread = threading.current_thread().name + received: dict[str, tuple[Any, str, Any]] = {} + objects = make_objects() + + async def async_handler(event_object): + received["async"] = ( + event_object, + threading.current_thread().name, + await event_object.read("target.value"), + ) + + def sync_handler(event_object): + received["sync"] = ( + event_object, + threading.current_thread().name, + event_object.target.value, + ) + + @component + def MyComponent(): + return reactpy.html.div( + {"onSync": sync_handler, "onAsync": async_handler} + ) + + _backend, client = make_client(objects) + + async with Layout(MyComponent()) as layout: + connect(layout, client) + model = (await layout.render())["model"] + handlers = event_handlers_of(model) + assert all(handler["eventProxy"] for handler in handlers.values()) + + data = {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"} + await layout.deliver(event_message(handlers["onSync"]["target"], dict(data))) + await poll(lambda: "sync" in received) + await layout.deliver(event_message(handlers["onAsync"]["target"], dict(data))) + await poll(lambda: "async" in received) + + sync_event, sync_thread, sync_value = received["sync"] + assert isinstance(sync_event, SyncEvent) + assert sync_value == "typed" + assert sync_thread != main_thread, "blocking handlers run off the event loop" + + async_event, async_thread, async_value = received["async"] + assert isinstance(async_event, AsyncEvent) + assert async_value == "typed" + assert async_thread == main_thread + + assert requested_paths(client) == [["target"], ["target", "value"]] + + +async def test_layout_respects_the_per_handler_kill_switch(): + received: dict[str, Any] = {} + + def sync_handler(args): + received["event"] = args[0] + + @component + def MyComponent(): + return reactpy.html.div( + {"onClick": EventHandler(sync_handler, event_proxy=False)} + ) + + _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) + assert "eventProxy" not in handlers["onClick"] + + target = handlers["onClick"]["target"] + await layout.deliver( + event_message(target, {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}) + ) + await poll(lambda: "event" in received) + + assert type(received["event"]) is Event + assert received["event"]["type"] == "click" + assert client.requests == [] + + +async def test_merged_handlers_get_the_flavour_each_needs(): + received: dict[str, Any] = {} + + def sync_handler(args): + received["sync"] = args[0] + + async def async_handler(args): + received["async"] = args[0] + + merged = merge_event_handlers( + [EventHandler(sync_handler), EventHandler(async_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["sync"], SyncEvent) + assert isinstance(received["async"], AsyncEvent) + + +async def test_layout_without_a_client_falls_back_to_snapshots(): + received: dict[str, Any] = {} + + def sync_handler(event): + received["event"] = event + + @component + def MyComponent(): + return reactpy.html.div({"onClick": sync_handler}) + + async with Layout(MyComponent()) as layout: + 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: "event" in received) + + # nobody bound a sender, so the live marker is simply dropped + assert type(received["event"]) is Event + assert EVENT_MARKER_KEY not in received["event"] + + +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(): + 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 + diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 21c03b3da..eda7d65f5 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -21,8 +21,8 @@ def test_event_handler_repr(): handler = EventHandler(lambda: None) 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, event_proxy=None)" ) diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 20ec0d8d5..5149da37b 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -60,6 +60,7 @@ def make_events_and_expected_model(): "target": STATIC_EVENT_HANDLER.target, "preventDefault": False, "stopPropagation": False, + "eventProxy": True, } }, } From 06bfedd5634696b7ede14bf415986501ab7d64dd Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Fri, 9 Oct 2026 22:34:36 -0700 Subject: [PATCH 02/36] Make the live event proxy opt-in With live access enabled by default every dispatched object was registered with the client, which made even plain synchronous handlers block on the browser. Those handlers are therefore run in a worker thread, where calling set_state() fails with 'no running event loop' because scheduling a render needs the layout's loop. Default the option to off so existing apps keep the plain snapshot behaviour, and enable live access per handler or globally. --- CHANGELOG.md | 2 +- src/reactpy/config.py | 13 +++++++++---- tests/test_core/test_event_proxy.py | 7 +++++-- tests/test_core/test_serve.py | 1 - 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3581cb9de..96ef46f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Don't forget to remove deprecated code on each major release! - `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 rather than being limited to the snapshot sent along with the event. Handlers can `await event.target.value` or `await event.target.focus()` when asynchronous, or simply use `event.target.value` and `event.target.focus()` when synchronous. The new `reactpy.types.AsyncEvent` and `reactpy.types.SyncEvent` types describe these objects for editors and type checkers. - - This can be disabled entirely via `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. + - This is opt-in. Enable it globally with `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, or per handler with `EventHandler(fn, event_proxy=True)`. - 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`. ### Changed diff --git a/src/reactpy/config.py b/src/reactpy/config.py index 47bee7d9c..02a663211 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -141,18 +141,23 @@ def boolean(value: str | bool | int) -> bool: REACTPY_ENABLE_EVENT_PROXY = Option( "REACTPY_ENABLE_EVENT_PROXY", - default=True, + default=False, mutable=True, validator=boolean, ) """Whether event handlers receive live client-backed event objects. -When enabled (the default) the client keeps a reference to the dispatched JavaScript -``Event`` around and lets the server read properties from, and call methods on, that -object. Event handlers then receive a :class:`reactpy.types.SyncEvent` or +Disabled by default. When enabled the client keeps a reference to the dispatched +JavaScript ``Event`` around and lets the server read properties from, and call methods +on, that object. Event handlers then receive a :class:`reactpy.types.SyncEvent` or :class:`reactpy.types.AsyncEvent` (both are ``dict`` subclasses holding the usual snapshot). When disabled, or when the client does not support live access, handlers receive the plain :class:`reactpy.types.Event` snapshot as they always have. + +Individual handlers can opt in with ``EventHandler(fn, event_proxy=True)`` without +turning this on globally. Note that a *synchronous* handler which reads a live value +is run in a worker thread so that it may block, and cannot safely call ``set_state`` +from there yet. """ REACTPY_EVENT_PROXY_TIMEOUT = Option( diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index eea7238a2..735993651 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -31,6 +31,7 @@ serialize_argument, ) from reactpy.config import ( + REACTPY_ENABLE_EVENT_PROXY, REACTPY_EVENT_PROXY_MAX_TIMEOUTS, REACTPY_EVENT_PROXY_TIMEOUT, ) @@ -450,7 +451,8 @@ def event_handlers_of(model: dict[str, Any]) -> dict[str, Any]: raise KeyError(msg) -async def test_layout_hands_handlers_events_of_their_own_flavour(): +async def test_layout_hands_handlers_events_of_their_own_flavour(monkeypatch): + monkeypatch.setattr(REACTPY_ENABLE_EVENT_PROXY, "current", True) main_thread = threading.current_thread().name received: dict[str, tuple[Any, str, Any]] = {} objects = make_objects() @@ -533,7 +535,8 @@ def MyComponent(): assert client.requests == [] -async def test_merged_handlers_get_the_flavour_each_needs(): +async def test_merged_handlers_get_the_flavour_each_needs(monkeypatch): + monkeypatch.setattr(REACTPY_ENABLE_EVENT_PROXY, "current", True) received: dict[str, Any] = {} def sync_handler(args): diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 5149da37b..20ec0d8d5 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -60,7 +60,6 @@ def make_events_and_expected_model(): "target": STATIC_EVENT_HANDLER.target, "preventDefault": False, "stopPropagation": False, - "eventProxy": True, } }, } From 9d76593f5a4c81e75874dcbc677bb7ea514695d7 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 10 Oct 2026 20:15:24 -0700 Subject: [PATCH 03/36] Carry the event ack per event, and finish the proxy opt-in The acknowledgement which tells an input that the server has caught up with the keyboard was kept as a single figure per element, and recorded before the handler which consumed the event had finished with it. A render made while a handler was still running could therefore look up to date, and the value it carried - a snapshot of the keystroke before the one the user has just made - was written back over what they had typed. The figure is now per event, since an element bound to more than one event cannot be described by one number: the keystrokes of one event would otherwise make a render which predates the newest keystroke of another look current. It is recorded only once the handler is out of the way, and a request to deliver an acknowledgement is dropped when the client already holds it, when a render already queued will carry it, or when the element is not one which waits on an acknowledgement at all - prompting a render for an ordinary button click spent a round trip on a figure nobody was waiting for, and broke the rule that setting state to an equal value re-renders nothing. Asking for a render as teardown begins is now refused outright. Tasks cancelled on the way out hand control back to the event loop, which is exactly when a handler finishing its business gets its turn, and a render task created then survives the loop over the tasks teardown knows about - only to reach, after everything has been deleted, for the handlers it renders with. Live access to the object behind an event is asked for per handler with EventHandler(fn, event_proxy=True) or @event(event_proxy=True), or globally with REACTPY_ENABLE_EVENT_PROXY. The preference reaches the client as eventProxy in the handler spec, and the client registers only what the server said it would read from, so dispatching an event nobody intends to look behind costs the page nothing to remember. Handlers which do not ask are handed the plain Event snapshot, and the field the protocol uses to carry the acknowledgement is stripped before props are handed to Preact, so it never reaches the DOM. --- .github/copilot-instructions.md | 5 +- .github/workflows/publish.yml | 7 - CHANGELOG.md | 4 +- pyproject.toml | 13 +- src/build_scripts/build_js_event_to_object.py | 36 - src/js/bun.lockb | Bin 98106 -> 62240 bytes src/js/package.json | 4 +- src/js/packages/@reactpy/app/package.json | 1 - src/js/packages/@reactpy/client/package.json | 3 +- .../@reactpy/client/src/components.tsx | 140 ++-- .../@reactpy/client/src/event-proxy.ts | 40 +- .../packages/@reactpy/client/src/handler.ts | 12 + src/js/packages/@reactpy/client/src/types.ts | 7 +- src/js/packages/@reactpy/client/src/vdom.tsx | 42 +- .../@reactpy/client/tests/event-proxy.test.ts | 26 + src/js/packages/@reactpy/client/tsconfig.json | 7 +- src/js/packages/event-to-object/README.md | 3 - src/js/packages/event-to-object/bun.lockb | Bin 17949 -> 0 bytes src/js/packages/event-to-object/package.json | 40 - src/js/packages/event-to-object/src/index.ts | 323 -------- .../tests/event-to-object.test.ts | 679 ---------------- .../event-to-object/tests/tooling/check.ts | 137 ---- .../event-to-object/tests/tooling/mock.ts | 50 -- .../event-to-object/tests/tooling/setup.js | 29 - src/js/packages/event-to-object/tsconfig.json | 10 - .../packages/event-to-object/vitest.config.ts | 8 - src/reactpy/_event_proxy.py | 14 +- src/reactpy/core/_life_cycle_hook.py | 25 +- src/reactpy/core/events.py | 29 +- src/reactpy/core/layout.py | 233 ++++-- src/reactpy/types.py | 52 +- tests/test_core/test_event_proxy.py | 743 +++++++++++++++++- tests/test_core/test_events.py | 486 +++++++++++- tests/test_core/test_hooks.py | 70 ++ tests/test_core/test_layout.py | 203 ++++- tests/test_core/test_serve.py | 61 ++ tests/test_widgets.py | 27 +- tests/tooling/common.py | 11 + 38 files changed, 1950 insertions(+), 1630 deletions(-) delete mode 100644 src/build_scripts/build_js_event_to_object.py mode change 100644 => 100755 src/js/bun.lockb delete mode 100644 src/js/packages/event-to-object/README.md delete mode 100644 src/js/packages/event-to-object/bun.lockb delete mode 100644 src/js/packages/event-to-object/package.json delete mode 100644 src/js/packages/event-to-object/src/index.ts delete mode 100644 src/js/packages/event-to-object/tests/event-to-object.test.ts delete mode 100644 src/js/packages/event-to-object/tests/tooling/check.ts delete mode 100644 src/js/packages/event-to-object/tests/tooling/mock.ts delete mode 100644 src/js/packages/event-to-object/tests/tooling/setup.js delete mode 100644 src/js/packages/event-to-object/tsconfig.json delete mode 100644 src/js/packages/event-to-object/vitest.config.ts 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 96ef46f11..c741cf839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Don't forget to remove deprecated code on each major release! - `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 rather than being limited to the snapshot sent along with the event. Handlers can `await event.target.value` or `await event.target.focus()` when asynchronous, or simply use `event.target.value` and `event.target.focus()` when synchronous. The new `reactpy.types.AsyncEvent` and `reactpy.types.SyncEvent` types describe these objects for editors and type checkers. - - This is opt-in. Enable it globally with `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, or per handler with `EventHandler(fn, event_proxy=True)`. + - Live access is opt-in. Ask for it on a single handler with `EventHandler(fn, event_proxy=True)` or `@event(event_proxy=True)`, or for every handler with `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. Until the server has asked, the browser holds nothing back and a handler is simply handed the plain `reactpy.types.Event` snapshot which arrived with the event - so an application which never reaches into the page pays nothing for the possibility. Reading something the client did not send up front costs a round trip to the browser, so synchronous handlers which do are run in a worker thread where they are free to block. - 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`. ### Changed @@ -56,7 +56,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. @@ -86,6 +85,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 serializable snapshot it produced is now produced by the client's own serializer, and is what a handler is handed unless it has asked for the live object behind the event (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. diff --git a/pyproject.toml b/pyproject.toml index 21e5f89fa..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` 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 1d91089a36add1494994fd03640f139af5b875fa..84a2123a58e9e1dc5bda8d012c7ee259c0c48cb2 GIT binary patch delta 8999 zcmeHNdt6mj_CNc;1un=-#Vi+ zUVHDg*Is*{yLtVhZPn)<&1t=F-hbiLrydUH0snDBw{1G}`>e8Uj-|_z7kdR9-12#| z_v+@;ik_ZoDT{4<_!)!w@p%@n!HUup0O|``Szk4^xsVHkqe$v$Hiqx4kN{5e;ztf~OsZ}$|-3@59YU=c<4a!cm#`Tw>9(TBm z+#=lW0TD~*(OePkxQJI{kTrFcXrm5gLy$Y0T1EJ8CV}e$?zq3v^<40HZ90GlYa9i> z8)!x4#F>*}is|)i5Jk}-=Qb8&+&uK-fz(y)-{^u_xp+8eBxp~wx({d!_zS9`J{3LS zW~}@#A@2(QBT(*+3rd)O3w#)88z}3I2~`yQHLU>e0G$KMdd(SZRICiEqNVU^1y@hi zqzJ=G`jkPTWhJX)4d?~iV=r1|>rJaqo$a1prMT-R*SPEID|bNN1vyPWVAwo@uRwW( z^J$Dnuf{2;&Qi3E8%2cnr!-ao49#s&Y@>1fI?OFb2um z4uNt#t9xsNq9lOtAEzkYK|?{~K|hXF6pYB4wz10h5a6%rJv_K0yFgjdXw+htom4Tk z4(%)TmDBI4#M?tq3|&okg0i9S>0$8WLD8qC(U5Zkb7+ib?9hLpCTj`kX>_*)lv}Ze znT>q*5_q7yyg{#Nm;+w#E}z0f(5+mj64t)Oi4HK08B6`F;<>T;-n{@Er z>W8+i|J~4iUw1Tb(2lOZ?Ww=|k6-4UTsP{oaZ5g(PU(TCgb&3BY3fBm(}IeGMy)}b z5OfaDX;c`jiH)={ShIiPsVD={4TUC+K5267f zoIe-O7io8hWd?dubPmr4s4!Af_jjR3B8x;Sbwp}n z0y&(TAaXl3^`t+obry-9)CrNB21IG%S(+QAsiyiWyfvglV-KxQ7z~zF2AL=QHN`kXRE)9*>L<6 zp=bBf?8mS)Imd&7lk!C#*^@L;PbEp3eI3G2fu7Uq%omgoUGa3k219Ru#2kU(G-c145QW*jW(os+A{RcbtLB7 z_aHXh0M464WWGHZ(TeB7@V%kb(MPi{gN!FsWpB101;=YcP%DNOMbD@5+UdmeRvM7T zD`;+-X7i253ZcBT!ElY>zMAa;@HaDWKZG|Prb=_u^F=Zhg!n5C)r_oD72ifrr9cqn}0I@)lPJqYDkwQBarn_3dH z)r-C8{D>m^VMMT7k*Ux^Ooj__Z-~ypJM6Q;vEdc=6?IE*iYzIzrz1q(s0&*#Uo*iK zT1D)eFLRhn^=vZbjw-TWhZt&TsdePr?*mtW3LfO{mT!9l+(7bA8f^1IWWAmPlYI!@ zxVs2`=+Q!O`ZA+q5&8Bv!Hv}Oz^NgrbkEJ#HzHgq3FLtgp&sBgU1m@&g^AJAm6W|~7{K{A>A6=?T0cJA!P-j9Qt(YZ>6G$r32$T_ zWdQ4dE9mK$P#(!xv;38m<+lN>Z@ih$l=<5M?u{GZ1lnl5=%y(L*8`ZZOAX2u9|o8% z09e7J04Js_Uj%T&ivdp8qMY9hU@uda1Duv~`>bGv&Ki^p{?_D~a{kjMea$?Hmb})m~$S;1t9>5#;3&08vnfynfyoQbgEdLDPbS=t?KL?n80dQi< z{0V?NeiGos6!J#pYn@g8gW4cFZOWPQoLw;aYf-k0ja&I2sRz%$4=Qq9yGeabMORZ+ z?qh;2@_!O-lA!3y@WX9iGu%*gY3#3o2G-%k0f(LO*Ff`Yp!t6iXt1sQY@lfq6?0N4 zy0w^gwfNJ0EyZF!?QSWfSxfzCW^1unK<%w%ltliNxUpC~LbV&q=&#@of?GuKZDq7z znLo{ID;A5X16G79~@lAOQ+z{zcH*PM* zDY#{G8T|n6J8(}@;VWfgIW5O?1$Dk6ZMj%={%!89jiRk-Yk{B%L7p^ni%)2Rzwv3z zi_%>_qplh>fu?Wy!afaO^HXD{1uk0h*#*yksO6)&XYBXZAABgRz2qJ>IztZ~FKRK~D|TaXzGRCf+%R z_blG0INb%XjJFBiTshqhu*@6aZG_V-fMv|T1#p@Tu#9zW0yy0Ru#ES)j{(+kF92B= zW&cXNA#Go+(2QpV7g!#j`UwMFLObBQ=Hd2NJ1fD zn*@SdKVZdqy$eYMB%gx9O`5`NW1@#gKe*dR-0n(GOHXsbwd8v?G0AxrC4y06NByc- z-_IX*03~wLvU1Wgl+p4rr*Mcn<#aFMa9Y0(?s|9c-y=ufo~8=3n6gH$_7W*Qc3~c( zpeVp>a%EyupOkr(6NAL3$bqzJj{F9i#0t660fpAjvmeJhy87k?sH(Uu$eAR^F{zU)yP&{gA2D3alb!w|%rg_yCeL!w zH8RBx@{+uR$?NhfXjYGS$nxY{FS>+HB>RAuDAxK2X^LDh7CtH1?FwIv)I<2dN z?EzmrueDWln&ZgAB9rC5BGYNzJ;p9_)D85%OeaNI7YYTTN1n{x=5wDpesuJB zp!}1+$S~%_A;M*F7@Bs;!~k)V)4E9b_HR8xS3iF|AJx*+&}GCQ`B(tv=p$ABF#xq) z@&mBWpZ2F185k%stvU7Rr_{-oD3O8@FANl&EUyj`5m8q!CRGeb%N)RCH0R!ME{EYU z4#Q*gZp5KFe9Z+f6jNj|h-!JN;hUz{+2D1iKZypp2j#M?d!CG{wli<it8t&mz%%y8CD}4=Q zvVHY|8TOTw=QNf8@6=o2)bhblv9V*q!)U{d?4fbS%~IC`fBxi-aj_hs&75m#GosP8a^yV` zBF>1hVYVOpE&}Af2!ucDKB{!W$xG*(-QS}bo;@su(K6ZrOa8PKthHipRmRSeWnEac zyz=IoQ@gts{AiP(w$?rJYsW9z#O{>WMZ!6M-u}8qjs?6sCj9*FXlhiykCi>0BICb| z+AJx|QU9+kh&eJa3ZC&F9sC8`w-FS3M1(1dllS?a#>HPt-?5MNd^vA`Dw47Db1IQX zqA;%scnv|pgAYEXZd_8m-p7u6Yuokp(FtA2e!|KJ0&E|zX^*+bQA zEy}v>>^0Z>Q10>qf4x5lCW>N?R=sNU>bVqI4J)W-Tb5PSrap2jD8~ciH66L)brZ;9LqS2pA#W?x>Ea8-=N`%)dV`qwe zHopzu7_GgE)>7t(L>y|Zv#freM{dq3bmU&#_`T{(_nUIcC_f8Al+W_pZVL88@WC9B z*TXs*TLGUQe@p8EBaA8Bb)b8L-pyBqt(4yvzH?$Jq^cIm$ct6oN)O3`}Nh(c^^oE hd;=uWYUZ*TJd2fgwa@M-`6 delta 31275 zcmeIbXFwE9voN}|EW(Op1ePEmh@dW6f(aC5&58*#5>|2s5d(`EBVsFx2{D2R5m5|C z5CbYIDwrMv7!VVpk2&4ynQ3+jo^$TK-}!#Mqct^M)m>9vT^*)psNL~G?$mGjwN{qV z8O4ba-WwYoZ;cE-Jt3#_t&TU$PF~nzpPuYyyZlG?j{S)g2OpnOy^JJ7zSGw-Cn0dv zIh>?mptT1qjtLD4kL7TLJSM#suo}=q#k0b3v860MhQ;#%^MUU#7Doz$VmO^3tqSRm zEPX*cCS46!B@`I%^!ln&noir`YG(E>_G;Lw3oGBR2W z_sD|0n!wFLxs=Y(t&oBYNdgt7N|BIOhBP12SYk4ywE+8zed7Ya6k##gARNwopyPX# zLA`O+1!_ox>nI5Tv*PR{0P6vs$`I8rgoc_h- zaI^u}1IBX4b>wj1f6@&|3jiMljODT|DB%zWDM$pi6wtD%w(2rgqMHl^5-SnK_JbDK z9z3WOwq9_IFu^A*l;aZ}5a|;gBmN57bO270l|EAg6TrBJXV4_Mu9EFg9M_AOTMs;t z@w})wadZO5FVH761kC!$5IhF*1^UDYXNLL&yn-|~b0Jg`OY307lq2fh2RUXy4>jg+ zIs+aC*aWZ^V5kvkw#l3xK!CDIih!{qzga=cp%ixInf^i195`acVY9?=bqWMS)}&0p z*w9BznKS|nIwdU!I=;bCG>K;eqM12A@II~I3haeEejwr2RXEWXC#Q!L&G7+YXH#uB(#z!Jh)JQXnRK|@*W%wlsE zYq2;wA~Y068Vv?F>drWmD`4zA^r$V43dKG#%O{jm%R1FH)(LM6Wb*s@ z_~GK5^DG_9kBo>2#aKLB>=zU3FBFH*;)F&R`TN8K(vC%YEsV21e&E%8pfGNuN2r9S zBY7rbddzLWYCydR80%jM7!L(efU)N+1B^YxbqIrVAdOwt4$?TkIbiJC27qxngJBHa zFT!8!4?epK(tMzAVdYE(3?7%~B`@Gf;@V{{bYGIS`q0!Zca^8DSLhZ!=J}MAXJLIS z9hTV+(g>cRW)W6lDz7 z$?^^AJKVrrFK2#8&-d;6Ef=qow@{T#a+Ryt_%4fLahaRq&hCf4_8X->`_(R;bJ}LVtQQWGEHd}B;039P z_}^z2tX-(H=*p0zQ)8A7{(WqZ**x!2MrNH$t5>R-_H~a} z_;{i+Q_Zl?Vo&ABdyQfG%W0?l}|GtESl+In|8mXl=DtxUe9&Y z{r=r{%wDDysMEUO=tCn#?;G=&jys_Rj3Y;CG5$ZrTjk?0ur0D%WxN z)#I7#j?F1~o|>g8(Gh*AON;%G>H7I}(8)eo4(-BTJbF~L?SNgO%jnPtgH`wRPaSa9 zY?s+i&bH*}EnOcb4^dXFt+eg&^UBYK!!kl%XSW}2IA;{~d|p-3!a+kSpPwzYcUAD5 z8J%42KkiXc=CXem=%trGI+2)BG_=di#nTr(Xt?Oq^^mjOj~gCpDQ52^fvfs+U-a*y z_{n`>+1Kgmi4I#Y|5=&xYwHP9^;gdC=Z;cQTUMFZg*VOS8q-qJ7nVv!)AH8(a5Us#l~YN#I0Uc zbN%>>_rq#OT8&()saE&g#3(W#sLdRdd4^7aiI)Pxs#>4zks8rN*Iz z4kBtTD(N6Xqm_B6gYrN=2Nri8(9bA3hX+bm#yM)>{0v#Di1-!?91cvRz&Fuwr8c9J zDkADWqEtol+KL=bKcuYe4sugfMf_x(hMetCjjk(o0hy?YxEg#EtmZ+DM!C=p)@t`K7C>;^C6rF_gEkx;x_~tOM4FE;uXhj22s;-DSg-irG z=)JBD^#-m)+)nB!Sl~g;MY#eIKL^%{xMp0GsqISDq5gUzz7tGaQ)ybNx+{fHCD3jI zZ9LFuW2#%is0!0n5+0rLp@wpmoTx~YtuLbXqeeKtMlJ>-d21L)ozeUb?oTfFI&w(ipTaxjf^QaPNe}D#)Uy=%K zT}R|&CZa-6Hk`MkMmSd>mo6f{GAz%qG)3%}+!2OoPA3oW19fu|zW`Wvz~UiWV^_W$ z%(5`mB`F{uEmwIFq?}QhnmZ*ywp~U1%aFzjaIyRHwP6N^=?v6SS9YbwAif3eE}n2+ zgVN#r64h9U%!rTf$}`2 zIERl+z#}Fgflvfp&qpZY--I04cQ)z8xi@-Z&PSQnBEDx=4#yGla8Zels~{CpSa~jcbI48~d_;AyR?mUu z2sWK6OI1RRMo#<=mK@G-peo?DQ|WU0`AC_BW{lF>nU}msJ_B$LOOYU87Rq zVq=qf%A?8-PJFE%jB~+wXzVKRfE2EEYptWSY;=K%`)_L|7>s|06(ol{kl@{41wmfH z09XXtf+QHN>4UUCttfvLaGBAi#>7><3{uztFxt|GJRUXV)vchac#PnWg)7F#qdL2) zZ-W$e135M~Xk+Z8{t&3xi&!G3Bd)soBUrSLY(?z>iVbf?o!ut2tWByKbk^1qrUG?n zE4h1tI=B_}U7J)#`?m5Xwn@FvCRGLez4hHDv`O9GCiMkSvCB8NWDhWTYie|x)Pgpt zzkxcimD~Z)@U5v!+oWD@ld1yE-dchWP~nD2JhmD=QL3hsx+OTbPb=yQpiXH;eGb$~ zt*HHaGxJ?@xd^DOCAaNr3jsksg(fZDolx-bT{ zrX~Qj_1&t08qi8^UsrgVZbjV*RPR<)%B?Nx44}5YTOm+eTSvQpTN2`c+B)wwpnA2` zf!_tjNB9pzc8Q9sAOJ4$*urJ~fMPKp*=pD*vP?O=z7yPn6dq<;XT^h`syl}RqX{ha zV94T6f)wr!%p{xvDX<-+V1VL3gcKg!Ag8V?za#i2tpJ|M)Q3R|=Ok)yQEGc9K^jp1 zI_%0n2CfIH&~#VtGKj;0-GQWbY$afh5?G_+_KND`fFmn`k9?G!_-lZQHKAy|F0d&M zYSeO7{|zZTV9K$w+M_5lXI-chR+h&S)mhvT*|zVBr(Eh4QnwayLp;!AYY%Rw2ePyA zkS~W^9qDNpt5Od#=&_wGtAM9fu89b>xAoxv6md8c;RYO3Z{*4!2O||dSkd}zg-d7x z;DHsnj|QV|b{_n(LpU7J6o{ZSe-osj(U~m#drc{rV!3)l(NKF2{&X0D87Y;TuKXR4 za)Sc2Gsr)IR38-9*gg`URv$lN3BKq7^*{ z`0$}#7;qzyr31!B05A#BbQnVE17qw?U{elmaxgf3{8yMlhP{nYsH37K&I-!Vhb+dD zU}&KwL#O9((6rt<0!($oDLC8!aJoML7gabKfu-nC5+giX(p<1BePE2;0PIg67~|4( zp%$2jl>6wI;A}9j(g(&k_b4VwV>#5fkB$TrV5FnD<5(`nJOFS4nhw31KK>^dTM|ZD z`jEw#4&w_gZ#u)~0G7}M7^LXR`LYaIj0^e$m;x+g`55EsB(r!qU~HUK0QkTd^H&4l z@+koLXpN!%I72D`A7C>8J}|}tw%`OD7~_1~Z~_jDF@HNwz#)sVBAGOm!Pt^}*fhpi z&OSE158EFL$^yWQ{cQRGV0>VV`3G5i2rxcmaeGLg0Khjo4S@5X0l){wI9&*U9lNj{xJ|^$Y;hUjX2vEsW*X0bu+R03R4b zTEeM^3weNd41v=C7#|qx0q`LgHfaZ3`Y#x>lvzH;SRqw5EsL=n4WMI|Cd~)?GZzfT z1v>%*XVj%LFc>Rn2xPTN*_&df0LfCx&Uob8o$(C=z5+LAi zG>c_mj0Yl2$46Tj_jmjT2_OG&Fm~2eP!1Pe&0@(KR!~cfg=0l9U4$^XPx(QF{8sMiHbHF;64^Mq(n*f8WAri`|Q4I8l(e3TqKTgw#t+XH=fT;QcR zKfae#E6()V(teXme@kE8Q03=?3R8whay5tS=+5a=uAF<|L8Ef--L=XOOsl*(n7XiOje*;b?ngQl{DNJiuFL&+Tr_3+K{f*|n zs4p=N&am7({Ko~a*Jisv%rcp9~u z6v0MJNM^hD>)SGo8@==*QNNe^G7(F zES38*vv2v9s!4(K-db$*Rp;& zrt5CK=O#Rp6fAoDwJ^6)aC^<{`9htO<--=+79aeVn|$fq1;>?*9~+mUz7e{TZd!w5 z#k0m%UK`uTJ2_#~`vsyA_Yd{buy4O{-XxtN$s^DGj583A{#S#?o#1lT{jQm3+>Ul1 z%|}+xFb?lidiaftjCSO)6D!Fpsgct-eDC?Qi+!ADU+DScd~EVRXJXY&Q>`8?cDu1Wzx?go=bNkFpLFUnrn8;XlcUo(Unc}v zG=7}O=&irl94HL)5dlf!)FxKP? zPF#H)((#jPtch3qyK~>oef{zo=fRj^K?@?#wn$w``I*>>ZzYY#7W5djab~08J2~x# zmS4;!O?cS*PIc_SP`k>Wa+7D@nm6%~`D_E#*mi47kG&nFH6)|4#~tU8gC_6Z$!ND$ zR=XXVXYS{k=4n)zTmCaaqb9^)QtYttH~Ncg4iDYZDYR?mFKch7*eI@GP1g)(g@FC* zj7M!uFX`*tFWGoZ+?fv_7xY9UqI4yN50}&|u~Uy;8kzg@z_YST7L6yLZSNEnb;Y7$ zUxnSGK~=S%Otv~5_&uc|ZI^5Ck%X{V@#0RyLGbKMJ?|8c$)Fy zgW(A?pMMKU9=&v@=Z=3;^4A>LShaYFU(bo^^PZdd-T5GR;Lwn`BJF7WmStso8U~Gt zoN%fAhkzbCVtb)qpk1lP!-*G^?)6c;bt&NE=V0no)tr!HJ*rnbqVeYYa?@CMubWw+?hlez1UvuQt&*N#v2*@D{CG=om_EB+Ry%R*x@_CQIZolKhaMbT zR(7d=b);bJS-A~IK8~6@`0lZRJEsqx_TlYz!9lw^d%@C!Hb)GMmuVJ6m8g}DniE#} zTC)}%iq@4B4Gj$}+wHKe`0lOlqx+v1*@bmz4EXVBLcoUH&NF6Jxq7du<9qm@E;Bo^ zH?=FJ{p-rKk#dfUzWz$dd-0nm{)UH1qFtJ-cKm-{?cxpL*WPZJd9g6~$Urr*@Eq^h z$_t05Jd&I>vx}R>T|VYhimhPGufdh0t~$uCS}YjadDUT!_WYZVA4E_OglJlft|WiV z_kv9po_9|zzZ{i*zwY^>z(>mGF8oP0SQ~Ofd@)coHb(jUw{w@bpFh8J;;F3-8Gm*! zvS=*pK3@6zC^X;~Z>fxSn`E`yl~!1m_2Br+i7$0e4wEb^JT}lH*eWQ0OS#B$S{$dk zVQJ3+Sp#1dN+L{`zpv9T_B^yKDCGOSPB zr(EtY6sPRT4J$ROY{=hhb9ujk|EXufhBJn`oeJc?8cs~#_VwwM;VJ3gAB}LlBd*x7 z)jmzj@B6K{mMgr-}LUUcl&(Y|&D z=BkAn&v#dcMJ+g|{(8>1-%~66>u)*i9ks6bp5+M5x&34;f%mq^!Sjq;)lK7C$(St- z5sG7Ot9t9)9sBxVy!qQPM~gnxNEW|0>u|PU6~|cTsl}YWbY$@Y{qv(g z*2=l-dArF7-b$nb=AFN|?0$Jo{@LN5C+=4%Uwm%+p1FzN(;fZQB~R6hD$6RP=g5U` zJhA%d=#8$G?kOMsIX&p7#qd>8Pd{(JKe^M9vT<0hg!Ox}3y0^S@~L@^Rt)CJ5st??!wy}afhQ6CA&L+e~)J7X#AT|ws&y>;{C*Em%5D&gM$}$SG+oH%*)jC36~{Q z_4C5=a9U$V@D3ssFz;@>SBH!9buVe^u2I~6aCy4fyt5#C^f|M>H|ru|DSo>vh6shwZD51H1I ziu)hAS&oAj6y1s__-R{GpEESV{Mgjq?>h`wxWig7$k)YeOGVt^x6#HP9aTmZ2!hm} z-$Sw9}e%@YnMEOSG=`f z66QsowT~<3tIt`hyhy!onq1obopqawIh@nr$%=lpFujW>q0H)(<`ftBBrz}SbJGcXaADXF6}s97azR!YtKVh z$+~`yyOj6t-FIN1fxCIa?$I`z`@24zS5@u(c@neSfX)HJ$*w<^%~L&|GH8&pp<1Mo zL8Hg_d3F1rN=C;Hnlf&i8}IVe`I%YDW>ngz#o0S=`DOcPZ{0Ys(Kz(aOmX#w<1sepODU+<{wtjv1< z{N95~XGinR244?;*3?0C#{}0lGPXw6h~wQ=QfnD@>Wjs-Jri*x$~PeTkp^J(zYKo+8<&MUkFmFYU-OzC#O=4Z1UM-k5)>P-o z4>iu$_ol31HxPPMDwxB2{nB*PWbZ3(qOt4i_ujKwcKz_Vgnfs<_V{)?rGKnSQT*=k z6%R`#^KOmv__{yZy8i{816lUStS&QSnt#Ni+I9!Jv4XLQ^JE2AA2%GiHRsmsqmk1R z+ZnvB;0}m=oz$y(zhTei{#|lb*e$yI$m{gLx8tl`e|X)WyifJB@qmf-yEf}7d|4*? zI3ubrEtgA=6UU_MA85PK&)+z+!or?4eG8kE<9yd9lyDlr zc-t_2`uqUlw%$RHx1Bz_H+70#Lg4|6An_53ep!lLjd|TiWb=B}t)v3G_cXmgRT*}6 zNb^=b{Aa|1K~7yIs-v7%yWjs_)Wt+~YkF0IuFakE{!Uj7;+H)gbA6Xq-0<4RiEFi1 zThj`26mioP5TSs1a~6+W>SXY8);-0|zmIhqs^NB(t^^xm)a`b~ro< z@*Fo(YtM!O9OGvX2M#mNd#|)0^77s%N@&d)W4Fvji3`H7k62t#CURTlns{=PrHo+m zW)=(Qb-gW@Tr1eH?qT%Ggq~5IcDwXAzRc|S62H`)>yICNa&613^1urlj(6_fE%rtJ z5hPjuVb5_b#|3WJDvej_DFvBmu44sbufVT($-z_3P<~>zI?;R1Gs~29H|OmTJoYU- z-+23$gO-_x*W+y-2f7BEoV+b}=d?inP~SHhwo8;x$tjAurss?`({8*oHkXl0&--VI zP(1(h#;=sO+n^-p+FKmHVEig4(*xRbO0tjq8QJ&hin*7KW~mM{(%Lfc;@oTbAvs%p z|0Hynd4GcSPwuw?{E2BDy|*A{oM7htq9(y0S7X7tkn)H14{tTtz01CDS|&WUL#JX? z`J*!JlHVt7ld`^z+EOFg&1>H+^7pbhvmIAZd4lQ-ho{}Y81xjZJ^0c6G@~&+P!!7w zeqHXksOZ?)io^f*a6geQoaXW*PjhXmNyk5GQ%WaqiW*uxp;OAggFDP@4Ba}fVDRAe z8`o@<3{VLZPqF*>Iw#PEU0Km`B_*^B&jUkN(p5$|<6d+GG-zb@j-&-#hTjQVIr4JW!O$3NEx4cgtD* zvU5@65aH3>@R)`lj-I++x_TNsuknM1KFAQ%S!V8v2t_^fkG`Ja+iUgq14~QBtAw`8 z4Ql8fu(t#ASkA~jFDn<_m%FpYLg_guPzIzp59;n&mJW~&=_qM_kZ?G-_=#}_OkHU zgN5^~ne8EW5zsBKm% zZ$I{=eeb-7^G^H@YBbOI7Bz9wy*XStjet!Lc;!#|$Jd=LeJgJ^KJObfd$Uc_8+ovMi^Q@Sl)BBa8AYw10+lvVSk$ zFzEXiNmo_-E+O^0F+oQw8YWCQUsrsOEVq+D=w(^Kx;5e5w%Ce(n&dg2|8?d=+>0yA zqZNiK@y2Xlbz@zhxRBa!jgzZvzWcN*2r?HPPtSbtBPq@G+?Dzl5yD$t=lG9e6~?RL zD?~1y|9Si)7XmL&(|lm^u6XarW4A9)@;R(>^2F{VOI?PWm%jd*p}T5I$|e)jP_5jW zj7K$=i!RN-udvA@aD(doDdT^C=|L36Bj!~i7|V4$dvk-req+6nUOPkvGiS`uUl*5M zI{l5qXT7d>7PWKE-8+?%OiVG1bo_pJ+_&tR39-ito;=t%aMoEx_skvTy||-kjhT7B zLRRqGj_v2q*03qJKe0D_dhq7muMhO_yPl}{edB}FfRmnj%OlY626P{uz4w(=u7v&?hsfq>j*fpe)qYy+n}0nky6-)g*Sq)Tf3`VV z4LA9uz31Zc##xUeCQ^16IT_ps8Vw7cF56Zgc4t+QWRAvlLqqS>Uqgjb!JO+vD7Hj~ zm(7O}{hGnVwtt*#62|McF7 zO@~voN6o(t6}eJVN#yF z+;a1y{db~|_EBL9sPwd)1 z$uDmjxj8(4an|zPsSL^1$U2R<&+-l#^~=(DD}CPv@Y&~Z_=EF zM}x~>e0bOU)7=ef3;m0?Ce?+N_dG4=F=|FDyDGt-oxo%ul@S9dR5NL8HS5eTnhHx{Qb~1cyd~B zK%iS_-n)}43QO}H8vX6$?Y;@}o;{b5i@*3H2k+0fH%n(ozCFLzOS>lW!NKu=)ZGF* ziFB$S25-M;rWSB;pIdy+g)*DT4$AWy+)saL2%n>pAGPl0`0Hx|CwXsrad*6o;A$cj zFwb1wLwU^H<1@-T-^}#o4$#d@TT@$j=BMxUCGytXb1{!cqy=?ZF`?FNt3`CRB<#er z-(^?(-Y(w#OIzQnRRcEoZFJDneE^E_pZn%hjYL6veH!86Ze>xe=Fd);pK;> zHIual{64H(tsfV$Th=G;5pA*Dh@D+dUElgp@1e@5<%^H5rxZS3f3K%L=~rD_DH|%>C=VjVBk%RlQi#<=e@%u2jcG2MwK^6N0}@?K!_|*o~Be zp9c<|v!+~^x?cIbX5i&sx5qi0S^6zyjrGtqqnO1jGh+TDE7y{z{>5~zC}YjAb}K4l zEmFd=i|1&jwd*EHA7;>Zh1u3u+Wdg0e=>^Y?bTQ23_7^SW&X#iW$O*JEiEUmb9J3; zV9=y7hx1TY@H7AXyIv#x@A=xNC-qkMTy(Vi%?)|)F9!LBC;9CewKjN)-0_(7GZm2< zqi^^%6m{7VwdMv_ZHJ`F?&D2|ho4u9Wi`ehK$3%36eOqqZtjLU%~KN=b$&Y5Raki? zSxI+$`t7$3nv=ROm^I>XS-&3RE*MT7zxw?3yy;h#93L;Jb2I;=6EGzrB{hQCA!RJ_ z7&Xowjw)Q0s3)i<)QhS?^!udHG65&O?K$&o^L*L;15_v{? zQS~Sr&aV&^1e`O3jXrZVqHQAMoclcc^j$r$=`7$;IsB0D0REbpx=L(1C#KQ1-!fIb3Bs9b?OL! z54`%odGL1%_!C}yTtn&^ni4$CV-Eflu>$};@C=P<_+vi&u^&G09D69Kz-u5!-eZVfU%li0sRDM0Qd%gmHq(m5#Texzxb`&C%C{(@*dy|00jl>0DA*W z1DFah8DKoX1OP99(Ey$RqX0$%3;`GhFdSeIfCqpZKyQFP04@OFarjS1IQ9VGm-uh< z;LllL)Ws`n+z#ac_{j^mBW{CZ0EYpN0N|%6yg*6OVt^umvjBwvX8=wE zoB}urPym2i8Q%bRwn_kO8EhqDu`0O6gWW9vJUHG1Ak8bmroIbM1AzTt5dijsCjid? zo&sR{V*t#10sv!8)9CjRuHOK>1gHae0q`845ugF!9l%=v%)|LG|24oXfO_cI^u>Ef z;7pi^2em+X3eKRlkV2+=yNt$8+5tw4(aO7**e9DL8Mnj6>j)GTz^Z^rFbLyzScWc8 zMzI_c3&pq{7ldMt9)%M3>T=B}v}x~5u8>-{PnTMbhMf}VXF?7EmUhqe=y;*?b1vm< z)zjI^4(yFa>=Qs>hp;o>BTs4hf#2+0!YljvIi-Z$--nf=t2&gOu%jzk$*_7MCcC0Bsn zWa&YK18dN0D>BIzP+7?5q&D)*_M}cD@1p|hGAhirhZ;7*`8n#c-yW7+xp@Ku5<<&} zTRYwJ-MdJTZVMH#a)5YfXw80Iu8@ba_S;c0=mC_Ok5mr`ATEIA0ec9OF%7OU9aB=t zwLXcPHmT^H{MJWrEkI&VD;r$zYV;U#-9X=<^iL!_DBvpa(cpu_^+^OY>wW!-Uo7#R zPgl{_$_B<5lyh(d<$``eW`_<)_mCZxgN7X<^_zRBqp6!R6WfRcJKNvAALrxM-Ux&^ zSlMH9VmoQeJ455EplgS8v7ino2ts;?M;erYlVLw|>-cz6uOdaPfF0-z3P4D-#fR-^ zBWm+N)(BL2SU?4!!s7zO%W-gQvvuroNfI6HrqT=Fxky;TRLTVug7y#3u>&EQIRY-@ z3=l-5D#u>`@2!!Ayb8NhBhjcMjogkrv?E`a3ywZDS683JyEC3O^hmVU&|>;NE>`xi z0KuL?x4FQ81gW!DRrEDV&3naY1I>)Jxp7Qap9D4Al5Tc(1lsp6WN@}}z!lk!)Q<|d ztWRQF3K*Z_G9IE&qN<4#>*J#*)$D_k_Ke}N(+Tw3^m7N?Fj=SuT(chiIQn0?IqlZ^ zZFVQR8{m$pBWKHDx+UFdXy>-awuP!OhDN%_^jOP*u}2&ewB@L_R<>47*ijc8!yReM zF;>QA>MIJ#w7i5kc3cyE^e$glp9G70^VRpEx2B>4)F14IEq4Z)9Jg4ZI1h*qBva61^G ztP{ik>N<9u-$=DUz*RIx#%FZ7TE=MJNnLIaV>J4-uKqA%Cd`+{t+HD0DMm1Qkr6Qz zr4|UZ+8P7V*#cuM=1GC>;MV2AY3L!ft&+^>*%k-6oiwJ0ECH(^6^u5VwAUwr?Z(e| z^XL0opUZQtBX zOnGK{F#GSPhh`>5yfb=4O@_g$Nl!NaV`=&%Mq<#W&#d$UneU&+m>w!oSs(B zcTAB?uE(se)qE-RNPGaSP z#gV6Q66lIc;R?FIssV!8;RnSe5=%?UAyMi`kSzK}biXD+5lKuongeqXJQ{*DygDLL z*til}Z&1&U1VAM5*Q7Th;qyrNE-8luNhC4aq#P1fkwkTqa!5c%CLo+-NQ9I{c0foB zIcy*>D(FqZJ(6g1G>0}&aEyOM7})8+g1Y0W)d4-VsBVzLM@t`+a$+o))mXb>7bO`7 z?>EfVYBD#8SD9)(d9`_dUl%QEK4jJeldT1er*(BRBh7JJf4-EK$pDTTaH3MrMVt2s zNRZx$M5`ph@<43|E9eKXu4Sc3NIX4YK}|7>guEmX_kiQXaQ@z|B-$m3wny8Cmc|Ce zOd|34xD=4FZ!>O>A1Ye=eJeL<@{-mKK^&C?)?@?wrGpB#P*+&0{k?zjEa3jIIc$b#Ua6sL^ke7$U<~D3A?0A-6pxgI zL*!A3xUYb6p<)P63diO?H(y>>mU}OC#~2@mGp2693iZ^$*}%c=kB^wxgwQxB=DOX- zUZvlXVrijRF$A-XhNrZJlmF~9rTWt~qStaUaRCtMHimPu!tZvG{H4RvGJo$yF+yel zN-T(rx#V1BCjoKLs(9Fv01NwqPFjzIhlhT>l8Pgnog}m)-4L)Ex8;!Nt0ed%aPZI# zgDuaBkw(Hp(j4$r2%%}s#6hLQEJ`5GA`iL*XaJsS&ay}(Mk$8`OC_-yr5qAYl@8|! z(Hx~55^$9Se3WuXNLHHx5+)|3l(hg<&;%=xsF6|*9oQ8(B(9{CLqflj$dghI2@XqQ zQ%X5(_*h92312B?kqEOSz@?N!V$hNhm{JajQcHqlN;xEsEeWG3<={xS5-q3^iL@zY zkzlx&*jSuW4hfe_qIF6+B%m&d=PBiokh@IC&si6xoGlX20oE@`Br2$sMdJ37xS>)G ziR?=vi%K~pR(8MX+%vZu6d!XbaBRFz`CE6jgZ@Xt*OKU&JPPIrx+jwWwZQT@vV1T4)d`Zv^%sp+Kb^60Mg63zc$6JYNz{ zRLUU{e_yZxM|qTl)RhDU#z9GOOj2o~I#9tFR3KqXr5qAim;^SJa!BZ568coi!O@93 zfiyNaszgu9A_0s^{8TB2gg9QvMp%_{NDyQagH_5QVUkIdRw;)BQpRyy-EkmSDT~Bf zCZS%X91@M01cQ}w=p6~{$9Uz5!yS`wtMo8Q&-H&_9RGD<L7SeLs+gnAU|8Z-rsPS4)--EHp^I-jkTBkUE6(2OEo?nM!@ zDb_V0!Kly9f3c(YhS7wP(}hIAKE0z}SO3Cl zW^)y^nz<0tYkI5LfH`K$uS~ zkrEd)-4-6xt!#|v%`@)T*A74O*bt^~bVNL?Xx+sA0b(pIHb_29Y&|OsnLIIcW6cDn z2qY#G!p?;)Y~^8l#0UDs#s>()g2KeO+VNuFApcOIUqm?kL_$nZM0gBRzo2i~nhf$< z6NOI5^NFcx3z;};^ScP`aXxMGFbNsRrO?3&MLp`=om8!vF(6;FG}Ef6q{_%prnw-O zOiBoAab)t?q)!_brxk0}4j>gjw}NeAV!;7@0Z`)uLml=S3N_J%&Dr4VtnZ%{bh zebJL=o#+cFK=)dhrtsqplk{7)csQ>c zVfqBk7Dh(;IXW~^;3t3LW($3y!&*>g%jAiP3y+8q%2MKk!tL#vQ6&C8(LilRV`K;U z*xNM;4;RP!#`rhO#mH)w%O~7F8h%)$nOcMF(}mEny4s41}agqIhD~UG$JBAKt?V@jSh=%>}(-)nXEFYzh{M;f>e?fQZiZ1WbKN(E98{+ z-PReUT^%w?6Rk5c^<^bW6Rk5!hZvA3O~_>IEUP0^Z$?G9Zk?@ZTmx}U*R8WLDlyWU zuK$*eu^^MJx&3-Fx-|{KaMPx1RvI3w{exm+t??|t?48nMJ97yImF>)Spx~okYt2xGlLAjjY=|iV7mU_ ziqj)X8}_3cRKiTFZM7Np>((-*KHo-#Nn=9MHf`Bjq#gV;7W~v6UMOH@8&$+(W*~S+ zM0f(8v>hGrB<0#%;Z}L1{@jX1dpKT&_4=!dSPQ9y=GrwCcm8W}Fj`xsf!tKMrFmow zCoL*NY%17N>E_KLE!&*lRJ^6q(rnF?Zboh@Y}ca3(qU41Un#ArSf3Wf7<)E(rL=4d z#(yKA$?01PXks)87|^B$q}#RC+OoVRF>e2*m}UlKszRj5wtU+{WX6b=)sbN}Y0}c9 zOnc#JPKbM@R749#lK_u4tAZyLYz=0plS*M~)12ERYT#c*LAC!bsfpPns9y^~EeCL^ zJ}o(a715+PJG4kewB$62XlYv6jBPYAJtE;@vPnS8E=GDWol~knGjdbmmNsD8kGV-( zEi2_TiD-F$8Dr7wI%eb)HX}C`c5KlGvXj14cUfvvQT=95&yZOrjY{5j)gce{u+s%x zI7^?`JK`-9hR$BW(~dkT%q|(drG~c;I%0#x+t#!KXM2> zvf~c7rt!9sC2e=?wG}y@Iy2MLt(LYct<20fV^! zSHd4Ip^`Vo=+{d}6kgpSFTcKAUTGr!s|~!N3>(JF8_e{{7suosz!8>jJdZ->8U~@+ zot;snmIZQauc$N{|Fs7$3%unGT~OeAHO;1SEbkKVhJ0{>J$ZbP@0(x2wJvO1lW&*( z`p5PU9ThNE7ys=D924i$r99~beZt=#leZ4!JqCH#FdcH>P4KXY=*U3WI3_<~Nxsrb zCQ=jW@2=tHIrBx{2hS(rU2~n%Iru#Uymqn0UpdonK!_DMJVT)1mx_Im(udY>NZ_jq zyj=@usW)J-)}8+cf^3t8 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/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 index 55c71df72..1ead29418 100644 --- a/src/js/packages/@reactpy/client/src/event-proxy.ts +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -1,9 +1,9 @@ /** * Live ("event proxy") access to the objects which triggered a ReactPy event. * - * When a VDOM event handler is flagged with ``eventProxy``, the client keeps a short - * lived reference to the object which was dispatched (usually a JavaScript ``Event``) - * and tags the snapshot it sends to the server with an identifier: + * Every object dispatched by a VDOM event handler is registered here, so the server + * can reach back into whatever actually triggered the event. What the client sends + * alongside the ``layout-event`` is the serializable half of that object plus an id: * * ```js * { type: "click", target: { tagName: "INPUT" }, __reactpy_event__: { id: "..." } } @@ -75,8 +75,8 @@ export class ProxyAccessError extends Error {} /** * 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 - the - * snapshot the client sends alongside already contains those. + * data properties (``value``, ``checked``, ``key``, ``className``, ...) are fine - they + * are what the snapshot the client sends up front is made of. */ const BLOCKED_MEMBERS = new Set([ // reaching the rest of the document, or the browser itself @@ -277,6 +277,36 @@ export function serializeValue( return result; } +/** + * Replace every reference in a serialized value with ``null``. + * + * ``serializeValue`` leans on references for whatever JSON cannot carry - a function, + * a DOM node, something too deep or already seen. Those are only worth sending to a + * server which can ask about them afterwards, which in turn needs the object to still + * be held here. A snapshot taken for a handler which never asked for live access has + * nothing behind it, so the references are emptied rather than handed over as + * something nobody will ever be able to answer. + */ +export function dropReferences(value: any): any { + if (Array.isArray(value)) return value.map(dropReferences); + if (value === null || typeof value !== "object") return value; + if (readReference(value)) return null; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, dropReferences(item)]), + ); +} + +/** + * Serialize an event for a handler which will not be reaching back into the page. + * + * The object is not registered, so dispatching it costs the page nothing to remember, + * and the snapshot carries no references which could only be answered by a lookup that + * would never succeed. The empty event id is never resolved against the registry. + */ +export function serializeSnapshot(value: any): any { + return dropReferences(serializeValue(value, "")); +} + /** Read a single property of a live object, refusing anything blocked */ export function readProperty(node: any, name: string): any { if (isBlockedMember(name)) 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/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 1deec1595..7a7daf86d 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -62,14 +62,9 @@ export type ReactPyVdomEventHandler = { preventDefault?: boolean; stopPropagation?: boolean; stopImmediatePropagation?: boolean; - /** - * Keep the dispatched object around so the server can read properties from, and call - * methods on, whatever is actually in the page. Sent by the server when one of its - * handlers is able to make use of it. - */ - eventProxy?: boolean; debounce?: number; throttle?: number; + eventProxy?: boolean; }; export type ReactPyVdomImportSource = { diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index c00e84ff4..8b13474d4 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,6 +1,9 @@ -import eventToObject from "event-to-object"; import { Fragment } from "preact"; -import { EVENT_MARKER_KEY } from "./event-proxy"; +import { + EVENT_MARKER_KEY, + serializeSnapshot, + serializeValue, +} from "./event-proxy"; import { HANDLER_DEBOUNCE, HANDLER_MARKER, @@ -246,33 +249,40 @@ function createEventHandler( event.stopImmediatePropagation(); } - // Convert JavaScript objects to plain JSON, if needed + // Whatever came out of the page can be kept alive by the registry so the server + // can read any property it likes and call methods such as "focus()" on it. Only + // the serializable half of the object goes over the wire, alongside a marker + // which the server turns back into a live proxy for the rest. + // + // That is only done for a handler which asked for it. Remembering an event costs + // the page memory, and an event nobody intends to look behind is memory spent + // for nothing; without the marker the server hands the handler the snapshot and + // leaves it at that. if (typeof event === "object" && event !== null) { - const snapshot = eventToObject(event); - if ( - eventProxy && - snapshot && - typeof snapshot === "object" && - !(EVENT_MARKER_KEY in snapshot) - ) { - // Hand the server a handle on the real object, not just on what could be - // squeezed into a snapshot, so it can read whatever it needs and call - // methods such as "focus()" on it. + if (eventProxy) { + const id = client.eventProxy.register(event); return { - ...snapshot, - [EVENT_MARKER_KEY]: { id: client.eventProxy.register(event) }, + ...serializeValue(event, id), + [EVENT_MARKER_KEY]: { id }, }; } - return snapshot; + return serializeSnapshot(event); } else { return event; } }); 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 index 5c695ce42..9aa2e6bf7 100644 --- a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -15,11 +15,13 @@ import { ProxyAccessError, ProxyNotFoundError, collectProperties, + dropReferences, isBlockedMember, readPath, readProperty, readReference, reference, + serializeSnapshot, serializeValue, } from "../src/event-proxy"; @@ -575,3 +577,27 @@ test("handleMessage only answers proxy requests, and only when asked", () => { value: "click", }); }); + +test("serializeSnapshot hands over data and nothing which needs asking about", () => { + // A handler which never asked to reach back into the page gets the serializable half + // of the event, and no handle which could only be answered by a lookup that would + // fail - the object was never registered here in the first place. + const loop: Record = { type: "click", value: "typed" }; + loop["again"] = loop; + + expect(serializeSnapshot(loop)).toEqual({ + type: "click", + value: "typed", + again: null, + }); +}); + +test("dropReferences empties references and leaves data alone", () => { + expect( + dropReferences({ + a: 1, + b: [2, reference("e", ["x"])], + c: { d: null }, + }), + ).toEqual({ a: 1, b: [2, null], c: { d: null } }); +}); 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 1c6a0e669cb074fc10785e56d1709479593d1e88..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17949 zcmeHO2{@G9+aH>;g+#kTl9U;Yv6ZD%La3~zlw}4}W`-GKi9}koNV27@p+(6Xp|tOd zw?ZmQk)qZ1MyZs(b7tn4$J_faUf=(^zVEs^*Y(VK&biO;ckXjP=bY!9GtaH8q`_gc zG~8(n4Jso*(VfE(2N%JI>9Nj}=0hcTGFfyte?ow!xG)ZflZj27S9!c~>9upxNo7uv z2NvAc7e8-5U8{YJYh7U8%z4oZK_L({co9zYZ*e$3-AUx%a3g6PE_^Du8wp}iAJxsn z8^;OYaAj;a3KibaSu}2Xoe!160(TUerzgyyQ2jV=J~-TXa4!z<{%ktT#!=bs9GVXW zCx*x2#)ACLVZ68=#A87EHa|YakJI__4t^W}VmVOGogXg&u`Ea%^5dBx9u3mt`SA#T z+y%@8^PltM3J}YH^d)|r&yUkVJPPE;@Z*jA*oR4RV|(Lpz$Q2`AWx9s@ofb$v{N8I z_5d+#Zy7Kev>TnDwv*)LBb+~rtPAb2f-m>xS`&xkjL6(Hf6I~Ryj2IPC0*i0X0mLX zSH?8at8bP)6AiKGNM$^HY<>01$rD<-2IrNYlsFk}+U0U)@txN}0aejf+b7n$lI^#V zUqq#=gsacmLUk{i@35|9=F(TAl#S*GigcDNy2sio)ww6^m>JE5WulrQ`GKusBg0I0 z_z)Fb`)ycEVcqO8qEGZkm(P1YW%2OPF{UGG!cK~W$dc6)dyF)WcHa_Nnq>Bxe&wyJ zP@wS6dlWWJ)qZCe?tu?S2G<;LsmJ$as-8RP+?3~q>CyGs zU&}gnmu<7wOL}%<*d=qC`C8AV{#NqlZgCQYnZ*s(;!JV&1kQ-85wjq+Cw^&cVJY|qB(=7Q%A6C=Qd@?rXC0hMEbLZD-Wqcr3gNI>PYL1CanIK&JG*I@CL0HX=; z&>nD>7^q!P{lWkM>xVd$2X22EP+3g?Bmq2_9=LVGHVh;nygR@{{UHwJ4FY+7I##6bltZ^)CW9`T!5};9Ldfya5G-R{(~090HyL z@YX}XUjcagA>hY?2R{`2+9BdE3=uyX4A`OQ?+Ng>L$KdzfVUU|UPuIoTRH^%vLWI# z0p4Z^^|ucZZv+PYQ0m_~M10K<@v2~PV>JZ(v4@DiI7GYzSbSLyL4SuK;?swSe*^IF z{ST%=xITqz;sJ%e`5UY!X9GNZ_W)B4I3WCCfQQc?41+-hgzp4+Gk_n=y9-u?@CIPf z4Dl#EP$f|LjQ|hdzYqtevH`zC5&kj2&*j$-Ugdpyf!m)3gdYdi`LKSN9*hkU-i42c z>zBaxFF?Z2ijsULDjw7~3PA z;PZwT#3I|n7>*^-ExGyN@p8jJV#ou`0r#mmWiIOfXAEsHldnHEhV7dLE*Jxwa|4Xw zvn29k&=Zubk2lOEv|G?#Uio9`kuwkOulSTXvt8|u<2W;heV_7+yGcpn$)AG*B$z z!)j-fxNx;gO6w*s72f&y@{zQMUwo8Cl%;>Sl~VhI^`K#Rh(hAJ-le3M9S>@4y?V~c z#7H}fn2@kcXeCX>-ykO-iz$rd%8vR)GZ>`iZVXwnHJpS zj5#XVGLF2(&Qn$?_hD&?nMjk^`6tQ3Z^)g;u=>KWhyp(Ag-y@(V`kIvvN1jBc#F+X z7da<*PG@i)?O9|eTUMMZv~+V}{6wn3*wM*)D&j1rXo=0w%%@IrizpH^G`J?2!e+UsTyR#vgLg&!4v z_R1zQrb>yh>Y(DCipxe$4NVwcxK2RaEG6>1B->q7y0MC3PxR)=rU9mdVo5S{*?kNzd_SRpzu6U1qn22IZHpO$eWE~F zcvRXv#jyv2O2pl+9qpP$*(|Fa>>&0yGf*PxXjWb*;dtT~m-yn+YR?&Z*G?{>zkCA* z8K8#enD7j;FW^ZwF^5NX-Vc~WT2n(gA5)n}Pu-E(TU+hru!h__M`8nOiRrnLx;b7p zJ*IzL>XnFpx7bWKb?Y45p0xBx_ujheTzz@G@V$rvUL)$Enue~+a^KpJPNMXOF15wZ zlBBw`Ig2D>+MGxl8IzW|277-}af$x3ZpHr7o{%Ec7LRmP zb-D8AZb#qh^UB2AIzelNUeOa>F#5u?5EStAyiEqV!(R!1u`hN^x0&_{mmHlI?H;tt z0-u*zVX4sB_C}sZsMX?H+iCL};pJC9yBw;PQrZ?F(&;0-m|B zNpHvE4^(1&SM4|3Z8v=+#3gPCoz}$39G-DQ=YzK;rK`6?-i=yY_}YBhk*UprMW1sO z_f@Z{3rmno^jp!*w*wp#aP5x*e$kgP`OV!n=3$GMZ*yJA_E0Dl%Fnng%eHGrJ)a;C(O4HKgk8uwVKfU*tsdiGpx=qVfEu^=dz3AFDmhtv+>9>ey z_9>>?l-$s$oOPywaf{T$0>9cDCAPI@-{so@^1`(u3iww$j=eWGd8@YSdc!HFpbaO; zpK`sQl&i{XIUL*7II=fZ-fm9ln!IhHa%t4AkE3QG${la;{}~kUS+5F+t-GREyzdPOlU*E>BES4} z?Bxp+4bQI=u60;nCDn0RXH&g_WS!)f-Q~u2zS&1?7crZ$P6xc*_`P=h6v!N^`=c=j^^27qFq? z9WLuHy_TB|((6y#=6IZoN{{j9&pV=UTuj9BS{tm!1k$1ATk1Bt9wC-e&vWus8B(pv6 zp-f0X+>D@E+s|(dv#&a#=W=(?gkbhCN%8KPaxDqUv$?#&+<9j*mUn4b&qoQu!ZWsw zxua`yt&&2OBa6r}Eg5J1vXq)$r>IJ{KVoKgr#+?+(?gyFj_GYEJfpsf-SDt}rMnSx z`k(fEec?Esg5|Ao)hMiI7;`L)Uy7T|n|bqDbFc2clP?!6TkxSk->8-3{5+iQBYfC6 zIFK1&$_!IkHzd+KA zzeMZuUWGZT2+MzFoueNPjh*D67-5_L~;@0ZU%-u+f_jM))#waD;R{Dv|C0vc@y_7d=j+DVyE-ET8`AiR{_)T!MHodYgX%h zxSKKV<61Yxt*<3ZbM8{4YYz6fk*)Wyi;#T5IhN5X)S2R7sXL9q<>jrbr($_8(uT>j zg(SR^Ep?2X`ohkHP*D~oF+MH1sl4?E)$MOgUgZ#d{eI!m=eb;?1yoZ67kbFU?a3 zdHLqD;@Whz{Z>6UoZd~#ejlLZxWhnfYjW9~K-WptMU!kk=Z;7`z9h0Ew51}!tGn$j zhF1m4o7NCr^W;|P!)4d-evg8b3^Vf|%RG;@xvBQJHuR_j`SjrckE(CC0_`?f>nm*? z*6nF@`@q8$-l44;DH-ww^cmkUywkC~hoikCRd?B*(~I^}nM^5S<(YqrZa|D70cV$k=_18ee+E= zDfO6wO|Emng6A_;j+{1Kx^ef8=Zbdiu_8;(KdYIqao71&+l@78jHSoYvKITv*D+_SCWiQ-i&m3?DDRzxzfC? zmZj%9?3&joAHTEb|BRhtEzcBBF+Ck$jNw(o^0FhAZLqk=DH-ckWc+X~_-nmD?qW&W z(*v@S2A99u-hcKeXWi6dvAq%73u>w+ubjH+aemOK!ndUR)pr*}XOUVvFudse0Mg@6 zW*W4G9KB(a|0F=~)$2}?5fdv5_LeQI=!ic_`Km~~@V?Mg=$=e|l;lS1dE1GUgxvdT z%yQLp;Yp)kY$NWkUV`C;=MX61tM{ljnBW%WzL<6G(EA+~66PD}w>L^1>?x9cIw4K8 zYR1!XjT1G`?7JrOe)aR6oiVG=T)n=v!~0s9LPF<(_g2o>^*TJyKmo7E*;Vj1;oVWY zhPlU9J-QY%WrVDl_tlUuCoW7EEBkWjOv4qqQ;*Il-g5FVEMFe@=v_;xlhc~b`zPy! z2EVQLa?Qu+OF*O`#=DuPpX9Xd^=r1hofA2oYc;oF|C%dZ4=-_(7oe1oZXDZu^3 zmibyz-$Okp(b3db1*K}lc2iuS^n`E zBJ*Qeql795ifU)J?Al*kc_lJ{w6Q0yF{j~{Sn8Dbw{$6Q+#g?2IudAWv%LJyB)~hA z0Qc(Xdk^p-3UMuaC~O1A!uJpiD2De?7xWDY(go(B?@0_n0_>r}LDui^+XDaREdcv2 z`sN3I$V{tst>qmkM{%;1) zEychEb%g)V!M!fj2cBgrfeW6U!*fD-o(0b#;h8r)--G|L!2d(we*y5Eb}G2w`5Zh; zgXgR8EFYd@OM?rZJHs<#MR39YlHfiPHZh3bK7B2a0O42dgl&QKKwY8Eu&t=wupO{H zunnmFurAmJMQ}mApngzy*dEvx*hZ);)F0{x^@KXZHo|tpHo*45w!?P7_QG~U+rc)# z=LPKv?F#J+?F?;>Yz)sM;M0cogm(4jx6@n~BtQ^1TuIV(;pl^2qucjvwFJEe)(P4f zj=nD!6JWjJ(AuE2;pmHhVJOihl5jZWbAfySfP_TUB$5T*73gb!z65B1{4bC{ z0FdYa43HoX4dgKZBxE1~x*=Z<qkKLLT{5*Ui0=Y%6q0)Yhib|7B^fdqMf zAa4YL1o?*`e+7XAd5|EF27v_mm>{1AfdqM>Ag>641o^2TzX^c^d9onS3V{Uqx**>R zfdqNOAny!;1o_J#{|$izdDI{e4}k>v+#nwifdqNwATJSt1o`bCKN5iidG;Vr6M+Qz z{vclzfduV>kyi${zkxn4@RLD)8(azKZa5tBxIrEu+@7e*?Y&@ff};`nf&dAL7(@)h z;gGiu@*d$zxT6I5^C15cAORyAYJfa^t>@xT@V|06yKUCj`BNxJs#jEx3&(9)$cw@I2+Yk| z|1>po3s3{741a<@lfZOeOZD*gcJuWOCQz7k|6pG#+k-{(^ZJzp!_=moQrjTfda>; z00DkM9bmTqMQJz@gW@nEXo2^J)-TF~=@}FUzo0bK=I1Toz5qaZZcG60GN94V5%6UK z05Brx44?*{(;q}|z5lot(uQ^LdI_&*{KUqBhYC0Z2~an9hyFrW@KS~#HXYOo`#!%` z{(I^tmGVmfF#irbD)=JPhZHEwhsNLp5wuDD)4F7oW^))!HbIc4xUm9h3`_=(z>P&m z2nN;PolU`%aAQzdOd2-F6RdXomtp!+8U1rWEfi)T8>1S^5h&AF_usSF+6J0IbNL!+ zAO$c=@|yv21W-&dK_*hQUrAoQzP=v1WFIPOH!qV#V|xS$;7DgKyiXxsQ6DCg;Uy@_ z%cRqM*^u?u&y~<0fO*XQ1=y$l6V`uL4zTu9c7SBaCjFX^(Xe0sAUS#Z@a>G@?3e$q zIQb9s2Tp$eAUXT>91Lf_{6TW|>!KLWe))ss#0~`vB{qA2jD1}QqYhxiqy|WYx)1M> zatAX;GfeKk;^U6RANaVrgXBZqSeJ~U6J-8NHf_>BuxXP9$tIXlF>Hd&L9(Gv+pnd9 z%t5lD9?_3YkU2=Uz84Orp?!ISU&jUxcc%2$%S6S7%G%CNG4uu z`dJ=j{VS$^y_Lr_;J(T`i2!Zl-N29I%p`C(Q@?1_-}Hb)JVlXa1n#zHU@WL{0v43RUAX>gIV~_7AP^)#?*My0sDZJO zA_*+ANs5MwT&>t{o>UgWo#P3jpV$jq3@~GpKrgt^2i!lw3mzgs3labeEex=HlW|x+ zKVcCR5|{@dz)~D;Ie)Ifj}I4e{Lp8(&+1pb818ui8b-jfV6*#+x&_@Dpaco9K>|zq XUtqoA=F1yyz4)6c-WK)$-tYea;wMJ* 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 index 8de8d8307..a13ef5550 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -1,14 +1,14 @@ """Client-backed ("live") proxies for browser events. Historically an event handler was handed a plain :class:`reactpy.types.Event` - a -snapshot of the JavaScript ``Event`` object built by the ``event-to-object`` package. -Snapshots are cheap and never block, but they are frozen at the moment the event was -dispatched: methods such as ``focus()`` or ``composedPath()`` cannot be called from -Python, and any property that the snapshot left out is simply gone. +snapshot of the JavaScript ``Event`` object. Snapshots are cheap and never block, but +they are frozen at the moment the event was dispatched: methods such as ``focus()`` or +``composedPath()`` cannot be called from Python, and any property that the snapshot left +out is simply gone. This module implements the machinery which lets a handler reach *live* into the browser -instead. When the client supports it, each item of a ``layout-event`` message carries a -small marker next to the usual snapshot:: +instead. Alongside the snapshot it serialized, the client marks every object it kept +alive with an id:: { "type": "layout-event", @@ -413,7 +413,7 @@ def serialize_argument(value: Any) -> Any: except (TypeError, ValueError) as error: msg = f"cannot pass {value!r} to the client: {error}" raise EventProxyAccessError(msg) from error - return value + return value # nocov - json.dumps only accepts types already handled above def deserialize_value( 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 cc36a6bf6..e18ff104b 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -84,9 +84,9 @@ def my_callback(*data): ... stop_immediate_propagation: Also block any other listener of the same event in the browser. event_proxy: - Whether this handler receives a live, client backed event object. Defaults - to :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. Pass ``False`` for - handlers which never reach back into the browser. + Keep the browser object this event fired from alive so the handler can + read past the snapshot which arrived with the event. See + :attr:`BaseEventHandler.event_proxy`. """ def setup(function: Callable[..., Any]) -> EventHandler: @@ -124,7 +124,8 @@ class EventHandler(BaseEventHandler): stop_immediate_propagation: Also block any other listener of the same event in the browser. event_proxy: - Whether this handler is handed a live, client backed event object. See + Keep the browser object this event fired from alive so the handler can + read past the snapshot which arrived with the event. See :attr:`BaseEventHandler.event_proxy`. """ @@ -145,8 +146,8 @@ def __init__( self.stop_immediate_propagation = stop_immediate_propagation self.debounce = debounce self.throttle = throttle - self.event_proxy = event_proxy self.target = target + self.event_proxy = event_proxy # Check if our `preventDefault` or `stopPropagation` methods were called # by inspecting the function's bytecode @@ -264,8 +265,7 @@ def merge_event_handlers( :attr:`~reactpy.core.proto.EventHandlerType.stop_propagation`, :attr:`~reactpy.core.proto.EventHandlerType.prevent_default`, :attr:`~reactpy.core.proto.EventHandlerType.debounce`, - :attr:`~reactpy.core.proto.EventHandlerType.throttle`, or - :attr:`~reactpy.types.BaseEventHandler.event_proxy` attributes. + :attr:`~reactpy.core.proto.EventHandlerType.throttle` attributes. """ if not event_handlers: msg = "No event handlers to merge" @@ -283,8 +283,6 @@ def merge_event_handlers( stop_immediate_propagation = getattr( first_handler, "stop_immediate_propagation", False ) - event_proxy = getattr(first_handler, "event_proxy", None) - for handler in event_handlers: if ( handler.stop_propagation != stop_propagation @@ -294,15 +292,22 @@ def merge_event_handlers( or handler.target != target or getattr(handler, "stop_immediate_propagation", False) != stop_immediate_propagation - or getattr(handler, "event_proxy", None) != event_proxy ): msg = ( "Cannot merge handlers - 'stop_propagation', 'prevent_default', " - "'debounce', 'throttle', 'target', 'stop_immediate_propagation' or " - "'event_proxy' mismatch." + "'debounce', 'throttle', 'target', or " + "'stop_immediate_propagation' mismatch." ) raise ValueError(msg) + # Asking the client to keep the originating object alive is not something the + # handlers can disagree about: if any of them wants it, the merged handler asks + # for it, and the others simply receive objects they never read past. + wants_proxy = [getattr(handler, "event_proxy", None) for handler in event_handlers] + event_proxy: bool | None = ( + True if True in wants_proxy else False if False in wants_proxy else None + ) + return EventHandler( merge_event_handler_funcs([h.function for h in event_handlers]), stop_propagation, diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 9b1ac880b..e941dda4c 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -34,6 +34,7 @@ EVENT_PROXY_RESPONSE_TYPE, EventArguments, EventProxyBackend, + event_marker_id, ) from reactpy.config import ( REACTPY_ASYNC_RENDERING, @@ -91,6 +92,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 @@ -99,6 +106,19 @@ 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() @@ -114,6 +134,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] @@ -142,6 +169,9 @@ 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""" @@ -203,18 +233,19 @@ def _make_event_arguments( ) -> EventArguments: """Wrap raw event data for the given handler - Handlers which ask for it (or are given it by default) receive live, - client-backed event objects. Everything else gets the plain snapshot. + A handler which asks for live access is given it, and one which declines is + given the snapshot and nothing else. With no preference stated the data + decides: the client marks whatever it has agreed to keep hold of, and marked + data comes back as a live object. A client which has gone away cannot answer + for that mark either way, because a backend without a sender reports itself + unavailable and the mark is dropped with the rest of the overhead. """ want_proxy = getattr(handler, "event_proxy", None) if want_proxy is None: - want_proxy = REACTPY_ENABLE_EVENT_PROXY.current - if not want_proxy: - from reactpy._event_proxy import build_event_args - - args, _ = build_event_args(data, None, prefer_async=True) - return EventArguments(args) - return EventArguments(data, self._event_proxy_backend) + want_proxy = REACTPY_ENABLE_EVENT_PROXY.current or any( + event_marker_id(item) is not None for item in data + ) + return EventArguments(data, self._event_proxy_backend if want_proxy else None) async def _process_event_queue( self, target: str, queue: Queue[LayoutEventMessage | dict[str, Any]] @@ -222,16 +253,6 @@ async def _process_event_queue( 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) @@ -242,18 +263,41 @@ async def _process_event_queue( if handler is not None: break - if handler is not None: - try: - await handler.function( - self._make_event_arguments(handler, event["data"]) + try: + if handler is not None: + try: + await handler.function( + self._make_event_arguments(handler, 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" ) - 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: + if self._has_closed: + # The layout has been torn down around this handler. There is no + # client left to acknowledge to, and nothing left to record into. + return None + # 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: @@ -478,6 +522,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")) @@ -500,36 +550,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 + 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, @@ -648,6 +768,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 @@ -657,6 +783,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/types.py b/src/reactpy/types.py index ba85dad7b..62a82a496 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -925,16 +925,6 @@ class JsonEventTarget(TypedDict): preventDefault: bool stopPropagation: bool stopImmediatePropagation: NotRequired[bool] - eventProxy: NotRequired[bool] - """Whether the client should keep the event around for live access - - When present and ``true`` the client registers the dispatched JavaScript ``Event`` - with its live object registry and tags it with - :data:`~reactpy._event_proxy.EVENT_MARKER_KEY` in the ``layout-event`` message. - This lets the corresponding handler read properties from, and call methods on, the - real browser object. When absent ReactPy is talking to a client which cannot do - this and only the snapshot is sent. - """ debounce: NotRequired[int] throttle: NotRequired[int] @@ -987,15 +977,6 @@ class BaseEventHandler: ``event.stopImmediatePropagation()`` inside the handler. """ - event_proxy: bool | None - """Whether this handler is handed a live client backed event object - - :data:`None` (the default) defers to :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. - Set this to ``False`` for handlers which only ever look at the data the client - already sent along, since live access requires a round trip to the browser for - anything the snapshot is missing. - """ - debounce: int | None """Server-→client debounce window in milliseconds. @@ -1026,6 +1007,20 @@ class BaseEventHandler: updates on input elements. ``throttle`` defaults to ``None`` (no throttling).""" + event_proxy: bool | None + """Whether this handler wants the event's originating browser object kept alive. + + :data:`True` asks the client to hold on to the object the event fired from, so the + handler can read properties from it and call methods on it past what the snapshot + carried. :data:`False` takes the snapshot and nothing else - the browser is not + asked to remember anything for this handler. + + The default, :data:`None`, leaves the choice to the server: a handler is given + live objects when the client has marked the event data as something it is still + holding, and otherwise gets the plain :class:`~reactpy.types.Event` snapshot. + Setting :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY` asks for live objects + on every handler.""" + target: str | None """Typically left as ``None`` except when a static target is useful. @@ -1170,7 +1165,6 @@ class ReactPyConfig(TypedDict, total=False): debug: bool max_queue_size: int tests_default_timeout: int - enable_event_proxy: bool event_proxy_timeout: float event_proxy_max_depth: int event_proxy_max_timeouts: int @@ -1200,10 +1194,10 @@ class Event(dict): """ A light `dict` wrapper for event data passed to event handler functions. - This is what handlers receive when the client cannot (or is not asked to) keep the - original JavaScript ``Event`` around. See :class:`SyncEvent` and - :class:`AsyncEvent` for the live, client backed variants which additionally allow - reading arbitrary properties from and calling methods on the browser object. + This is what handlers receive for data which is not a browser object. Anything + which actually came out of the page is kept alive by the client and handed over as + a :class:`SyncEvent` or :class:`AsyncEvent`, which can additionally be read from + and called into. """ debounce: int | None @@ -1215,17 +1209,15 @@ def __getattr__(self, name: str) -> Any: def preventDefault(self) -> None: """Prevent the default action of the event. - This is a no-op on a plain snapshot - use :func:`reactpy.event`'s - ``prevent_default=True``, or a live event - (:data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`) to actually call it. + This is a no-op here - use :func:`reactpy.event`'s ``prevent_default=True`` + instead, which asks the browser to do it before the event reaches the server. """ def stopPropagation(self) -> None: """Stop the event from propagating. - This is a no-op on a plain snapshot - use :func:`reactpy.event`'s - ``stop_propagation=True``, or a live event - (:data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`) to actually call it. + This is a no-op here - use :func:`reactpy.event`'s ``stop_propagation=True`` + instead, which asks the browser to do it before the event reaches the server. """ diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 735993651..f767ae8a8 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -4,7 +4,9 @@ import asyncio import threading +from collections.abc import Callable from time import monotonic +from types import MappingProxyType from typing import Any import pytest @@ -18,6 +20,7 @@ AsyncEvent, AsyncEventProxy, EventArguments, + EventProxy, EventProxyAccessError, EventProxyBackend, EventProxyBlocked, @@ -31,13 +34,13 @@ serialize_argument, ) from reactpy.config import ( - REACTPY_ENABLE_EVENT_PROXY, 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.events import EventHandler, event, merge_event_handlers from reactpy.core.layout import Layout +from reactpy.testing import assert_reactpy_did_log from reactpy.types import Event from reactpy.utils import Ref from tests.tooling.common import event_message @@ -166,8 +169,7 @@ def _serialize(self, value: Any, path: list[str]) -> Any: return {EVENT_REFERENCE_KEY: {"path": path}} if isinstance(value, dict): return { - key: self._serialize(item, [*path, key]) - for key, item in value.items() + key: self._serialize(item, [*path, key]) for key, item in value.items() } if isinstance(value, list): return [ @@ -226,6 +228,78 @@ def make_objects() -> dict[str, Any]: } +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]]: + 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 message.get("reply"): + return + self.backend.handle_response( + { + "type": EVENT_PROXY_RESPONSE_TYPE, + "requestId": message["requestId"], + "ok": self.ok, + "value": self.value, + "error": self.error, + "code": self.code, + } + ) + + +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 + + +async def in_worker(function: Callable[[], Any]) -> Any: + """Run ``function`` off the event loop, returning its result or raising its error + + Synchronous live access refuses to block the loop thread, so anything which blocks + has to run somewhere else - just like the worker thread ReactPy puts plain ``def`` + handlers in. + """ + outcome: Ref[tuple[bool, Any]] = Ref(None) + + def run() -> None: + try: + outcome.current = (True, function()) + except Exception as error: + outcome.current = (False, error) + + thread = threading.Thread(target=run) + thread.start() + await asyncio.to_thread(thread.join, 10) + assert not thread.is_alive(), "the worker thread never finished" + succeeded, result = outcome.current + if not succeeded: + raise result + return result + + async def test_async_event_prefers_the_snapshot(): backend, client = make_client(make_objects()) event_object = AsyncEvent( @@ -451,8 +525,7 @@ def event_handlers_of(model: dict[str, Any]) -> dict[str, Any]: raise KeyError(msg) -async def test_layout_hands_handlers_events_of_their_own_flavour(monkeypatch): - monkeypatch.setattr(REACTPY_ENABLE_EVENT_PROXY, "current", True) +async def test_layout_hands_handlers_events_of_their_own_flavour(): main_thread = threading.current_thread().name received: dict[str, tuple[Any, str, Any]] = {} objects = make_objects() @@ -473,9 +546,7 @@ def sync_handler(event_object): @component def MyComponent(): - return reactpy.html.div( - {"onSync": sync_handler, "onAsync": async_handler} - ) + return reactpy.html.div({"onSync": sync_handler, "onAsync": async_handler}) _backend, client = make_client(objects) @@ -483,7 +554,6 @@ def MyComponent(): connect(layout, client) model = (await layout.render())["model"] handlers = event_handlers_of(model) - assert all(handler["eventProxy"] for handler in handlers.values()) data = {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"} await layout.deliver(event_message(handlers["onSync"]["target"], dict(data))) @@ -504,17 +574,15 @@ def MyComponent(): assert requested_paths(client) == [["target"], ["target", "value"]] -async def test_layout_respects_the_per_handler_kill_switch(): +async def test_layout_hands_data_which_is_not_a_browser_object_as_is(): received: dict[str, Any] = {} - def sync_handler(args): - received["event"] = args[0] + def sync_handler(event): + received["event"] = event @component def MyComponent(): - return reactpy.html.div( - {"onClick": EventHandler(sync_handler, event_proxy=False)} - ) + return reactpy.html.div({"onClick": sync_handler}) _backend, client = make_client(make_objects()) @@ -522,21 +590,17 @@ def MyComponent(): connect(layout, client) model = (await layout.render())["model"] handlers = event_handlers_of(model) - assert "eventProxy" not in handlers["onClick"] target = handlers["onClick"]["target"] - await layout.deliver( - event_message(target, {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}) - ) + await layout.deliver(event_message(target, {"type": "click", "detail": 3})) await poll(lambda: "event" in received) assert type(received["event"]) is Event assert received["event"]["type"] == "click" - assert client.requests == [] + assert client.requests == [], "nothing to reach into, so nothing was asked" -async def test_merged_handlers_get_the_flavour_each_needs(monkeypatch): - monkeypatch.setattr(REACTPY_ENABLE_EVENT_PROXY, "current", True) +async def test_merged_handlers_get_the_flavour_each_needs(): received: dict[str, Any] = {} def sync_handler(args): @@ -591,6 +655,86 @@ def MyComponent(): assert EVENT_MARKER_KEY not in received["event"] +async def test_a_handler_which_declines_live_access_takes_the_snapshot(): + """Refusing live access is honoured even while it is on offer + + The mark alongside the data says the browser is still holding whatever the event + fired from, but a handler which has said it will not reach into the page is not + handed a proxy on the off chance that it might. It is given the snapshot, and + nothing is asked of the browser afterwards. + """ + received: dict[str, Any] = {} + + def handler(event_object): + received["event"] = event_object + + @component + def MyComponent(): + return reactpy.html.div({"onClick": event(handler, event_proxy=False)}) + + _backend, client = make_client(make_objects()) + + async with Layout(MyComponent()) as layout: + connect(layout, client) + model = (await layout.render())["model"] + handler_spec = event_handlers_of(model)["onClick"] + assert "eventProxy" not in handler_spec, ( + "a handler which declined was still reported as wanting live access" + ) + + await layout.deliver( + event_message( + handler_spec["target"], + {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, + ) + ) + await poll(lambda: "event" in received) + + assert type(received["event"]) is Event + assert EVENT_MARKER_KEY not in received["event"] + assert client.requests == [], "the browser was asked about an event nobody wanted" + + +async def test_a_handler_which_asks_for_live_access_gets_it(): + """Asking on the handler is enough; the global switch is not needed + + Saying so on the handler does two things: the client is told to hold on to the + object the event came from, and the server turns the mark the client leaves + behind into something the handler can actually read through. + """ + received: dict[str, Any] = {} + + async def handler(event_object): + received["event"] = event_object + + @component + def MyComponent(): + return reactpy.html.div({"onClick": event(handler, event_proxy=True)}) + + _backend, client = make_client(make_objects()) + + async with Layout(MyComponent()) as layout: + connect(layout, client) + model = (await layout.render())["model"] + handler_spec = event_handlers_of(model)["onClick"] + assert handler_spec["eventProxy"] is True, ( + "the client was never told to keep the object alive" + ) + + await layout.deliver( + event_message( + handler_spec["target"], + {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, + ) + ) + await poll(lambda: "event" in received) + + assert isinstance(received["event"], AsyncEvent) + assert await received["event"].read("target.value") == "typed" + + assert requested_paths(client) == [["target", "value"]] + + async def test_proxy_responses_are_routed_not_delivered(): _backend, client = make_client(make_objects()) @@ -620,3 +764,556 @@ def MyComponent(): assert handler["stopPropagation"] is True assert handler["stopImmediatePropagation"] is True + +# -- what a proxy says about itself ----------------------------------------- + + +async def test_proxies_describe_themselves(): + backend, _client = make_client(make_objects()) + event_object = AsyncEvent( + backend, "evt", snapshot={"type": "click", 3: "not-an-attribute"} + ) + target = event_object.target + + assert event_object.backend is backend + assert event_object.event_id == "evt" + assert event_object.path == () + assert event_object.has_snapshot is True + assert repr(event_object) == ( + "AsyncEvent(event_id='evt', path=[], " + "snapshot={'type': 'click', 3: 'not-an-attribute'})" + ) + # snapshot keys show up as attributes, but only the string ones + assert "type" in dir(event_object) + assert "3" not in dir(event_object) + + assert target.backend is backend + assert target.path == ("target",) + assert target.has_snapshot is False + assert repr(target) == ( + "AsyncEventProxy(event_id='evt', path=['target'], snapshot={})" + ) + + +async def test_internal_attributes_are_never_resolved_against_the_browser(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent(backend, "evt", snapshot={}) + + 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" + + +def test_only_the_two_flavours_know_how_to_resolve(): + base = EventProxy(EventProxyBackend(), "evt", ["target"]) + + assert dict(base) == {} + with pytest.raises(NotImplementedError, match="cannot resolve live event data"): + base._reactpy_resolve_path(False, ()) + + +# -- reading through the snapshot ------------------------------------------- + + +async def test_read_walks_the_whole_dotted_path_at_once(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent( + backend, + "evt", + snapshot={ + "target": {"value": "cached"}, + "meta": MappingProxyType({"kind": "mouse", "sub": {"n": 5}}), + "touches": [{"pageX": 7}, {"pageX": 8}], + }, + ) + + assert await event_object.read("target.value") == "cached" + assert await event_object.read("meta.kind") == "mouse" + assert await event_object.read("meta.sub.n") == 5 + assert await event_object.read("touches.1.pageX") == 8 + # a path may be handed over as a sequence too, indices included + assert await event_object.read(["touches", 0, "pageX"]) == 7 + assert requested_paths(client) == [], "the snapshot answered every one of them" + + # what the snapshot lacks costs exactly one request, however deep it goes + assert await event_object.read("deep.a.b.c") == 42 + assert requested_paths(client) == [["deep", "a", "b", "c"]] + + +async def test_read_gives_up_on_paths_the_snapshot_cannot_hold(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent( + backend, + "evt", + snapshot={ + "type": "click", + "meta": MappingProxyType({"kind": "mouse"}), + "touches": [{"pageX": 7}], + }, + ) + + # strings are not walked into, out of range and non-numeric indices miss too + assert await event_object.read("type.deeper", default="d1") == "d1" + assert await event_object.read("meta.absent", default="d2") == "d2" + assert await event_object.read("touches.9.pageX", default="d3") == "d3" + assert await event_object.read("touches.first", default="d4") == "d4" + + assert requested_paths(client) == [ + ["type", "deeper"], + ["meta", "absent"], + ["touches", "9", "pageX"], + ["touches", "first"], + ] + # without a default the client's refusal is handed straight back + with pytest.raises(EventProxyNotFound): + await event_object.read("type.deeper") + + +async def test_chained_attributes_ask_for_one_level_at_a_time(): + objects = { + "evt": { + "chain": Reference( + {"next": Reference({"next": Reference({"answer": 5})})}, + ), + }, + } + backend, client = make_client(objects) + event_object = SyncEvent(backend, "evt", snapshot={}) + + # read() asks for the whole path in a single round trip ... + assert await in_worker(lambda: event_object.read("chain.next.next.answer")) == 5 + assert requested_paths(client) == [["chain", "next", "next", "answer"]] + + # ... while walking attributes resolves whatever the last answer left over + client.sent.clear() + assert await in_worker(lambda: event_object.chain.next.next.answer) == 5 + assert requested_paths(client) == [ + ["chain"], + ["chain", "next"], + ["chain", "next", "next"], + ["chain", "next", "next", "answer"], + ] + + +# -- snapshot data which is a reference or a list --------------------------- + + +async def test_references_inside_a_snapshot_become_real_proxies(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent( + backend, + "evt", + snapshot={ + "node": { + EVENT_REFERENCE_KEY: { + "path": ["target"], + "callable": True, + "eventId": "other", + } + }, + "self_ref": {EVENT_REFERENCE_KEY: {}}, + }, + ) + + node = event_object.node + assert isinstance(node, AsyncEventProxy) + # the path the client handed us wins over where this proxy sits + assert node.path == ("target",) + assert node.event_id == "other" + assert node._reactpy_callable is True, "the client said this one is a function" + assert EVENT_REFERENCE_KEY not in dict(node) + + # a reference which does not say where it points at keeps our own path + self_ref = event_object.self_ref + assert isinstance(self_ref, AsyncEventProxy) + assert self_ref.path == ("self_ref",) + assert self_ref.event_id == "evt" + assert self_ref._reactpy_callable is False + assert requested_paths(client) == [] + + +async def test_lists_inside_a_snapshot_are_wrapped_with_child_proxies(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent( + backend, + "evt", + snapshot={ + "touches": [{"pageX": 1}, {"pageX": 2}], + "matrix": [[{"x": 1}], [3, "four"], None], + }, + ) + + touches = event_object.touches + assert [touch["pageX"] for touch in touches] == [1, 2] + assert [touch.path for touch in touches] == [ + ("touches", "0"), + ("touches", "1"), + ] + assert all(isinstance(touch, AsyncEventProxy) for touch in touches) + + matrix = event_object.matrix + assert isinstance(matrix[0][0], AsyncEventProxy) + assert matrix[0][0].path == ("matrix", "0", "0") + assert matrix[0][0]["x"] == 1 + assert matrix[1] == [3, "four"] + assert matrix[2] is None + assert requested_paths(client) == [] + + +# -- the dict protocol of an asynchronous proxy ----------------------------- + + +async def test_async_proxies_refuse_to_be_inspected_while_unknown(): + backend, client = make_client(make_objects()) + target = AsyncEventProxy(backend, "evt", ["target"]) + + with pytest.raises(EventProxyAccessError) as measure: + len(target) + assert "cannot measure AsyncEventProxy for ['target'] of event 'evt'" in str( + measure.value + ) + assert "await the proxy" in str(measure.value) + + with pytest.raises(EventProxyAccessError, match="cannot iterate over"): + iter(target) + with pytest.raises(EventProxyAccessError, match="cannot test membership of"): + assert "type" in target + + assert requested_paths(client) == [] + + # once the client has told us something the dict protocol is free + event_object = AsyncEvent(backend, "evt", snapshot={"type": "click"}) + assert len(event_object) == 1 + assert list(event_object) == ["type"] + assert "type" in event_object + assert "target" not in event_object + assert requested_paths(client) == [] + + +async def test_proxies_without_a_connection_are_quietly_empty(): + event_object = AsyncEvent(EventProxyBackend(), "evt", snapshot={}) + + assert len(event_object) == 0 + assert list(event_object) == [] + assert "type" not in event_object + + +# -- what the synchronous flavour does when it has to block ----------------- + + +async def test_sync_reads_either_use_the_snapshot_or_block_for_the_browser(): + objects = make_objects() + objects["evt"]["detail"] = 7 + backend, client = make_client(objects) + event_object = SyncEvent(backend, "evt", snapshot={"type": "click"}) + + def access(): + return ( + event_object.read("type"), + event_object.read("target.value"), + event_object.read("nope", default="fallback"), + event_object.detail, + SyncEventProxy(backend, "evt", ["target"]).resolve(), + ) + + cached, typed, fallback, detail, resolved = await in_worker(access) + assert (cached, typed, fallback, detail) == ("click", "typed", "fallback", 7) + assert type(resolved) is dict + assert resolved["value"] == "typed" + assert requested_paths(client) == [ + ["target", "value"], + ["nope"], + ["detail"], + ["target"], + ] + + # without a default the client's refusal is raised rather than swallowed + with pytest.raises(EventProxyNotFound): + await in_worker(lambda: event_object.read("nope")) + + +async def test_sync_reads_the_browser_cannot_answer_degrade_to_a_blank_proxy(): + backend, client = make_client(make_objects()) + event_object = SyncEvent(backend, "evt", snapshot={}) + + with assert_reactpy_did_log("Falling back to snapshot data", EventProxyNotFound): + missing = await in_worker(lambda: event_object.nope) + assert isinstance(missing, SyncEventProxy) + assert missing.path == ("nope",) + assert missing.has_snapshot is False + assert requested_paths(client) == [["nope"]] + + # printing and iterating still work, so a failed read is safe to log + with assert_reactpy_did_log("Could not resolve", EventProxyNotFound): + assert await in_worker(lambda: str(missing)) == "{}" + empty = await in_worker(lambda: iter(missing)) + assert next(empty, None) is None + assert requested_paths(client) == [["nope"], ["nope"], ["nope"]] + + +async def test_sync_proxies_compare_by_what_they_point_at(): + objects = make_objects() + objects["evt"]["numbers"] = [1, 2, 3] + backend, _client = make_client(objects) + left = SyncEventProxy(backend, "evt", ["target"], snapshot={"k": 1}) + same = SyncEventProxy(backend, "evt", ["target"], snapshot={"k": 2}) + other = SyncEventProxy(backend, "evt", ["deep"], snapshot={"k": 2}) + + # the very same browser object, whatever either snapshot happens to say + assert (left == same) is True + assert (left != same) is False + # a different path leaves it to the data we hold + assert (left == other) is False + assert (left != other) is True + assert (left == SyncEventProxy(backend, "evt", ["deep"], {"k": 1})) is True + + # against anything else the live value is what counts + numbers = SyncEventProxy(backend, "evt", ["numbers"]) + assert await in_worker(lambda: numbers == [1, 2, 3]) is True + assert await in_worker(lambda: numbers == [1, 2]) is False + assert await in_worker(lambda: numbers != [1, 2, 3]) is False + + # a snapshot we already have is never compared against the browser + answered = SyncEventProxy(backend, "evt", ["x"], snapshot={"k": 1}) + assert await in_worker(lambda: answered == 5) is False + assert await in_worker(lambda: answered != 5) is True + + +async def test_sync_proxies_print_and_measure_like_their_values(): + objects = make_objects() + objects["evt"]["numbers"] = [1, 2, 3] + backend, _client = make_client(objects) + numbers = SyncEventProxy(backend, "evt", ["numbers"]) + node = SyncEventProxy(backend, "evt", ["target"]) + answered = SyncEventProxy(backend, "evt", ["x"], snapshot={"k": 1}) + + assert await in_worker(lambda: str(numbers)) == "[1, 2, 3]" + assert await in_worker(lambda: format(numbers, "")) == "[1, 2, 3]" + assert await in_worker(lambda: str(answered)) == "{'k': 1}" + assert await in_worker(lambda: format(answered, "")) == "{'k': 1}" + + assert await in_worker(lambda: len(numbers)) == 3 + assert await in_worker(lambda: list(numbers)) == [1, 2, 3] + assert await in_worker(lambda: len(answered)) == 1 + assert await in_worker(lambda: list(answered)) == ["k"] + + assert await in_worker(lambda: 2 in numbers) is True + assert await in_worker(lambda: 9 in numbers) is False + assert await in_worker(lambda: "tagName" in node) is True + assert await in_worker(lambda: "k" in answered) is True + assert await in_worker(lambda: "zzz" in answered) is False + + # a reference to something else says nothing about this proxy + deep = SyncEventProxy(backend, "evt", ["deep"]) + assert await in_worker(lambda: str(deep)) == "{}" + deep_items = await in_worker(lambda: iter(deep)) + assert next(deep_items, None) is None + + # a read which failed is simply "not there" + missing = SyncEventProxy(backend, "evt", ["nope"]) + assert await in_worker(lambda: "anything" in missing) is False + # with no connection at all membership is never asked of the browser + offline = SyncEventProxy(EventProxyBackend(), "evt", ["target"]) + assert ("tagName" in offline) is False + + +# -- when the connection is not what it should be --------------------------- + + +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 = AsyncEvent(backend, "evt", snapshot={}) + + 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_closing_the_backend_fails_everything_still_in_flight(): + backend, client = make_client(make_objects(), respond=False) + event_object = AsyncEvent(backend, "evt", snapshot={}) + 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_blocking_reads_time_out(monkeypatch): + monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05) + monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 1) + backend, client = make_client(make_objects(), respond=False) + event_object = SyncEvent(backend, "evt", snapshot={}) + + with pytest.raises(EventProxyTimeout, match="did not answer"): + await in_worker(lambda: event_object.read("target")) + + assert len(client.requests) == 1 + assert backend.disabled + + +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 = AsyncEvent(backend, "evt", snapshot={}) + + 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_failures_the_client_cannot_explain_are_still_reported(): + backend, client = make_canned_client(ok=False) + event_object = AsyncEvent(backend, "evt", snapshot={}) + + 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 + + +async def test_requests_made_just_after_the_loop_is_gone_fail_fast(monkeypatch): + backend, client = make_client(make_objects()) + event_object = AsyncEvent(backend, "evt", snapshot={}) + loop = asyncio.get_running_loop() + # the loop which owned this connection has gone away, the sender has not noticed + monkeypatch.setattr(backend, "_loop", None) + + with pytest.raises(EventProxyUnavailable, match="no sender is bound"): + await event_object.read("target") + + assert client.sent == [], "nothing was handed over a loop which no longer runs" + monkeypatch.setattr(backend, "_loop", loop) + # the request was dropped rather than left behind waiting for an answer + assert await event_object.read("target.value") == "typed" + + +# -- calls the client may or may not have to answer ------------------------- + + +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 = AsyncEvent(EventProxyBackend(), "evt", snapshot={}) + + 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_stop_immediate_propagation_is_forwarded_to_the_client(): + objects = make_objects() + objects["evt"]["stopImmediatePropagation"] = Method() + backend, client = make_client(objects) + event_object = AsyncEvent(backend, "evt", snapshot={}) + + event_object.stopImmediatePropagation() + + await poll(lambda: len(client.requests) == 1) + assert requested_paths(client) == [["stopImmediatePropagation"]] + assert client.requests[0]["reply"] is False + assert objects["evt"]["stopImmediatePropagation"].calls == [()] + assert event_object.propagationStopped is True + assert event_object.defaultPrevented is False + + +async def test_client_side_calls_can_be_waited_for(): + objects = make_objects() + backend, client = make_client(objects) + target = AsyncEventProxy(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_the_event_object_itself_cannot_be_called(): + backend, client = make_client(make_objects()) + event_object = AsyncEvent(backend, "evt", snapshot={}) + + with pytest.raises(EventProxyAccessError, match="cannot be called"): + await event_object() + with pytest.raises(EventProxyAccessError, match="cannot be called"): + await AsyncEventProxy(backend, "evt")() + + # ... and the blocking flavour says the same thing from a worker thread + def call_the_root(): + return SyncEvent(backend, "evt", snapshot={})() + + with pytest.raises(EventProxyAccessError, match="cannot be called"): + await in_worker(call_the_root) + + assert client.requests == [] + + +# -- 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, flavor = build_event_args( + [{EVENT_MARKER_KEY: "evt", "type": "click"}, {"type": "click"}, "extra"], + backend, + ) + + assert isinstance(args[0], AsyncEvent) + assert args[0].event_id == "evt" + assert args[0]["type"] == "click" + assert type(args[1]) is Event + assert args[2] == "extra" + assert flavor is True + + sync_args, sync_flavor = build_event_args( + [{EVENT_MARKER_KEY: {"id": "evt"}}], backend, prefer_async=False + ) + assert isinstance(sync_args[0], SyncEvent) + assert sync_flavor is False + + +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) diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index eda7d65f5..829abecd8 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,8 +14,14 @@ 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 def test_event_handler_repr(): @@ -179,6 +186,25 @@ async def some_other_func(data): assert calls == ["some_func", "some_other_func"] +def test_merge_event_handlers_event_proxy(): + """Merging never has to settle an argument about live access + + Whether the browser object behind an event should be kept alive is not something + two handlers can contradict each other over - one of them simply wants it. The + merged handler asks for it whenever any of the originals did, and states nothing + at all when none of them said either way. + """ + plain = EventHandler(lambda data: None) + wants = EventHandler(lambda data: None, event_proxy=True) + declines = EventHandler(lambda data: None, event_proxy=False) + other_plain = EventHandler(lambda data: None) + + assert merge_event_handlers([plain, other_plain]).event_proxy is None + assert merge_event_handlers([plain, wants]).event_proxy is True + assert merge_event_handlers([plain, declines]).event_proxy is False + assert merge_event_handlers([declines, wants]).event_proxy is True + + async def test_can_prevent_event_default_operation(display: DisplayFixture): @reactpy.component def Input(): @@ -784,56 +810,184 @@ 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), - ) + def on_change(event): + set_value(event["target"]["value"]) - await display.show(ControlledInput) + 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=10,
+        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=10,
+        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=10,
+        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("")
+
+    def on_change(event):
+        set_value(event["target"]["value"])
+
+    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)
+    await inp.press("Enter")
+
+    async def current_value():
+        return await inp.evaluate("node => node.value")
+
+    await poll(current_value).until(
+        lambda value: value == "",
+        timeout=10,
+        description="the server's clear to reach the input",
+    )
+    assert await display.page.locator("#server-value").text_content() == ""
+
+
 async def test_controlled_input_respects_custom_debounce(display: DisplayFixture):
     @reactpy.component
     def ControlledInput():
@@ -918,3 +1072,259 @@ 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
+
+    The keystrokes and the clear are racing. The field is emptied by an event which
+    reaches the server while the text typed into it is still on its way there, so
+    renders carrying that text are queued up behind the clear. 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.
+    """
+    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()
+
+    # Fill the field and empty it without pausing between, so the clear overtakes
+    # the keystrokes instead of following them.
+    await inp.type("hello", delay=0)
+    await inp.press("Enter")
+
+    await poll(field_value).until(
+        lambda value: value == "",
+        timeout=10,
+        description="the clear to reach the field",
+    )
+    await poll(server_value).until(
+        lambda value: value == "",
+        timeout=10,
+        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"
+        )
+
+
+@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)
+
+    def on_change(event):
+        set_value(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 = [400]
+
+        def tick():
+            set_ticks(lambda old: old + 1)
+            left[0] -= 1
+            if left[0]:
+                handle[0] = loop.call_later(0.005, tick)
+
+        handle = [loop.call_later(0.005, tick)]
+        # Cancelled through the cell, not captured directly, because every tick
+        # replaces the handle with a fresh one.
+        return lambda: handle[0].cancel()  # noqa: PLW0108
+
+    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(),
+        )
+
+    await poll(values).until(
+        lambda pair: pair[0] == pair[1] == target_text,
+        timeout=15,
+        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..f3c70baf8 100644
--- a/tests/test_core/test_hooks.py
+++ b/tests/test_core/test_hooks.py
@@ -1472,3 +1472,73 @@ 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..4e795c34f 100644
--- a/tests/test_core/test_layout.py
+++ b/tests/test_core/test_layout.py
@@ -35,7 +35,7 @@
 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
 
 
 @pytest.fixture(autouse=True, params=[True, False])
@@ -1692,6 +1692,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()
+
+    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(lambda e: None, 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 +1865,71 @@ 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(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"
+    )
diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py
index 20ec0d8d5..e1cdfa609 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
@@ -150,3 +155,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_widgets.py b/tests/test_widgets.py
index 9841fc231..8a54271b8 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -1,9 +1,13 @@
 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 +33,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 +45,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"),
+        )
+
+    await input_1.type("hello", delay=delay)
 
-    assert (await input_1.evaluate("e => e.value")) == "hello"
-    assert (await input_2.evaluate("e => e.value")) == "hello"
+    # 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}

From 4719a466d9bb91e970ef1a05a110eeb98614f6fc Mon Sep 17 00:00:00 2001
From: Mark Bakhit 
Date: Sat, 10 Oct 2026 20:23:32 -0700
Subject: [PATCH 04/36] Keep the linter happy where the protocol touches a
 model's attributes

A return from a finally block swallows whatever exception is unwinding
through it, so the check for a layout which has begun closing wraps the
recording of an event's sequence number rather than cutting it short.

The acknowledgement which tells an input the server has caught up is not a DOM
attribute and has no business in the attribute types, so the one line which
writes it into the rendered model says so out loud.
---
 src/reactpy/core/layout.py | 43 +++++++++++++++++++-------------------
 1 file changed, 21 insertions(+), 22 deletions(-)

diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py
index e941dda4c..9e7d2d420 100644
--- a/src/reactpy/core/layout.py
+++ b/src/reactpy/core/layout.py
@@ -277,27 +277,26 @@ async def _process_event_queue(
                         "does not exist or its component unmounted"
                     )
             finally:
-                if self._has_closed:
-                    # The layout has been torn down around this handler. There is no
-                    # client left to acknowledge to, and nothing left to record into.
-                    return None
-                # 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)
+                # 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:
@@ -593,7 +592,7 @@ def _inject_event_ack_seq(
         # 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
+        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.

From e80500709d1251a97f1c59cd53ae3b22c0354615 Mon Sep 17 00:00:00 2001
From: Mark Bakhit 
Date: Sat, 10 Oct 2026 20:39:04 -0700
Subject: [PATCH 05/36] Hold a closed layout to what it no longer owes

The two guards which refuse to schedule on behalf of a layout that has begun
closing, and the walk which stops when nothing remains above an element, had
no test that reached them - and the project asks that every line be answered
for. Each is now asked about directly: a closed layout is asked for an
acknowledgement and a render and refuses both without touching what teardown
released, and an element whose parent has gone is asked which component owns
it and is told that none does.
---
 tests/test_core/test_layout.py | 66 +++++++++++++++++++++++++++++++++-
 1 file changed, 65 insertions(+), 1 deletion(-)

diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py
index 4e795c34f..0bb6c7013 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,
@@ -1933,3 +1933,67 @@ def note_late_render(self, model_state):
         "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(lambda e: None, 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

From 4219d27b4905a23d7c916105d802e80996c1c4a7 Mon Sep 17 00:00:00 2001
From: Mark Bakhit 
Date: Sat, 10 Oct 2026 21:14:15 -0700
Subject: [PATCH 06/36] Wait on the client's own calls in the fire-and-forget
 proxy tests

Both of these waited on how many requests had gone out and then asserted on
what the fake client had done with them, which happens a turn later. Windows'
coarser loop clock lost that race on every one of the ten attempts CI allows.

The fake client now answers a request with no deliberate delay inside the same
turn of the loop, and each test waits for the very calls it goes on to assert,
so neither depends on how the loop hands out its turns.

An unused noqa and some drift in two unrelated test files are tidied away by
the same formatter the checks run.
---
 tests/test_core/test_event_proxy.py | 26 +++++++++++++++++++++-----
 tests/test_core/test_events.py      |  2 +-
 tests/test_core/test_hooks.py       |  5 +----
 tests/test_widgets.py               |  1 -
 4 files changed, 23 insertions(+), 11 deletions(-)

diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py
index f767ae8a8..43fb571af 100644
--- a/tests/test_core/test_event_proxy.py
+++ b/tests/test_core/test_event_proxy.py
@@ -90,7 +90,14 @@ 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
-        asyncio.get_running_loop().call_later(self.delay, self._answer, dict(message))
+        loop = asyncio.get_running_loop()
+        if self.delay > 0:
+            loop.call_later(self.delay, self._answer, dict(message))
+        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, dict(message))
 
     def _answer(self, message: dict[str, Any]) -> None:
         # A real client carries out the request either way - it only answers when the
@@ -371,10 +378,18 @@ async def test_prevent_default_is_scheduled_but_not_awaited():
     assert event_object.defaultPrevented
     assert event_object.propagationStopped
 
-    await asyncio.sleep(0.01)
+    # 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 objects["evt"]["preventDefault"].calls == [()]
+    assert prevented.calls == [()]
+    assert stopped.calls == [()]
 
 
 def test_snapshot_data_is_plain_json():
@@ -1246,10 +1261,11 @@ async def test_stop_immediate_propagation_is_forwarded_to_the_client():
 
     event_object.stopImmediatePropagation()
 
-    await poll(lambda: len(client.requests) == 1)
+    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 objects["evt"]["stopImmediatePropagation"].calls == [()]
+    assert stop_immediately.calls == [()]
     assert event_object.propagationStopped is True
     assert event_object.defaultPrevented is False
 
diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py
index 829abecd8..c9d4c3144 100644
--- a/tests/test_core/test_events.py
+++ b/tests/test_core/test_events.py
@@ -1281,7 +1281,7 @@ def tick():
         handle = [loop.call_later(0.005, tick)]
         # Cancelled through the cell, not captured directly, because every tick
         # replaces the handle with a fresh one.
-        return lambda: handle[0].cancel()  # noqa: PLW0108
+        return lambda: handle[0].cancel()
 
     use_effect(keep_rendering)
 
diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py
index f3c70baf8..28c6ae004 100644
--- a/tests/test_core/test_hooks.py
+++ b/tests/test_core/test_hooks.py
@@ -1486,9 +1486,7 @@ def test_a_render_is_marked_before_the_scheduler_is_handed_it():
     of its life.
     """
     seen_while_handing_off: list[bool] = []
-    hook = LifeCycleHook(
-        lambda: seen_while_handing_off.append(hook._scheduled_render)
-    )
+    hook = LifeCycleHook(lambda: seen_while_handing_off.append(hook._scheduled_render))
 
     hook.schedule_render()
 
@@ -1541,4 +1539,3 @@ def test_a_cancelled_render_does_not_swallow_later_state_changes():
 
     hook.schedule_render()
     assert scheduled == [1, 1]
-
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 8a54271b8..a38cb3305 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -5,7 +5,6 @@
 
 import reactpy
 from reactpy.testing import DEFAULT_TYPE_DELAY, DisplayFixture, poll
-
 from tests.tooling.common import TYPE_DELAYS
 
 HERE = Path(__file__).parent

From 586b29ef71b7c3000caffd8dbcd54c92978badff Mon Sep 17 00:00:00 2001
From: Mark Bakhit 
Date: Sun, 11 Oct 2026 12:40:40 -0700
Subject: [PATCH 07/36] Make every event handler a coroutine

Reading what an event fired from means asking the browser for it, and only a
coroutine can await that round trip without stalling the layout. Handlers
which were plain functions had to run in a worker thread to keep the loop
free, which is the thread where calling set_state() fails with 'no running
event loop', and supporting them meant carrying two shapes of event data - a
snapshot for one, a live proxy for the other - through the protocol, the
types, and the docs. There is now a single shape: the Event snapshot which
arrived with the event, with an EventProxy in front of whatever the handler
asks to read past it. Anything which is not a coroutine is refused with a
TypeError as it is made into a handler, saying why in terms of the round trip
it would have to block on.

REACTPY_ENABLE_EVENT_PROXY therefore defaults to on, since a coroutine can
afford to wait on the browser and a handler which never reads past the
snapshot never sends a request in the first place. A handler which knows it
will not look behind the event can decline with
EventHandler(fn, event_proxy=False), and one which must have the live object
can insist with event_proxy=True even while the option is off.

What a handler wants is read off its bytecode, and a callable object keeps
that bytecode on its class's __call__ rather than on the instance, so the
handler check and the inspector both look there now. That is likewise how a
partial which wraps such an object is still found to carry a debounce.
---
 src/reactpy/_event_proxy.py                   | 605 +++++-------------
 src/reactpy/config.py                         |  39 +-
 src/reactpy/core/_event_inspect.py            |  43 +-
 src/reactpy/core/events.py                    | 163 ++---
 src/reactpy/core/layout.py                    |  14 +-
 src/reactpy/core/vdom.py                      |   8 +-
 src/reactpy/testing/common.py                 |  14 +-
 src/reactpy/transforms.py                     |   8 +-
 src/reactpy/types.py                          |  70 +-
 src/reactpy/widgets.py                        |   2 +-
 .../pyscript_components/load_second.py        |   2 +-
 tests/test_asgi/pyscript_components/root.py   |   2 +-
 tests/test_asgi/test_standalone.py            |   6 +-
 tests/test_client.py                          |  14 +-
 tests/test_core/test_event_inspect.py         |  28 +-
 tests/test_core/test_event_proxy.py           | 450 ++++---------
 tests/test_core/test_events.py                | 155 +++--
 tests/test_core/test_hooks.py                 |  16 +-
 tests/test_core/test_layout.py                |  74 ++-
 tests/test_core/test_serve.py                 |   7 +-
 tests/test_core/test_vdom.py                  |  14 +-
 tests/test_html.py                            |   6 +-
 .../pyscript_components/custom_root_name.py   |   2 +-
 .../test_pyscript/pyscript_components/root.py |   2 +-
 .../pyscript_components/root_error.py         |   2 +-
 tests/test_utils.py                           |   6 +-
 tests/test_web/test_module.py                 |  14 +-
 tests/tooling/select.py                       |   5 +-
 28 files changed, 691 insertions(+), 1080 deletions(-)

diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py
index a13ef5550..04d5cb787 100644
--- a/src/reactpy/_event_proxy.py
+++ b/src/reactpy/_event_proxy.py
@@ -1,10 +1,9 @@
 """Client-backed ("live") proxies for browser events.
 
-Historically an event handler was handed a plain :class:`reactpy.types.Event` - a
-snapshot of the JavaScript ``Event`` object. Snapshots are cheap and never block, but
-they are frozen at the moment the event was dispatched: methods such as ``focus()`` or
-``composedPath()`` cannot be called from Python, and any property that the snapshot left
-out is simply gone.
+Every event handler is handed a snapshot of the JavaScript ``Event`` object. Snapshots
+are cheap and never block, but they are frozen at the moment the event was dispatched:
+methods such as ``focus()`` or ``composedPath()`` cannot be called from Python, and any
+property that the snapshot left out is simply gone.
 
 This module implements the machinery which lets a handler reach *live* into the browser
 instead. Alongside the snapshot it serialized, the client marks every object it kept
@@ -22,14 +21,13 @@
         ],
     }
 
-On receipt, ReactPy swaps that dictionary for a :class:`SyncEvent` or
-:class:`AsyncEvent` - both are ``dict`` subclasses which *also* hold the snapshot.
-Reading an attribute returns a lazily resolved :class:`EventProxy` whose value is
-fetched from the browser only when it is actually needed:
+On receipt, ReactPy swaps that dictionary for an :class:`Event` - a ``dict`` subclass
+which *also* holds the snapshot. Reading an attribute returns a lazily resolved
+:class:`EventProxy` whose value is fetched from the browser only when it is needed:
 
 .. code-block:: python
 
-    async def on_click(event: AsyncEvent):
+    async def on_click(event: Event):
         # one round trip: read "target.value" from the live DOM node
         value = await event.target.value
         # one round trip: call "focus()" on the live DOM node
@@ -37,10 +35,11 @@ async def on_click(event: AsyncEvent):
         # nothing is sent for values which the snapshot already contains
         print(event["type"])
 
-Which flavour a handler receives is decided by the handler itself: ``async def``
-handlers get :class:`AsyncEvent` (resolve with ``await`` or ``.resolve()``), plain
-``def`` handlers get :class:`SyncEvent` whose accessors block - transparently running
-the whole handler in a worker thread when it needs the value of a live property.
+Every event handler is a coroutine, so live values are always pulled from the browser
+with ``await`` and the event loop is never blocked. When the client did not keep the
+originating object alive - because the handler opted out, or it is too old to support
+this - the very same :class:`Event` is handed over without any backing, and awaiting
+one of its proxies raises :class:`EventProxyUnavailable`.
 
 Resolution travels over the existing connection with two extra message types:
 
@@ -117,16 +116,6 @@ class EventProxyAccessError(EventProxyError):
     """The client refused or failed to resolve the requested path"""
 
 
-class EventProxyBlocked(EventProxyError):
-    """Synchronous live event access was attempted from the event loop thread
-
-    A synchronous event handler which needs a live value is run in a worker thread by
-    ReactPy. Seeing this error means that a blocking access was attempted somewhere
-    which ReactPy could not run off of the event loop - typically from an ``async def``
-    handler using a :class:`SyncEvent`. Prefer ``await`` on an :class:`AsyncEvent`.
-    """
-
-
 class EventProxyBackend:
     """Sends live event access requests and routes the answers back to waiters
 
@@ -219,7 +208,6 @@ async def resolve(
         path: Sequence[str | int],
         call: bool = False,
         args: Sequence[Any] = (),
-        sync: bool = False,
     ) -> Any:
         """Resolve ``path`` against the client side event (awaited from the loop)"""
         request_id, message, future = self._begin(
@@ -234,38 +222,7 @@ async def resolve(
             )
         except (TimeoutError, FuturesTimeoutError):
             self._handle_timeout(request_id, event_id, path)
-        return self._to_value(payload, event_id, path, sync)
-
-    def resolve_blocking(
-        self,
-        event_id: str,
-        path: Sequence[str | int],
-        call: bool = False,
-        args: Sequence[Any] = (),
-    ) -> Any:
-        """Resolve ``path`` by blocking the calling (non-loop) thread until answered"""
-        if (
-            self._loop_thread_id is not None
-            and threading.get_ident() == self._loop_thread_id
-        ):
-            msg = (
-                f"cannot synchronously resolve {list(path)!r} of event {event_id!r} from "
-                "the event loop thread - access live values from a synchronous event "
-                "handler, or await them from an asynchronous one"
-            )
-            raise EventProxyBlocked(msg)
-
-        request_id, message, future = self._begin(
-            event_id, path, call, args, reply=True
-        )
-        if future is None:  # nocov - reply=True always yields a future
-            return None
-        self._submit(message, future, request_id)
-        try:
-            payload = future.result(timeout=REACTPY_EVENT_PROXY_TIMEOUT.current)
-        except (TimeoutError, FuturesTimeoutError):
-            self._handle_timeout(request_id, event_id, path)
-        return self._to_value(payload, event_id, path, sync=True)
+        return self._to_value(payload, event_id, path)
 
     def notify(
         self,
@@ -376,7 +333,6 @@ def _to_value(
         payload: Mapping[str, Any],
         event_id: str,
         path: Sequence[str | int],
-        sync: bool,
     ) -> Any:
         if not payload.get("ok"):
             code = payload.get("code") or "access"
@@ -389,7 +345,15 @@ def _to_value(
             raise EventProxyAccessError(message)
 
         self._timeouts = 0
-        return deserialize_value(payload.get("value"), event_id, self, sync)
+        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`.
+"""
 
 
 def serialize_argument(value: Any) -> Any:
@@ -420,7 +384,6 @@ def deserialize_value(
     value: Any,
     event_id: str,
     backend: EventProxyBackend,
-    sync: bool,
     depth: int = 0,
 ) -> Any:
     """Turn a client supplied value into data, replacing references with proxies"""
@@ -434,29 +397,25 @@ def deserialize_value(
     if isinstance(value, Mapping):
         reference = value.get(EVENT_REFERENCE_KEY)
         if isinstance(reference, Mapping):
-            child_class = SyncEventProxy if sync else AsyncEventProxy
-            child = child_class(
-                backend,
-                str(reference.get("eventId", event_id)),
-                tuple(reference.get("path", ())),
+            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, sync, depth + 1)
+            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, sync, depth + 1)
-            for item in value
-        ]
+        return [deserialize_value(item, event_id, backend, depth + 1) for item in value]
 
     return value
 
 
-class _EventProxyBase(dict, Generic[_T]):
+class EventProxy(dict, Generic[_T]):
     """A live, lazily resolved view onto a browser object
 
     Instances are ``dict`` subclasses which hold on to whatever the client chose to
@@ -464,21 +423,30 @@ class _EventProxyBase(dict, Generic[_T]):
     further proxies for anything the snapshot does not already answer with a plain
     value, so a chain such as ``event.target.value`` costs a single round trip - and
     only when the value is genuinely needed.
-    """
 
-    _reactpy_sync: bool = False
-    """Whether resolving this proxy blocks (sync) or must be awaited (async)"""
+    Anything which has to come from the browser is awaited, because every event handler
+    is a coroutine and nothing may block the event loop::
+
+        async def on_input(event: Event):
+            value = await event.target.value
+            await event.target.focus()
+
+    A proxy which no client is backing - because the handler opted out of live access,
+    or the client is too old to support it - hands out whatever its snapshot already
+    holds, and raises :class:`EventProxyUnavailable` when asked for anything more.
+    """
 
     def __init__(
         self,
-        backend: EventProxyBackend,
-        event_id: str,
-        path: Sequence[str | int] = (),
         snapshot: Mapping[str, Any] | None = None,
+        *,
+        backend: EventProxyBackend | None = None,
+        event_id: str = "",
+        path: Sequence[str | int] = (),
         flags: dict[str, bool] | None = None,
     ) -> None:
         dict.__init__(self, snapshot or {})
-        self._reactpy_backend = backend
+        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
@@ -508,6 +476,9 @@ def has_snapshot(self) -> bool:
         return bool(dict.__len__(self))
 
     def __repr__(self) -> str:
+        if not self._reactpy_backend.available:
+            # Nothing is behind this proxy - show the snapshot it was handed
+            return dict.__repr__(self)
         return (
             f"{type(self).__name__}(event_id={self._reactpy_event_id!r}, "
             f"path={list(self._reactpy_path)!r}, snapshot={dict.__repr__(self)})"
@@ -531,14 +502,11 @@ def _reactpy_snapshot_value(self, segment: str | int) -> Any:
 
     def _reactpy_make_child(
         self, segment: str | int, snapshot: Mapping[str, Any] | None = None
-    ) -> _EventProxyBase[Any]:
-        child_class: type[_EventProxyBase[Any]] = (
-            SyncEventProxy if self._reactpy_sync else AsyncEventProxy
-        )
-        child = child_class(
-            self._reactpy_backend,
-            self._reactpy_event_id,
-            (*self._reactpy_path, segment),
+    ) -> EventProxy[Any]:
+        child = EventProxy(
+            backend=self._reactpy_backend,
+            event_id=self._reactpy_event_id,
+            path=(*self._reactpy_path, segment),
             flags=self._reactpy_flags,
         )
         if snapshot is None:
@@ -551,10 +519,10 @@ def _reactpy_make_child(
             # The snapshot only points at what the browser still holds - hand back a
             # real proxy for it rather than a dict containing the marker.
             path = tuple(reference.get("path", ())) or (*self._reactpy_path, segment)
-            child = child_class(
-                self._reactpy_backend,
-                str(reference.get("eventId", self._reactpy_event_id)),
-                path,
+            child = EventProxy(
+                backend=self._reactpy_backend,
+                event_id=str(reference.get("eventId", self._reactpy_event_id)),
+                path=path,
                 flags=self._reactpy_flags,
             )
             child._reactpy_callable = bool(reference.get("callable"))
@@ -631,9 +599,7 @@ def _reactpy_invoke(
         path = (*self._reactpy_path, name)
         backend = self._reactpy_backend
         if wait:
-            return backend.resolve(
-                self._reactpy_event_id, path, call=True, args=args, sync=False
-            )
+            return backend.resolve(self._reactpy_event_id, path, call=True, args=args)
         backend.notify(self._reactpy_event_id, path, args)
         return None
 
@@ -642,20 +608,38 @@ def _reactpy_invoke(
     def preventDefault(self) -> None:
         """Prevent the default action of the underlying DOM event
 
-        The matching JavaScript call is scheduled on the client but is *not* waited
-        for. Use :func:`reactpy.event`'s ``prevent_default=True`` if the action must be
-        cancelled before the browser carries on.
+        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"""
+        """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"""
+        """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")
@@ -693,78 +677,54 @@ def __getattr__(self, name: str) -> Any:
         ):
             # Never resolve internals or interpreter lookups against the browser
             raise AttributeError(name)
+        if not self._reactpy_backend.available:
+            # Nothing is behind this proxy - behave like the plain snapshot it is
+            value = dict.get(self, name, _MISSING)
+            if value is _MISSING:
+                return None
+            return EventProxy(value) if isinstance(value, dict) else value
         return self._reactpy_child_or_value(name)
 
     def __getitem__(self, key: Any) -> Any:
+        if not self._reactpy_backend.available:
+            return dict.__getitem__(self, key)
         return self._reactpy_child_or_value(key)
 
     def __bool__(self) -> bool:
         return bool(dict.__len__(self)) or bool(self._reactpy_path)
 
-    # -- resolution (implemented by the sync and async flavours) ----------
+    # -- resolution -------------------------------------------------------
 
     def _reactpy_resolve_path(self, call: bool, args: Sequence[Any]) -> Any:
-        msg = f"{type(self).__name__} cannot resolve live event data"
-        raise NotImplementedError(msg)
+        return self._reactpy_backend.resolve(
+            self._reactpy_event_id,
+            self._reactpy_path,
+            call=call,
+            args=args,
+        )
 
     def _reactpy_call_method(self, name: str, args: Sequence[Any]) -> Any:
         path = (*self._reactpy_path, name)
-        backend = self._reactpy_backend
-        if self._reactpy_sync:
-            return backend.resolve_blocking(
-                self._reactpy_event_id, path, call=True, args=args
-            )
-        return backend.resolve(
-            self._reactpy_event_id, path, call=True, args=args, sync=False
+        return self._reactpy_backend.resolve(
+            self._reactpy_event_id, path, call=True, args=args
         )
 
-    def composedPath(self) -> Any:  # the JavaScript method name is kept as-is
-        """Return the event's path, resolving on the client if needed
+    def composedPath(self) -> Awaitable[list[Any]]:
+        """Return the event's path, asking the client for it if the snapshot lacks it
+
+        Await the result::
 
-        This blocks for a :class:`SyncEventProxy` and must be awaited for an
-        :class:`AsyncEventProxy`.
+            async def on_click(event: Event):
+                print(await event.composedPath())
         """
         return self._reactpy_call_method("composedPath", ())
 
-    def _reactpy_description(self) -> str:
-        return (
-            f"{type(self).__name__} for {list(self._reactpy_path)!r} of event "
-            f"{self._reactpy_event_id!r}"
-        )
-
-    def _reactpy_require_snapshot(self, action: str) -> None:
-        """Complain about an operation which needs a value we do not have yet"""
-        if self._reactpy_backend.available:
-            msg = (
-                f"cannot {action} {self._reactpy_description()} because nothing is "
-                "known about it yet - await the proxy (or call .resolve() on it) first"
-            )
-            raise EventProxyAccessError(msg)
-
-
-class AsyncEventProxy(_EventProxyBase[_T]):
-    """The asynchronous flavour of a live event object
-
-    Values are pulled from the browser with ``await``::
-
-        async def on_input(event: AsyncEvent):
-            value = await event.target.value
-            await event.target.focus()
-    """
-
-    _reactpy_sync = False
-
-    def _reactpy_resolve_path(self, call: bool, args: Sequence[Any]) -> Any:
-        return self._reactpy_backend.resolve(
-            self._reactpy_event_id,
-            self._reactpy_path,
-            call=call,
-            args=args,
-            sync=False,
-        )
-
     def resolve(self) -> Awaitable[_T]:
-        """Fetch the value this proxy currently points at from the browser"""
+        """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.
+        """
         return self._reactpy_resolve_path(False, ())
 
     async def read(self, path: str | Sequence[Any], default: Any = _MISSING) -> Any:
@@ -773,7 +733,7 @@ async def read(self, path: str | Sequence[Any], default: Any = _MISSING) -> Any:
         Unlike awaiting an attribute, this always works - including for values which
         the snapshot already answered with a plain ``str``, ``bool`` or ``None``::
 
-            async def on_input(event: AsyncEvent):
+            async def on_input(event: Event):
                 value = await event.read("target.value")
         """
         segments = self._reactpy_normalize_path(path)
@@ -786,7 +746,6 @@ async def on_input(event: AsyncEvent):
                 (*self._reactpy_path, *segments),
                 call=False,
                 args=(),
-                sync=False,
             )
         except EventProxyError:
             if default is _MISSING:
@@ -817,267 +776,61 @@ def __contains__(self, key: Any) -> bool:
             self._reactpy_require_snapshot("test membership of")
         return False
 
-
-class SyncEventProxy(_EventProxyBase[_T]):
-    """The synchronous flavour of a live event object
-
-    Accessing something the snapshot does not already know about blocks until the
-    browser answers. ReactPy runs synchronous event handlers in a worker thread for
-    exactly this reason, so the event loop is never stalled.
-    """
-
-    _reactpy_sync = True
-
-    def _reactpy_resolve_segment(self, segment: str | int) -> Any:
-        """Read a value the snapshot does not have, blocking until the browser answers"""
-        try:
-            value = self._reactpy_backend.resolve_blocking(
-                self._reactpy_event_id,
-                (*self._reactpy_path, segment),
-                call=False,
-                args=(),
-            )
-        except (
-            EventProxyTimeout,
-            EventProxyNotFound,
-            EventProxyAccessError,
-            EventProxyUnavailable,
-        ):
-            # The browser could not tell us - hand back an (empty) proxy so that
-            # repr(), logging and "in" checks keep working.
-            logger.debug(
-                f"Falling back to snapshot data for {list(self._reactpy_path)!r}"
-                f" + {segment!r}",
-                exc_info=True,
-            )
-            return self._reactpy_make_child(segment)
-
-        if isinstance(value, _EventProxyBase):
-            return value
-        if isinstance(value, Mapping):
-            return self._reactpy_make_child(segment, value)
-        if isinstance(value, list | tuple):
-            return self._reactpy_wrap_sequence(segment, value)
-        return value
-
-    def _reactpy_resolve_path(self, call: bool, args: Sequence[Any]) -> Any:
-        return self._reactpy_backend.resolve_blocking(
-            self._reactpy_event_id, self._reactpy_path, call=call, args=args
+    def _reactpy_description(self) -> str:
+        return (
+            f"{type(self).__name__} for {list(self._reactpy_path)!r} of event "
+            f"{self._reactpy_event_id!r}"
         )
 
-    def resolve(self) -> _T:
-        """Fetch the value this proxy currently points at from the browser"""
-        return self._reactpy_resolve_path(False, ())
-
-    def read(self, path: str | Sequence[Any], default: Any = _MISSING) -> Any:
-        """Return the value at ``path`` whether or not the snapshot knows it
-
-        Blocking, like every other accessor on this class::
-
-            def on_input(event: SyncEvent):
-                value = event.read("target.value")
-        """
-        segments = self._reactpy_normalize_path(path)
-        value = self._reactpy_walk_snapshot(segments)
-        if value is not _MISSING:
-            return value
-        try:
-            return self._reactpy_backend.resolve_blocking(
-                self._reactpy_event_id,
-                (*self._reactpy_path, *segments),
-                call=False,
-                args=(),
-            )
-        except EventProxyError:
-            if default is _MISSING:
-                raise
-            return default
-
-    def __call__(self, *args: Any) -> Any:
-        """Call the client side method this proxy currently points at"""
-        return self._reactpy_resolve_path(True, args)
-
-    def _reactpy_resolved_or_self(self) -> Any:
-        """The live value if we do not have a snapshot for it, else this proxy"""
-        if dict.__len__(self) or not self._reactpy_backend.available:
-            return self
-        try:
-            value = self._reactpy_resolve_path(False, ())
-        except EventProxyError:
-            logger.debug(
-                f"Could not resolve {self._reactpy_description()}", exc_info=True
+    def _reactpy_require_snapshot(self, action: str) -> None:
+        """Complain about an operation which needs a value we do not have yet"""
+        if self._reactpy_backend.available:
+            msg = (
+                f"cannot {action} {self._reactpy_description()} because nothing is "
+                "known about it yet - await the proxy (or call .resolve() on it) first"
             )
-            return self
-        # a reference to something else (an object or a function) says nothing about
-        # how this proxy should be printed or compared
-        return self if isinstance(value, _EventProxyBase) else value
-
-    def __eq__(self, other: Any) -> Any:
-        if isinstance(other, _EventProxyBase):
-            if (self._reactpy_event_id, self._reactpy_path) == (
-                other._reactpy_event_id,
-                other._reactpy_path,
-            ):
-                return True
-            return dict.__eq__(self, other)
-        value = self._reactpy_resolved_or_self()
-        if value is self:
-            return dict.__eq__(self, other)
-        return bool(value == other)
-
-    def __ne__(self, other: Any) -> Any:
-        result = self.__eq__(other)
-        if result is NotImplemented:
-            return result
-        return not result
-
-    __hash__ = None  # type: ignore[assignment]
-
-    def __str__(self) -> str:
-        value = self._reactpy_resolved_or_self()
-        return dict.__repr__(self) if value is self else str(value)
-
-    def __format__(self, format_spec: str) -> str:
-        value = self._reactpy_resolved_or_self()
-        if value is self:
-            return dict.__repr__(self)
-        return format(value, format_spec)
-
-    def __len__(self) -> int:
-        if dict.__len__(self):
-            return dict.__len__(self)
-        return len(self._reactpy_resolved_or_self())
-
-    def __iter__(self) -> Any:
-        if dict.__len__(self):
-            return dict.__iter__(self)
-        value = self._reactpy_resolved_or_self()
-        return dict.__iter__(self) if value is self else iter(value)
-
-    def __contains__(self, key: Any) -> bool:
-        if dict.__contains__(self, key):
-            return True
-        if dict.__len__(self) or not self._reactpy_backend.available:
-            return False
-        try:
-            return bool(key in self._reactpy_resolve_path(False, ()))
-        except EventProxyError:
-            return False
-
-
-EventProxy = _EventProxyBase
-"""The base class of every lazily resolved live event object"""
+            raise EventProxyAccessError(msg)
 
 
 if TYPE_CHECKING:
     _ElementMembers = dict[str, Any]
 
 
-class AsyncEvent(AsyncEventProxy[_T]):
-    """The event object handed to ``async def`` event handlers
+class Event(EventProxy[_T]):
+    """The event object handed to event handler functions
 
     A ``dict`` subclass which holds the snapshot the client took when the event was
-    dispatched *and* knows how to reach back into the browser for anything missing::
+    dispatched *and*, when the client kept the originating object alive, knows how to
+    reach back into the browser for anything missing::
 
-        async def on_key_down(event: AsyncEvent):
+        async def on_key_down(event: Event):
             if await event.read("key") == "Enter":
                 await event.target.focus()
                 print(await event.target.value)
 
-    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:`AsyncEventProxy`.
-    """
-
-    if TYPE_CHECKING:
-        # -- identity -----------------------------------------------------
-        type: str
-        target: AsyncEventProxy[Any]
-        currentTarget: AsyncEventProxy[Any]
-        srcElement: AsyncEventProxy[Any]
-        relatedTarget: AsyncEventProxy[Any]
-        view: AsyncEventProxy[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: AsyncEventProxy[Any]
-        clipboardData: AsyncEventProxy[Any]
-        touches: list[Any]
-        targetTouches: list[Any]
-        changedTouches: list[Any]
-        deltaX: float
-        deltaY: float
-        deltaZ: float
-        deltaMode: int
-
-        # -- methods ------------------------------------------------------
-        # ``preventDefault()``, ``stopPropagation()``, ``stopImmediatePropagation()``
-        # and ``resolve()`` are typed by the base classes. Only ``composedPath()``
-        # differs, since it must be awaited here but not for a synchronous event.
-        def composedPath(self) -> Awaitable[list[Any]]: ...
-
+    Reading a key which the snapshot already answers with a plain value never touches
+    the connection, so ``event["type"]`` and ``event.target.value`` stay as cheap as
+    they always were. Anything the snapshot left out costs a single round trip, and is
+    awaited - if the client is not holding the object alive at all, awaiting raises
+    :class:`~reactpy._event_proxy.EventProxyUnavailable`.
 
-class SyncEvent(SyncEventProxy[_T]):
-    """The event object handed to plain ``def`` event handlers
+    Handlers are always coroutines, so this is the only event object there is. See
+    :attr:`~reactpy.types.BaseEventHandler.event_proxy` for turning live access off for
+    a single handler, and ``REACTPY_ENABLE_EVENT_PROXY`` for turning it off everywhere.
 
-    Behaves like an :class:`AsyncEvent` except that reading something the snapshot does
-    not already know about blocks until the browser answers. ReactPy runs these
-    handlers in a worker thread so that blocking never stalls the event loop::
-
-        def on_key_down(event: SyncEvent):
-            if event.key == "Enter":
-                event.target.focus()
+    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: SyncEventProxy[Any]
-        currentTarget: SyncEventProxy[Any]
-        srcElement: SyncEventProxy[Any]
-        relatedTarget: SyncEventProxy[Any]
-        view: SyncEventProxy[Any]
+        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
@@ -1122,8 +875,8 @@ def on_key_down(event: SyncEvent):
         # -- input / clipboard / wheel ------------------------------------
         data: str
         inputType: str
-        dataTransfer: SyncEventProxy[Any]
-        clipboardData: SyncEventProxy[Any]
+        dataTransfer: EventProxy[Any]
+        clipboardData: EventProxy[Any]
         touches: list[Any]
         targetTouches: list[Any]
         changedTouches: list[Any]
@@ -1133,10 +886,9 @@ def on_key_down(event: SyncEvent):
         deltaMode: int
 
         # -- methods ------------------------------------------------------
-        # ``preventDefault()``, ``stopPropagation()``, ``stopImmediatePropagation()``
-        # and ``resolve()`` are typed by the base classes. Only ``composedPath()``
-        # differs, since it blocks here rather than returning an awaitable.
-        def composedPath(self) -> list[Any]: ...
+        # ``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:
@@ -1155,28 +907,29 @@ def event_marker_id(item: Any) -> str | None:
 def build_event_args(
     data: Sequence[Any],
     backend: EventProxyBackend | None,
-    prefer_async: bool = True,
-) -> tuple[list[Any], bool | None]:
+) -> list[Any]:
     """Turn the ``data`` of a ``layout-event`` message into handler arguments
 
-    Items which the client marked as live become :class:`AsyncEvent` or
-    :class:`SyncEvent` objects, everything else stays exactly as it was. The second
-    element of the result is the flavour which was used, or :data:`None` when no live
-    event was present.
+    Items which the client marked as live become :class:`Event` objects which can be
+    read from and called into. Anything else is handed over as a plain :class:`Event`
+    snapshot, or exactly as it arrived when it was never an object at all.
     """
-    from reactpy.types import Event
-
     args: list[Any] = []
-    flavor: bool | None = None
+    is_live = backend is not None and backend.available
     for item in data:
-        event_id = event_marker_id(item)
-        if event_id is not None and backend is not None and backend.available:
-            snapshot = {
-                key: value for key, value in item.items() if key != EVENT_MARKER_KEY
-            }
-            event_class = AsyncEvent if prefer_async else SyncEvent
-            args.append(event_class(backend, event_id, snapshot=snapshot))
-            flavor = prefer_async
+        event_id = event_marker_id(item) if is_live else None
+        if event_id is not None:
+            args.append(
+                Event(
+                    {
+                        key: value
+                        for key, value in item.items()
+                        if key != EVENT_MARKER_KEY
+                    },
+                    backend=backend,
+                    event_id=event_id,
+                )
+            )
         elif isinstance(item, dict):
             args.append(
                 Event(
@@ -1189,37 +942,19 @@ def build_event_args(
             )
         else:
             args.append(item)
-    return args, flavor
+    return args
 
 
 class EventArguments(list):
     """The positional arguments a :class:`~reactpy.types.BaseEventHandler` is called with
 
-    Whether data should be handed out in blocking or awaitable form is only known once
-    the handler itself runs, and merged handlers may mix both. This list therefore
-    remembers the raw event data so that :meth:`rebuild` can hand each handler
-    arguments of the flavour it needs.
+    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,
-        prefer_async: bool = True,
     ) -> None:
-        built, flavor = build_event_args(data, backend, prefer_async)
-        super().__init__(built)
-        self._reactpy_data = data
-        self._reactpy_backend = backend
-        self._reactpy_flavor = flavor
-
-    def rebuild(self, prefer_async: bool) -> EventArguments:
-        """Return arguments of the requested flavour (possibly this very list)"""
-        if self._reactpy_flavor is None or self._reactpy_flavor is prefer_async:
-            return self
-        return EventArguments(self._reactpy_data, self._reactpy_backend, prefer_async)
-
-
-def uses_sync_event_proxy(args: Sequence[Any]) -> bool:
-    """Whether any of the given handler arguments blocks on the client"""
-    return any(isinstance(arg, SyncEventProxy) for arg in args)
+        super().__init__(build_event_args(data, backend))
diff --git a/src/reactpy/config.py b/src/reactpy/config.py
index 02a663211..cc2c19e0b 100644
--- a/src/reactpy/config.py
+++ b/src/reactpy/config.py
@@ -141,23 +141,34 @@ def boolean(value: str | bool | int) -> bool:
 
 REACTPY_ENABLE_EVENT_PROXY = Option(
     "REACTPY_ENABLE_EVENT_PROXY",
-    default=False,
+    default=True,
     mutable=True,
     validator=boolean,
 )
-"""Whether event handlers receive live client-backed event objects.
-
-Disabled by default. When enabled the client keeps a reference to the dispatched
-JavaScript ``Event`` around and lets the server read properties from, and call methods
-on, that object. Event handlers then receive a :class:`reactpy.types.SyncEvent` or
-:class:`reactpy.types.AsyncEvent` (both are ``dict`` subclasses holding the usual
-snapshot). When disabled, or when the client does not support live access, handlers
-receive the plain :class:`reactpy.types.Event` snapshot as they always have.
-
-Individual handlers can opt in with ``EventHandler(fn, event_proxy=True)`` without
-turning this on globally. Note that a *synchronous* handler which reads a live value
-is run in a worker thread so that it may block, and cannot safely call ``set_state``
-from there yet.
+"""Whether event handlers are given live client-backed event objects by default.
+
+Enabled by default, because reading the object an event fired from is what people
+expect - ``await event.target.value`` should just work, rather than having to be
+anticipated when the handler is written. When enabled the client keeps a reference to
+the dispatched JavaScript ``Event`` around, and the server can read properties from it
+and call methods on it.
+
+Handlers receive a :class:`reactpy.types.Event` either way. It is a ``dict`` subclass
+holding the usual snapshot, and additionally reaches back into the browser for anything
+the snapshot left out. When the client cannot or should not keep the object alive, the
+same :class:`~reactpy.types.Event` arrives without any backing and awaiting one of its
+properties raises :class:`~reactpy._event_proxy.EventProxyUnavailable`.
+
+This option is only the default for handlers which do not decide for themselves. An
+individual handler can opt out with ``EventHandler(fn, event_proxy=False)`` - worth
+doing for anything bound to an event which fires continuously, such as ``onScroll``,
+since registering and pruning a kept-alive object costs the browser work on every
+event - or insist on live access with ``event_proxy=True`` even while this option is
+off. Switching this off therefore does not override a handler which explicitly asked
+for live access; it only stops asking on every handler's behalf.
+
+Every event handler is a coroutine and live values are always awaited, so nothing here
+blocks the event loop.
 """
 
 REACTPY_EVENT_PROXY_TIMEOUT = Option(
diff --git a/src/reactpy/core/_event_inspect.py b/src/reactpy/core/_event_inspect.py
index f30853e8a..5624e8d16 100644
--- a/src/reactpy/core/_event_inspect.py
+++ b/src/reactpy/core/_event_inspect.py
@@ -19,6 +19,34 @@
 _STORE_ATTR_VALUE_OFFSET = 2
 
 
+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:
+        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.
 
@@ -45,14 +73,15 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None]
     ``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
@@ -69,7 +98,6 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None]
     ):
         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)
@@ -171,8 +199,10 @@ def _function_arg_defaults(func: Callable) -> dict[str, int]:
     Includes positional defaults (``__defaults__``) and keyword-only defaults
     (``__kwdefaults__``).
     """
-    code = func.__code__
+    code = _handler_code(func)
     result: dict[str, int] = {}
+    if code is None:
+        return result
 
     positional_defaults = getattr(func, "__defaults__", None)
     if positional_defaults:
@@ -198,7 +228,8 @@ def _closure_lookup(func: Callable) -> dict[str, int]:
     closure = getattr(func, "__closure__", None)
     if not closure:
         return {}
-    freevars = getattr(func.__code__, "co_freevars", ())
+    code = _handler_code(func)
+    freevars = getattr(code, "co_freevars", ()) if code is not None else ()
     result: dict[str, int] = {}
     for idx, cell in enumerate(closure):
         if idx >= len(freevars):
diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py
index e18ff104b..31d8b632c 100644
--- a/src/reactpy/core/events.py
+++ b/src/reactpy/core/events.py
@@ -1,20 +1,66 @@
 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
 
-from anyio import create_task_group, to_thread
+from anyio import create_task_group
 
-from reactpy._event_proxy import uses_sync_event_proxy
 from reactpy.core._event_inspect import inspect_event_handler
 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 = ...,
@@ -35,11 +81,11 @@ def event(
     debounce: int | None = ...,
     throttle: int | None = ...,
     event_proxy: bool | 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,
@@ -47,7 +93,7 @@ def event(
     debounce: int | None = None,
     throttle: int | None = None,
     event_proxy: bool | 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
@@ -63,14 +109,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:
@@ -89,7 +139,7 @@ def my_callback(*data): ...
             :attr:`BaseEventHandler.event_proxy`.
     """
 
-    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,
@@ -104,11 +154,13 @@ def setup(function: Callable[..., Any]) -> EventHandler:
 
 
 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:
@@ -131,7 +183,7 @@ class EventHandler(BaseEventHandler):
 
     def __init__(
         self,
-        function: EventHandlerFunc,
+        function: Callable[..., Awaitable[Any]],
         stop_propagation: bool = False,
         prevent_default: bool = False,
         target: str | None = None,
@@ -140,6 +192,7 @@ def __init__(
         stop_immediate_propagation: bool = False,
         event_proxy: bool | None = None,
     ) -> 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
@@ -149,17 +202,10 @@ def __init__(
         self.target = target
         self.event_proxy = event_proxy
 
-        # 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
-
+        # Check if our `preventDefault` or `stopPropagation` methods were called by
+        # inspecting the bytecode of the handler the user actually wrote
         found_prevent_default, found_stop_propagation, found_debounce = (
-            inspect_event_handler(func_to_inspect)
+            inspect_event_handler(_unwrap_handler(function))
         )
 
         if found_prevent_default:
@@ -198,62 +244,32 @@ def __repr__(self) -> str:
 
 
 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.
+            Whether to pass the event parameters as positional args or as a list.
 
-    Synchronous handlers are handed :class:`~reactpy.types.SyncEvent` objects which
-    block while reading from the browser. To keep that from stalling the event loop,
-    such handlers are run in their own thread.
+    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.
     """
-    is_async = inspect.iscoroutinefunction(function)
-
-    def prepare(data: Sequence[Any]) -> Any:
-        """Hand the function the flavour of event data it knows how to handle"""
-        rebuild = getattr(data, "rebuild", None)
-        return data if rebuild is None else rebuild(is_async)
+    check_is_async_handler(function)
 
     if positional_args:
-        if is_async:
-
-            async def wrapper(data: Sequence[Any]) -> None:
-                await function(*prepare(data))
-
-        else:
-
-            async def wrapper(data: Sequence[Any]) -> None:
-                args = prepare(data)
-                if uses_sync_event_proxy(args):
-                    # Live event access blocks on a round trip to the client - run the
-                    # handler somewhere it is allowed to block.
-                    await to_thread.run_sync(partial(function, *args))
-                else:
-                    function(*args)
-
-        cast(Any, wrapper).__wrapped__ = function
-        cast(Any, wrapper).__reactpy_is_async_handler__ = is_async
-        return wrapper
-    elif not is_async:
 
         async def wrapper(data: Sequence[Any]) -> None:
-            args = prepare(data)
-            if uses_sync_event_proxy(args):
-                await to_thread.run_sync(function, args)
-            else:
-                function(args)
+            await function(*data)
 
         cast(Any, wrapper).__wrapped__ = function
-        cast(Any, wrapper).__reactpy_is_async_handler__ = False
         return wrapper
-    else:
-        return function
+
+    return cast(EventHandlerFunc, function)
 
 
 def merge_event_handlers(
@@ -325,9 +341,8 @@ def merge_event_handler_funcs(
 ) -> EventHandlerFunc:
     """Make one event handler function from many
 
-    Each of the given functions is handed event data of the flavour it expects, which
-    matters when synchronous (blocking) and asynchronous (awaitable) handlers are
-    merged together.
+    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"
@@ -335,19 +350,9 @@ def merge_event_handler_funcs(
     elif len(functions) == 1:
         return functions[0]
 
-    is_async_by_func = [
-        bool(
-            getattr(
-                func, "__reactpy_is_async_handler__", inspect.iscoroutinefunction(func)
-            )
-        )
-        for func in functions
-    ]
-
     async def await_all_event_handlers(data: Sequence[Any]) -> None:
         async with create_task_group() as group:
-            for func, is_async in zip(functions, is_async_by_func, strict=True):
-                rebuild = getattr(data, "rebuild", None)
-                group.start_soon(func, data if rebuild is None else rebuild(is_async))
+            for func in functions:
+                group.start_soon(func, data)
 
     return await_all_event_handlers
diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py
index 9e7d2d420..f7a0b1618 100644
--- a/src/reactpy/core/layout.py
+++ b/src/reactpy/core/layout.py
@@ -235,16 +235,16 @@ def _make_event_arguments(
 
         A handler which asks for live access is given it, and one which declines is
         given the snapshot and nothing else. With no preference stated the data
-        decides: the client marks whatever it has agreed to keep hold of, and marked
-        data comes back as a live object. A client which has gone away cannot answer
-        for that mark either way, because a backend without a sender reports itself
-        unavailable and the mark is dropped with the rest of the overhead.
+        decides: the client marks whatever it has agreed to keep hold of - which
+        ``REACTPY_ENABLE_EVENT_PROXY`` asks it to do for every handler by default -
+        and marked data comes back as a live object. A client which has gone away
+        cannot answer for that mark either way, because a backend without a sender
+        reports itself unavailable and the mark is dropped with the rest of the
+        overhead.
         """
         want_proxy = getattr(handler, "event_proxy", None)
         if want_proxy is None:
-            want_proxy = REACTPY_ENABLE_EVENT_PROXY.current or any(
-                event_marker_id(item) is not None for item in data
-            )
+            want_proxy = any(event_marker_id(item) is not None for item in data)
         return EventArguments(data, self._event_proxy_backend if want_proxy else None)
 
     async def _process_event_queue(
diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py
index 8fa9fb656..9a8f3f8df 100644
--- a/src/reactpy/core/vdom.py
+++ b/src/reactpy/core/vdom.py
@@ -22,6 +22,7 @@
     CustomVdomConstructor,
     EllipsisRepr,
     EventHandlerDict,
+    EventHandlerFunc,
     ImportSourceDict,
     InlineJavaScript,
     InlineJavaScriptDict,
@@ -236,7 +237,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/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 62a82a496..f36aa35bd 100644
--- a/src/reactpy/types.py
+++ b/src/reactpy/types.py
@@ -20,13 +20,7 @@
     overload,
 )
 
-from reactpy._event_proxy import (
-    AsyncEvent,
-    AsyncEventProxy,
-    EventProxy,
-    SyncEvent,
-    SyncEventProxy,
-)
+from reactpy._event_proxy import Event, EventProxy
 
 CarrierType = TypeVar("CarrierType")
 _Type = TypeVar("_Type")
@@ -1015,11 +1009,17 @@ class BaseEventHandler:
     carried. :data:`False` takes the snapshot and nothing else - the browser is not
     asked to remember anything for this handler.
 
-    The default, :data:`None`, leaves the choice to the server: a handler is given
-    live objects when the client has marked the event data as something it is still
-    holding, and otherwise gets the plain :class:`~reactpy.types.Event` snapshot.
-    Setting :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY` asks for live objects
-    on every handler."""
+    The default, :data:`None`, follows
+    :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, which is switched on because
+    reading the object an event fired from is what people expect. Opt a single handler
+    out by setting this to :data:`False` - worthwhile for anything which fires
+    continuously, such as ``onScroll``, because keeping an object alive and pruning it
+    costs the browser work on every event.
+
+    Whatever this says, a handler is only ever given live access for data the client
+    actually marked as something it is still holding. Otherwise it receives the
+    :class:`~reactpy.types.Event` snapshot, and awaiting a property of it raises
+    :class:`~reactpy._event_proxy.EventProxyUnavailable`."""
 
     target: str | None
     """Typically left as ``None`` except when a static target is useful.
@@ -1190,56 +1190,16 @@ def __repr__(self) -> str:
         return "..."
 
 
-class Event(dict):
-    """
-    A light `dict` wrapper for event data passed to event handler functions.
-
-    This is what handlers receive for data which is not a browser object. Anything
-    which actually came out of the page is kept alive by the client and handed over as
-    a :class:`SyncEvent` or :class:`AsyncEvent`, which can additionally be read from
-    and called into.
-    """
-
-    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.
-
-        This is a no-op here - use :func:`reactpy.event`'s ``prevent_default=True``
-        instead, which asks the browser to do it before the event reaches the server.
-        """
-
-    def stopPropagation(self) -> None:
-        """Stop the event from propagating.
-
-        This is a no-op here - use :func:`reactpy.event`'s ``stop_propagation=True``
-        instead, which asks the browser to do it before the event reaches the server.
-        """
-
-
 SourceType = NewType("SourceType", str)
 
 
-EventObject: TypeAlias = Event | EventProxy[Any]
+EventObject: TypeAlias = Event[Any] | EventProxy[Any]
 """Anything which may be handed to an event handler as event data
 
-Either the plain :class:`Event` snapshot, or a live :class:`~reactpy._event_proxy.EventProxy`.
+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`.
 """
 
-LiveEvent: TypeAlias = SyncEvent[Any] | AsyncEvent[Any]
-"""The live event objects ReactPy hands to event handlers
-
-A :class:`~reactpy._event_proxy.SyncEvent` for synchronous handlers and an
-:class:`~reactpy._event_proxy.AsyncEvent` for asynchronous ones.
-"""
-
-LiveEventMember: TypeAlias = SyncEventProxy[Any] | AsyncEventProxy[Any]
-"""A lazily resolved member of a :data:`LiveEvent`"""
-
 
 @dataclass(frozen=True)
 class JavaScriptModule:
diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py
index ef9c6efaf..b2676126e 100644
--- a/src/reactpy/widgets.py
+++ b/src/reactpy/widgets.py
@@ -59,7 +59,7 @@ def use_linked_inputs(
     """
     value, set_value = reactpy.hooks.use_state(initial_value)
 
-    def sync_inputs(event: dict[str, Any]) -> None:
+    async def sync_inputs(event: dict[str, Any]) -> None:
         new_value = event["target"]["value"]
         set_value(new_value)
         if not new_value and ignore_empty:
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..f3e420e0d 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}",
diff --git a/tests/test_core/test_event_inspect.py b/tests/test_core/test_event_inspect.py
index 904c7dba8..37488d3ff 100644
--- a/tests/test_core/test_event_inspect.py
+++ b/tests/test_core/test_event_inspect.py
@@ -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,21 +193,21 @@ 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__) == {}
@@ -338,7 +338,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 +378,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
@@ -407,7 +407,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
diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py
index 43fb571af..74cfa477b 100644
--- a/tests/test_core/test_event_proxy.py
+++ b/tests/test_core/test_event_proxy.py
@@ -3,8 +3,7 @@
 from __future__ import annotations
 
 import asyncio
-import threading
-from collections.abc import Callable
+from collections.abc import Callable, Sequence
 from time import monotonic
 from types import MappingProxyType
 from typing import Any
@@ -17,18 +16,14 @@
     EVENT_PROXY_REQUEST_TYPE,
     EVENT_PROXY_RESPONSE_TYPE,
     EVENT_REFERENCE_KEY,
-    AsyncEvent,
-    AsyncEventProxy,
     EventArguments,
+    Event,
     EventProxy,
     EventProxyAccessError,
     EventProxyBackend,
-    EventProxyBlocked,
     EventProxyNotFound,
     EventProxyTimeout,
     EventProxyUnavailable,
-    SyncEvent,
-    SyncEventProxy,
     build_event_args,
     deserialize_value,
     serialize_argument,
@@ -37,15 +32,35 @@
     REACTPY_EVENT_PROXY_MAX_TIMEOUTS,
     REACTPY_EVENT_PROXY_TIMEOUT,
 )
+
 from reactpy.core.component import component
 from reactpy.core.events import EventHandler, event, merge_event_handlers
 from reactpy.core.layout import Layout
 from reactpy.testing import assert_reactpy_did_log
-from reactpy.types import Event
 from reactpy.utils import Ref
 from tests.tooling.common import event_message
 
 
+def make_event(
+    backend: EventProxyBackend,
+    event_id: str,
+    snapshot: dict[str, Any] | None = None,
+    path: Sequence[str | int] = (),
+) -> Event:
+    """Build an event the way the layout hands one to a handler"""
+    return Event(snapshot or {}, backend=backend, event_id=event_id, path=path)
+
+
+def make_proxy(
+    backend: EventProxyBackend,
+    event_id: str,
+    path: Sequence[str | int] = (),
+    snapshot: dict[str, Any] | None = None,
+) -> EventProxy:
+    """Build a member proxy of the kind an event hands out"""
+    return EventProxy(snapshot, backend=backend, event_id=event_id, path=path)
+
+
 class Method:
     """A callable which records how it was called"""
 
@@ -282,34 +297,9 @@ def make_canned_client(**response: Any) -> tuple[EventProxyBackend, CannedClient
     return backend, client
 
 
-async def in_worker(function: Callable[[], Any]) -> Any:
-    """Run ``function`` off the event loop, returning its result or raising its error
-
-    Synchronous live access refuses to block the loop thread, so anything which blocks
-    has to run somewhere else - just like the worker thread ReactPy puts plain ``def``
-    handlers in.
-    """
-    outcome: Ref[tuple[bool, Any]] = Ref(None)
-
-    def run() -> None:
-        try:
-            outcome.current = (True, function())
-        except Exception as error:
-            outcome.current = (False, error)
-
-    thread = threading.Thread(target=run)
-    thread.start()
-    await asyncio.to_thread(thread.join, 10)
-    assert not thread.is_alive(), "the worker thread never finished"
-    succeeded, result = outcome.current
-    if not succeeded:
-        raise result
-    return result
-
-
 async def test_async_event_prefers_the_snapshot():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(
+    event_object = make_event(
         backend,
         "evt",
         snapshot={"type": "click", "target": {"value": "cached"}},
@@ -318,13 +308,13 @@ async def test_async_event_prefers_the_snapshot():
     assert event_object["type"] == "click"
     assert event_object["target"]["value"] == "cached"
     assert event_object.target.value == "cached"
-    assert isinstance(event_object["target"]["nope"], AsyncEventProxy)
+    assert isinstance(event_object["target"]["nope"], EventProxy)
     assert requested_paths(client) == []
 
 
 async def test_async_event_resolves_what_the_snapshot_lacks():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(backend, "evt", snapshot={"type": "click"})
+    event_object = make_event(backend, "evt", snapshot={"type": "click"})
 
     assert await event_object.target.tagName == "INPUT"
     assert await event_object.read("target.value") == "typed"
@@ -338,10 +328,10 @@ async def test_async_event_resolves_what_the_snapshot_lacks():
 
 async def test_async_event_walks_references():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     deep = await event_object.deep.resolve()
-    assert isinstance(deep, AsyncEventProxy)
+    assert isinstance(deep, EventProxy)
     assert deep.path == ("deep",)
     assert await event_object.deep.a.b.c == 42
     assert await event_object.read("deep.a.b.c") == 42
@@ -355,7 +345,7 @@ async def test_async_event_walks_references():
 async def test_async_event_calls_client_side_methods():
     objects = make_objects()
     backend, _client = make_client(objects)
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     assert await event_object.target.focus() == "focused"
     assert await event_object.composedPath() == ["window", "input"]
@@ -371,7 +361,7 @@ async def test_async_event_calls_client_side_methods():
 async def test_prevent_default_is_scheduled_but_not_awaited():
     objects = make_objects()
     backend, client = make_client(objects)
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     event_object.preventDefault()
     event_object.stopPropagation()
@@ -396,7 +386,7 @@ def test_snapshot_data_is_plain_json():
     import json
 
     backend = EventProxyBackend()
-    event_object = AsyncEvent(
+    event_object = make_event(
         backend, "evt", snapshot={"type": "click", "target": {"value": "x"}}
     )
 
@@ -411,48 +401,11 @@ def test_snapshot_data_is_plain_json():
     assert event_object.debounce == 120
 
 
-async def test_sync_event_blocks_until_the_client_answers():
-    objects = make_objects()
-    backend, _client = make_client(objects, delay=0.01)
-    event_object = SyncEvent(backend, "evt", snapshot={"type": "click"})
-    outcome: Ref[tuple[Any, ...]] = Ref(None)
-
-    def handler() -> None:
-        outcome.current = (
-            event_object.target.value,
-            str(event_object.deep.a.b.c),
-            event_object.target.focus(),
-            event_object.composedPath(),
-            [touch["pageX"] for touch in event_object.touches],
-        )
-
-    thread = threading.Thread(target=handler)
-    thread.start()
-    await asyncio.to_thread(thread.join, 10)
-    assert not thread.is_alive()
-    assert outcome.current == (
-        "typed",
-        "42",
-        "focused",
-        ["window", "input"],
-        [1, 2],
-    )
-
-
-async def test_sync_access_from_the_event_loop_is_refused():
-    backend, _client = make_client(make_objects())
-    event_object = SyncEvent(backend, "evt", snapshot={})
-
-    with pytest.raises(EventProxyBlocked):
-        value = event_object.target.value
-        assert value
-
-
 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 = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     for _ in range(2):
         with pytest.raises(EventProxyTimeout):
@@ -466,18 +419,18 @@ async def test_unanswered_requests_eventually_disable_live_access(monkeypatch):
 
 async def test_missing_objects_and_paths_are_reported():
     backend, _client = make_client(make_objects())
-    missing = AsyncEvent(backend, "not-registered", snapshot={})
+    missing = make_event(backend, "not-registered", snapshot={})
     with pytest.raises(EventProxyNotFound):
         await missing.resolve()
 
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
     with pytest.raises(EventProxyNotFound):
         await event_object.nope.resolve()
 
 
 def test_arguments_are_serialized_or_refused():
     backend = EventProxyBackend()
-    proxy = SyncEventProxy(backend, "evt", ["target"])
+    proxy = make_proxy(backend, "evt", ["target"])
     assert serialize_argument(proxy) == {
         EVENT_REFERENCE_KEY: {"eventId": "evt", "path": ["target"]},
     }
@@ -493,7 +446,7 @@ def test_excessively_nested_references_are_refused():
         value = {"nested": value}
 
     with pytest.raises(EventProxyAccessError):
-        deserialize_value(value, "evt", backend, sync=False)
+        deserialize_value(value, "evt", backend)
 
 
 def test_backend_without_a_sender_is_unavailable():
@@ -501,29 +454,9 @@ def test_backend_without_a_sender_is_unavailable():
     assert not backend.available
 
     data = [{EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, 5]
-    args, flavor = build_event_args(data, backend)
+    args = build_event_args(data, backend)
     assert isinstance(args[0], Event)
     assert args[1] == 5
-    assert flavor is None
-
-
-async def test_event_arguments_are_rebuilt_for_each_handler():
-    backend, _client = make_client(make_objects())
-    data = [{EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}]
-
-    arguments = EventArguments(data, backend)
-    assert isinstance(arguments[0], AsyncEvent)
-    assert EVENT_MARKER_KEY not in arguments[0]
-
-    sync_arguments = arguments.rebuild(False)
-    assert isinstance(sync_arguments[0], SyncEvent)
-    assert arguments.rebuild(True) is arguments
-
-    plain = EventArguments([1, "two"])
-    assert plain.rebuild(False) is plain
-
-
-# -- layout integration -----------------------------------------------------
 
 
 def event_handlers_of(model: dict[str, Any]) -> dict[str, Any]:
@@ -540,64 +473,15 @@ def event_handlers_of(model: dict[str, Any]) -> dict[str, Any]:
     raise KeyError(msg)
 
 
-async def test_layout_hands_handlers_events_of_their_own_flavour():
-    main_thread = threading.current_thread().name
-    received: dict[str, tuple[Any, str, Any]] = {}
-    objects = make_objects()
-
-    async def async_handler(event_object):
-        received["async"] = (
-            event_object,
-            threading.current_thread().name,
-            await event_object.read("target.value"),
-        )
-
-    def sync_handler(event_object):
-        received["sync"] = (
-            event_object,
-            threading.current_thread().name,
-            event_object.target.value,
-        )
-
-    @component
-    def MyComponent():
-        return reactpy.html.div({"onSync": sync_handler, "onAsync": async_handler})
-
-    _backend, client = make_client(objects)
-
-    async with Layout(MyComponent()) as layout:
-        connect(layout, client)
-        model = (await layout.render())["model"]
-        handlers = event_handlers_of(model)
-
-        data = {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}
-        await layout.deliver(event_message(handlers["onSync"]["target"], dict(data)))
-        await poll(lambda: "sync" in received)
-        await layout.deliver(event_message(handlers["onAsync"]["target"], dict(data)))
-        await poll(lambda: "async" in received)
-
-    sync_event, sync_thread, sync_value = received["sync"]
-    assert isinstance(sync_event, SyncEvent)
-    assert sync_value == "typed"
-    assert sync_thread != main_thread, "blocking handlers run off the event loop"
-
-    async_event, async_thread, async_value = received["async"]
-    assert isinstance(async_event, AsyncEvent)
-    assert async_value == "typed"
-    assert async_thread == main_thread
-
-    assert requested_paths(client) == [["target"], ["target", "value"]]
-
-
 async def test_layout_hands_data_which_is_not_a_browser_object_as_is():
     received: dict[str, Any] = {}
 
-    def sync_handler(event):
+    async def on_click(event):
         received["event"] = event
 
     @component
     def MyComponent():
-        return reactpy.html.div({"onClick": sync_handler})
+        return reactpy.html.div({"onClick": on_click})
 
     _backend, client = make_client(make_objects())
 
@@ -615,17 +499,22 @@ def MyComponent():
     assert client.requests == [], "nothing to reach into, so nothing was asked"
 
 
-async def test_merged_handlers_get_the_flavour_each_needs():
+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] = {}
 
-    def sync_handler(args):
-        received["sync"] = args[0]
+    async def first_handler(args):
+        received["first"] = args[0]
 
-    async def async_handler(args):
-        received["async"] = args[0]
+    async def second_handler(args):
+        received["second"] = args[0]
 
     merged = merge_event_handlers(
-        [EventHandler(sync_handler), EventHandler(async_handler)]
+        [EventHandler(first_handler), EventHandler(second_handler)]
     )
 
     @component
@@ -643,19 +532,21 @@ def MyComponent():
         )
         await poll(lambda: len(received) == 2)
 
-    assert isinstance(received["sync"], SyncEvent)
-    assert isinstance(received["async"], AsyncEvent)
+    assert isinstance(received["first"], Event)
+    assert isinstance(received["second"], Event)
+    assert received["first"] is received["second"]
+    assert await received["first"].read("type") == "click"
 
 
 async def test_layout_without_a_client_falls_back_to_snapshots():
     received: dict[str, Any] = {}
 
-    def sync_handler(event):
+    async def on_click(event):
         received["event"] = event
 
     @component
     def MyComponent():
-        return reactpy.html.div({"onClick": sync_handler})
+        return reactpy.html.div({"onClick": on_click})
 
     async with Layout(MyComponent()) as layout:
         model = (await layout.render())["model"]
@@ -680,7 +571,7 @@ async def test_a_handler_which_declines_live_access_takes_the_snapshot():
     """
     received: dict[str, Any] = {}
 
-    def handler(event_object):
+    async def handler(event_object):
         received["event"] = event_object
 
     @component
@@ -744,7 +635,7 @@ def MyComponent():
         )
         await poll(lambda: "event" in received)
 
-        assert isinstance(received["event"], AsyncEvent)
+        assert isinstance(received["event"], Event)
         assert await received["event"].read("target.value") == "typed"
 
     assert requested_paths(client) == [["target", "value"]]
@@ -766,7 +657,7 @@ def MyComponent():
 
 
 async def test_stop_immediate_propagation_is_detected_and_forwarded():
-    def on_click(event):
+    async def on_click(event):
         event.stopImmediatePropagation()
 
     @component
@@ -785,7 +676,7 @@ def MyComponent():
 
 async def test_proxies_describe_themselves():
     backend, _client = make_client(make_objects())
-    event_object = AsyncEvent(
+    event_object = make_event(
         backend, "evt", snapshot={"type": "click", 3: "not-an-attribute"}
     )
     target = event_object.target
@@ -795,7 +686,7 @@ async def test_proxies_describe_themselves():
     assert event_object.path == ()
     assert event_object.has_snapshot is True
     assert repr(event_object) == (
-        "AsyncEvent(event_id='evt', path=[], "
+        "Event(event_id='evt', path=[], "
         "snapshot={'type': 'click', 3: 'not-an-attribute'})"
     )
     # snapshot keys show up as attributes, but only the string ones
@@ -805,14 +696,12 @@ async def test_proxies_describe_themselves():
     assert target.backend is backend
     assert target.path == ("target",)
     assert target.has_snapshot is False
-    assert repr(target) == (
-        "AsyncEventProxy(event_id='evt', path=['target'], snapshot={})"
-    )
+    assert repr(target) == ("EventProxy(event_id='evt', path=['target'], snapshot={})")
 
 
 async def test_internal_attributes_are_never_resolved_against_the_browser():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     for name in ("_reactpy_nothing", "__wrapped__", "__deepcopy__"):
         with pytest.raises(AttributeError, match=f"^{name}$"):
@@ -821,12 +710,12 @@ async def test_internal_attributes_are_never_resolved_against_the_browser():
     assert client.requests == [], "internals are not looked up in the browser"
 
 
-def test_only_the_two_flavours_know_how_to_resolve():
-    base = EventProxy(EventProxyBackend(), "evt", ["target"])
+async def test_a_proxy_with_nothing_behind_it_cannot_resolve():
+    base = make_proxy(EventProxyBackend(), "evt", ["target"])
 
     assert dict(base) == {}
-    with pytest.raises(NotImplementedError, match="cannot resolve live event data"):
-        base._reactpy_resolve_path(False, ())
+    with pytest.raises(EventProxyUnavailable, match="live access"):
+        await base
 
 
 # -- reading through the snapshot -------------------------------------------
@@ -834,7 +723,7 @@ def test_only_the_two_flavours_know_how_to_resolve():
 
 async def test_read_walks_the_whole_dotted_path_at_once():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(
+    event_object = make_event(
         backend,
         "evt",
         snapshot={
@@ -859,7 +748,7 @@ async def test_read_walks_the_whole_dotted_path_at_once():
 
 async def test_read_gives_up_on_paths_the_snapshot_cannot_hold():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(
+    event_object = make_event(
         backend,
         "evt",
         snapshot={
@@ -886,7 +775,7 @@ async def test_read_gives_up_on_paths_the_snapshot_cannot_hold():
         await event_object.read("type.deeper")
 
 
-async def test_chained_attributes_ask_for_one_level_at_a_time():
+async def test_chained_attributes_are_asked_for_in_one_round_trip():
     objects = {
         "evt": {
             "chain": Reference(
@@ -895,21 +784,17 @@ async def test_chained_attributes_ask_for_one_level_at_a_time():
         },
     }
     backend, client = make_client(objects)
-    event_object = SyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     # read() asks for the whole path in a single round trip ...
-    assert await in_worker(lambda: event_object.read("chain.next.next.answer")) == 5
+    assert await event_object.read("chain.next.next.answer") == 5
     assert requested_paths(client) == [["chain", "next", "next", "answer"]]
 
-    # ... while walking attributes resolves whatever the last answer left over
+    # ... 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
     client.sent.clear()
-    assert await in_worker(lambda: event_object.chain.next.next.answer) == 5
-    assert requested_paths(client) == [
-        ["chain"],
-        ["chain", "next"],
-        ["chain", "next", "next"],
-        ["chain", "next", "next", "answer"],
-    ]
+    assert await event_object.chain.next.next.answer == 5
+    assert requested_paths(client) == [["chain", "next", "next", "answer"]]
 
 
 # -- snapshot data which is a reference or a list ---------------------------
@@ -917,7 +802,7 @@ async def test_chained_attributes_ask_for_one_level_at_a_time():
 
 async def test_references_inside_a_snapshot_become_real_proxies():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(
+    event_object = make_event(
         backend,
         "evt",
         snapshot={
@@ -933,7 +818,7 @@ async def test_references_inside_a_snapshot_become_real_proxies():
     )
 
     node = event_object.node
-    assert isinstance(node, AsyncEventProxy)
+    assert isinstance(node, EventProxy)
     # the path the client handed us wins over where this proxy sits
     assert node.path == ("target",)
     assert node.event_id == "other"
@@ -942,7 +827,7 @@ async def test_references_inside_a_snapshot_become_real_proxies():
 
     # a reference which does not say where it points at keeps our own path
     self_ref = event_object.self_ref
-    assert isinstance(self_ref, AsyncEventProxy)
+    assert isinstance(self_ref, EventProxy)
     assert self_ref.path == ("self_ref",)
     assert self_ref.event_id == "evt"
     assert self_ref._reactpy_callable is False
@@ -951,7 +836,7 @@ async def test_references_inside_a_snapshot_become_real_proxies():
 
 async def test_lists_inside_a_snapshot_are_wrapped_with_child_proxies():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(
+    event_object = make_event(
         backend,
         "evt",
         snapshot={
@@ -966,10 +851,10 @@ async def test_lists_inside_a_snapshot_are_wrapped_with_child_proxies():
         ("touches", "0"),
         ("touches", "1"),
     ]
-    assert all(isinstance(touch, AsyncEventProxy) for touch in touches)
+    assert all(isinstance(touch, EventProxy) for touch in touches)
 
     matrix = event_object.matrix
-    assert isinstance(matrix[0][0], AsyncEventProxy)
+    assert isinstance(matrix[0][0], EventProxy)
     assert matrix[0][0].path == ("matrix", "0", "0")
     assert matrix[0][0]["x"] == 1
     assert matrix[1] == [3, "four"]
@@ -982,11 +867,11 @@ async def test_lists_inside_a_snapshot_are_wrapped_with_child_proxies():
 
 async def test_async_proxies_refuse_to_be_inspected_while_unknown():
     backend, client = make_client(make_objects())
-    target = AsyncEventProxy(backend, "evt", ["target"])
+    target = make_proxy(backend, "evt", ["target"])
 
     with pytest.raises(EventProxyAccessError) as measure:
         len(target)
-    assert "cannot measure AsyncEventProxy for ['target'] of event 'evt'" in str(
+    assert "cannot measure EventProxy for ['target'] of event 'evt'" in str(
         measure.value
     )
     assert "await the proxy" in str(measure.value)
@@ -999,7 +884,7 @@ async def test_async_proxies_refuse_to_be_inspected_while_unknown():
     assert requested_paths(client) == []
 
     # once the client has told us something the dict protocol is free
-    event_object = AsyncEvent(backend, "evt", snapshot={"type": "click"})
+    event_object = make_event(backend, "evt", snapshot={"type": "click"})
     assert len(event_object) == 1
     assert list(event_object) == ["type"]
     assert "type" in event_object
@@ -1008,7 +893,7 @@ async def test_async_proxies_refuse_to_be_inspected_while_unknown():
 
 
 async def test_proxies_without_a_connection_are_quietly_empty():
-    event_object = AsyncEvent(EventProxyBackend(), "evt", snapshot={})
+    event_object = make_event(EventProxyBackend(), "evt", snapshot={})
 
     assert len(event_object) == 0
     assert list(event_object) == []
@@ -1018,22 +903,18 @@ async def test_proxies_without_a_connection_are_quietly_empty():
 # -- what the synchronous flavour does when it has to block -----------------
 
 
-async def test_sync_reads_either_use_the_snapshot_or_block_for_the_browser():
+async def test_reads_either_use_the_snapshot_or_ask_the_browser():
     objects = make_objects()
     objects["evt"]["detail"] = 7
     backend, client = make_client(objects)
-    event_object = SyncEvent(backend, "evt", snapshot={"type": "click"})
-
-    def access():
-        return (
-            event_object.read("type"),
-            event_object.read("target.value"),
-            event_object.read("nope", default="fallback"),
-            event_object.detail,
-            SyncEventProxy(backend, "evt", ["target"]).resolve(),
-        )
+    event_object = make_event(backend, "evt", snapshot={"type": "click"})
+
+    cached = await event_object.read("type")
+    typed = await event_object.read("target.value")
+    fallback = await event_object.read("nope", default="fallback")
+    detail = await event_object.read("detail")
+    resolved = await make_proxy(backend, "evt", ["target"]).resolve()
 
-    cached, typed, fallback, detail, resolved = await in_worker(access)
     assert (cached, typed, fallback, detail) == ("click", "typed", "fallback", 7)
     assert type(resolved) is dict
     assert resolved["value"] == "typed"
@@ -1046,95 +927,7 @@ def access():
 
     # without a default the client's refusal is raised rather than swallowed
     with pytest.raises(EventProxyNotFound):
-        await in_worker(lambda: event_object.read("nope"))
-
-
-async def test_sync_reads_the_browser_cannot_answer_degrade_to_a_blank_proxy():
-    backend, client = make_client(make_objects())
-    event_object = SyncEvent(backend, "evt", snapshot={})
-
-    with assert_reactpy_did_log("Falling back to snapshot data", EventProxyNotFound):
-        missing = await in_worker(lambda: event_object.nope)
-    assert isinstance(missing, SyncEventProxy)
-    assert missing.path == ("nope",)
-    assert missing.has_snapshot is False
-    assert requested_paths(client) == [["nope"]]
-
-    # printing and iterating still work, so a failed read is safe to log
-    with assert_reactpy_did_log("Could not resolve", EventProxyNotFound):
-        assert await in_worker(lambda: str(missing)) == "{}"
-        empty = await in_worker(lambda: iter(missing))
-        assert next(empty, None) is None
-    assert requested_paths(client) == [["nope"], ["nope"], ["nope"]]
-
-
-async def test_sync_proxies_compare_by_what_they_point_at():
-    objects = make_objects()
-    objects["evt"]["numbers"] = [1, 2, 3]
-    backend, _client = make_client(objects)
-    left = SyncEventProxy(backend, "evt", ["target"], snapshot={"k": 1})
-    same = SyncEventProxy(backend, "evt", ["target"], snapshot={"k": 2})
-    other = SyncEventProxy(backend, "evt", ["deep"], snapshot={"k": 2})
-
-    # the very same browser object, whatever either snapshot happens to say
-    assert (left == same) is True
-    assert (left != same) is False
-    # a different path leaves it to the data we hold
-    assert (left == other) is False
-    assert (left != other) is True
-    assert (left == SyncEventProxy(backend, "evt", ["deep"], {"k": 1})) is True
-
-    # against anything else the live value is what counts
-    numbers = SyncEventProxy(backend, "evt", ["numbers"])
-    assert await in_worker(lambda: numbers == [1, 2, 3]) is True
-    assert await in_worker(lambda: numbers == [1, 2]) is False
-    assert await in_worker(lambda: numbers != [1, 2, 3]) is False
-
-    # a snapshot we already have is never compared against the browser
-    answered = SyncEventProxy(backend, "evt", ["x"], snapshot={"k": 1})
-    assert await in_worker(lambda: answered == 5) is False
-    assert await in_worker(lambda: answered != 5) is True
-
-
-async def test_sync_proxies_print_and_measure_like_their_values():
-    objects = make_objects()
-    objects["evt"]["numbers"] = [1, 2, 3]
-    backend, _client = make_client(objects)
-    numbers = SyncEventProxy(backend, "evt", ["numbers"])
-    node = SyncEventProxy(backend, "evt", ["target"])
-    answered = SyncEventProxy(backend, "evt", ["x"], snapshot={"k": 1})
-
-    assert await in_worker(lambda: str(numbers)) == "[1, 2, 3]"
-    assert await in_worker(lambda: format(numbers, "")) == "[1, 2, 3]"
-    assert await in_worker(lambda: str(answered)) == "{'k': 1}"
-    assert await in_worker(lambda: format(answered, "")) == "{'k': 1}"
-
-    assert await in_worker(lambda: len(numbers)) == 3
-    assert await in_worker(lambda: list(numbers)) == [1, 2, 3]
-    assert await in_worker(lambda: len(answered)) == 1
-    assert await in_worker(lambda: list(answered)) == ["k"]
-
-    assert await in_worker(lambda: 2 in numbers) is True
-    assert await in_worker(lambda: 9 in numbers) is False
-    assert await in_worker(lambda: "tagName" in node) is True
-    assert await in_worker(lambda: "k" in answered) is True
-    assert await in_worker(lambda: "zzz" in answered) is False
-
-    # a reference to something else says nothing about this proxy
-    deep = SyncEventProxy(backend, "evt", ["deep"])
-    assert await in_worker(lambda: str(deep)) == "{}"
-    deep_items = await in_worker(lambda: iter(deep))
-    assert next(deep_items, None) is None
-
-    # a read which failed is simply "not there"
-    missing = SyncEventProxy(backend, "evt", ["nope"])
-    assert await in_worker(lambda: "anything" in missing) is False
-    # with no connection at all membership is never asked of the browser
-    offline = SyncEventProxy(EventProxyBackend(), "evt", ["target"])
-    assert ("tagName" in offline) is False
-
-
-# -- when the connection is not what it should be ---------------------------
+        await event_object.read("nope")
 
 
 async def test_a_failed_send_switches_live_access_off():
@@ -1147,7 +940,7 @@ async def send(message: dict[str, Any]) -> None:
         raise ConnectionResetError(msg)
 
     backend.set_sender(send)
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     with pytest.raises(EventProxyUnavailable, match="failed to send"):
         await event_object.read("target")
@@ -1159,7 +952,7 @@ async def send(message: dict[str, Any]) -> None:
 
 async def test_closing_the_backend_fails_everything_still_in_flight():
     backend, client = make_client(make_objects(), respond=False)
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
     pending = [
         asyncio.create_task(event_object.read("target")),
         asyncio.create_task(event_object.read("type")),
@@ -1182,25 +975,12 @@ async def test_closing_the_backend_fails_everything_still_in_flight():
     )
 
 
-async def test_blocking_reads_time_out(monkeypatch):
-    monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05)
-    monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 1)
-    backend, client = make_client(make_objects(), respond=False)
-    event_object = SyncEvent(backend, "evt", snapshot={})
-
-    with pytest.raises(EventProxyTimeout, match="did not answer"):
-        await in_worker(lambda: event_object.read("target"))
-
-    assert len(client.requests) == 1
-    assert backend.disabled
-
-
 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 = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     with pytest.raises(EventProxyTimeout, match="did not answer"):
         await event_object.read("target")
@@ -1210,7 +990,7 @@ async def test_client_reported_timeouts_count_against_the_same_budget(monkeypatc
 
 async def test_failures_the_client_cannot_explain_are_still_reported():
     backend, client = make_canned_client(ok=False)
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     with pytest.raises(EventProxyAccessError) as info:
         await event_object.read("target")
@@ -1223,7 +1003,7 @@ async def test_failures_the_client_cannot_explain_are_still_reported():
 
 async def test_requests_made_just_after_the_loop_is_gone_fail_fast(monkeypatch):
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
     loop = asyncio.get_running_loop()
     # the loop which owned this connection has gone away, the sender has not noticed
     monkeypatch.setattr(backend, "_loop", None)
@@ -1242,7 +1022,7 @@ async def test_requests_made_just_after_the_loop_is_gone_fail_fast(monkeypatch):
 
 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 = AsyncEvent(EventProxyBackend(), "evt", snapshot={})
+    event_object = make_event(EventProxyBackend(), "evt", snapshot={})
 
     assert event_object.preventDefault() is None
     assert event_object.stopPropagation() is None
@@ -1257,7 +1037,7 @@ 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 = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     event_object.stopImmediatePropagation()
 
@@ -1273,7 +1053,7 @@ async def test_stop_immediate_propagation_is_forwarded_to_the_client():
 async def test_client_side_calls_can_be_waited_for():
     objects = make_objects()
     backend, client = make_client(objects)
-    target = AsyncEventProxy(backend, "evt", ["target"])
+    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
@@ -1286,19 +1066,12 @@ async def test_client_side_calls_can_be_waited_for():
 
 async def test_the_event_object_itself_cannot_be_called():
     backend, client = make_client(make_objects())
-    event_object = AsyncEvent(backend, "evt", snapshot={})
+    event_object = make_event(backend, "evt", snapshot={})
 
     with pytest.raises(EventProxyAccessError, match="cannot be called"):
         await event_object()
     with pytest.raises(EventProxyAccessError, match="cannot be called"):
-        await AsyncEventProxy(backend, "evt")()
-
-    # ... and the blocking flavour says the same thing from a worker thread
-    def call_the_root():
-        return SyncEvent(backend, "evt", snapshot={})()
-
-    with pytest.raises(EventProxyAccessError, match="cannot be called"):
-        await in_worker(call_the_root)
+        await make_proxy(backend, "evt")()
 
     assert client.requests == []
 
@@ -1309,23 +1082,20 @@ def call_the_root():
 async def test_a_plain_string_marker_names_a_live_event_too():
     backend, _client = make_client(make_objects())
 
-    args, flavor = build_event_args(
+    args = build_event_args(
         [{EVENT_MARKER_KEY: "evt", "type": "click"}, {"type": "click"}, "extra"],
         backend,
     )
 
-    assert isinstance(args[0], AsyncEvent)
+    assert isinstance(args[0], Event)
     assert args[0].event_id == "evt"
     assert args[0]["type"] == "click"
     assert type(args[1]) is Event
     assert args[2] == "extra"
-    assert flavor is True
 
-    sync_args, sync_flavor = build_event_args(
-        [{EVENT_MARKER_KEY: {"id": "evt"}}], backend, prefer_async=False
-    )
-    assert isinstance(sync_args[0], SyncEvent)
-    assert sync_flavor is False
+    marked = build_event_args([{EVENT_MARKER_KEY: {"id": "evt"}}], backend)
+    assert isinstance(marked[0], Event)
+    assert marked[0].event_id == "evt"
 
 
 def test_arguments_the_client_could_never_use_are_refused():
diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py
index c9d4c3144..5046d08b1 100644
--- a/tests/test_core/test_events.py
+++ b/tests/test_core/test_events.py
@@ -24,8 +24,12 @@
 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, stop_immediate_propagation=False, debounce=None, "
@@ -34,42 +38,42 @@ def test_event_handler_repr():
 
 
 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
@@ -81,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
@@ -106,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():
@@ -138,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"):
@@ -151,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({})
@@ -194,10 +227,10 @@ def test_merge_event_handlers_event_proxy():
     merged handler asks for it whenever any of the originals did, and states nothing
     at all when none of them said either way.
     """
-    plain = EventHandler(lambda data: None)
-    wants = EventHandler(lambda data: None, event_proxy=True)
-    declines = EventHandler(lambda data: None, event_proxy=False)
-    other_plain = EventHandler(lambda data: None)
+    plain = EventHandler(_noop_handler)
+    wants = EventHandler(_noop_handler, event_proxy=True)
+    declines = EventHandler(_noop_handler, event_proxy=False)
+    other_plain = EventHandler(_noop_handler)
 
     assert merge_event_handlers([plain, other_plain]).event_proxy is None
     assert merge_event_handlers([plain, wants]).event_proxy is True
@@ -250,10 +283,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(
@@ -336,12 +369,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",
             ),
@@ -379,7 +416,7 @@ def App():
 
 
 def test_detect_prevent_default():
-    def handler(event: Event):
+    async def handler(event: Event):
         event.preventDefault()
 
     eh = EventHandler(handler)
@@ -387,7 +424,7 @@ def handler(event: Event):
 
 
 def test_detect_stop_propagation():
-    def handler(event: Event):
+    async def handler(event: Event):
         event.stopPropagation()
 
     eh = EventHandler(handler)
@@ -395,7 +432,7 @@ def handler(event: Event):
 
 
 def test_detect_debounce():
-    def handler(event: Event):
+    async def handler(event: Event):
         event.debounce = 200
 
     eh = EventHandler(handler)
@@ -410,7 +447,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
@@ -426,7 +463,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)
@@ -434,7 +471,7 @@ def handler(event: Event):
 
 
 def test_detect_both():
-    def handler(event: Event):
+    async def handler(event: Event):
         event.preventDefault()
         event.stopPropagation()
 
@@ -444,7 +481,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()
 
@@ -454,7 +491,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))
@@ -462,7 +499,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)
@@ -470,7 +507,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)
@@ -479,7 +516,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
@@ -490,7 +527,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
@@ -500,7 +537,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
 
@@ -511,7 +548,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
@@ -523,7 +560,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)
@@ -531,7 +568,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)
@@ -539,7 +576,7 @@ def handler(event: Event):
 
 
 def test_no_detect():
-    def handler(event: Event):
+    async def handler(event: Event):
         pass
 
     eh = EventHandler(handler)
@@ -550,7 +587,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
@@ -559,7 +596,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
@@ -567,7 +604,7 @@ 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
 
 
@@ -583,7 +620,7 @@ def test_event_wrapper():
 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})
@@ -599,7 +636,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})
@@ -617,7 +654,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()
@@ -631,7 +668,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
@@ -699,7 +736,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(
@@ -818,10 +855,10 @@ def _ControlledInputWithMirror():
     """A controlled input whose model is also rendered somewhere un-clobberable"""
     value, set_value = use_state("")
 
-    def on_change(event):
+    async def on_change(event):
         set_value(event["target"]["value"])
 
-    def append_marker(event):
+    async def append_marker(event):
         set_value(lambda old: old + MARKER)
 
     return reactpy.html.div(
@@ -939,10 +976,10 @@ def _ControlledInputWhichClearsOnKeyDown():
     """
     value, set_value = use_state("")
 
-    def on_change(event):
+    async def on_change(event):
         set_value(event["target"]["value"])
 
-    def on_key_down(event):
+    async def on_key_down(event):
         set_value("")
 
     return reactpy.html.div(
@@ -993,7 +1030,7 @@ async def test_controlled_input_respects_custom_debounce(display: DisplayFixture
     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())
 
@@ -1028,7 +1065,7 @@ 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())
 
@@ -1264,7 +1301,7 @@ def _HeavyPage():
     value, set_value = use_state("")
     ticks, set_ticks = use_state(0)
 
-    def on_change(event):
+    async def on_change(event):
         set_value(event["target"]["value"])
 
     def keep_rendering():
diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py
index 28c6ae004..d5d811104 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",
             ),
@@ -265,7 +271,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)
 
diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py
index 0bb6c7013..a29b24d4f 100644
--- a/tests/test_core/test_layout.py
+++ b/tests/test_core/test_layout.py
@@ -38,6 +38,10 @@
 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])
 def async_rendering(request):
     with patch.object(REACTPY_ASYNC_RENDERING, "current", request.param):
@@ -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
@@ -1769,7 +1779,7 @@ async def test_ack_reaches_the_client_even_when_the_handler_changed_nothing():
     handler_has_run = Event()
     static = StaticEventHandler()
 
-    def on_change(event):
+    async def on_change(event):
         handler_has_run.set()
 
     handler = EventHandler(on_change, target=static.target)
@@ -1798,7 +1808,7 @@ async def test_redundant_ack_delivery_requests_are_dropped(monkeypatch):
     not queue another.
     """
     static = StaticEventHandler()
-    handler = EventHandler(lambda e: None, target=static.target)
+    handler = EventHandler(_noop_handler, target=static.target)
 
     @component
     def Root():
@@ -1945,7 +1955,7 @@ async def test_a_closed_layout_owes_neither_an_ack_delivery_nor_a_render():
     that, and reaching for those would raise where nothing is left to catch it.
     """
     static = StaticEventHandler()
-    handler = EventHandler(lambda e: None, target=static.target)
+    handler = EventHandler(_noop_handler, target=static.target)
 
     @component
     def Root():
diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py
index e1cdfa609..9c978b625 100644
--- a/tests/test_core/test_serve.py
+++ b/tests/test_core/test_serve.py
@@ -65,6 +65,7 @@ def make_events_and_expected_model():
                         "target": STATIC_EVENT_HANDLER.target,
                         "preventDefault": False,
                         "stopPropagation": False,
+                        "eventProxy": True,
                     }
                 },
             }
@@ -94,7 +95,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})
 
 
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/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() ) From f180c4579fd0caf12e5d78ebb9202ed3c299810a Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 12:41:14 -0700 Subject: [PATCH 08/36] Hand the docs examples coroutine event handlers Every example which bound a callback wrote it as a lambda, and a lambda cannot be awaited. They are named coroutines now, which is also the shape the guides tell people to write. --- .../_examples/adding_state_variable/main.py | 2 +- .../_examples/isolated_state/main.py | 4 +-- .../multiple_state_variables/main.py | 4 +-- .../when_variables_are_not_enough/main.py | 2 +- .../_examples/dict_remove.py | 10 +++--- .../_examples/dict_update.py | 6 ++-- .../_examples/list_insert.py | 4 +-- .../_examples/list_re_order.py | 4 +-- .../_examples/list_remove.py | 8 ++--- .../_examples/list_replace.py | 4 +-- .../_examples/moving_dot_broken.py | 2 +- .../_examples/set_remove.py | 4 +-- .../_examples/set_update.py | 4 +-- .../_examples/set_color_3_times.py | 4 +-- .../_examples/set_state_function.py | 2 +- .../_examples/audio_player.py | 6 +++- .../_examples/button_prints_event.py | 2 +- .../_examples/button_prints_message.py | 2 +- .../prevent_default_event_actions.py | 6 +++- .../_examples/stop_event_propagation.py | 18 ++++++---- .../_examples/print_chat_message.py | 10 ++++-- .../_examples/print_count_after_set.py | 2 +- .../_examples/send_message.py | 14 +++++--- .../_examples/set_counter_3_times.py | 2 +- .../_examples/material_ui_button_on_click.py | 5 ++- .../_examples/filterable_list/main.py | 2 +- .../_examples/synced_inputs/main.py | 2 +- .../_examples/character_movement/main.py | 34 +++++++++++++------ .../source/reference/_examples/click_count.py | 7 ++-- .../reference/_examples/material_ui_switch.py | 5 ++- .../reference/_examples/matplotlib_plot.py | 8 ++--- .../source/reference/_examples/pigeon_maps.py | 13 +++++-- docs/source/reference/_examples/slideshow.py | 2 +- docs/source/reference/_examples/snake_game.py | 9 ++--- .../_examples/use_reducer_counter.py | 16 +++++++-- .../reference/_examples/use_state_counter.py | 18 +++++++--- 36 files changed, 160 insertions(+), 87 deletions(-) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py index a919a2354..0f1a78560 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py @@ -12,7 +12,7 @@ def Gallery(): index, set_index = hooks.use_state(0) - def handle_click(event): + async def handle_click(event): set_index(index + 1) bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py index d07b87140..6809cd0ae 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py @@ -13,10 +13,10 @@ def Gallery(): index, set_index = hooks.use_state(0) show_more, set_show_more = hooks.use_state(False) - def handle_next_click(event): + async def handle_next_click(event): set_index(index + 1) - def handle_more_click(event): + async def handle_more_click(event): set_show_more(not show_more) bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py index 87f9651be..95fa2b7d6 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py @@ -13,10 +13,10 @@ def Gallery(): index, set_index = hooks.use_state(0) show_more, set_show_more = hooks.use_state(False) - def handle_next_click(event): + async def handle_next_click(event): set_index(index + 1) - def handle_more_click(event): + async def handle_more_click(event): set_show_more(not show_more) bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py index c617586de..42a36f9ab 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py @@ -19,7 +19,7 @@ def Gallery(): index = 0 - def handle_click(event): + async def handle_click(event): index = index + 1 bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py index 6c3c783da..03f30ae1e 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py @@ -7,20 +7,20 @@ def Definitions(): definition_to_add, set_definition_to_add = use_state(None) all_terms, set_all_terms = use_state({}) - def handle_term_to_add_change(event): + async def handle_term_to_add_change(event): set_term_to_add(event["target"]["value"]) - def handle_definition_to_add_change(event): + async def handle_definition_to_add_change(event): set_definition_to_add(event["target"]["value"]) - def handle_add_click(event): + async def handle_add_click(event): if term_to_add and definition_to_add: set_all_terms({**all_terms, term_to_add: definition_to_add}) set_term_to_add(None) set_definition_to_add(None) - def make_delete_click_handler(term_to_delete): - def handle_click(event): + async def make_delete_click_handler(term_to_delete): + async def handle_click(event): set_all_terms({t: d for t, d in all_terms.items() if t != term_to_delete}) return handle_click diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py index 32dd4073a..0e01ea73e 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py @@ -11,13 +11,13 @@ def Form(): } ) - def handle_first_name_change(event): + async def handle_first_name_change(event): set_person({**person, "first_name": event["target"]["value"]}) - def handle_last_name_change(event): + async def handle_last_name_change(event): set_person({**person, "last_name": event["target"]["value"]}) - def handle_email_change(event): + async def handle_email_change(event): set_person({**person, "email": event["target"]["value"]}) return html.div( diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py index 1f4072e0b..a1aa52b20 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py @@ -6,10 +6,10 @@ def ArtistList(): artist_to_add, set_artist_to_add = use_state("") artists, set_artists = use_state([]) - def handle_change(event): + async def handle_change(event): set_artist_to_add(event["target"]["value"]) - def handle_click(event): + async def handle_click(event): if artist_to_add and artist_to_add not in artists: set_artists([*artists, artist_to_add]) set_artist_to_add("") diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py index 3bd2fd601..d2430e01f 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py @@ -7,10 +7,10 @@ def ArtistList(): ["Marta Colvin Andrade", "Lamidi Olonade Fakeye", "Louise Nevelson"] ) - def handle_sort_click(event): + async def handle_sort_click(event): set_artists(sorted(artists)) - def handle_reverse_click(event): + async def handle_reverse_click(event): set_artists(list(reversed(artists))) return html.div( diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py index 6223284f6..3282c05ae 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py @@ -8,16 +8,16 @@ def ArtistList(): ["Marta Colvin Andrade", "Lamidi Olonade Fakeye", "Louise Nevelson"] ) - def handle_change(event): + async def handle_change(event): set_artist_to_add(event["target"]["value"]) - def handle_add_click(event): + async def handle_add_click(event): if artist_to_add not in artists: set_artists([*artists, artist_to_add]) set_artist_to_add("") - def make_handle_delete_click(index): - def handle_click(event): + async def make_handle_delete_click(index): + async def handle_click(event): set_artists(artists[:index] + artists[index + 1 :]) return handle_click diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py index 5577da5e1..7d454ba86 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py @@ -5,8 +5,8 @@ def CounterList(): counters, set_counters = use_state([0, 0, 0]) - def make_increment_click_handler(index): - def handle_click(event): + async def make_increment_click_handler(index): + async def handle_click(event): new_value = counters[index] + 1 set_counters([*counters[:index], new_value, *counters[index + 1 :]]) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py index 8972ce74e..a908fdade 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py @@ -7,7 +7,7 @@ def MovingDot(): position, _ = use_state({"x": 0, "y": 0}) - def handle_pointer_move(event): + async def handle_pointer_move(event): outer_div_info = event["currentTarget"] outer_div_bounds = outer_div_info["boundingClientRect"] position["x"] = event["clientX"] - outer_div_bounds["x"] diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py index abe55a918..b25892209 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py @@ -6,8 +6,8 @@ def Grid(): line_size = 5 selected_indices, set_selected_indices = use_state({1, 2, 4}) - def make_handle_click(index): - def handle_click(event): + async def make_handle_click(index): + async def handle_click(event): if index in selected_indices: set_selected_indices(selected_indices - {index}) else: diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py index 27f170a42..b7c006a88 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py @@ -6,8 +6,8 @@ def Grid(): line_size = 5 selected_indices, set_selected_indices = use_state(set()) - def make_handle_click(index): - def handle_click(event): + async def make_handle_click(index): + async def handle_click(event): set_selected_indices(selected_indices | {index}) return handle_click diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py index 59d7d0f20..ee216fe4f 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py @@ -5,12 +5,12 @@ def ColorButton(): color, set_color = use_state("gray") - def handle_click(event): + async def handle_click(event): set_color("orange") set_color("pink") set_color("blue") - def handle_reset(event): + async def handle_reset(event): set_color("gray") return html.div( diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py index 56bbe80e3..0720dba10 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py @@ -10,7 +10,7 @@ def increment(old_number): def Counter(): number, set_number = use_state(0) - def handle_click(event): + async def handle_click(event): set_number(increment) set_number(increment) set_number(increment) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py index 82826c50d..0f4c32a99 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py @@ -6,11 +6,15 @@ @reactpy.component def PlayDinosaurSound(): event, set_event = reactpy.hooks.use_state(None) + + async def handle_time_update(event): + set_event(event) + return reactpy.html.div( reactpy.html.audio( { "controls": True, - "on_time_update": lambda e: set_event(e), + "on_time_update": handle_time_update, "src": "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", } ), diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py index 38638db4b..061595000 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py @@ -3,7 +3,7 @@ @component def Button(): - def handle_event(event): + async def handle_event(event): print(event) return html.button({"on_click": handle_event}, "Click me!") diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py index 56118a57f..b43dcd77e 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py @@ -3,7 +3,7 @@ @component def PrintButton(display_text, message_text): - def handle_event(event): + async def handle_event(event): print(message_text) return html.button({"on_click": handle_event}, display_text) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py index d3f0941bd..362d20f11 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py @@ -3,11 +3,15 @@ @component def DoNotChangePages(): + async def do_nothing(event): + # the body is irrelevant here - 'prevent_default' does all the work + ... + return html.div( html.p("Normally clicking this link would take you to a new page"), html.a( { - "on_click": event(lambda event: None, prevent_default=True), + "on_click": event(do_nothing, prevent_default=True), "href": "https://google.com", }, "https://google.com", diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py index 41c575042..b6800744a 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py @@ -7,15 +7,24 @@ def DivInDiv(): inner_count, set_inner_count = hooks.use_state(0) outer_count, set_outer_count = hooks.use_state(0) + async def count_outer_click(event): + set_outer_count(outer_count + 1) + + async def count_inner_click(event): + set_inner_count(inner_count + 1) + + async def toggle_propagation(event): + set_stop_propagatation(not stop_propagatation) + div_in_div = html.div( { - "on_click": lambda event: set_outer_count(outer_count + 1), + "on_click": count_outer_click, "style": {"height": "100px", "width": "100px", "background_color": "red"}, }, html.div( { "on_click": event( - lambda event: set_inner_count(inner_count + 1), + count_inner_click, stop_propagation=stop_propagatation, ), "style": { @@ -28,10 +37,7 @@ def DivInDiv(): ) return html.div( - html.button( - {"on_click": lambda event: set_stop_propagatation(not stop_propagatation)}, - "Toggle Propagation", - ), + html.button({"on_click": toggle_propagation}, "Toggle Propagation"), html.pre(f"Will propagate: {not stop_propagatation}"), html.pre(f"Inner click count: {inner_count}"), html.pre(f"Outer click count: {outer_count}"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py index ecbad9381..6901ad2ee 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py @@ -8,6 +8,12 @@ def App(): recipient, set_recipient = use_state("Alice") message, set_message = use_state("") + async def handle_recipient_change(event): + set_recipient(event["target"]["value"]) + + async def handle_message_change(event): + set_message(event["target"]["value"]) + @event(prevent_default=True) async def handle_submit(event): set_message("") @@ -23,7 +29,7 @@ async def handle_submit(event): html.select( { "value": recipient, - "on_change": lambda event: set_recipient(event["target"]["value"]), + "on_change": handle_recipient_change, }, html.option({"value": "Alice"}, "Alice"), html.option({"value": "Bob"}, "Bob"), @@ -34,7 +40,7 @@ async def handle_submit(event): "type": "text", "placeholder": "Your message...", "value": message, - "on_change": lambda event: set_message(event["target"]["value"]), + "on_change": handle_message_change, } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py index 40ee78259..031f7a286 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py @@ -5,7 +5,7 @@ def Counter(): number, set_number = use_state(0) - def handle_click(event): + async def handle_click(event): set_number(number + 5) print(number) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py index 4702a7464..a88239c27 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py @@ -6,16 +6,20 @@ def App(): is_sent, set_is_sent = use_state(False) message, set_message = use_state("") + async def handle_send_again(event): + set_is_sent(False) + + async def handle_message_change(event): + set_message(event["target"]["value"]) + if is_sent: return html.div( html.h1("Message sent!"), - html.button( - {"on_click": lambda event: set_is_sent(False)}, "Send new message?" - ), + html.button({"on_click": handle_send_again}, "Send new message?"), ) @event(prevent_default=True) - def handle_submit(event): + async def handle_submit(event): set_message("") set_is_sent(True) @@ -25,7 +29,7 @@ def handle_submit(event): { "placeholder": "Your message here...", "value": message, - "on_change": lambda event: set_message(event["target"]["value"]), + "on_change": handle_message_change, } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py index fe97351af..f9e898567 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py @@ -5,7 +5,7 @@ def Counter(): number, set_number = use_state(0) - def handle_click(event): + async def handle_click(event): set_number(number + 1) set_number(number + 1) set_number(number + 1) diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py index 3fc684005..c5ee2cfa2 100644 --- a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py +++ b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py @@ -14,12 +14,15 @@ def ViewButtonEvents(): event, set_event = reactpy.hooks.use_state(None) + async def handle_click(event): + set_event(event) + return reactpy.html.div( Button( { "color": "primary", "variant": "contained", - "onClick": lambda event: set_event(event), + "onClick": handle_click, }, "Click Me!", ), diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py index ca68aedcb..75b0b5c28 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py @@ -16,7 +16,7 @@ def FilterableList(): @component def Search(value, set_value): - def handle_change(event): + async def handle_change(event): set_value(event["target"]["value"]) return html.label( diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py index e8bcdf333..78c503507 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py @@ -12,7 +12,7 @@ def SyncedInputs(): @component def Input(label, value, set_value): - def handle_change(event): + async def handle_change(event): set_value(event["target"]["value"]) return html.label( diff --git a/docs/source/reference/_examples/character_movement/main.py b/docs/source/reference/_examples/character_movement/main.py index 9545b0c0a..449ed0a04 100644 --- a/docs/source/reference/_examples/character_movement/main.py +++ b/docs/source/reference/_examples/character_movement/main.py @@ -34,6 +34,24 @@ def translate(x=0, y=0): def Scene(): position, set_position = use_state(Position(100, 100, 0)) + async def move_left(event): + set_position(translate(x=-10)) + + async def move_right(event): + set_position(translate(x=10)) + + async def move_up(event): + set_position(translate(y=-10)) + + async def move_down(event): + set_position(translate(y=10)) + + async def rotate_left(event): + set_position(rotate(-30)) + + async def rotate_right(event): + set_position(rotate(30)) + return html.div( {"style": {"width": "225px"}}, html.div( @@ -57,16 +75,12 @@ def Scene(): }, ), ), - html.button( - {"on_click": lambda e: set_position(translate(x=-10))}, "Move Left" - ), - html.button( - {"on_click": lambda e: set_position(translate(x=10))}, "Move Right" - ), - html.button({"on_click": lambda e: set_position(translate(y=-10))}, "Move Up"), - html.button({"on_click": lambda e: set_position(translate(y=10))}, "Move Down"), - html.button({"on_click": lambda e: set_position(rotate(-30))}, "Rotate Left"), - html.button({"on_click": lambda e: set_position(rotate(30))}, "Rotate Right"), + html.button({"on_click": move_left}, "Move Left"), + html.button({"on_click": move_right}, "Move Right"), + html.button({"on_click": move_up}, "Move Up"), + html.button({"on_click": move_down}, "Move Down"), + html.button({"on_click": rotate_left}, "Rotate Left"), + html.button({"on_click": rotate_right}, "Rotate Right"), ) diff --git a/docs/source/reference/_examples/click_count.py b/docs/source/reference/_examples/click_count.py index 3ee2c89c5..5e1892ac0 100644 --- a/docs/source/reference/_examples/click_count.py +++ b/docs/source/reference/_examples/click_count.py @@ -5,9 +5,10 @@ def ClickCount(): count, set_count = reactpy.hooks.use_state(0) - return reactpy.html.button( - {"on_click": lambda event: set_count(count + 1)}, [f"Click count: {count}"] - ) + async def handle_click(event): + set_count(count + 1) + + return reactpy.html.button({"on_click": handle_click}, [f"Click count: {count}"]) reactpy.run(ClickCount) diff --git a/docs/source/reference/_examples/material_ui_switch.py b/docs/source/reference/_examples/material_ui_switch.py index 704ae3145..5cdc9c206 100644 --- a/docs/source/reference/_examples/material_ui_switch.py +++ b/docs/source/reference/_examples/material_ui_switch.py @@ -8,11 +8,14 @@ def DayNightSwitch(): checked, set_checked = reactpy.hooks.use_state(False) + async def handle_change(event, checked): + set_checked(checked) + return reactpy.html.div( Switch( { "checked": checked, - "onChange": lambda event, checked: set_checked(checked), + "onChange": handle_change, } ), "🌞" if checked else "🌚", diff --git a/docs/source/reference/_examples/matplotlib_plot.py b/docs/source/reference/_examples/matplotlib_plot.py index a70f173ff..bcc360750 100644 --- a/docs/source/reference/_examples/matplotlib_plot.py +++ b/docs/source/reference/_examples/matplotlib_plot.py @@ -30,17 +30,17 @@ def set_value_at_index(event, index=i): inputs.append(poly_coef_input(i + 1, set_value_at_index)) - def add_input(): + async def add_a_term(event): set_values([*values, 0]) - def del_input(): + async def remove_a_term(event): set_values(values[:-1]) return reactpy.html.div( reactpy.html.div( "add/remove term:", - reactpy.html.button({"on_click": lambda event: add_input()}, "+"), - reactpy.html.button({"on_click": lambda event: del_input()}, "-"), + reactpy.html.button({"on_click": add_a_term}, "+"), + reactpy.html.button({"on_click": remove_a_term}, "-"), ), inputs, ) diff --git a/docs/source/reference/_examples/pigeon_maps.py b/docs/source/reference/_examples/pigeon_maps.py index 1ddf04fdc..696b830ef 100644 --- a/docs/source/reference/_examples/pigeon_maps.py +++ b/docs/source/reference/_examples/pigeon_maps.py @@ -8,24 +8,33 @@ def MapWithMarkers(): marker_anchor, add_marker_anchor, remove_marker_anchor = use_set() + def make_remove_marker_handler(anchor): + async def remove_marker(event): + remove_marker_anchor(anchor) + + return remove_marker + markers = [ Marker( { "anchor": anchor, - "onClick": lambda event, a=anchor: remove_marker_anchor(a), + "onClick": make_remove_marker_handler(anchor), }, key=str(anchor), ) for anchor in marker_anchor ] + async def add_marker_on_click(event): + add_marker_anchor(tuple(event["latLng"])) + return Map( { "defaultCenter": (37.774, -122.419), "defaultZoom": 12, "height": "300px", "metaWheelZoom": True, - "onClick": lambda event: add_marker_anchor(tuple(event["latLng"])), + "onClick": add_marker_on_click, }, markers, ) diff --git a/docs/source/reference/_examples/slideshow.py b/docs/source/reference/_examples/slideshow.py index b490b3feb..0d5c11109 100644 --- a/docs/source/reference/_examples/slideshow.py +++ b/docs/source/reference/_examples/slideshow.py @@ -5,7 +5,7 @@ def Slideshow(): index, set_index = reactpy.hooks.use_state(0) - def next_image(event): + async def next_image(event): set_index(index + 1) return reactpy.html.img( diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index 5b5afc778..d56c553e5 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -17,12 +17,13 @@ class GameState(enum.Enum): def GameView(): game_state, set_game_state = reactpy.hooks.use_state(GameState.init) + async def start_game(event): + set_game_state(GameState.play) + if game_state == GameState.play: return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state) - start_button = reactpy.html.button( - {"on_click": lambda event: set_game_state(GameState.play)}, "Start" - ) + start_button = reactpy.html.button({"on_click": start_game}, "Start") if game_state == GameState.won: menu = reactpy.html.div(reactpy.html.h3("You won!"), start_button) @@ -64,7 +65,7 @@ def GameLoop(grid_size, block_scale, set_game_state): grid = create_grid(grid_size, block_scale) @reactpy.event(prevent_default=True) - def on_direction_change(event): + async def on_direction_change(event): if hasattr(Direction, event["key"]): maybe_new_direction = Direction[event["key"]].value direction_vector_sum = tuple( diff --git a/docs/source/reference/_examples/use_reducer_counter.py b/docs/source/reference/_examples/use_reducer_counter.py index 6f9581dfd..8a82da4d4 100644 --- a/docs/source/reference/_examples/use_reducer_counter.py +++ b/docs/source/reference/_examples/use_reducer_counter.py @@ -16,11 +16,21 @@ def reducer(count, action): @reactpy.component def Counter(): count, dispatch = reactpy.hooks.use_reducer(reducer, 0) + + async def reset_count(event): + dispatch("reset") + + async def increase_count(event): + dispatch("increment") + + async def decrease_count(event): + dispatch("decrement") + return reactpy.html.div( f"Count: {count}", - reactpy.html.button({"on_click": lambda event: dispatch("reset")}, "Reset"), - reactpy.html.button({"on_click": lambda event: dispatch("increment")}, "+"), - reactpy.html.button({"on_click": lambda event: dispatch("decrement")}, "-"), + reactpy.html.button({"on_click": reset_count}, "Reset"), + reactpy.html.button({"on_click": increase_count}, "+"), + reactpy.html.button({"on_click": decrease_count}, "-"), ) diff --git a/docs/source/reference/_examples/use_state_counter.py b/docs/source/reference/_examples/use_state_counter.py index b2d8c84a9..057beab47 100644 --- a/docs/source/reference/_examples/use_state_counter.py +++ b/docs/source/reference/_examples/use_state_counter.py @@ -13,13 +13,21 @@ def decrement(last_count): def Counter(): initial_count = 0 count, set_count = reactpy.hooks.use_state(initial_count) + + async def reset_count(event): + set_count(initial_count) + + async def increase_count(event): + set_count(increment) + + async def decrease_count(event): + set_count(decrement) + return reactpy.html.div( f"Count: {count}", - reactpy.html.button( - {"on_click": lambda event: set_count(initial_count)}, "Reset" - ), - reactpy.html.button({"on_click": lambda event: set_count(increment)}, "+"), - reactpy.html.button({"on_click": lambda event: set_count(decrement)}, "-"), + reactpy.html.button({"on_click": reset_count}, "Reset"), + reactpy.html.button({"on_click": increase_count}, "+"), + reactpy.html.button({"on_click": decrease_count}, "-"), ) From 1ab8b00f2e1db745606b5c3c2101fba81281bcba Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 13:33:09 -0700 Subject: [PATCH 09/36] Cover the corners the switch to coroutines left untested The coverage job draws the line at one hundred percent, and the switch to coroutine-only handlers left seven lines on the other side of it. Two belong to reading a handler's bytecode. An object's ``__call__`` is only absent from its class if a metaclass went out of its way to hide it, so that branch is marked off rather than prodded for its own sake. The case a program can actually reach - an object made callable by a built-in, which carries no bytecode at all - is tested for what it is worth, which is that the inspector has nothing to find. The rest were a proxy with nothing behind it. What it says about itself, the attribute nobody can answer for, and the value its snapshot does hold all went unexercised, and so did the one path out of ``_submit`` which is not the loop's own thread. A call made from a worker now has to reach the browser by way of the loop which owns the connection, which is what that thread check is there for in the first place. The typing tests which timed out on a loaded runner wait longer, and the page which churns while the user types churns at a hundred renders a second rather than two hundred. Its render count is polled alongside the field and the model, so the next timeout says whether renders were reaching the page at all - the distinction which decides whether a stuck field is a stalled layout or merely a slow machine. --- src/reactpy/_event_proxy.py | 4 +-- src/reactpy/core/_event_inspect.py | 2 +- tests/test_core/test_event_inspect.py | 16 ++++++++++ tests/test_core/test_event_proxy.py | 44 ++++++++++++++++++++++----- tests/test_core/test_events.py | 29 +++++++++++------- 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 04d5cb787..7f8b09c30 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -559,8 +559,8 @@ def _reactpy_child_or_value(self, segment: str | int) -> Any: def _reactpy_resolve_segment(self, segment: str | int) -> Any: """Read something the snapshot does not know about - The asynchronous flavour stays lazy here; the synchronous one asks the browser - right away so that handlers always deal with real values. + Nothing is asked of the browser yet - the child handed back stays lazy, and + it is awaiting that child which sends the request. """ return self._reactpy_make_child(segment) diff --git a/src/reactpy/core/_event_inspect.py b/src/reactpy/core/_event_inspect.py index 5624e8d16..d40b5a50e 100644 --- a/src/reactpy/core/_event_inspect.py +++ b/src/reactpy/core/_event_inspect.py @@ -31,7 +31,7 @@ def _handler_code(func: Callable) -> CodeType | None: # a callable object's own ``__call__`` is defined on its class try: call = type(func).__call__ - except AttributeError: + except AttributeError: # nocov - only a metaclass can hide this return None return getattr(call, "__code__", None) diff --git a/tests/test_core/test_event_inspect.py b/tests/test_core/test_event_inspect.py index 37488d3ff..5e6ceef85 100644 --- a/tests/test_core/test_event_inspect.py +++ b/tests/test_core/test_event_inspect.py @@ -460,3 +460,19 @@ 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) == {} diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 74cfa477b..c06da80e8 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Sequence +from collections.abc import Sequence from time import monotonic from types import MappingProxyType from typing import Any @@ -16,7 +16,6 @@ EVENT_PROXY_REQUEST_TYPE, EVENT_PROXY_RESPONSE_TYPE, EVENT_REFERENCE_KEY, - EventArguments, Event, EventProxy, EventProxyAccessError, @@ -32,12 +31,9 @@ REACTPY_EVENT_PROXY_MAX_TIMEOUTS, REACTPY_EVENT_PROXY_TIMEOUT, ) - from reactpy.core.component import component from reactpy.core.events import EventHandler, event, merge_event_handlers from reactpy.core.layout import Layout -from reactpy.testing import assert_reactpy_did_log -from reactpy.utils import Ref from tests.tooling.common import event_message @@ -714,6 +710,8 @@ async def test_a_proxy_with_nothing_behind_it_cannot_resolve(): base = make_proxy(EventProxyBackend(), "evt", ["target"]) assert dict(base) == {} + # with nothing behind it, the snapshot is all there is to describe + assert repr(base) == "{}" with pytest.raises(EventProxyUnavailable, match="live access"): await base @@ -862,7 +860,7 @@ async def test_lists_inside_a_snapshot_are_wrapped_with_child_proxies(): assert requested_paths(client) == [] -# -- the dict protocol of an asynchronous proxy ----------------------------- +# -- the dict protocol of a proxy ------------------------------------------- async def test_async_proxies_refuse_to_be_inspected_while_unknown(): @@ -898,9 +896,18 @@ async def test_proxies_without_a_connection_are_quietly_empty(): assert len(event_object) == 0 assert list(event_object) == [] assert "type" not in event_object + # reading through to an attribute nobody can answer for gives nothing at all + assert event_object.type is None + assert event_object.target is None + + # whatever the snapshot does hold is handed back without asking anybody + held = make_event( + EventProxyBackend(), "evt", snapshot={"target": {"value": "kept"}} + ) + assert held.target["value"] == "kept" -# -- what the synchronous flavour does when it has to block ----------------- +# -- what a proxy does when it has to ask the browser ----------------------- async def test_reads_either_use_the_snapshot_or_ask_the_browser(): @@ -950,6 +957,29 @@ async def send(message: dict[str, Any]) -> None: 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_closing_the_backend_fails_everything_still_in_flight(): backend, client = make_client(make_objects(), respond=False) event_object = make_event(backend, "evt", snapshot={}) diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 5046d08b1..3d7f882f1 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -907,7 +907,7 @@ async def values(): # 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=10, + timeout=30, description="the server to catch up with what was typed", ) @@ -920,7 +920,7 @@ async def values(): await poll(values).until( lambda client_and_server: client_and_server[1] == value_after_typing + MARKER, - timeout=10, + timeout=20, description="the state change after the burst to reach the rendered model", ) @@ -955,7 +955,7 @@ async def values(): await poll(values).until( lambda client_and_server: client_and_server[0] == client_and_server[1], - timeout=10, + timeout=30, description="the server to catch up with what was typed", ) @@ -1278,12 +1278,12 @@ async def server_value(): await poll(field_value).until( lambda value: value == "", - timeout=10, + timeout=20, description="the clear to reach the field", ) await poll(server_value).until( lambda value: value == "", - timeout=10, + timeout=20, description="the clear to reach the model", ) @@ -1307,18 +1307,22 @@ async def on_change(event): def keep_rendering(): """Re-render on a timer, so renders are always in flight behind the typing""" loop = asyncio.get_running_loop() - left = [400] + left = [300] def tick(): set_ticks(lambda old: old + 1) left[0] -= 1 if left[0]: - handle[0] = loop.call_later(0.005, tick) + handle[0] = loop.call_later(0.01, tick) - handle = [loop.call_later(0.005, 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. - return lambda: handle[0].cancel() + + def stop(): + handle[0].cancel() + + return stop use_effect(keep_rendering) @@ -1354,11 +1358,14 @@ 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 pair: pair[0] == pair[1] == target_text, - timeout=15, + 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", ) From 9c0e4ee47a7eb3ca97294dd4546f1230cc915494 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 13:49:55 -0700 Subject: [PATCH 10/36] Tell the docs and changelog that handlers are coroutines now The guides still described a synchronous handler as the common case and an asynchronous one as the interesting exception - "so long as the event handler is synchronous ... ReactPy waits until all the code in an event handler has run", "by defining your event handler as an asynchronous function instead of a normal synchronous one". There is no synchronous handler left to describe, and what those passages were really getting at is when a render is allowed to start, which turns on whether a handler has yielded rather than whether it was written with a ``def``. The wording is in those terms now, and the couple of snippets which still bound ``def handle_click`` are coroutines like the example they mirror. The changelog was left describing the pair of event types this branch did away with. It now describes the one snapshot and the one proxy, and says plainly in the Changed section that a handler which cannot be awaited is refused. --- CHANGELOG.md | 7 +++--- .../components-with-state/index.rst | 4 ++-- .../guides/adding-interactivity/index.rst | 10 ++++---- .../multiple-state-updates/index.rst | 24 +++++++++---------- .../responding-to-events/index.rst | 11 ++++----- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c741cf839..bc78a78c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Don't forget to remove deprecated code on each major release! - 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`). - 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,8 +40,8 @@ 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 rather than being limited to the snapshot sent along with the event. Handlers can `await event.target.value` or `await event.target.focus()` when asynchronous, or simply use `event.target.value` and `event.target.focus()` when synchronous. The new `reactpy.types.AsyncEvent` and `reactpy.types.SyncEvent` types describe these objects for editors and type checkers. - - Live access is opt-in. Ask for it on a single handler with `EventHandler(fn, event_proxy=True)` or `@event(event_proxy=True)`, or for every handler with `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`. Until the server has asked, the browser holds nothing back and a handler is simply handed the plain `reactpy.types.Event` snapshot which arrived with the event - so an application which never reaches into the page pays nothing for the possibility. Reading something the client did not send up front costs a round trip to the browser, so synchronous handlers which do are run in a worker thread where they are free to block. +- Added a live event proxy which lets event handlers read properties from, and call methods on, the browser objects which triggered them rather than being limited to the snapshot sent along with the event. A handler awaits what it needs (`await event.target.value`, `await event.target.focus()`), and reading something the client did not send up front costs a single round trip to the browser. What arrives with the event is a `reactpy.types.Event` snapshot; whatever the handler reaches past is an `EventProxy` describing where in the browser to look for it. + - Live access is on by default via `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, and can be settled per handler with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or `event_proxy=True` for one which must have it even while that option is off. The browser registers only the events of handlers which asked to read them, so dispatching an event nobody looks behind costs the page nothing to remember. - 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`. ### Changed @@ -67,6 +67,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 diff --git a/docs/source/guides/adding-interactivity/components-with-state/index.rst b/docs/source/guides/adding-interactivity/components-with-state/index.rst index f8235ac0d..3e4fae055 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/index.rst +++ b/docs/source/guides/adding-interactivity/components-with-state/index.rst @@ -66,7 +66,7 @@ Aren't Enough>`: - index = 0 + index, set_index = use_state - def handle_click(event): + async def handle_click(event): - index = index + 1 + set_index(index + 1) @@ -78,7 +78,7 @@ After making those changes we should get: index, set_index = use_state(0) - def handle_click(event): + async def handle_click(event): set_index(index + 1) We'll talk more about what this is doing :ref:`shortly `, but for diff --git a/docs/source/guides/adding-interactivity/index.rst b/docs/source/guides/adding-interactivity/index.rst index af84fab88..d40bf20a4 100644 --- a/docs/source/guides/adding-interactivity/index.rst +++ b/docs/source/guides/adding-interactivity/index.rst @@ -57,8 +57,9 @@ Section 1: Responding to Events ------------------------------- ReactPy lets you add event handlers to your parts of the interface. This means that you can -define synchronous or asynchronous functions that are triggered when a particular user -interaction occurs like clicking, hovering, focusing on form inputs, and more. +define coroutine functions (declared with ``async def``) that are triggered when a +particular user interaction occurs like clicking, hovering, focusing on form inputs, and +more. .. reactpy:: responding-to-events/_examples/button_prints_message @@ -158,9 +159,8 @@ As we saw in an earlier example, :ref:`setting state triggers renders`. In other changes to state only take effect in the next render, not in the current one. Further, changes to state are batched, calling a particular state setter 3 times won't trigger 3 renders, it will only trigger 1. This means that multiple state assignments are batched -- so long as the event handler is synchronous (i.e. the event handler is not an -``async`` function), ReactPy waits until all the code in an event handler has run before -processing state and starting the next render: +- until an event handler awaits something, ReactPy waits until all the code in that +handler has run before processing state and starting the next render: .. reactpy:: multiple-state-updates/_examples/set_color_3_times diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst b/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst index bc3245d7e..6b79d6f1e 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst @@ -17,27 +17,27 @@ This is why, in the example below, even though it might seem like clicking the .. reactpy:: ../state-as-a-snapshot/_examples/set_counter_3_times -The reason this happens is because, so long as the event handler is synchronous (i.e. -the event handler is not an ``async`` function), ReactPy waits until all the code in an -event handler has run before processing state and starting the next render. Thus, it's -the last call to a given state setter that matters. In the example below, even though we -set the color of the button to ``"orange"`` and then ``"pink"`` before ``"blue"``, -the color does not quickly flash orange and pink before blue - it always remains blue: +The reason this happens is because, until an event handler awaits something, ReactPy +waits until all the code in that handler has run before processing state and starting the +next render. Thus, it's the last call to a given state setter that matters. In the example +below, even though we set the color of the button to ``"orange"`` and then ``"pink"`` +before ``"blue"``, the color does not quickly flash orange and pink before blue - it +always remains blue: .. reactpy:: _examples/set_color_3_times This behavior let's you make multiple state changes without triggering unnecessary renders or renders with inconsistent state where only some of the variables have been -updated. With that said, it also means that the UI won't change until after synchronous -handlers have finished running. +updated. With that said, it also means that the UI won't change until the handler which +changed state has finished, or paused to await something. .. note:: - For asynchronous event handlers, ReactPy will not render until you ``await`` something. - As we saw in :ref:`prior examples `, if you introduce - an asynchronous delay to an event handler after changing state, renders may take + ReactPy will not render until an event handler ``await``s something. As we saw in + :ref:`prior examples `, if you introduce an + asynchronous delay to an event handler after changing state, renders may take place before the remainder of the event handler completes. However, state variables - within handlers, even async ones, always remains static. + within handlers always remain static. This behavior of ReactPy to "batch" state changes that take place inside a single event handler, do not extend across event handlers. In other words, distinct events will diff --git a/docs/source/guides/adding-interactivity/responding-to-events/index.rst b/docs/source/guides/adding-interactivity/responding-to-events/index.rst index 89f567ca0..3853290e1 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/index.rst +++ b/docs/source/guides/adding-interactivity/responding-to-events/index.rst @@ -85,12 +85,11 @@ want to create a generic ``Button`` component that can be used for a variety of Async Event Handlers -------------------- -Sometimes event handlers need to execute asynchronous tasks when they are triggered. -Behind the scenes, ReactPy is running an :mod:`asyncio` event loop for just this purpose. -By defining your event handler as an asynchronous function instead of a normal -synchronous one. In the layout below we sleep for several seconds before printing out a -message in the first button. However, because the event handler is asynchronous, the -handler for the second button is still able to respond: +Sometimes event handlers need to await something - a trip to the browser for the details +of an event, or any other asynchronous task. Behind the scenes, ReactPy is running an +:mod:`asyncio` event loop for just this purpose. In the layout below we sleep for several +seconds before printing out a message in the first button. However, because that handler +spends its time awaiting, the handler for the second button is still able to respond: .. reactpy:: _examples/button_async_handlers From d25c9cc4253f41fa08d5b5385431e98addefb880 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 13:57:29 -0700 Subject: [PATCH 11/36] Make the last handler in the examples a coroutine too The example which passes a handler to a component as an argument bound it as ``on_click=handle_click`` rather than in an attributes dict, so it slipped past the pass which converted the rest of them - and it would have been refused at render time. --- .../responding-to-events/_examples/button_handler_as_arg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py index e5276bef3..8eea98504 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py @@ -8,7 +8,7 @@ def Button(display_text, on_click): @component def PlayButton(movie_name): - def handle_click(event): + async def handle_click(event): print(f"Playing {movie_name}") return Button(f"Play {movie_name}", on_click=handle_click) @@ -16,7 +16,7 @@ def handle_click(event): @component def FastForwardButton(): - def handle_click(event): + async def handle_click(event): print("Skipping ahead") return Button("Fast forward", on_click=handle_click) From fd8d68ce918e115e306f4f847d71cdb33633b1ad Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 14:46:24 -0700 Subject: [PATCH 12/36] Put the docs back the way they were on main This change is about the event proxy, not the docs. Every file under docs/ goes back to how it looks on main, so the docs can be updated separately once the move away from Sphinx is done. --- .../_examples/adding_state_variable/main.py | 2 +- .../_examples/isolated_state/main.py | 4 +-- .../multiple_state_variables/main.py | 4 +-- .../when_variables_are_not_enough/main.py | 2 +- .../components-with-state/index.rst | 4 +-- .../_examples/dict_remove.py | 10 +++--- .../_examples/dict_update.py | 6 ++-- .../_examples/list_insert.py | 4 +-- .../_examples/list_re_order.py | 4 +-- .../_examples/list_remove.py | 8 ++--- .../_examples/list_replace.py | 4 +-- .../_examples/moving_dot_broken.py | 2 +- .../_examples/set_remove.py | 4 +-- .../_examples/set_update.py | 4 +-- .../guides/adding-interactivity/index.rst | 10 +++--- .../_examples/set_color_3_times.py | 4 +-- .../_examples/set_state_function.py | 2 +- .../multiple-state-updates/index.rst | 24 ++++++------- .../_examples/audio_player.py | 6 +--- .../_examples/button_handler_as_arg.py | 4 +-- .../_examples/button_prints_event.py | 2 +- .../_examples/button_prints_message.py | 2 +- .../prevent_default_event_actions.py | 6 +--- .../_examples/stop_event_propagation.py | 18 ++++------ .../responding-to-events/index.rst | 11 +++--- .../_examples/print_chat_message.py | 10 ++---- .../_examples/print_count_after_set.py | 2 +- .../_examples/send_message.py | 14 +++----- .../_examples/set_counter_3_times.py | 2 +- .../_examples/material_ui_button_on_click.py | 5 +-- .../_examples/filterable_list/main.py | 2 +- .../_examples/synced_inputs/main.py | 2 +- .../_examples/character_movement/main.py | 34 ++++++------------- .../source/reference/_examples/click_count.py | 7 ++-- .../reference/_examples/material_ui_switch.py | 5 +-- .../reference/_examples/matplotlib_plot.py | 8 ++--- .../source/reference/_examples/pigeon_maps.py | 13 ++----- docs/source/reference/_examples/slideshow.py | 2 +- docs/source/reference/_examples/snake_game.py | 9 +++-- .../_examples/use_reducer_counter.py | 16 ++------- .../reference/_examples/use_state_counter.py | 18 +++------- 41 files changed, 114 insertions(+), 186 deletions(-) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py index 0f1a78560..a919a2354 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py @@ -12,7 +12,7 @@ def Gallery(): index, set_index = hooks.use_state(0) - async def handle_click(event): + def handle_click(event): set_index(index + 1) bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py index 6809cd0ae..d07b87140 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py @@ -13,10 +13,10 @@ def Gallery(): index, set_index = hooks.use_state(0) show_more, set_show_more = hooks.use_state(False) - async def handle_next_click(event): + def handle_next_click(event): set_index(index + 1) - async def handle_more_click(event): + def handle_more_click(event): set_show_more(not show_more) bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py index 95fa2b7d6..87f9651be 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py @@ -13,10 +13,10 @@ def Gallery(): index, set_index = hooks.use_state(0) show_more, set_show_more = hooks.use_state(False) - async def handle_next_click(event): + def handle_next_click(event): set_index(index + 1) - async def handle_more_click(event): + def handle_more_click(event): set_show_more(not show_more) bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py index 42a36f9ab..c617586de 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py @@ -19,7 +19,7 @@ def Gallery(): index = 0 - async def handle_click(event): + def handle_click(event): index = index + 1 bounded_index = index % len(sculpture_data) diff --git a/docs/source/guides/adding-interactivity/components-with-state/index.rst b/docs/source/guides/adding-interactivity/components-with-state/index.rst index 3e4fae055..f8235ac0d 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/index.rst +++ b/docs/source/guides/adding-interactivity/components-with-state/index.rst @@ -66,7 +66,7 @@ Aren't Enough>`: - index = 0 + index, set_index = use_state - async def handle_click(event): + def handle_click(event): - index = index + 1 + set_index(index + 1) @@ -78,7 +78,7 @@ After making those changes we should get: index, set_index = use_state(0) - async def handle_click(event): + def handle_click(event): set_index(index + 1) We'll talk more about what this is doing :ref:`shortly `, but for diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py index 03f30ae1e..6c3c783da 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py @@ -7,20 +7,20 @@ def Definitions(): definition_to_add, set_definition_to_add = use_state(None) all_terms, set_all_terms = use_state({}) - async def handle_term_to_add_change(event): + def handle_term_to_add_change(event): set_term_to_add(event["target"]["value"]) - async def handle_definition_to_add_change(event): + def handle_definition_to_add_change(event): set_definition_to_add(event["target"]["value"]) - async def handle_add_click(event): + def handle_add_click(event): if term_to_add and definition_to_add: set_all_terms({**all_terms, term_to_add: definition_to_add}) set_term_to_add(None) set_definition_to_add(None) - async def make_delete_click_handler(term_to_delete): - async def handle_click(event): + def make_delete_click_handler(term_to_delete): + def handle_click(event): set_all_terms({t: d for t, d in all_terms.items() if t != term_to_delete}) return handle_click diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py index 0e01ea73e..32dd4073a 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py @@ -11,13 +11,13 @@ def Form(): } ) - async def handle_first_name_change(event): + def handle_first_name_change(event): set_person({**person, "first_name": event["target"]["value"]}) - async def handle_last_name_change(event): + def handle_last_name_change(event): set_person({**person, "last_name": event["target"]["value"]}) - async def handle_email_change(event): + def handle_email_change(event): set_person({**person, "email": event["target"]["value"]}) return html.div( diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py index a1aa52b20..1f4072e0b 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py @@ -6,10 +6,10 @@ def ArtistList(): artist_to_add, set_artist_to_add = use_state("") artists, set_artists = use_state([]) - async def handle_change(event): + def handle_change(event): set_artist_to_add(event["target"]["value"]) - async def handle_click(event): + def handle_click(event): if artist_to_add and artist_to_add not in artists: set_artists([*artists, artist_to_add]) set_artist_to_add("") diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py index d2430e01f..3bd2fd601 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py @@ -7,10 +7,10 @@ def ArtistList(): ["Marta Colvin Andrade", "Lamidi Olonade Fakeye", "Louise Nevelson"] ) - async def handle_sort_click(event): + def handle_sort_click(event): set_artists(sorted(artists)) - async def handle_reverse_click(event): + def handle_reverse_click(event): set_artists(list(reversed(artists))) return html.div( diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py index 3282c05ae..6223284f6 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py @@ -8,16 +8,16 @@ def ArtistList(): ["Marta Colvin Andrade", "Lamidi Olonade Fakeye", "Louise Nevelson"] ) - async def handle_change(event): + def handle_change(event): set_artist_to_add(event["target"]["value"]) - async def handle_add_click(event): + def handle_add_click(event): if artist_to_add not in artists: set_artists([*artists, artist_to_add]) set_artist_to_add("") - async def make_handle_delete_click(index): - async def handle_click(event): + def make_handle_delete_click(index): + def handle_click(event): set_artists(artists[:index] + artists[index + 1 :]) return handle_click diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py index 7d454ba86..5577da5e1 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py @@ -5,8 +5,8 @@ def CounterList(): counters, set_counters = use_state([0, 0, 0]) - async def make_increment_click_handler(index): - async def handle_click(event): + def make_increment_click_handler(index): + def handle_click(event): new_value = counters[index] + 1 set_counters([*counters[:index], new_value, *counters[index + 1 :]]) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py index a908fdade..8972ce74e 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py @@ -7,7 +7,7 @@ def MovingDot(): position, _ = use_state({"x": 0, "y": 0}) - async def handle_pointer_move(event): + def handle_pointer_move(event): outer_div_info = event["currentTarget"] outer_div_bounds = outer_div_info["boundingClientRect"] position["x"] = event["clientX"] - outer_div_bounds["x"] diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py index b25892209..abe55a918 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py @@ -6,8 +6,8 @@ def Grid(): line_size = 5 selected_indices, set_selected_indices = use_state({1, 2, 4}) - async def make_handle_click(index): - async def handle_click(event): + def make_handle_click(index): + def handle_click(event): if index in selected_indices: set_selected_indices(selected_indices - {index}) else: diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py index b7c006a88..27f170a42 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py @@ -6,8 +6,8 @@ def Grid(): line_size = 5 selected_indices, set_selected_indices = use_state(set()) - async def make_handle_click(index): - async def handle_click(event): + def make_handle_click(index): + def handle_click(event): set_selected_indices(selected_indices | {index}) return handle_click diff --git a/docs/source/guides/adding-interactivity/index.rst b/docs/source/guides/adding-interactivity/index.rst index d40bf20a4..af84fab88 100644 --- a/docs/source/guides/adding-interactivity/index.rst +++ b/docs/source/guides/adding-interactivity/index.rst @@ -57,9 +57,8 @@ Section 1: Responding to Events ------------------------------- ReactPy lets you add event handlers to your parts of the interface. This means that you can -define coroutine functions (declared with ``async def``) that are triggered when a -particular user interaction occurs like clicking, hovering, focusing on form inputs, and -more. +define synchronous or asynchronous functions that are triggered when a particular user +interaction occurs like clicking, hovering, focusing on form inputs, and more. .. reactpy:: responding-to-events/_examples/button_prints_message @@ -159,8 +158,9 @@ As we saw in an earlier example, :ref:`setting state triggers renders`. In other changes to state only take effect in the next render, not in the current one. Further, changes to state are batched, calling a particular state setter 3 times won't trigger 3 renders, it will only trigger 1. This means that multiple state assignments are batched -- until an event handler awaits something, ReactPy waits until all the code in that -handler has run before processing state and starting the next render: +- so long as the event handler is synchronous (i.e. the event handler is not an +``async`` function), ReactPy waits until all the code in an event handler has run before +processing state and starting the next render: .. reactpy:: multiple-state-updates/_examples/set_color_3_times diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py index ee216fe4f..59d7d0f20 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py @@ -5,12 +5,12 @@ def ColorButton(): color, set_color = use_state("gray") - async def handle_click(event): + def handle_click(event): set_color("orange") set_color("pink") set_color("blue") - async def handle_reset(event): + def handle_reset(event): set_color("gray") return html.div( diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py index 0720dba10..56bbe80e3 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py @@ -10,7 +10,7 @@ def increment(old_number): def Counter(): number, set_number = use_state(0) - async def handle_click(event): + def handle_click(event): set_number(increment) set_number(increment) set_number(increment) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst b/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst index 6b79d6f1e..bc3245d7e 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst @@ -17,27 +17,27 @@ This is why, in the example below, even though it might seem like clicking the .. reactpy:: ../state-as-a-snapshot/_examples/set_counter_3_times -The reason this happens is because, until an event handler awaits something, ReactPy -waits until all the code in that handler has run before processing state and starting the -next render. Thus, it's the last call to a given state setter that matters. In the example -below, even though we set the color of the button to ``"orange"`` and then ``"pink"`` -before ``"blue"``, the color does not quickly flash orange and pink before blue - it -always remains blue: +The reason this happens is because, so long as the event handler is synchronous (i.e. +the event handler is not an ``async`` function), ReactPy waits until all the code in an +event handler has run before processing state and starting the next render. Thus, it's +the last call to a given state setter that matters. In the example below, even though we +set the color of the button to ``"orange"`` and then ``"pink"`` before ``"blue"``, +the color does not quickly flash orange and pink before blue - it always remains blue: .. reactpy:: _examples/set_color_3_times This behavior let's you make multiple state changes without triggering unnecessary renders or renders with inconsistent state where only some of the variables have been -updated. With that said, it also means that the UI won't change until the handler which -changed state has finished, or paused to await something. +updated. With that said, it also means that the UI won't change until after synchronous +handlers have finished running. .. note:: - ReactPy will not render until an event handler ``await``s something. As we saw in - :ref:`prior examples `, if you introduce an - asynchronous delay to an event handler after changing state, renders may take + For asynchronous event handlers, ReactPy will not render until you ``await`` something. + As we saw in :ref:`prior examples `, if you introduce + an asynchronous delay to an event handler after changing state, renders may take place before the remainder of the event handler completes. However, state variables - within handlers always remain static. + within handlers, even async ones, always remains static. This behavior of ReactPy to "batch" state changes that take place inside a single event handler, do not extend across event handlers. In other words, distinct events will diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py index 0f4c32a99..82826c50d 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py @@ -6,15 +6,11 @@ @reactpy.component def PlayDinosaurSound(): event, set_event = reactpy.hooks.use_state(None) - - async def handle_time_update(event): - set_event(event) - return reactpy.html.div( reactpy.html.audio( { "controls": True, - "on_time_update": handle_time_update, + "on_time_update": lambda e: set_event(e), "src": "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", } ), diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py index 8eea98504..e5276bef3 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py @@ -8,7 +8,7 @@ def Button(display_text, on_click): @component def PlayButton(movie_name): - async def handle_click(event): + def handle_click(event): print(f"Playing {movie_name}") return Button(f"Play {movie_name}", on_click=handle_click) @@ -16,7 +16,7 @@ async def handle_click(event): @component def FastForwardButton(): - async def handle_click(event): + def handle_click(event): print("Skipping ahead") return Button("Fast forward", on_click=handle_click) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py index 061595000..38638db4b 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py @@ -3,7 +3,7 @@ @component def Button(): - async def handle_event(event): + def handle_event(event): print(event) return html.button({"on_click": handle_event}, "Click me!") diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py index b43dcd77e..56118a57f 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py @@ -3,7 +3,7 @@ @component def PrintButton(display_text, message_text): - async def handle_event(event): + def handle_event(event): print(message_text) return html.button({"on_click": handle_event}, display_text) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py index 362d20f11..d3f0941bd 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py @@ -3,15 +3,11 @@ @component def DoNotChangePages(): - async def do_nothing(event): - # the body is irrelevant here - 'prevent_default' does all the work - ... - return html.div( html.p("Normally clicking this link would take you to a new page"), html.a( { - "on_click": event(do_nothing, prevent_default=True), + "on_click": event(lambda event: None, prevent_default=True), "href": "https://google.com", }, "https://google.com", diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py index b6800744a..41c575042 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py @@ -7,24 +7,15 @@ def DivInDiv(): inner_count, set_inner_count = hooks.use_state(0) outer_count, set_outer_count = hooks.use_state(0) - async def count_outer_click(event): - set_outer_count(outer_count + 1) - - async def count_inner_click(event): - set_inner_count(inner_count + 1) - - async def toggle_propagation(event): - set_stop_propagatation(not stop_propagatation) - div_in_div = html.div( { - "on_click": count_outer_click, + "on_click": lambda event: set_outer_count(outer_count + 1), "style": {"height": "100px", "width": "100px", "background_color": "red"}, }, html.div( { "on_click": event( - count_inner_click, + lambda event: set_inner_count(inner_count + 1), stop_propagation=stop_propagatation, ), "style": { @@ -37,7 +28,10 @@ async def toggle_propagation(event): ) return html.div( - html.button({"on_click": toggle_propagation}, "Toggle Propagation"), + html.button( + {"on_click": lambda event: set_stop_propagatation(not stop_propagatation)}, + "Toggle Propagation", + ), html.pre(f"Will propagate: {not stop_propagatation}"), html.pre(f"Inner click count: {inner_count}"), html.pre(f"Outer click count: {outer_count}"), diff --git a/docs/source/guides/adding-interactivity/responding-to-events/index.rst b/docs/source/guides/adding-interactivity/responding-to-events/index.rst index 3853290e1..89f567ca0 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/index.rst +++ b/docs/source/guides/adding-interactivity/responding-to-events/index.rst @@ -85,11 +85,12 @@ want to create a generic ``Button`` component that can be used for a variety of Async Event Handlers -------------------- -Sometimes event handlers need to await something - a trip to the browser for the details -of an event, or any other asynchronous task. Behind the scenes, ReactPy is running an -:mod:`asyncio` event loop for just this purpose. In the layout below we sleep for several -seconds before printing out a message in the first button. However, because that handler -spends its time awaiting, the handler for the second button is still able to respond: +Sometimes event handlers need to execute asynchronous tasks when they are triggered. +Behind the scenes, ReactPy is running an :mod:`asyncio` event loop for just this purpose. +By defining your event handler as an asynchronous function instead of a normal +synchronous one. In the layout below we sleep for several seconds before printing out a +message in the first button. However, because the event handler is asynchronous, the +handler for the second button is still able to respond: .. reactpy:: _examples/button_async_handlers diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py index 6901ad2ee..ecbad9381 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py @@ -8,12 +8,6 @@ def App(): recipient, set_recipient = use_state("Alice") message, set_message = use_state("") - async def handle_recipient_change(event): - set_recipient(event["target"]["value"]) - - async def handle_message_change(event): - set_message(event["target"]["value"]) - @event(prevent_default=True) async def handle_submit(event): set_message("") @@ -29,7 +23,7 @@ async def handle_submit(event): html.select( { "value": recipient, - "on_change": handle_recipient_change, + "on_change": lambda event: set_recipient(event["target"]["value"]), }, html.option({"value": "Alice"}, "Alice"), html.option({"value": "Bob"}, "Bob"), @@ -40,7 +34,7 @@ async def handle_submit(event): "type": "text", "placeholder": "Your message...", "value": message, - "on_change": handle_message_change, + "on_change": lambda event: set_message(event["target"]["value"]), } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py index 031f7a286..40ee78259 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py @@ -5,7 +5,7 @@ def Counter(): number, set_number = use_state(0) - async def handle_click(event): + def handle_click(event): set_number(number + 5) print(number) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py index a88239c27..4702a7464 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py @@ -6,20 +6,16 @@ def App(): is_sent, set_is_sent = use_state(False) message, set_message = use_state("") - async def handle_send_again(event): - set_is_sent(False) - - async def handle_message_change(event): - set_message(event["target"]["value"]) - if is_sent: return html.div( html.h1("Message sent!"), - html.button({"on_click": handle_send_again}, "Send new message?"), + html.button( + {"on_click": lambda event: set_is_sent(False)}, "Send new message?" + ), ) @event(prevent_default=True) - async def handle_submit(event): + def handle_submit(event): set_message("") set_is_sent(True) @@ -29,7 +25,7 @@ async def handle_submit(event): { "placeholder": "Your message here...", "value": message, - "on_change": handle_message_change, + "on_change": lambda event: set_message(event["target"]["value"]), } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py index f9e898567..fe97351af 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py @@ -5,7 +5,7 @@ def Counter(): number, set_number = use_state(0) - async def handle_click(event): + def handle_click(event): set_number(number + 1) set_number(number + 1) set_number(number + 1) diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py index c5ee2cfa2..3fc684005 100644 --- a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py +++ b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py @@ -14,15 +14,12 @@ def ViewButtonEvents(): event, set_event = reactpy.hooks.use_state(None) - async def handle_click(event): - set_event(event) - return reactpy.html.div( Button( { "color": "primary", "variant": "contained", - "onClick": handle_click, + "onClick": lambda event: set_event(event), }, "Click Me!", ), diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py index 75b0b5c28..ca68aedcb 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py @@ -16,7 +16,7 @@ def FilterableList(): @component def Search(value, set_value): - async def handle_change(event): + def handle_change(event): set_value(event["target"]["value"]) return html.label( diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py index 78c503507..e8bcdf333 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py @@ -12,7 +12,7 @@ def SyncedInputs(): @component def Input(label, value, set_value): - async def handle_change(event): + def handle_change(event): set_value(event["target"]["value"]) return html.label( diff --git a/docs/source/reference/_examples/character_movement/main.py b/docs/source/reference/_examples/character_movement/main.py index 449ed0a04..9545b0c0a 100644 --- a/docs/source/reference/_examples/character_movement/main.py +++ b/docs/source/reference/_examples/character_movement/main.py @@ -34,24 +34,6 @@ def translate(x=0, y=0): def Scene(): position, set_position = use_state(Position(100, 100, 0)) - async def move_left(event): - set_position(translate(x=-10)) - - async def move_right(event): - set_position(translate(x=10)) - - async def move_up(event): - set_position(translate(y=-10)) - - async def move_down(event): - set_position(translate(y=10)) - - async def rotate_left(event): - set_position(rotate(-30)) - - async def rotate_right(event): - set_position(rotate(30)) - return html.div( {"style": {"width": "225px"}}, html.div( @@ -75,12 +57,16 @@ async def rotate_right(event): }, ), ), - html.button({"on_click": move_left}, "Move Left"), - html.button({"on_click": move_right}, "Move Right"), - html.button({"on_click": move_up}, "Move Up"), - html.button({"on_click": move_down}, "Move Down"), - html.button({"on_click": rotate_left}, "Rotate Left"), - html.button({"on_click": rotate_right}, "Rotate Right"), + html.button( + {"on_click": lambda e: set_position(translate(x=-10))}, "Move Left" + ), + html.button( + {"on_click": lambda e: set_position(translate(x=10))}, "Move Right" + ), + html.button({"on_click": lambda e: set_position(translate(y=-10))}, "Move Up"), + html.button({"on_click": lambda e: set_position(translate(y=10))}, "Move Down"), + html.button({"on_click": lambda e: set_position(rotate(-30))}, "Rotate Left"), + html.button({"on_click": lambda e: set_position(rotate(30))}, "Rotate Right"), ) diff --git a/docs/source/reference/_examples/click_count.py b/docs/source/reference/_examples/click_count.py index 5e1892ac0..3ee2c89c5 100644 --- a/docs/source/reference/_examples/click_count.py +++ b/docs/source/reference/_examples/click_count.py @@ -5,10 +5,9 @@ def ClickCount(): count, set_count = reactpy.hooks.use_state(0) - async def handle_click(event): - set_count(count + 1) - - return reactpy.html.button({"on_click": handle_click}, [f"Click count: {count}"]) + return reactpy.html.button( + {"on_click": lambda event: set_count(count + 1)}, [f"Click count: {count}"] + ) reactpy.run(ClickCount) diff --git a/docs/source/reference/_examples/material_ui_switch.py b/docs/source/reference/_examples/material_ui_switch.py index 5cdc9c206..704ae3145 100644 --- a/docs/source/reference/_examples/material_ui_switch.py +++ b/docs/source/reference/_examples/material_ui_switch.py @@ -8,14 +8,11 @@ def DayNightSwitch(): checked, set_checked = reactpy.hooks.use_state(False) - async def handle_change(event, checked): - set_checked(checked) - return reactpy.html.div( Switch( { "checked": checked, - "onChange": handle_change, + "onChange": lambda event, checked: set_checked(checked), } ), "🌞" if checked else "🌚", diff --git a/docs/source/reference/_examples/matplotlib_plot.py b/docs/source/reference/_examples/matplotlib_plot.py index bcc360750..a70f173ff 100644 --- a/docs/source/reference/_examples/matplotlib_plot.py +++ b/docs/source/reference/_examples/matplotlib_plot.py @@ -30,17 +30,17 @@ def set_value_at_index(event, index=i): inputs.append(poly_coef_input(i + 1, set_value_at_index)) - async def add_a_term(event): + def add_input(): set_values([*values, 0]) - async def remove_a_term(event): + def del_input(): set_values(values[:-1]) return reactpy.html.div( reactpy.html.div( "add/remove term:", - reactpy.html.button({"on_click": add_a_term}, "+"), - reactpy.html.button({"on_click": remove_a_term}, "-"), + reactpy.html.button({"on_click": lambda event: add_input()}, "+"), + reactpy.html.button({"on_click": lambda event: del_input()}, "-"), ), inputs, ) diff --git a/docs/source/reference/_examples/pigeon_maps.py b/docs/source/reference/_examples/pigeon_maps.py index 696b830ef..1ddf04fdc 100644 --- a/docs/source/reference/_examples/pigeon_maps.py +++ b/docs/source/reference/_examples/pigeon_maps.py @@ -8,33 +8,24 @@ def MapWithMarkers(): marker_anchor, add_marker_anchor, remove_marker_anchor = use_set() - def make_remove_marker_handler(anchor): - async def remove_marker(event): - remove_marker_anchor(anchor) - - return remove_marker - markers = [ Marker( { "anchor": anchor, - "onClick": make_remove_marker_handler(anchor), + "onClick": lambda event, a=anchor: remove_marker_anchor(a), }, key=str(anchor), ) for anchor in marker_anchor ] - async def add_marker_on_click(event): - add_marker_anchor(tuple(event["latLng"])) - return Map( { "defaultCenter": (37.774, -122.419), "defaultZoom": 12, "height": "300px", "metaWheelZoom": True, - "onClick": add_marker_on_click, + "onClick": lambda event: add_marker_anchor(tuple(event["latLng"])), }, markers, ) diff --git a/docs/source/reference/_examples/slideshow.py b/docs/source/reference/_examples/slideshow.py index 0d5c11109..b490b3feb 100644 --- a/docs/source/reference/_examples/slideshow.py +++ b/docs/source/reference/_examples/slideshow.py @@ -5,7 +5,7 @@ def Slideshow(): index, set_index = reactpy.hooks.use_state(0) - async def next_image(event): + def next_image(event): set_index(index + 1) return reactpy.html.img( diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index d56c553e5..5b5afc778 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -17,13 +17,12 @@ class GameState(enum.Enum): def GameView(): game_state, set_game_state = reactpy.hooks.use_state(GameState.init) - async def start_game(event): - set_game_state(GameState.play) - if game_state == GameState.play: return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state) - start_button = reactpy.html.button({"on_click": start_game}, "Start") + start_button = reactpy.html.button( + {"on_click": lambda event: set_game_state(GameState.play)}, "Start" + ) if game_state == GameState.won: menu = reactpy.html.div(reactpy.html.h3("You won!"), start_button) @@ -65,7 +64,7 @@ def GameLoop(grid_size, block_scale, set_game_state): grid = create_grid(grid_size, block_scale) @reactpy.event(prevent_default=True) - async def on_direction_change(event): + def on_direction_change(event): if hasattr(Direction, event["key"]): maybe_new_direction = Direction[event["key"]].value direction_vector_sum = tuple( diff --git a/docs/source/reference/_examples/use_reducer_counter.py b/docs/source/reference/_examples/use_reducer_counter.py index 8a82da4d4..6f9581dfd 100644 --- a/docs/source/reference/_examples/use_reducer_counter.py +++ b/docs/source/reference/_examples/use_reducer_counter.py @@ -16,21 +16,11 @@ def reducer(count, action): @reactpy.component def Counter(): count, dispatch = reactpy.hooks.use_reducer(reducer, 0) - - async def reset_count(event): - dispatch("reset") - - async def increase_count(event): - dispatch("increment") - - async def decrease_count(event): - dispatch("decrement") - return reactpy.html.div( f"Count: {count}", - reactpy.html.button({"on_click": reset_count}, "Reset"), - reactpy.html.button({"on_click": increase_count}, "+"), - reactpy.html.button({"on_click": decrease_count}, "-"), + reactpy.html.button({"on_click": lambda event: dispatch("reset")}, "Reset"), + reactpy.html.button({"on_click": lambda event: dispatch("increment")}, "+"), + reactpy.html.button({"on_click": lambda event: dispatch("decrement")}, "-"), ) diff --git a/docs/source/reference/_examples/use_state_counter.py b/docs/source/reference/_examples/use_state_counter.py index 057beab47..b2d8c84a9 100644 --- a/docs/source/reference/_examples/use_state_counter.py +++ b/docs/source/reference/_examples/use_state_counter.py @@ -13,21 +13,13 @@ def decrement(last_count): def Counter(): initial_count = 0 count, set_count = reactpy.hooks.use_state(initial_count) - - async def reset_count(event): - set_count(initial_count) - - async def increase_count(event): - set_count(increment) - - async def decrease_count(event): - set_count(decrement) - return reactpy.html.div( f"Count: {count}", - reactpy.html.button({"on_click": reset_count}, "Reset"), - reactpy.html.button({"on_click": increase_count}, "+"), - reactpy.html.button({"on_click": decrease_count}, "-"), + reactpy.html.button( + {"on_click": lambda event: set_count(initial_count)}, "Reset" + ), + reactpy.html.button({"on_click": lambda event: set_count(increment)}, "+"), + reactpy.html.button({"on_click": lambda event: set_count(decrement)}, "-"), ) From dc0b9184e0083394c397e19240a18514a75a03d1 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 15:54:28 -0700 Subject: [PATCH 13/36] Let a handler turn its own event proxy off `inspect_event_handler` already reads `event.debounce = ` out of the bytecode of the handler a user wrote, so `event.eventProxy = False` is now read the same way. A handler which knows it will not look behind the event can say so itself instead of everyone having to wrap it in `EventHandler(fn, event_proxy=False)`, and one which must have live access can insist with `event.eventProxy = True`. What the handler says about itself wins over what was set on it, matching how `debounce` already behaves. An assignment whose value cannot be settled from the bytecode alone - a local variable, or a branch which may not run - leaves the handler without an opinion rather than a guessed one. --- CHANGELOG.md | 2 +- src/reactpy/config.py | 13 ++- src/reactpy/core/_event_inspect.py | 130 +++++++++++++++------ src/reactpy/core/events.py | 25 ++-- src/reactpy/types.py | 6 + tests/test_core/test_event_inspect.py | 161 +++++++++++++++++++++----- tests/test_core/test_event_proxy.py | 61 ++++++++++ tests/test_core/test_events.py | 40 +++++++ 8 files changed, 361 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc78a78c4..9d0ebdf0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Don't forget to remove deprecated code on each major release! - `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 rather than being limited to the snapshot sent along with the event. A handler awaits what it needs (`await event.target.value`, `await event.target.focus()`), and reading something the client did not send up front costs a single round trip to the browser. What arrives with the event is a `reactpy.types.Event` snapshot; whatever the handler reaches past is an `EventProxy` describing where in the browser to look for it. - - Live access is on by default via `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, and can be settled per handler with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or `event_proxy=True` for one which must have it even while that option is off. The browser registers only the events of handlers which asked to read them, so dispatching an event nobody looks behind costs the page nothing to remember. + - Live access is on by default via `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, and can be settled per handler with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or `event_proxy=True` for one which must have it even while that option is off. A handler can also decide for itself by assigning `event.eventProxy = False` or `event.eventProxy = True` in its own body, which is read out of the handler the same way `event.debounce = ` is and takes precedence over what was set on it. The browser registers only the events of handlers which asked to read them, so dispatching an event nobody looks behind costs the page nothing to remember. - 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`. ### Changed diff --git a/src/reactpy/config.py b/src/reactpy/config.py index cc2c19e0b..2f828fe74 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -160,12 +160,13 @@ def boolean(value: str | bool | int) -> bool: properties raises :class:`~reactpy._event_proxy.EventProxyUnavailable`. This option is only the default for handlers which do not decide for themselves. An -individual handler can opt out with ``EventHandler(fn, event_proxy=False)`` - worth -doing for anything bound to an event which fires continuously, such as ``onScroll``, -since registering and pruning a kept-alive object costs the browser work on every -event - or insist on live access with ``event_proxy=True`` even while this option is -off. Switching this off therefore does not override a handler which explicitly asked -for live access; it only stops asking on every handler's behalf. +individual handler can opt out with ``EventHandler(fn, event_proxy=False)``, or by +assigning ``event.eventProxy = False`` inside its own body - worth doing for anything +bound to an event which fires continuously, such as ``onScroll``, since registering and +pruning a kept-alive object costs the browser work on every event - or insist on live +access with ``event_proxy=True`` even while this option is off. Switching this off +therefore does not override a handler which explicitly asked for live access; it only +stops asking on every handler's behalf. Every event handler is a coroutine and live values are always awaited, so nothing here blocks the event loop. diff --git a/src/reactpy/core/_event_inspect.py b/src/reactpy/core/_event_inspect.py index d40b5a50e..6f6673529 100644 --- a/src/reactpy/core/_event_inspect.py +++ b/src/reactpy/core/_event_inspect.py @@ -1,5 +1,5 @@ """ -In order to retain a JavaScript-like event handler API, ReactPy allows users to configure event handlers with `event.debounce = `. +In order to retain a JavaScript-like event handler API, ReactPy allows users to configure event handlers with `event.debounce = ` or `event.eventProxy = `. Implementing this syntax necessitates inspecting the bytecode of event handler functions. """ @@ -19,6 +19,16 @@ _STORE_ATTR_VALUE_OFFSET = 2 +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 _is_bool(value: Any) -> bool: + """Whether ``value`` is a ``bool`` rather than a number dressed up as one""" + return isinstance(value, bool) + + def _handler_code(func: Callable) -> CodeType | None: """The bytecode of the callable a user handed us @@ -47,8 +57,10 @@ def _event_arg_name(func: Callable, code: CodeType | None) -> str | None: return code.co_varnames[offset] -def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None]: - """Detect ``preventDefault``/``stopPropagation``/``debounce`` usage. +def inspect_event_handler( + func: Callable, +) -> tuple[bool, bool | str, int | None, bool | None]: + """Detect ``preventDefault``/``stopPropagation``/``debounce``/``eventProxy`` usage. Walks the bytecode of ``func`` looking for the patterns: @@ -56,52 +68,61 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None] - ``event.stopPropagation()`` -> ``stop_propagation = True`` - ``event.stopImmediatePropagation()`` -> ``stop_propagation = "immediate"`` - ``event.debounce = `` -> ``debounce = `` + - ``event.eventProxy = `` -> ``event_proxy = `` - The ``debounce`` value can come from any of the following: + The value behind ``debounce`` and ``eventProxy`` 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. + ``debounce=...``/``event_proxy=...`` explicitly in that case. - Returns ``(prevent_default, stop_propagation, debounce)``, where + Returns ``(prevent_default, stop_propagation, debounce, event_proxy)``, where ``stop_propagation`` is the string ``"immediate"`` if the handler asked to stop - *other listeners* as well. + *other listeners* as well, and ``event_proxy`` is :data:`None` for a handler + which never said anything about reading past the event it was handed. """ prevent_default = False stop_propagation = False stop_immediate_propagation = False debounce: int | None = None + event_proxy: bool | None = None 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 + return prevent_default, stop_propagation, debounce, event_proxy 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 + check_event_proxy = "eventProxy" in names if not ( check_prevent_default or check_stop_propagation or check_stop_immediate_propagation or check_debounce + or check_event_proxy ): - return prevent_default, stop_propagation, debounce + return prevent_default, stop_propagation, debounce, event_proxy instructions = list(dis.get_instructions(code)) arg_defaults = _function_arg_defaults(func) closure_by_name = _closure_lookup(func) constant_ints_by_name = _constant_ints_by_name(code) + # The same lookups again, keeping only the ``bool`` values ``eventProxy`` accepts + arg_flags = _function_arg_defaults(func, _is_bool) + closure_flags = _closure_lookup(func, _is_bool) last_was_event = False # When the function assigns ``event.debounce`` in multiple branches we @@ -109,6 +130,7 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None] # unresolvable value (e.g. a local variable) we treat the whole detection # as ambiguous and return ``None`` rather than picking a fallback literal. debounce_tainted = False + event_proxy_tainted = False for index, instr in enumerate(instructions): if ( @@ -155,7 +177,7 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None] and instr.opname == "STORE_ATTR" and instr.argval == "debounce" ): - candidate = _resolve_debounce_value( + candidate = _resolve_stored_value( instructions, index, event_arg_name, @@ -175,12 +197,38 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None] debounce_tainted = True debounce = None check_debounce = False + elif ( + check_event_proxy + and instr.opname == "STORE_ATTR" + and instr.argval == "eventProxy" + ): + candidate = _resolve_stored_value( + instructions, + index, + event_arg_name, + arg_flags, + closure_flags, + {}, + is_value=_is_bool, + ) + if isinstance(candidate, bool): + if not event_proxy_tainted: + event_proxy = candidate + check_event_proxy = False + elif candidate is None: + # Same as ``debounce`` above: a value we cannot settle from the + # bytecode alone leaves the handler without an opinion, rather + # than a guessed one. + event_proxy_tainted = True + event_proxy = None + check_event_proxy = False if not ( check_prevent_default or check_stop_propagation or check_stop_immediate_propagation or check_debounce + or check_event_proxy ): break @@ -190,17 +238,22 @@ def inspect_event_handler(func: Callable) -> tuple[bool, bool | str, int | None] prevent_default, "immediate" if stop_immediate_propagation else stop_propagation, debounce, + event_proxy, ) -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 = _handler_code(func) - result: dict[str, int] = {} + result: dict[str, Any] = {} if code is None: return result @@ -210,27 +263,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 {} code = _handler_code(func) freevars = getattr(code, "co_freevars", ()) if code is not None else () - result: dict[str, int] = {} + result: dict[str, Any] = {} for idx, cell in enumerate(closure): if idx >= len(freevars): break @@ -238,7 +296,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 @@ -255,17 +313,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 @@ -282,7 +342,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 @@ -298,9 +358,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 @@ -309,12 +369,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/events.py b/src/reactpy/core/events.py index 31d8b632c..616131cee 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -135,8 +135,10 @@ async def my_callback(event): ... Also block any other listener of the same event in the browser. event_proxy: Keep the browser object this event fired from alive so the handler can - read past the snapshot which arrived with the event. See - :attr:`BaseEventHandler.event_proxy`. + read past the snapshot which arrived with the event. The handler can say + so itself as well by assigning ``event.eventProxy = True`` or + ``event.eventProxy = False`` in its own body, which takes precedence over + what is given here. See :attr:`BaseEventHandler.event_proxy`. """ def setup(function: Callable[..., Awaitable[Any]]) -> EventHandler: @@ -177,7 +179,9 @@ class EventHandler(BaseEventHandler): Also block any other listener of the same event in the browser. event_proxy: Keep the browser object this event fired from alive so the handler can - read past the snapshot which arrived with the event. See + read past the snapshot which arrived with the event. Assigning + ``event.eventProxy = `` inside the handler is detected the same way, + and takes precedence over what is given here. See :attr:`BaseEventHandler.event_proxy`. """ @@ -202,11 +206,14 @@ def __init__( self.target = target self.event_proxy = event_proxy - # Check if our `preventDefault` or `stopPropagation` methods were called by - # inspecting the bytecode of the handler the user actually wrote - found_prevent_default, found_stop_propagation, found_debounce = ( - inspect_event_handler(_unwrap_handler(function)) - ) + # 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, + found_event_proxy, + ) = inspect_event_handler(_unwrap_handler(function)) if found_prevent_default: self.prevent_default = True @@ -216,6 +223,8 @@ def __init__( self.stop_immediate_propagation = True if found_debounce is not None: self.debounce = found_debounce + if found_event_proxy is not None: + self.event_proxy = found_event_proxy __hash__ = None # type: ignore diff --git a/src/reactpy/types.py b/src/reactpy/types.py index f36aa35bd..5845680c7 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1016,6 +1016,12 @@ class BaseEventHandler: continuously, such as ``onScroll``, because keeping an object alive and pruning it costs the browser work on every event. + A handler can also decide for itself by assigning ``event.eventProxy = False`` or + ``event.eventProxy = True`` in its own body. That assignment is read out of the + handler's bytecode when the :class:`BaseEventHandler` is made, the same way + ``event.debounce = `` is, and it wins over whatever this attribute + was set to. + Whatever this says, a handler is only ever given live access for data the client actually marked as something it is still holding. Otherwise it receives the :class:`~reactpy.types.Event` snapshot, and awaiting a property of it raises diff --git a/tests/test_core/test_event_inspect.py b/tests/test_core/test_event_inspect.py index 5e6ceef85..a163890b9 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, ) @@ -213,23 +213,23 @@ async def handler(event): 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(): @@ -354,8 +348,9 @@ async def handler(event, ms=400): with patch.object(dis, "get_instructions", return_value=fake_list): result = inspect_event_handler(handler) - # Expected: (prevent_default=False, stop_propagation=False, debounce=400). - assert result == (False, False, 400) + # Expected: (prevent_default=False, stop_propagation=False, debounce=400, + # event_proxy=None). + assert result == (False, False, 400, None) # --------------------------------------------------------------------------- @@ -397,9 +392,9 @@ async 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 @@ -474,5 +469,117 @@ class Talkative: handler = Talkative() - assert inspect_event_handler(handler) == (False, False, None) + assert inspect_event_handler(handler) == (False, False, None, None) assert _function_arg_defaults(handler) == {} + + +# --------------------------------------------------------------------------- +# ``event.eventProxy`` detection +# --------------------------------------------------------------------------- + + +def test_detect_event_proxy_being_turned_off(): + """The point of the flag: a handler which will not look behind the event""" + + async def handler(event): + event.eventProxy = False + + assert inspect_event_handler(handler) == (False, False, None, False) + + +def test_detect_event_proxy_being_turned_on(): + async def handler(event): + event.eventProxy = True + + assert inspect_event_handler(handler) == (False, False, None, True) + + +def test_detect_event_proxy_alongside_everything_else(): + async def handler(event): + event.preventDefault() + event.stopPropagation() + event.debounce = 150 + event.eventProxy = False + + assert inspect_event_handler(handler) == (True, True, 150, False) + + +def test_detect_event_proxy_from_positional_default(): + async def handler(event, keep=True): + event.eventProxy = keep + + assert inspect_event_handler(handler) == (False, False, None, True) + + +def test_detect_event_proxy_from_keyword_only_default(): + async def handler(event, *, keep=False): + event.eventProxy = keep + + assert inspect_event_handler(handler) == (False, False, None, False) + + +def test_detect_event_proxy_from_closure(): + def make(keep): + async def handler(event): + event.eventProxy = keep + + return handler + + assert inspect_event_handler(make(True)) == (False, False, None, True) + + +def test_detect_event_proxy_from_closure_among_multiple(): + def make(unused, keep): + async def handler(event): + event.eventProxy = keep + + return handler + + assert inspect_event_handler(make("x", False)) == (False, False, None, False) + + +def test_no_detect_event_proxy_when_a_local_var_is_used(): + async def handler(event): + keep = False + event.eventProxy = keep + + assert inspect_event_handler(handler) == (False, False, None, None) + + +def test_no_detect_event_proxy_when_an_unresolvable_fallback_is_used(): + # An unresolvable branch makes the whole assignment ambiguous, so the handler + # is left without an opinion rather than a guessed one. + async def handler(event): + keep = False + if keep: + event.eventProxy = keep + else: + event.eventProxy = True + + assert inspect_event_handler(handler) == (False, False, None, None) + + +def test_no_detect_event_proxy_from_a_number(): + """A number is not a flag - only ``True`` and ``False`` answer for it""" + + async def handler(event): + event.eventProxy = 1 + + assert inspect_event_handler(handler) == (False, False, None, None) + + +def test_no_detect_event_proxy_when_the_target_is_not_the_event(): + async def handler(other): + event = other + event.eventProxy = False + + assert inspect_event_handler(handler) == (False, False, None, None) + + +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, None) diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index c06da80e8..2dc0442bc 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -28,6 +28,7 @@ serialize_argument, ) from reactpy.config import ( + REACTPY_ENABLE_EVENT_PROXY, REACTPY_EVENT_PROXY_MAX_TIMEOUTS, REACTPY_EVENT_PROXY_TIMEOUT, ) @@ -667,6 +668,66 @@ def MyComponent(): assert handler["stopImmediatePropagation"] is True +async def test_a_handler_which_refuses_live_access_in_its_own_body(): + """Saying it inside the handler counts as saying it on the handler + + ``event.eventProxy = False`` is read out of the handler the same way + ``event.debounce = `` is, so the spec which goes out to the client + already knows this one will not look behind the event it is handed - and nothing is + remembered about an event nobody reads past. + """ + received: dict[str, Any] = {} + + async def on_click(event_object): + event_object.eventProxy = False + received["event"] = event_object + + @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"] + handler_spec = event_handlers_of(model)["onClick"] + assert "eventProxy" not in handler_spec, ( + "a handler which refused live access in its own body was still reported " + "as wanting it" + ) + + await layout.deliver( + event_message( + handler_spec["target"], + {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, + ) + ) + await poll(lambda: "event" in received) + + assert type(received["event"]) is Event + assert client.requests == [], "the browser was asked about an event nobody wanted" + + +async def test_a_handler_which_asks_for_live_access_in_its_own_body(monkeypatch): + """The other way round works too, even while the global switch is off""" + monkeypatch.setattr(REACTPY_ENABLE_EVENT_PROXY, "current", False) + + async def on_click(event_object): + event_object.eventProxy = True + + @component + def MyComponent(): + return reactpy.html.div({"onClick": on_click}) + + async with Layout(MyComponent()) as layout: + model = (await layout.render())["model"] + handler_spec = event_handlers_of(model)["onClick"] + assert handler_spec["eventProxy"] is True, ( + "the handler asked for live access and the client was never told" + ) + + # -- what a proxy says about itself ----------------------------------------- diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 3d7f882f1..3378c2ddc 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -583,6 +583,46 @@ async def handler(event: Event): assert eh.prevent_default is False assert eh.stop_propagation is False assert eh.debounce is None + assert eh.event_proxy is None + + +def test_detect_event_proxy_being_refused(): + async def handler(event: Event): + event.eventProxy = False + + assert EventHandler(handler).event_proxy is False + + +def test_detect_event_proxy_being_insisted_on(): + async def handler(event: Event): + event.eventProxy = True + + assert EventHandler(handler).event_proxy is True + + +def test_detected_event_proxy_overrides_the_stated_preference(): + """What the handler says about itself is taken over what was said about it""" + + async def handler(event: Event): + event.eventProxy = False + + assert EventHandler(handler, event_proxy=True).event_proxy is False + + +def test_detect_event_proxy_when_handler_is_partial(): + async def handler(event: Event, *, extra_param): + event.eventProxy = False + + eh = EventHandler(partial(handler, extra_param="extra_value")) + assert eh.event_proxy is False + + +def test_event_decorator_detects_event_proxy(): + @event + async def handler(event: Event): + event.eventProxy = False + + assert handler.event_proxy is False def test_event_decorator_accepts_throttle(): From 9a47a7c727f05db0c40db6b3ed3ad57ad0d37e5d Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 22:22:24 -0700 Subject: [PATCH 14/36] Drop the global event proxy switch Live access is what a handler gets unless it has refused it for itself, on the handler or from inside its own body, so there is nothing left for a global option to say. The per-handler event_proxy preference is the only knob. --- CHANGELOG.md | 2 +- src/reactpy/_event_proxy.py | 7 ++-- src/reactpy/config.py | 33 ---------------- src/reactpy/core/layout.py | 20 +++++----- src/reactpy/types.py | 13 ++++--- tests/test_core/test_event_proxy.py | 58 +++++++++++++++++++++++++---- 6 files changed, 72 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0ebdf0b..8bc7f3945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Don't forget to remove deprecated code on each major release! - `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 rather than being limited to the snapshot sent along with the event. A handler awaits what it needs (`await event.target.value`, `await event.target.focus()`), and reading something the client did not send up front costs a single round trip to the browser. What arrives with the event is a `reactpy.types.Event` snapshot; whatever the handler reaches past is an `EventProxy` describing where in the browser to look for it. - - Live access is on by default via `reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, and can be settled per handler with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or `event_proxy=True` for one which must have it even while that option is off. A handler can also decide for itself by assigning `event.eventProxy = False` or `event.eventProxy = True` in its own body, which is read out of the handler the same way `event.debounce = ` is and takes precedence over what was set on it. The browser registers only the events of handlers which asked to read them, so dispatching an event nobody looks behind costs the page nothing to remember. + - Live access is on unless a handler turns it off, which it can do either by being set with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or by assigning `event.eventProxy = False` inside its own body - the same way `event.debounce = ` is read out of the handler, and it takes precedence over what was set on it. There is no global switch. The browser registers only the events of handlers which have not refused, so dispatching an event nobody looks behind costs the page nothing to remember. - 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`. ### Changed diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 7f8b09c30..c713ef48e 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -814,9 +814,10 @@ async def on_key_down(event: Event): awaited - if the client is not holding the object alive at all, awaiting raises :class:`~reactpy._event_proxy.EventProxyUnavailable`. - Handlers are always coroutines, so this is the only event object there is. See - :attr:`~reactpy.types.BaseEventHandler.event_proxy` for turning live access off for - a single handler, and ``REACTPY_ENABLE_EVENT_PROXY`` for turning it off everywhere. + Handlers are always coroutines, so this is the only event object there is. Live + access is what a handler gets unless it has declined it - see + :attr:`~reactpy.types.BaseEventHandler.event_proxy` for turning it off for a single + handler, which is the only knob there is. 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 diff --git a/src/reactpy/config.py b/src/reactpy/config.py index 2f828fe74..b56ab3017 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -139,39 +139,6 @@ def boolean(value: str | bool | int) -> bool: ) """The maximum size for internal queues used by ReactPy""" -REACTPY_ENABLE_EVENT_PROXY = Option( - "REACTPY_ENABLE_EVENT_PROXY", - default=True, - mutable=True, - validator=boolean, -) -"""Whether event handlers are given live client-backed event objects by default. - -Enabled by default, because reading the object an event fired from is what people -expect - ``await event.target.value`` should just work, rather than having to be -anticipated when the handler is written. When enabled the client keeps a reference to -the dispatched JavaScript ``Event`` around, and the server can read properties from it -and call methods on it. - -Handlers receive a :class:`reactpy.types.Event` either way. It is a ``dict`` subclass -holding the usual snapshot, and additionally reaches back into the browser for anything -the snapshot left out. When the client cannot or should not keep the object alive, the -same :class:`~reactpy.types.Event` arrives without any backing and awaiting one of its -properties raises :class:`~reactpy._event_proxy.EventProxyUnavailable`. - -This option is only the default for handlers which do not decide for themselves. An -individual handler can opt out with ``EventHandler(fn, event_proxy=False)``, or by -assigning ``event.eventProxy = False`` inside its own body - worth doing for anything -bound to an event which fires continuously, such as ``onScroll``, since registering and -pruning a kept-alive object costs the browser work on every event - or insist on live -access with ``event_proxy=True`` even while this option is off. Switching this off -therefore does not override a handler which explicitly asked for live access; it only -stops asking on every handler's behalf. - -Every event handler is a coroutine and live values are always awaited, so nothing here -blocks the event loop. -""" - REACTPY_EVENT_PROXY_TIMEOUT = Option( "REACTPY_EVENT_PROXY_TIMEOUT", default=5.0, diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index f7a0b1618..69e01745f 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -40,7 +40,6 @@ REACTPY_ASYNC_RENDERING, REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG, - REACTPY_ENABLE_EVENT_PROXY, REACTPY_MAX_QUEUE_SIZE, ) from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook @@ -235,9 +234,9 @@ def _make_event_arguments( A handler which asks for live access is given it, and one which declines is given the snapshot and nothing else. With no preference stated the data - decides: the client marks whatever it has agreed to keep hold of - which - ``REACTPY_ENABLE_EVENT_PROXY`` asks it to do for every handler by default - - and marked data comes back as a live object. A client which has gone away + decides: the client marks whatever it has agreed to keep hold of - which is + whatever it dispatched, since live access is on unless a handler has declined + it - and marked data comes back as a live object. A client which has gone away cannot answer for that mark either way, because a backend without a sender reports itself unavailable and the mark is dropped with the rest of the overhead. @@ -680,9 +679,9 @@ def _serialize_event_handler(handler: Any, target: str) -> dict[str, Any]: forwarded when set and applies to every element. Both are omitted from the payload when ``None`` so the wire format stays minimal. - ``eventProxy`` is only forwarded when live event access is wanted for this - handler: an absent key means "this server cannot do live access" which keeps - the client from holding on to events nobody will ask about. + ``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, @@ -696,10 +695,9 @@ def _serialize_event_handler(handler: Any, target: str) -> dict[str, Any]: if handler.throttle is not None: entry["throttle"] = handler.throttle - want_proxy = getattr(handler, "event_proxy", None) - if want_proxy is None: - want_proxy = REACTPY_ENABLE_EVENT_PROXY.current - if want_proxy: + # Live access is on unless the handler has refused it, whether on the handler + # itself or from inside its own body - see ``BaseEventHandler.event_proxy`` + if getattr(handler, "event_proxy", None) is not False: entry["eventProxy"] = True return entry diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 5845680c7..0ea66a491 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1009,12 +1009,13 @@ class BaseEventHandler: carried. :data:`False` takes the snapshot and nothing else - the browser is not asked to remember anything for this handler. - The default, :data:`None`, follows - :data:`reactpy.config.REACTPY_ENABLE_EVENT_PROXY`, which is switched on because - reading the object an event fired from is what people expect. Opt a single handler - out by setting this to :data:`False` - worthwhile for anything which fires - continuously, such as ``onScroll``, because keeping an object alive and pruning it - costs the browser work on every event. + The default, :data:`None`, wants live access, because reading the object an event + fired from is what people expect - ``await event.target.value`` should just work + rather than having to be anticipated when the handler is written. This attribute is + the only say in the matter: there is no global switch. Opt a single handler out by + setting it to :data:`False` - worthwhile for anything which fires continuously, + such as ``onScroll``, because keeping an object alive and pruning it costs the + browser work on every event. A handler can also decide for itself by assigning ``event.eventProxy = False`` or ``event.eventProxy = True`` in its own body. That assignment is read out of the diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 2dc0442bc..638d3e95a 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -28,7 +28,6 @@ serialize_argument, ) from reactpy.config import ( - REACTPY_ENABLE_EVENT_PROXY, REACTPY_EVENT_PROXY_MAX_TIMEOUTS, REACTPY_EVENT_PROXY_TIMEOUT, ) @@ -598,12 +597,53 @@ def MyComponent(): assert client.requests == [], "the browser was asked about an event nobody wanted" +async def test_live_access_is_on_for_a_handler_which_says_nothing(): + """A handler which states no preference is given live access + + There is no global switch to lean on: not saying anything is itself the answer, so + the client is told to hold on to the object the event came from, and the mark it + leaves behind is turned into something the handler can read through. + """ + received: dict[str, Any] = {} + + async def handler(event_object): + received["event"] = event_object + + @component + def MyComponent(): + return reactpy.html.div({"onClick": event(handler)}) + + _backend, client = make_client(make_objects()) + + async with Layout(MyComponent()) as layout: + connect(layout, client) + model = (await layout.render())["model"] + handler_spec = event_handlers_of(model)["onClick"] + assert handler_spec["eventProxy"] is True, ( + "a handler which said nothing was left without live access" + ) + + await layout.deliver( + event_message( + handler_spec["target"], + {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, + ) + ) + await poll(lambda: "event" in received) + + assert isinstance(received["event"], Event) + assert await received["event"].read("target.value") == "typed" + + assert requested_paths(client) == [["target", "value"]] + + async def test_a_handler_which_asks_for_live_access_gets_it(): - """Asking on the handler is enough; the global switch is not needed + """Asking on the handler says so out loud rather than leaving it to the default Saying so on the handler does two things: the client is told to hold on to the object the event came from, and the server turns the mark the client leaves - behind into something the handler can actually read through. + behind into something the handler can actually read through - the latter even for + data the client never marked, which is what separates asking from not saying. """ received: dict[str, Any] = {} @@ -709,16 +749,20 @@ def MyComponent(): assert client.requests == [], "the browser was asked about an event nobody wanted" -async def test_a_handler_which_asks_for_live_access_in_its_own_body(monkeypatch): - """The other way round works too, even while the global switch is off""" - monkeypatch.setattr(REACTPY_ENABLE_EVENT_PROXY, "current", False) +async def test_a_handler_which_asks_for_live_access_in_its_own_body(): + """The other way round works too, overruling what was set on the handler + + This one was told not to look behind the event, and then said otherwise from + inside its own body - which is read out of the handler the same way + ``event.debounce = `` is, and wins. + """ async def on_click(event_object): event_object.eventProxy = True @component def MyComponent(): - return reactpy.html.div({"onClick": on_click}) + return reactpy.html.div({"onClick": EventHandler(on_click, event_proxy=False)}) async with Layout(MyComponent()) as layout: model = (await layout.render())["model"] From 158ccb44e673e20d0b4ca163e27f4d9a96c23622 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 11 Oct 2026 23:20:11 -0700 Subject: [PATCH 15/36] Let a merged handler ask for live access unless all refused Saying nothing wants live access just as much as saying so does now there is no global switch, so one handler declining cannot take it away from a handler merged alongside it which never refused. The merged handler refuses only when every one of them did. --- CHANGELOG.md | 2 +- src/reactpy/core/events.py | 25 ++++++++++---- tests/test_core/test_event_proxy.py | 51 +++++++++++++++++++++++++++++ tests/test_core/test_events.py | 13 +++++--- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc7f3945..bcd988f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Don't forget to remove deprecated code on each major release! - `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 rather than being limited to the snapshot sent along with the event. A handler awaits what it needs (`await event.target.value`, `await event.target.focus()`), and reading something the client did not send up front costs a single round trip to the browser. What arrives with the event is a `reactpy.types.Event` snapshot; whatever the handler reaches past is an `EventProxy` describing where in the browser to look for it. - - Live access is on unless a handler turns it off, which it can do either by being set with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or by assigning `event.eventProxy = False` inside its own body - the same way `event.debounce = ` is read out of the handler, and it takes precedence over what was set on it. There is no global switch. The browser registers only the events of handlers which have not refused, so dispatching an event nobody looks behind costs the page nothing to remember. + - Live access is on unless a handler turns it off, which it can do either by being set with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or by assigning `event.eventProxy = False` inside its own body - the same way `event.debounce = ` is read out of the handler, and it takes precedence over what was set on it. There is no global switch. The browser registers only the events of handlers which have not refused, so dispatching an event nobody looks behind costs the page nothing to remember, and a group merged with `merge_event_handlers()` asks for it unless every handler in it refused - one declining cannot speak for the rest. - 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`. ### Changed diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index 616131cee..be824938a 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -291,6 +291,11 @@ def merge_event_handlers( :attr:`~reactpy.core.proto.EventHandlerType.prevent_default`, :attr:`~reactpy.core.proto.EventHandlerType.debounce`, :attr:`~reactpy.core.proto.EventHandlerType.throttle` attributes. + + Live access is not among the things they can conflict over. Every handler is + handed the same event, so the merged one asks the client to keep that event alive + unless every handler given to it refused - see + :attr:`~reactpy.types.BaseEventHandler.event_proxy`. """ if not event_handlers: msg = "No event handlers to merge" @@ -326,12 +331,20 @@ def merge_event_handlers( raise ValueError(msg) # Asking the client to keep the originating object alive is not something the - # handlers can disagree about: if any of them wants it, the merged handler asks - # for it, and the others simply receive objects they never read past. - wants_proxy = [getattr(handler, "event_proxy", None) for handler in event_handlers] - event_proxy: bool | None = ( - True if True in wants_proxy else False if False in wants_proxy else None - ) + # handlers can disagree about. Stating nothing wants it just as much as saying so + # does, so the merged handler refuses only when every one of them refused - one + # handler declining cannot take live access away from another which never said it + # would not use it. Those which asked for less simply receive objects they never + # read past, since every handler here is handed the very same event. + preferences: list[bool | None] = [ + getattr(handler, "event_proxy", None) for handler in event_handlers + ] + if all(preference is False for preference in preferences): + event_proxy: bool | None = False + elif True in preferences: + event_proxy = True + else: + event_proxy = None return EventHandler( merge_event_handler_funcs([h.function for h in event_handlers]), diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 638d3e95a..f21c7ba58 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -772,6 +772,57 @@ def MyComponent(): ) +async def test_one_declining_handler_cannot_speak_for_a_merged_pair(): + """Merged handlers are handed the very same event, so one refusal is not theirs to give + + Both functions below run off one handler spec. Had the declining one been allowed + to settle it, the one which never refused would have been handed a dead snapshot + and its read would have raised rather than reached the page. + """ + received: dict[str, Any] = {} + + async def reads_past_the_snapshot(event_object): + received["value"] = await event_object.read("target.value") + + async def stays_where_it_is(event_object): + received["event"] = event_object + + @component + def MyComponent(): + return reactpy.html.div( + { + "onClick": merge_event_handlers( + [ + event(reads_past_the_snapshot), + event(stays_where_it_is, event_proxy=False), + ] + ) + } + ) + + _backend, client = make_client(make_objects()) + + async with Layout(MyComponent()) as layout: + connect(layout, client) + model = (await layout.render())["model"] + handler_spec = event_handlers_of(model)["onClick"] + assert handler_spec["eventProxy"] is True, ( + "a handler which never refused was merged with one which did, and the " + "refusal was taken to speak for both" + ) + + await layout.deliver( + event_message( + handler_spec["target"], + {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, + ) + ) + await poll(lambda: "value" in received and "event" in received) + + assert received["value"] == "typed" + assert requested_paths(client) == [["target", "value"]] + + # -- what a proxy says about itself ----------------------------------------- diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 3378c2ddc..1b320c27e 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -223,19 +223,24 @@ def test_merge_event_handlers_event_proxy(): """Merging never has to settle an argument about live access Whether the browser object behind an event should be kept alive is not something - two handlers can contradict each other over - one of them simply wants it. The - merged handler asks for it whenever any of the originals did, and states nothing - at all when none of them said either way. + two handlers can contradict each other over - one of them simply wants it, and a + handler which says nothing wants it just as much. The merged handler therefore + refuses only when every original refused, and states nothing at all - which means + the same thing as asking - when none of them said either way. """ plain = EventHandler(_noop_handler) wants = EventHandler(_noop_handler, event_proxy=True) declines = EventHandler(_noop_handler, event_proxy=False) other_plain = EventHandler(_noop_handler) + other_declines = EventHandler(_noop_handler, event_proxy=False) assert merge_event_handlers([plain, other_plain]).event_proxy is None assert merge_event_handlers([plain, wants]).event_proxy is True - assert merge_event_handlers([plain, declines]).event_proxy is False + assert merge_event_handlers([plain, declines]).event_proxy is None, ( + "a handler which never refused lost live access to one which did" + ) assert merge_event_handlers([declines, wants]).event_proxy is True + assert merge_event_handlers([declines, other_declines]).event_proxy is False async def test_can_prevent_event_default_operation(display: DisplayFixture): From 886a18cedeb628b5b55dae5b49f37eaa9b49bb97 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 12:59:50 -0700 Subject: [PATCH 16/36] update changelog --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd988f2a..713e390eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,10 @@ 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 rather than being limited to the snapshot sent along with the event. A handler awaits what it needs (`await event.target.value`, `await event.target.focus()`), and reading something the client did not send up front costs a single round trip to the browser. What arrives with the event is a `reactpy.types.Event` snapshot; whatever the handler reaches past is an `EventProxy` describing where in the browser to look for it. - - Live access is on unless a handler turns it off, which it can do either by being set with `EventHandler(fn, event_proxy=False)` for one which knows it will not look behind the event, or by assigning `event.eventProxy = False` inside its own body - the same way `event.debounce = ` is read out of the handler, and it takes precedence over what was set on it. There is no global switch. The browser registers only the events of handlers which have not refused, so dispatching an event nobody looks behind costs the page nothing to remember, and a group merged with `merge_event_handlers()` asks for it unless every handler in it refused - one declining cannot speak for the rest. +- Added a live event proxy which lets event handlers read properties from, and call methods on, the browser objects which triggered them rather than being limited to the snapshot sent along with the event. + - A handler awaits what it needs (`await event.target.value`, `await event.target.focus()`). + - The initial event is a snapshot of serializable event data; whatever the handler reaches past is an `EventProxy` describing where in the browser to look for it. + - Live access is on unless a handler turns it off by assigning `event.eventProxy = False` inside its own body. - 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`. ### Changed From 96c769136c33043ba45c6f17f152f223b5d9f297 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 18:02:01 -0700 Subject: [PATCH 17/36] Read browser events live, and only live An event no longer arrives with the browser's data pre-packaged. The client keeps hold of the object it dispatched and sends only a marker carrying its id, alongside the one plain value too cheap, and too useful for telling a handler what it is holding, to withhold: the event "type". A click now costs 154 bytes on the wire where a snapshot cost 32,666. Everything else is read when it is asked for. Every proxy is awaitable, so `await event.target.value` names the same property chain in Python as it does in JavaScript, the only difference being the await which fetches it from the browser. Reads are cached against the event, a read already in flight is shared rather than duplicated, and reads made together leave as one batched message. `read()` takes any number of paths and answers with a tuple of the same length. The opt-outs have gone with the snapshot they existed to skip. There is no `event_proxy` on a handler and no `eventProxy` on the model, because there is no longer any eager work to decline, and the event is no longer a dict - reading it means awaiting a proxy, which is the only way to keep a handler free to set state while a value is on its way from the page. What a read reports belongs to the moment it is awaited rather than to the event which woke the handler. A handler still queued behind another event's work sees the element as it stands by then, not as it was, exactly as the same expression would in JavaScript. Tests which pinned the dispatch-time ordering now say what still holds, and one covers the new behaviour directly. The answers also had nowhere to go. The ASGI middleware recognised `layout-event` alone and logged anything else as an unknown message type, so a reply was answered into the void while the handler waited out its timeout - invisible to every test which did not drive a real browser. The websocket now holds the layout and hands responses to it directly, on the loop which is waiting for them. --- .../@reactpy/client/src/event-proxy.ts | 167 +- src/js/packages/@reactpy/client/src/types.ts | 1 - src/js/packages/@reactpy/client/src/vdom.tsx | 39 +- .../@reactpy/client/tests/event-proxy.test.ts | 328 +++- src/reactpy/_event_proxy.py | 664 ++++---- src/reactpy/core/_event_inspect.py | 66 +- src/reactpy/core/events.py | 44 - src/reactpy/core/layout.py | 44 +- src/reactpy/core/vdom.py | 1 - src/reactpy/executors/asgi/middleware.py | 22 +- src/reactpy/types.py | 28 - src/reactpy/widgets.py | 6 +- tests/test_client.py | 2 +- tests/test_core/test_event_inspect.py | 112 +- tests/test_core/test_event_proxy.py | 1455 ++++++++--------- tests/test_core/test_events.py | 189 +-- tests/test_core/test_hooks.py | 5 +- tests/test_core/test_layout.py | 2 +- tests/test_core/test_serve.py | 3 +- 19 files changed, 1616 insertions(+), 1562 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts index 1ead29418..2520dd3ba 100644 --- a/src/js/packages/@reactpy/client/src/event-proxy.ts +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -1,16 +1,17 @@ /** * Live ("event proxy") access to the objects which triggered a ReactPy event. * - * Every object dispatched by a VDOM event handler is registered here, so the server - * can reach back into whatever actually triggered the event. What the client sends - * alongside the ``layout-event`` is the serializable half of that object plus an id: + * 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", target: { tagName: "INPUT" }, __reactpy_event__: { id: "..." } } + * { type: "click", __reactpy_event__: { id: "..." } } * ``` * - * The server can then walk that object property by property, and call its methods, by - * sending ``event-proxy-request`` messages: + * 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: "...", @@ -24,9 +25,14 @@ * { __reactpy_ref__: { eventId: "...", path: ["target"], callable?: boolean } } * ``` * - * Only what is needed is serialized, which removes the fixed recursion depth that the - * snapshot alone imposes, while keeping anything which could be used to break out of - * the page (window, document, prototype, eval, ...) firmly out of reach. + * 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"; @@ -34,8 +40,17 @@ 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 */ -export const MAX_LIVE_OBJECTS = 100; +/** + * 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 "not-found" 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. + */ +export const MAX_LIVE_OBJECTS = 512; /** How long an object may sit unused before it is forgotten (ms) */ export const LIVE_OBJECT_TTL_MS = 60_000; /** How many properties of a single object are handed over at most */ @@ -63,6 +78,25 @@ export type EventProxyResponse = { code?: "not-found" | "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; }; @@ -76,7 +110,10 @@ export class ProxyAccessError extends Error {} * 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 the snapshot the client sends up front is made of. + * 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 @@ -179,7 +216,9 @@ export function readReference(value: unknown): Reference | null { * 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 the snapshot serializer does. + * 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, @@ -208,10 +247,11 @@ export function collectProperties( } /** - * Turn a live browser value into something JSON can carry. + * 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 for it later. + * replaced with a :data:`Reference` so the server can ask about it separately - which is + * exactly what a handler which reaches further down does. */ export function serializeValue( value: unknown, @@ -237,10 +277,13 @@ export function serializeValue( if (depth >= (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return reference(eventId, path); - const nested = new Set(seen); - nested.add(value); + // 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, nested); + serializeValue(item, eventId, [...path, segment], options, at, seen); if (value instanceof Date) return value.toISOString(); if (value instanceof RegExp) return String(value); @@ -277,36 +320,6 @@ export function serializeValue( return result; } -/** - * Replace every reference in a serialized value with ``null``. - * - * ``serializeValue`` leans on references for whatever JSON cannot carry - a function, - * a DOM node, something too deep or already seen. Those are only worth sending to a - * server which can ask about them afterwards, which in turn needs the object to still - * be held here. A snapshot taken for a handler which never asked for live access has - * nothing behind it, so the references are emptied rather than handed over as - * something nobody will ever be able to answer. - */ -export function dropReferences(value: any): any { - if (Array.isArray(value)) return value.map(dropReferences); - if (value === null || typeof value !== "object") return value; - if (readReference(value)) return null; - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, dropReferences(item)]), - ); -} - -/** - * Serialize an event for a handler which will not be reaching back into the page. - * - * The object is not registered, so dispatching it costs the page nothing to remember, - * and the snapshot carries no references which could only be answered by a lookup that - * would never succeed. The empty event id is never resolved against the registry. - */ -export function serializeSnapshot(value: any): any { - return dropReferences(serializeValue(value, "")); -} - /** Read a single property of a live object, refusing anything blocked */ export function readProperty(node: any, name: string): any { if (isBlockedMember(name)) @@ -353,10 +366,12 @@ type LiveObject = { value: any; added: number }; * Keeps the objects which recently triggered an event around so the server can walk * their properties and call their methods. * - * Entries are bounded in count and in age: an object which nobody has asked about for - * a while, or which does not fit alongside the most recent ones, is forgotten. Every - * value which leaves this registry goes through ``serializeValue``, so functions and - * anything which could be used to escape the page never do. + * 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". Entries are + * still bounded in count and in age, since a page cannot hold on to events forever. + * Every value which leaves this registry goes through ``serializeValue``, so functions + * and anything which could be used to escape the page never do. */ export class EventProxyRegistry { private readonly live = new Map(); @@ -395,7 +410,12 @@ export class EventProxyRegistry { this.live.clear(); } - /** Drop objects which went unused for too long, or which no longer fit */ + /** + * Drop objects which went unused for too long, 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 "not-found". + */ prune(): void { const now = this.clock(); for (const [eventId, entry] of this.live) { @@ -408,11 +428,44 @@ export class EventProxyRegistry { } } - /** Answer an incoming ``event-proxy-request``, replying unless told not to */ - handleMessage(message: EventProxyRequest): void { + /** + * 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; - const response = this.resolve(message); - if (message.reply !== false) this.send(response); + + 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 (const request of requests) { + // 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; + const response = 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 */ diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 7a7daf86d..501b71a0b 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -64,7 +64,6 @@ export type ReactPyVdomEventHandler = { stopImmediatePropagation?: boolean; debounce?: number; throttle?: number; - eventProxy?: boolean; }; export type ReactPyVdomImportSource = { diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 8b13474d4..2f8e8ce5d 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,9 +1,5 @@ import { Fragment } from "preact"; -import { - EVENT_MARKER_KEY, - serializeSnapshot, - serializeValue, -} from "./event-proxy"; +import { EVENT_MARKER_KEY } from "./event-proxy"; import { HANDLER_DEBOUNCE, HANDLER_MARKER, @@ -225,7 +221,6 @@ function createEventHandler( stopImmediatePropagation, debounce, throttle, - eventProxy, }: ReactPyVdomEventHandler, ): [string, TaggedEventHandler] { // Sequence number for the next outgoing event on this handler. @@ -249,27 +244,21 @@ function createEventHandler( event.stopImmediatePropagation(); } - // Whatever came out of the page can be kept alive by the registry so the server - // can read any property it likes and call methods such as "focus()" on it. Only - // the serializable half of the object goes over the wire, alongside a marker - // which the server turns back into a live proxy for the rest. - // - // That is only done for a handler which asked for it. Remembering an event costs - // the page memory, and an event nobody intends to look behind is memory spent - // for nothing; without the marker the server hands the handler the snapshot and - // leaves it at that. - if (typeof event === "object" && event !== null) { - if (eventProxy) { - const id = client.eventProxy.register(event); - return { - ...serializeValue(event, id), - [EVENT_MARKER_KEY]: { id }, - }; - } - return serializeSnapshot(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 diff --git a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts index 9aa2e6bf7..896c9de45 100644 --- a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -1,6 +1,7 @@ -// Tests for the live "event proxy" boundary. Everything which leaves the page goes -// through one of these functions, so the interesting cases are the ones where a value -// must *not* be handed over. +// 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 { DEFAULT_MAX_DEPTH, @@ -10,18 +11,18 @@ import { EVENT_REFERENCE_KEY, EventProxyRegistry, type EventProxyRequest, + type EventProxyRequestBatch, + type EventProxyResponse, LIVE_OBJECT_TTL_MS, MAX_LIVE_OBJECTS, ProxyAccessError, ProxyNotFoundError, collectProperties, - dropReferences, isBlockedMember, readPath, readProperty, readReference, reference, - serializeSnapshot, serializeValue, } from "../src/event-proxy"; @@ -278,6 +279,24 @@ function request( }; } +/** + * 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 { @@ -546,6 +565,11 @@ test("handleMessage only answers proxy requests, and only when asked", () => { 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( @@ -578,26 +602,288 @@ test("handleMessage only answers proxy requests, and only when asked", () => { }); }); -test("serializeSnapshot hands over data and nothing which needs asking about", () => { - // A handler which never asked to reach back into the page gets the serializable half - // of the event, and no handle which could only be answered by a lookup that would - // fail - the object was never registered here in the first place. - const loop: Record = { type: "click", value: "typed" }; - loop["again"] = loop; +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(serializeSnapshot(loop)).toEqual({ - type: "click", + expect(replies).toHaveLength(1); + expect(replies[0]).toEqual({ + type: EVENT_PROXY_RESPONSE_TYPE, + requestId: "solo", + ok: true, + error: null, + code: null, value: "typed", - again: null, }); + expect(replies[0].responses).toBeUndefined(); }); -test("dropReferences empties references and leaves data alone", () => { - expect( - dropReferences({ - a: 1, - b: [2, reference("e", ["x"])], - c: { d: null }, +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 evicted by the cap is answered as not-found", () => { + // 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. + 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: `nothing is registered for event ${JSON.stringify(eventId)}`, + code: "not-found", + }); +}); + +test("an object which aged out is answered as not-found", () => { + 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"], }), - ).toEqual({ a: 1, b: [2, null], c: { d: null } }); + ); + + expect(replies).toHaveLength(1); + expect(replies[0].requestId).toBe("late"); + expect(replies[0].ok).toBe(false); + expect(replies[0].code).toBe("not-found"); +}); + +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); }); diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index c713ef48e..59ee57570 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -1,45 +1,50 @@ """Client-backed ("live") proxies for browser events. -Every event handler is handed a snapshot of the JavaScript ``Event`` object. Snapshots -are cheap and never block, but they are frozen at the moment the event was dispatched: -methods such as ``focus()`` or ``composedPath()`` cannot be called from Python, and any -property that the snapshot left out is simply gone. - -This module implements the machinery which lets a handler reach *live* into the browser -instead. Alongside the snapshot it serialized, the client marks every object it kept -alive with an id:: +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": [ - { - "type": "click", - "target": {"tagName": "INPUT", "value": "hello"}, - "__reactpy_event__": {"id": "5b1c..."}, - } - ], + "data": [{"__reactpy_event__": {"id": "5b1c..."}, "type": "click"}], + "seq": 3, } -On receipt, ReactPy swaps that dictionary for an :class:`Event` - a ``dict`` subclass -which *also* holds the snapshot. Reading an attribute returns a lazily resolved -:class:`EventProxy` whose value is fetched from the browser only when it is needed: +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): - # one round trip: read "target.value" from the live DOM node - value = await event.target.value - # one round trip: call "focus()" on the live DOM node - await event.target.focus() - # nothing is sent for values which the snapshot already contains - print(event["type"]) - -Every event handler is a coroutine, so live values are always pulled from the browser -with ``await`` and the event loop is never blocked. When the client did not keep the -originating object alive - because the handler opted out, or it is too old to support -this - the very same :class:`Event` is handed over without any backing, and awaiting -one of its proxies raises :class:`EventProxyUnavailable`. + 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: @@ -52,6 +57,9 @@ async def on_click(event: Event): {"type": "event-proxy-response", "requestId": "...", "ok": true, "value": "hello", "error": null, "code": null} +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. @@ -92,6 +100,10 @@ async def on_click(event: Event): _T = TypeVar("_T") _MISSING = object() +CACHE_LIMIT = 2048 +"""How many resolved property values a single connection remembers before starting over +""" + SendCoroutine = Callable[[dict[str, Any]], Awaitable[None]] """Sends a single message to the client which owns a given event""" @@ -131,6 +143,12 @@ def __init__(self) -> None: self._send_tasks: set[Any] = set() self._timeouts = 0 self._disabled = False + 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: @@ -170,15 +188,33 @@ def _abandon_pending(self, reason: str) -> None: if not future.done(): future.set_exception(EventProxyUnavailable(reason)) self._pending.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 + 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._pending.pop(request_id, None) if isinstance(request_id, str) else None @@ -189,7 +225,7 @@ def handle_response(self, message: Mapping[str, Any]) -> bool: logger.debug( f"Ignored live event response to unknown request {request_id!r}" ) - return True + return if not future.done(): future.set_result( @@ -200,7 +236,6 @@ def handle_response(self, message: Mapping[str, Any]) -> bool: "code": message.get("code"), } ) - return True async def resolve( self, @@ -209,20 +244,79 @@ async def resolve( call: bool = False, args: Sequence[Any] = (), ) -> Any: - """Resolve ``path`` against the client side event (awaited from the loop)""" - request_id, message, future = self._begin( - event_id, path, call, args, reply=True - ) - self._submit(message, future, request_id) - if future is None: # nocov - reply=True always yields a future - return None + """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. + """ + key = None if call else self._cache_key(event_id, path) + if key is not None: + if key in self._cache: + return self._cache[key] + shared = self._inflight.get(key) + if shared is not None: + payload = await self._await(*shared, event_id, path) + 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 asyncio.wait_for( - asyncio.wrap_future(future), REACTPY_EVENT_PROXY_TIMEOUT.current + 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]] + ) -> 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. + """ + values: list[Any] = [None] * len(paths) + outstanding: list[ + tuple[int, tuple[str, tuple[str, ...]], tuple[str, Future | None]] + ] = [] + ours: list[tuple[str, tuple[str, ...]]] = [] + for index, path in enumerate(paths): + key = self._cache_key(event_id, path) + if key in self._cache: + values[index] = self._cache[key] + continue + shared = self._inflight.get(key) + if shared is not None: + outstanding.append((index, key, shared)) + continue + entry = self._enqueue(event_id, path, False, (), True) + self._inflight[key] = entry + ours.append(key) + outstanding.append((index, key, entry)) + + try: + payloads = await asyncio.gather( + *( + self._await(request_id, future, event_id, paths[index]) + for index, _, (request_id, future) in outstanding + ) ) - except (TimeoutError, FuturesTimeoutError): - self._handle_timeout(request_id, event_id, path) - return self._to_value(payload, event_id, path) + finally: + for key in ours: + self._inflight.pop(key, None) + for (index, key, _), payload in zip(outstanding, payloads, strict=True): + values[index] = self._to_value(payload, event_id, paths[index]) + self._remember(key, values[index]) + return values def notify( self, @@ -232,18 +326,100 @@ def notify( ) -> None: """Call a client side method without waiting for its result (best effort)""" try: - request_id, message, future = self._begin( - event_id, path, True, args, reply=False - ) + 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 - self._submit(message, future, request_id) # -- 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], + ) -> dict[str, Any]: + if future is None: # nocov - only reply=False requests have no future + return {"ok": True, "value": None, "error": None, "code": None} + try: + return await asyncio.wait_for( + asyncio.wrap_future(future), REACTPY_EVENT_PROXY_TIMEOUT.current + ) + except (TimeoutError, FuturesTimeoutError): + self._handle_timeout(request_id, event_id, path) + + 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 + if self._loop is None or self._send is None: + self._abandon(batch) + return + 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: + request_id = message.get("requestId") + future = ( + self._pending.pop(request_id, None) + if isinstance(request_id, str) + else None + ) + 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, @@ -276,33 +452,9 @@ def _begin( self._pending[request_id] = future return request_id, message, future - def _submit( - self, - message: dict[str, Any], - future: Future | None, - request_id: str, - ) -> None: - loop, send = self._loop, self._send - if loop is None or send is None: - self._discard(request_id) - if future is not None and not future.done(): - future.set_exception( - EventProxyUnavailable("no sender is bound to this connection") - ) - return - - if threading.get_ident() == self._loop_thread_id: - task = loop.create_task(self._send_message(message, future)) - self._send_tasks.add(task) - task.add_done_callback(self._send_tasks.discard) - else: - asyncio.run_coroutine_threadsafe(self._send_message(message, future), loop) - - async def _send_message( - self, message: dict[str, Any], future: Future | None - ) -> None: + async def _send_message(self, message: dict[str, Any]) -> None: send = self._send - if send is None: # nocov - guarded by _submit + if send is None: # nocov - guarded by _flush return try: await send(message) @@ -356,6 +508,15 @@ def _to_value( """ +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) -> Any: """Prepare a Python value to be handed to a client side method""" if isinstance(value, EventProxy): @@ -415,43 +576,62 @@ def deserialize_value( return value -class EventProxy(dict, Generic[_T]): +class EventProxy(Generic[_T]): """A live, lazily resolved view onto a browser object - Instances are ``dict`` subclasses which hold on to whatever the client chose to - serialize when the event was dispatched (the "snapshot"). Attribute access returns - further proxies for anything the snapshot does not already answer with a plain - value, so a chain such as ``event.target.value`` costs a single round trip - and - only when the value is genuinely needed. - - Anything which has to come from the browser is awaited, because every event handler - is a coroutine and nothing may block the event loop:: + 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() - A proxy which no client is backing - because the handler opted out of live access, - or the client is too old to support it - hands out whatever its snapshot already - holds, and raises :class:`EventProxyUnavailable` when asked for anything more. + 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") + + 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, - snapshot: Mapping[str, Any] | None = None, *, 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: - dict.__init__(self, snapshot or {}) 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 --------------------------------------------------------- @@ -471,120 +651,44 @@ def backend(self) -> EventProxyBackend: return self._reactpy_backend @property - def has_snapshot(self) -> bool: - """Whether the client already gave us the value of this proxy""" - return bool(dict.__len__(self)) + 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: - if not self._reactpy_backend.available: - # Nothing is behind this proxy - show the snapshot it was handed - return dict.__repr__(self) return ( f"{type(self).__name__}(event_id={self._reactpy_event_id!r}, " - f"path={list(self._reactpy_path)!r}, snapshot={dict.__repr__(self)})" + f"path={list(self._reactpy_path)!r})" ) def __dir__(self) -> list[str]: - return sorted( - { - *dir(type(self)), - *(key for key in dict.keys(self) if isinstance(key, str)), - } - ) + return sorted({*dir(type(self)), *self._reactpy_known_members()}) - # -- child access ----------------------------------------------------- + def _reactpy_known_members(self) -> list[str]: + """Properties the client told us about when it dispatched the event""" + return list(self._reactpy_known) - def _reactpy_snapshot_value(self, segment: str | int) -> Any: - try: - return dict.__getitem__(self, segment) - except KeyError: - return _MISSING + # -- child access ----------------------------------------------------- def _reactpy_make_child( - self, segment: str | int, snapshot: Mapping[str, Any] | None = None + self, segment: str | int, value: Any = _MISSING ) -> EventProxy[Any]: - child = EventProxy( + """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, ) - if snapshot is None: - value = self._reactpy_snapshot_value(segment) - snapshot = value if isinstance(value, Mapping) else {} - reference = ( - snapshot.get(EVENT_REFERENCE_KEY) if isinstance(snapshot, Mapping) else None - ) - if isinstance(reference, Mapping): - # The snapshot only points at what the browser still holds - hand back a - # real proxy for it rather than a dict containing the marker. - path = tuple(reference.get("path", ())) or (*self._reactpy_path, segment) - child = EventProxy( - backend=self._reactpy_backend, - event_id=str(reference.get("eventId", self._reactpy_event_id)), - path=path, - flags=self._reactpy_flags, - ) - child._reactpy_callable = bool(reference.get("callable")) - return child - dict.update(child, snapshot) - return child - - def _reactpy_wrap_sequence( - self, segment: str | int, value: Sequence[Any] - ) -> list[Any]: - """Copy a list of snapshot data, promoting object entries to child proxies""" - parent = self._reactpy_make_child(segment) - items: list[Any] = [] - for index, item in enumerate(value): - if isinstance(item, Mapping): - items.append(parent._reactpy_make_child(str(index), item)) - elif isinstance(item, list | tuple): - items.append(parent._reactpy_wrap_sequence(str(index), item)) - else: - items.append(item) - return items - - def _reactpy_child_or_value(self, segment: str | int) -> Any: - """Return the snapshot value, or a proxy for whatever the browser holds""" - value = self._reactpy_snapshot_value(segment) - if value is _MISSING: - return self._reactpy_resolve_segment(segment) - if isinstance(value, Mapping): - return self._reactpy_make_child(segment) - if isinstance(value, list | tuple): - return self._reactpy_wrap_sequence(segment, value) - return value - - def _reactpy_resolve_segment(self, segment: str | int) -> Any: - """Read something the snapshot does not know about - - Nothing is asked of the browser yet - the child handed back stays lazy, and - it is awaiting that child which sends the request. - """ - return self._reactpy_make_child(segment) - - def _reactpy_walk_snapshot(self, segments: Sequence[str]) -> Any: - """Follow ``segments`` through the snapshot, or return ``_MISSING``""" - node: Any = self - for segment in segments: - if isinstance(node, dict): - if not dict.__contains__(node, segment): - return _MISSING - # bypass proxy __getitem__ so nested data stays plain JSON - node = dict.__getitem__(node, segment) - elif isinstance(node, Mapping): - if segment not in node: - return _MISSING - node = node[segment] - elif isinstance(node, Sequence) and not isinstance(node, str | bytes): - try: - node = node[int(segment)] - except (ValueError, IndexError): - return _MISSING - else: - return _MISSING - return node @staticmethod def _reactpy_normalize_path(path: str | Sequence[Any]) -> list[str]: @@ -646,19 +750,18 @@ def stopImmediatePropagation(self) -> None: @property def defaultPrevented(self) -> bool: - """Whether :meth:`preventDefault` has been called for this event""" - return bool( - self._reactpy_flags.get("preventDefault") - or dict.get(self, "defaultPrevented", False), - ) + """Whether :meth:`preventDefault` has been asked for on this proxy + + This reports what Python has asked the browser to do, not what the browser + settled on - read the live value with ``await event.defaultPrevented`` if the + distinction matters. + """ + return bool(self._reactpy_flags.get("preventDefault")) @property def propagationStopped(self) -> bool: - """Whether :meth:`stopPropagation` has been called for this event""" - return bool( - self._reactpy_flags.get("stopPropagation") - or dict.get(self, "propagationStopped", False), - ) + """Whether :meth:`stopPropagation` has been asked for on this proxy""" + return bool(self._reactpy_flags.get("stopPropagation")) @property def debounce(self) -> int | None: @@ -669,7 +772,7 @@ def debounce(self) -> int | None: def debounce(self, value: int | None) -> None: self._reactpy_debounce = value - # -- dict like behaviour ---------------------------------------------- + # -- property access ---------------------------------------------------- def __getattr__(self, name: str) -> Any: if name.startswith("_reactpy") or ( @@ -677,21 +780,12 @@ def __getattr__(self, name: str) -> Any: ): # Never resolve internals or interpreter lookups against the browser raise AttributeError(name) - if not self._reactpy_backend.available: - # Nothing is behind this proxy - behave like the plain snapshot it is - value = dict.get(self, name, _MISSING) - if value is _MISSING: - return None - return EventProxy(value) if isinstance(value, dict) else value - return self._reactpy_child_or_value(name) - - def __getitem__(self, key: Any) -> Any: - if not self._reactpy_backend.available: - return dict.__getitem__(self, key) - return self._reactpy_child_or_value(key) + return self._reactpy_make_child(name) - def __bool__(self) -> bool: - return bool(dict.__len__(self)) or bool(self._reactpy_path) + # 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 ------------------------------------------------------- @@ -710,7 +804,7 @@ def _reactpy_call_method(self, name: str, args: Sequence[Any]) -> Any: ) def composedPath(self) -> Awaitable[list[Any]]: - """Return the event's path, asking the client for it if the snapshot lacks it + """Return the event's path, read from the browser Await the result:: @@ -727,54 +821,56 @@ def resolve(self) -> Awaitable[_T]: """ return self._reactpy_resolve_path(False, ()) - async def read(self, path: str | Sequence[Any], default: Any = _MISSING) -> Any: - """Return the value at ``path`` whether or not the snapshot knows it + async def read(self, *paths: str | Sequence[Any]) -> tuple[Any, ...]: + """Read one or more properties in as few round trips as possible - Unlike awaiting an attribute, this always works - including for values which - the snapshot already answered with a plain ``str``, ``bool`` or ``None``:: + 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 = await event.read("target.value") + 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 cached, and a cached answer is not re-fetched. """ - segments = self._reactpy_normalize_path(path) - value = self._reactpy_walk_snapshot(segments) - if value is not _MISSING: - return value - try: - return await self._reactpy_backend.resolve( - self._reactpy_event_id, - (*self._reactpy_path, *segments), - call=False, - args=(), + 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 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 ) - except EventProxyError: - if default is _MISSING: - raise - return default + 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 __len__(self) -> int: - if not dict.__len__(self): - self._reactpy_require_snapshot("measure") - return dict.__len__(self) - - def __iter__(self) -> Any: - if not dict.__len__(self): - self._reactpy_require_snapshot("iterate over") - return dict.__iter__(self) - - def __contains__(self, key: Any) -> bool: - if dict.__contains__(self, key): - return True - if not dict.__len__(self): - self._reactpy_require_snapshot("test membership of") - return False + def __bool__(self) -> bool: + return True def _reactpy_description(self) -> str: return ( @@ -782,15 +878,6 @@ def _reactpy_description(self) -> str: f"{self._reactpy_event_id!r}" ) - def _reactpy_require_snapshot(self, action: str) -> None: - """Complain about an operation which needs a value we do not have yet""" - if self._reactpy_backend.available: - msg = ( - f"cannot {action} {self._reactpy_description()} because nothing is " - "known about it yet - await the proxy (or call .resolve() on it) first" - ) - raise EventProxyAccessError(msg) - if TYPE_CHECKING: _ElementMembers = dict[str, Any] @@ -799,25 +886,25 @@ def _reactpy_require_snapshot(self, action: str) -> None: class Event(EventProxy[_T]): """The event object handed to event handler functions - A ``dict`` subclass which holds the snapshot the client took when the event was - dispatched *and*, when the client kept the originating object alive, knows how to - reach back into the browser for anything missing:: + 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.read("key") == "Enter": + if await event.key == "Enter": await event.target.focus() print(await event.target.value) - Reading a key which the snapshot already answers with a plain value never touches - the connection, so ``event["type"]`` and ``event.target.value`` stay as cheap as - they always were. Anything the snapshot left out costs a single round trip, and is - awaited - if the client is not holding the object alive at all, awaiting raises - :class:`~reactpy._event_proxy.EventProxyUnavailable`. + 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. - Handlers are always coroutines, so this is the only event object there is. Live - access is what a handler gets unless it has declined it - see - :attr:`~reactpy.types.BaseEventHandler.event_proxy` for turning it off for a single - handler, which is the only knob there is. + Each read is a round trip, so reads which are in flight together travel together:: + + value, key = await event.read("target.value", "key") + + 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 @@ -911,38 +998,23 @@ def build_event_args( ) -> 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 can be - read from and called into. Anything else is handed over as a plain :class:`Event` - snapshot, or exactly as it arrived when it was never an object at all. + 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] = [] - is_live = backend is not None and backend.available for item in data: - event_id = event_marker_id(item) if is_live else None - if event_id is not None: - args.append( - Event( - { - key: value - for key, value in item.items() - if key != EVENT_MARKER_KEY - }, - backend=backend, - event_id=event_id, - ) - ) - elif isinstance(item, dict): - args.append( - Event( - { - key: value - for key, value in item.items() - if key != EVENT_MARKER_KEY - } - ) - ) - else: + 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 diff --git a/src/reactpy/core/_event_inspect.py b/src/reactpy/core/_event_inspect.py index 6f6673529..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 = ` or `event.eventProxy = `. -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 @@ -24,11 +26,6 @@ def _is_plain_int(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) -def _is_bool(value: Any) -> bool: - """Whether ``value`` is a ``bool`` rather than a number dressed up as one""" - return isinstance(value, bool) - - def _handler_code(func: Callable) -> CodeType | None: """The bytecode of the callable a user handed us @@ -59,8 +56,8 @@ def _event_arg_name(func: Callable, code: CodeType | None) -> str | None: def inspect_event_handler( func: Callable, -) -> tuple[bool, bool | str, int | None, bool | None]: - """Detect ``preventDefault``/``stopPropagation``/``debounce``/``eventProxy`` usage. +) -> tuple[bool, bool | str, int | None]: + """Detect ``preventDefault``/``stopPropagation``/``debounce`` usage. Walks the bytecode of ``func`` looking for the patterns: @@ -68,10 +65,8 @@ def inspect_event_handler( - ``event.stopPropagation()`` -> ``stop_propagation = True`` - ``event.stopImmediatePropagation()`` -> ``stop_propagation = "immediate"`` - ``event.debounce = `` -> ``debounce = `` - - ``event.eventProxy = `` -> ``event_proxy = `` - The value behind ``debounce`` and ``eventProxy`` can come from any of the - following: + The value behind ``debounce`` can come from any of the following: - A literal (``LOAD_CONST`` / ``LOAD_SMALL_INT``) - A function argument with a matching default @@ -80,49 +75,42 @@ def inspect_event_handler( 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=...``/``event_proxy=...`` 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, event_proxy)``, where + Returns ``(prevent_default, stop_propagation, debounce)``, where ``stop_propagation`` is the string ``"immediate"`` if the handler asked to stop - *other listeners* as well, and ``event_proxy`` is :data:`None` for a handler - which never said anything about reading past the event it was handed. + *other listeners* as well. """ prevent_default = False stop_propagation = False stop_immediate_propagation = False debounce: int | None = None - event_proxy: bool | None = None 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, event_proxy + 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 - check_event_proxy = "eventProxy" in names if not ( check_prevent_default or check_stop_propagation or check_stop_immediate_propagation or check_debounce - or check_event_proxy ): - return prevent_default, stop_propagation, debounce, event_proxy + return prevent_default, stop_propagation, debounce instructions = list(dis.get_instructions(code)) arg_defaults = _function_arg_defaults(func) closure_by_name = _closure_lookup(func) constant_ints_by_name = _constant_ints_by_name(code) - # The same lookups again, keeping only the ``bool`` values ``eventProxy`` accepts - arg_flags = _function_arg_defaults(func, _is_bool) - closure_flags = _closure_lookup(func, _is_bool) last_was_event = False # When the function assigns ``event.debounce`` in multiple branches we @@ -130,7 +118,6 @@ def inspect_event_handler( # unresolvable value (e.g. a local variable) we treat the whole detection # as ambiguous and return ``None`` rather than picking a fallback literal. debounce_tainted = False - event_proxy_tainted = False for index, instr in enumerate(instructions): if ( @@ -197,38 +184,12 @@ def inspect_event_handler( debounce_tainted = True debounce = None check_debounce = False - elif ( - check_event_proxy - and instr.opname == "STORE_ATTR" - and instr.argval == "eventProxy" - ): - candidate = _resolve_stored_value( - instructions, - index, - event_arg_name, - arg_flags, - closure_flags, - {}, - is_value=_is_bool, - ) - if isinstance(candidate, bool): - if not event_proxy_tainted: - event_proxy = candidate - check_event_proxy = False - elif candidate is None: - # Same as ``debounce`` above: a value we cannot settle from the - # bytecode alone leaves the handler without an opinion, rather - # than a guessed one. - event_proxy_tainted = True - event_proxy = None - check_event_proxy = False if not ( check_prevent_default or check_stop_propagation or check_stop_immediate_propagation or check_debounce - or check_event_proxy ): break @@ -238,7 +199,6 @@ def inspect_event_handler( prevent_default, "immediate" if stop_immediate_propagation else stop_propagation, debounce, - event_proxy, ) diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index be824938a..027d6f35e 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -67,7 +67,6 @@ def event( prevent_default: bool = ..., debounce: int | None = ..., throttle: int | None = ..., - event_proxy: bool | None = ..., ) -> EventHandler: ... @@ -80,7 +79,6 @@ def event( prevent_default: bool = ..., debounce: int | None = ..., throttle: int | None = ..., - event_proxy: bool | None = ..., ) -> Callable[[Callable[..., Awaitable[Any]]], EventHandler]: ... @@ -92,7 +90,6 @@ def event( prevent_default: bool = False, debounce: int | None = None, throttle: int | None = None, - event_proxy: bool | None = None, ) -> EventHandler | Callable[[Callable[..., Awaitable[Any]]], EventHandler]: """A decorator for constructing an :class:`EventHandler`. @@ -133,12 +130,6 @@ async def my_callback(event): ... :attr:`BaseEventHandler.throttle`. stop_immediate_propagation: Also block any other listener of the same event in the browser. - event_proxy: - Keep the browser object this event fired from alive so the handler can - read past the snapshot which arrived with the event. The handler can say - so itself as well by assigning ``event.eventProxy = True`` or - ``event.eventProxy = False`` in its own body, which takes precedence over - what is given here. See :attr:`BaseEventHandler.event_proxy`. """ def setup(function: Callable[..., Awaitable[Any]]) -> EventHandler: @@ -149,7 +140,6 @@ def setup(function: Callable[..., Awaitable[Any]]) -> EventHandler: debounce=debounce, throttle=throttle, stop_immediate_propagation=stop_immediate_propagation, - event_proxy=event_proxy, ) return setup(function) if function is not None else setup @@ -177,12 +167,6 @@ class EventHandler(BaseEventHandler): :attr:`BaseEventHandler.throttle`. stop_immediate_propagation: Also block any other listener of the same event in the browser. - event_proxy: - Keep the browser object this event fired from alive so the handler can - read past the snapshot which arrived with the event. Assigning - ``event.eventProxy = `` inside the handler is detected the same way, - and takes precedence over what is given here. See - :attr:`BaseEventHandler.event_proxy`. """ def __init__( @@ -194,7 +178,6 @@ def __init__( debounce: int | None = None, throttle: int | None = None, stop_immediate_propagation: bool = False, - event_proxy: bool | None = None, ) -> None: check_is_async_handler(function) self.function = to_event_handler_function(function, positional_args=False) @@ -204,7 +187,6 @@ def __init__( self.debounce = debounce self.throttle = throttle self.target = target - self.event_proxy = event_proxy # Check what the handler says about the event it is handed by inspecting the # bytecode of the function the user actually wrote @@ -212,7 +194,6 @@ def __init__( found_prevent_default, found_stop_propagation, found_debounce, - found_event_proxy, ) = inspect_event_handler(_unwrap_handler(function)) if found_prevent_default: @@ -223,8 +204,6 @@ def __init__( self.stop_immediate_propagation = True if found_debounce is not None: self.debounce = found_debounce - if found_event_proxy is not None: - self.event_proxy = found_event_proxy __hash__ = None # type: ignore @@ -236,7 +215,6 @@ def __init__( "debounce", "target", "throttle", - "event_proxy", ) def __eq__(self, other: object) -> bool: @@ -291,11 +269,6 @@ def merge_event_handlers( :attr:`~reactpy.core.proto.EventHandlerType.prevent_default`, :attr:`~reactpy.core.proto.EventHandlerType.debounce`, :attr:`~reactpy.core.proto.EventHandlerType.throttle` attributes. - - Live access is not among the things they can conflict over. Every handler is - handed the same event, so the merged one asks the client to keep that event alive - unless every handler given to it refused - see - :attr:`~reactpy.types.BaseEventHandler.event_proxy`. """ if not event_handlers: msg = "No event handlers to merge" @@ -330,22 +303,6 @@ def merge_event_handlers( ) raise ValueError(msg) - # Asking the client to keep the originating object alive is not something the - # handlers can disagree about. Stating nothing wants it just as much as saying so - # does, so the merged handler refuses only when every one of them refused - one - # handler declining cannot take live access away from another which never said it - # would not use it. Those which asked for less simply receive objects they never - # read past, since every handler here is handed the very same event. - preferences: list[bool | None] = [ - getattr(handler, "event_proxy", None) for handler in event_handlers - ] - if all(preference is False for preference in preferences): - event_proxy: bool | None = False - elif True in preferences: - event_proxy = True - else: - event_proxy = None - return EventHandler( merge_event_handler_funcs([h.function for h in event_handlers]), stop_propagation, @@ -354,7 +311,6 @@ def merge_event_handlers( debounce, throttle, stop_immediate_propagation=stop_immediate_propagation, - event_proxy=event_proxy, ) diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 69e01745f..790982aeb 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -34,7 +34,6 @@ EVENT_PROXY_RESPONSE_TYPE, EventArguments, EventProxyBackend, - event_marker_id, ) from reactpy.config import ( REACTPY_ASYNC_RENDERING, @@ -223,28 +222,26 @@ def set_event_proxy_sender( 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. + 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. """ - return self._event_proxy_backend.handle_response(message) - - def _make_event_arguments( - self, handler: Any, data: Sequence[Any] - ) -> EventArguments: - """Wrap raw event data for the given handler - - A handler which asks for live access is given it, and one which declines is - given the snapshot and nothing else. With no preference stated the data - decides: the client marks whatever it has agreed to keep hold of - which is - whatever it dispatched, since live access is on unless a handler has declined - it - and marked data comes back as a live object. A client which has gone away + 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 the mark is dropped with the rest of the - overhead. + reports itself unavailable and awaiting anything through it raises + :class:`~reactpy._event_proxy.EventProxyUnavailable`. """ - want_proxy = getattr(handler, "event_proxy", None) - if want_proxy is None: - want_proxy = any(event_marker_id(item) is not None for item in data) - return EventArguments(data, self._event_proxy_backend if want_proxy else None) + return EventArguments(data, self._event_proxy_backend) async def _process_event_queue( self, target: str, queue: Queue[LayoutEventMessage | dict[str, Any]] @@ -266,7 +263,7 @@ async def _process_event_queue( if handler is not None: try: await handler.function( - self._make_event_arguments(handler, event["data"]) + self._make_event_arguments(event["data"]) ) except Exception: logger.exception(f"Failed to execute event handler {handler}") @@ -695,11 +692,6 @@ def _serialize_event_handler(handler: Any, target: str) -> dict[str, Any]: if handler.throttle is not None: entry["throttle"] = handler.throttle - # Live access is on unless the handler has refused it, whether on the handler - # itself or from inside its own body - see ``BaseEventHandler.event_proxy`` - if getattr(handler, "event_proxy", None) is not False: - entry["eventProxy"] = True - return entry async def _render_model_children( diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 9a8f3f8df..c45da3cb5 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -74,7 +74,6 @@ "preventDefault": {"type": "boolean"}, "stopPropagation": {"type": "boolean"}, "stopImmediatePropagation": {"type": "boolean"}, - "eventProxy": {"type": "boolean"}, "debounce": {"type": "integer", "minimum": 0}, "throttle": {"type": "integer", "minimum": 0}, }, diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index d6e81a324..a138553ba 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,20 @@ 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, or the layout + does not recognise it as an answer to one of its own requests. + """ + layout = self.layout + if layout is None: + return False + return 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/types.py b/src/reactpy/types.py index 0ea66a491..10b5394b2 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -946,7 +946,6 @@ class BaseEventHandler: __slots__ = ( "__weakref__", "debounce", - "event_proxy", "function", "prevent_default", "stop_immediate_propagation", @@ -1001,33 +1000,6 @@ class BaseEventHandler: updates on input elements. ``throttle`` defaults to ``None`` (no throttling).""" - event_proxy: bool | None - """Whether this handler wants the event's originating browser object kept alive. - - :data:`True` asks the client to hold on to the object the event fired from, so the - handler can read properties from it and call methods on it past what the snapshot - carried. :data:`False` takes the snapshot and nothing else - the browser is not - asked to remember anything for this handler. - - The default, :data:`None`, wants live access, because reading the object an event - fired from is what people expect - ``await event.target.value`` should just work - rather than having to be anticipated when the handler is written. This attribute is - the only say in the matter: there is no global switch. Opt a single handler out by - setting it to :data:`False` - worthwhile for anything which fires continuously, - such as ``onScroll``, because keeping an object alive and pruning it costs the - browser work on every event. - - A handler can also decide for itself by assigning ``event.eventProxy = False`` or - ``event.eventProxy = True`` in its own body. That assignment is read out of the - handler's bytecode when the :class:`BaseEventHandler` is made, the same way - ``event.debounce = `` is, and it wins over whatever this attribute - was set to. - - Whatever this says, a handler is only ever given live access for data the client - actually marked as something it is still holding. Otherwise it receives the - :class:`~reactpy.types.Event` snapshot, and awaiting a property of it raises - :class:`~reactpy._event_proxy.EventProxyUnavailable`.""" - target: str | None """Typically left as ``None`` except when a static target is useful. diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index b2676126e..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) - async 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_client.py b/tests/test_client.py index f3e420e0d..8603aec08 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -116,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 a163890b9..6815ba09d 100644 --- a/tests/test_core/test_event_inspect.py +++ b/tests/test_core/test_event_inspect.py @@ -348,9 +348,8 @@ async def handler(event, ms=400): with patch.object(dis, "get_instructions", return_value=fake_list): result = inspect_event_handler(handler) - # Expected: (prevent_default=False, stop_propagation=False, debounce=400, - # event_proxy=None). - assert result == (False, False, 400, None) + # Expected: (prevent_default=False, stop_propagation=False, debounce=400). + assert result == (False, False, 400) # --------------------------------------------------------------------------- @@ -469,117 +468,14 @@ class Talkative: handler = Talkative() - assert inspect_event_handler(handler) == (False, False, None, None) + assert inspect_event_handler(handler) == (False, False, None) assert _function_arg_defaults(handler) == {} -# --------------------------------------------------------------------------- -# ``event.eventProxy`` detection -# --------------------------------------------------------------------------- - - -def test_detect_event_proxy_being_turned_off(): - """The point of the flag: a handler which will not look behind the event""" - - async def handler(event): - event.eventProxy = False - - assert inspect_event_handler(handler) == (False, False, None, False) - - -def test_detect_event_proxy_being_turned_on(): - async def handler(event): - event.eventProxy = True - - assert inspect_event_handler(handler) == (False, False, None, True) - - -def test_detect_event_proxy_alongside_everything_else(): - async def handler(event): - event.preventDefault() - event.stopPropagation() - event.debounce = 150 - event.eventProxy = False - - assert inspect_event_handler(handler) == (True, True, 150, False) - - -def test_detect_event_proxy_from_positional_default(): - async def handler(event, keep=True): - event.eventProxy = keep - - assert inspect_event_handler(handler) == (False, False, None, True) - - -def test_detect_event_proxy_from_keyword_only_default(): - async def handler(event, *, keep=False): - event.eventProxy = keep - - assert inspect_event_handler(handler) == (False, False, None, False) - - -def test_detect_event_proxy_from_closure(): - def make(keep): - async def handler(event): - event.eventProxy = keep - - return handler - - assert inspect_event_handler(make(True)) == (False, False, None, True) - - -def test_detect_event_proxy_from_closure_among_multiple(): - def make(unused, keep): - async def handler(event): - event.eventProxy = keep - - return handler - - assert inspect_event_handler(make("x", False)) == (False, False, None, False) - - -def test_no_detect_event_proxy_when_a_local_var_is_used(): - async def handler(event): - keep = False - event.eventProxy = keep - - assert inspect_event_handler(handler) == (False, False, None, None) - - -def test_no_detect_event_proxy_when_an_unresolvable_fallback_is_used(): - # An unresolvable branch makes the whole assignment ambiguous, so the handler - # is left without an opinion rather than a guessed one. - async def handler(event): - keep = False - if keep: - event.eventProxy = keep - else: - event.eventProxy = True - - assert inspect_event_handler(handler) == (False, False, None, None) - - -def test_no_detect_event_proxy_from_a_number(): - """A number is not a flag - only ``True`` and ``False`` answer for it""" - - async def handler(event): - event.eventProxy = 1 - - assert inspect_event_handler(handler) == (False, False, None, None) - - -def test_no_detect_event_proxy_when_the_target_is_not_the_event(): - async def handler(other): - event = other - event.eventProxy = False - - assert inspect_event_handler(handler) == (False, False, None, None) - - 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, None) + 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 index f21c7ba58..a7836aee6 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -1,11 +1,15 @@ -"""Tests for the live, client backed event objects in :mod:`reactpy._event_proxy`.""" +"""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 types import MappingProxyType from typing import Any import pytest @@ -20,6 +24,7 @@ EventProxy, EventProxyAccessError, EventProxyBackend, + EventProxyError, EventProxyNotFound, EventProxyTimeout, EventProxyUnavailable, @@ -32,7 +37,7 @@ REACTPY_EVENT_PROXY_TIMEOUT, ) from reactpy.core.component import component -from reactpy.core.events import EventHandler, event, merge_event_handlers +from reactpy.core.events import EventHandler, merge_event_handlers from reactpy.core.layout import Layout from tests.tooling.common import event_message @@ -40,21 +45,21 @@ def make_event( backend: EventProxyBackend, event_id: str, - snapshot: dict[str, Any] | None = None, + 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(snapshot or {}, backend=backend, event_id=event_id, path=path) + return Event(backend=backend, event_id=event_id, path=path, known=known) def make_proxy( backend: EventProxyBackend, event_id: str, path: Sequence[str | int] = (), - snapshot: dict[str, Any] | None = None, + known: dict[str, Any] | None = None, ) -> EventProxy: """Build a member proxy of the kind an event hands out""" - return EventProxy(snapshot, backend=backend, event_id=event_id, path=path) + return EventProxy(backend=backend, event_id=event_id, path=path, known=known) class Method: @@ -95,6 +100,19 @@ def __init__( @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: @@ -102,15 +120,41 @@ async def send(self, message: dict[str, Any]) -> None: 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, dict(message)) + 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, dict(message)) + 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 _answer(self, message: dict[str, Any]) -> None: + 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")) @@ -126,9 +170,7 @@ def _answer(self, message: dict[str, Any]) -> None: root = self.objects.get(message["eventId"]) if root is None: response.update(ok=False, error="not registered", code="not-found") - if should_reply: - self.backend.handle_response(response) - return + return response if should_reply else None try: if message["call"]: @@ -145,8 +187,7 @@ def _answer(self, message: dict[str, Any]) -> None: response.update(ok=False, error=str(error), code="not-found") else: response["value"] = self._serialize(value, path) - if should_reply: - self.backend.handle_response(response) + return response if should_reply else None def _walk(self, node: Any, path: list[str]) -> Any: for segment in path: @@ -267,21 +308,43 @@ def __init__( @property def requests(self) -> list[dict[str, Any]]: - return [m for m in self.sent if m["type"] == EVENT_PROXY_REQUEST_TYPE] + 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 or not message.get("reply"): + if message["type"] != EVENT_PROXY_REQUEST_TYPE: return - self.backend.handle_response( + 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 = [ { - "type": EVENT_PROXY_RESPONSE_TYPE, - "requestId": message["requestId"], + "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}, ) @@ -293,55 +356,305 @@ def make_canned_client(**response: Any) -> tuple[EventProxyBackend, CannedClient return backend, client -async def test_async_event_prefers_the_snapshot(): +# -- 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()) - event_object = make_event( - backend, - "evt", - snapshot={"type": "click", "target": {"value": "cached"}}, + 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 event_object["type"] == "click" - assert event_object["target"]["value"] == "cached" - assert event_object.target.value == "cached" - assert isinstance(event_object["target"]["nope"], EventProxy) + 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_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_async_event_resolves_what_the_snapshot_lacks(): + +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", snapshot={"type": "click"}) + event_object = make_event(backend, "evt") + assert await event_object.target.value == "typed" assert await event_object.target.tagName == "INPUT" - assert await event_object.read("target.value") == "typed" - assert await event_object.read("nope", default="fallback") == "fallback" + + assert len(client.messages) == 2 + assert requested_paths(client) == [["target", "value"], ["target", "tagName"]] + + +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): + "type" in event_object + with pytest.raises(TypeError): + len(event_object) + with pytest.raises(TypeError): + list(event_object) + + +# -- 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"], - ["nope"], + ["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", snapshot={}) + 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 - assert await event_object.read("deep.a.b.c") == 42 - assert requested_paths(client) == [ - ["deep"], - ["deep", "a", "b", "c"], - ["deep", "a", "b", "c"], - ] + # 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", snapshot={}) + event_object = make_event(backend, "evt") assert await event_object.target.focus() == "focused" assert await event_object.composedPath() == ["window", "input"] @@ -354,10 +667,60 @@ async def test_async_event_calls_client_side_methods(): 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_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", snapshot={}) + event_object = make_event(backend, "evt") event_object.preventDefault() event_object.stopPropagation() @@ -378,50 +741,24 @@ async def test_prevent_default_is_scheduled_but_not_awaited(): assert stopped.calls == [()] -def test_snapshot_data_is_plain_json(): - import json - - backend = EventProxyBackend() - event_object = make_event( - backend, "evt", snapshot={"type": "click", "target": {"value": "x"}} - ) - - assert json.loads(json.dumps(event_object)) == { - "type": "click", - "target": {"value": "x"}, - } - assert list(event_object) == ["type", "target"] - assert bool(event_object) is True - assert event_object.debounce is None - event_object.debounce = 120 - assert event_object.debounce == 120 - - -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", snapshot={}) - - for _ in range(2): - with pytest.raises(EventProxyTimeout): - await event_object.read("target") +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") - assert backend.disabled - assert not backend.available - with pytest.raises(EventProxyUnavailable): - await event_object.read("target") + 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_missing_objects_and_paths_are_reported(): - backend, _client = make_client(make_objects()) - missing = make_event(backend, "not-registered", snapshot={}) - with pytest.raises(EventProxyNotFound): - await missing.resolve() - event_object = make_event(backend, "evt", snapshot={}) - with pytest.raises(EventProxyNotFound): - await event_object.nope.resolve() +# -- what may cross the connection in either direction ----------------------- def test_arguments_are_serialized_or_refused(): @@ -435,6 +772,13 @@ def test_arguments_are_serialized_or_refused(): 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"]}} @@ -445,6 +789,9 @@ def test_excessively_nested_references_are_refused(): deserialize_value(value, "evt", backend) +# -- when there is nobody on the other end to answer ------------------------- + + def test_backend_without_a_sender_is_unavailable(): backend = EventProxyBackend() assert not backend.available @@ -455,642 +802,49 @@ def test_backend_without_a_sender_is_unavailable(): assert args[1] == 5 -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"] - await layout.deliver(event_message(target, {"type": "click", "detail": 3})) - await poll(lambda: "event" in received) - - assert type(received["event"]) is Event - assert received["event"]["type"] == "click" - 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" - - -async def test_layout_without_a_client_falls_back_to_snapshots(): - received: dict[str, Any] = {} - - async def on_click(event): - received["event"] = event - - @component - def MyComponent(): - return reactpy.html.div({"onClick": on_click}) - - async with Layout(MyComponent()) as layout: - 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: "event" in received) - - # nobody bound a sender, so the live marker is simply dropped - assert type(received["event"]) is Event - assert EVENT_MARKER_KEY not in received["event"] - - -async def test_a_handler_which_declines_live_access_takes_the_snapshot(): - """Refusing live access is honoured even while it is on offer - - The mark alongside the data says the browser is still holding whatever the event - fired from, but a handler which has said it will not reach into the page is not - handed a proxy on the off chance that it might. It is given the snapshot, and - nothing is asked of the browser afterwards. - """ - received: dict[str, Any] = {} - - async def handler(event_object): - received["event"] = event_object - - @component - def MyComponent(): - return reactpy.html.div({"onClick": event(handler, event_proxy=False)}) - - _backend, client = make_client(make_objects()) - - async with Layout(MyComponent()) as layout: - connect(layout, client) - model = (await layout.render())["model"] - handler_spec = event_handlers_of(model)["onClick"] - assert "eventProxy" not in handler_spec, ( - "a handler which declined was still reported as wanting live access" - ) - - await layout.deliver( - event_message( - handler_spec["target"], - {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, - ) - ) - await poll(lambda: "event" in received) - - assert type(received["event"]) is Event - assert EVENT_MARKER_KEY not in received["event"] - assert client.requests == [], "the browser was asked about an event nobody wanted" - - -async def test_live_access_is_on_for_a_handler_which_says_nothing(): - """A handler which states no preference is given live access - - There is no global switch to lean on: not saying anything is itself the answer, so - the client is told to hold on to the object the event came from, and the mark it - leaves behind is turned into something the handler can read through. - """ - received: dict[str, Any] = {} - - async def handler(event_object): - received["event"] = event_object - - @component - def MyComponent(): - return reactpy.html.div({"onClick": event(handler)}) - - _backend, client = make_client(make_objects()) - - async with Layout(MyComponent()) as layout: - connect(layout, client) - model = (await layout.render())["model"] - handler_spec = event_handlers_of(model)["onClick"] - assert handler_spec["eventProxy"] is True, ( - "a handler which said nothing was left without live access" - ) - - await layout.deliver( - event_message( - handler_spec["target"], - {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, - ) - ) - await poll(lambda: "event" in received) - - assert isinstance(received["event"], Event) - assert await received["event"].read("target.value") == "typed" - - assert requested_paths(client) == [["target", "value"]] - - -async def test_a_handler_which_asks_for_live_access_gets_it(): - """Asking on the handler says so out loud rather than leaving it to the default - - Saying so on the handler does two things: the client is told to hold on to the - object the event came from, and the server turns the mark the client leaves - behind into something the handler can actually read through - the latter even for - data the client never marked, which is what separates asking from not saying. - """ - received: dict[str, Any] = {} - - async def handler(event_object): - received["event"] = event_object - - @component - def MyComponent(): - return reactpy.html.div({"onClick": event(handler, event_proxy=True)}) - - _backend, client = make_client(make_objects()) - - async with Layout(MyComponent()) as layout: - connect(layout, client) - model = (await layout.render())["model"] - handler_spec = event_handlers_of(model)["onClick"] - assert handler_spec["eventProxy"] is True, ( - "the client was never told to keep the object alive" - ) - - await layout.deliver( - event_message( - handler_spec["target"], - {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, - ) - ) - await poll(lambda: "event" in received) - - assert isinstance(received["event"], Event) - assert await received["event"].read("target.value") == "typed" - - assert requested_paths(client) == [["target", "value"]] - - -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_handler_which_refuses_live_access_in_its_own_body(): - """Saying it inside the handler counts as saying it on the handler - - ``event.eventProxy = False`` is read out of the handler the same way - ``event.debounce = `` is, so the spec which goes out to the client - already knows this one will not look behind the event it is handed - and nothing is - remembered about an event nobody reads past. - """ - received: dict[str, Any] = {} - - async def on_click(event_object): - event_object.eventProxy = False - received["event"] = event_object - - @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"] - handler_spec = event_handlers_of(model)["onClick"] - assert "eventProxy" not in handler_spec, ( - "a handler which refused live access in its own body was still reported " - "as wanting it" - ) - - await layout.deliver( - event_message( - handler_spec["target"], - {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, - ) - ) - await poll(lambda: "event" in received) - - assert type(received["event"]) is Event - assert client.requests == [], "the browser was asked about an event nobody wanted" - - -async def test_a_handler_which_asks_for_live_access_in_its_own_body(): - """The other way round works too, overruling what was set on the handler - - This one was told not to look behind the event, and then said otherwise from - inside its own body - which is read out of the handler the same way - ``event.debounce = `` is, and wins. - """ - - async def on_click(event_object): - event_object.eventProxy = True - - @component - def MyComponent(): - return reactpy.html.div({"onClick": EventHandler(on_click, event_proxy=False)}) - - async with Layout(MyComponent()) as layout: - model = (await layout.render())["model"] - handler_spec = event_handlers_of(model)["onClick"] - assert handler_spec["eventProxy"] is True, ( - "the handler asked for live access and the client was never told" - ) - - -async def test_one_declining_handler_cannot_speak_for_a_merged_pair(): - """Merged handlers are handed the very same event, so one refusal is not theirs to give - - Both functions below run off one handler spec. Had the declining one been allowed - to settle it, the one which never refused would have been handed a dead snapshot - and its read would have raised rather than reached the page. - """ - received: dict[str, Any] = {} - - async def reads_past_the_snapshot(event_object): - received["value"] = await event_object.read("target.value") - - async def stays_where_it_is(event_object): - received["event"] = event_object - - @component - def MyComponent(): - return reactpy.html.div( - { - "onClick": merge_event_handlers( - [ - event(reads_past_the_snapshot), - event(stays_where_it_is, event_proxy=False), - ] - ) - } - ) - - _backend, client = make_client(make_objects()) - - async with Layout(MyComponent()) as layout: - connect(layout, client) - model = (await layout.render())["model"] - handler_spec = event_handlers_of(model)["onClick"] - assert handler_spec["eventProxy"] is True, ( - "a handler which never refused was merged with one which did, and the " - "refusal was taken to speak for both" - ) - - await layout.deliver( - event_message( - handler_spec["target"], - {EVENT_MARKER_KEY: {"id": "evt"}, "type": "click"}, - ) - ) - await poll(lambda: "value" in received and "event" in received) - - assert received["value"] == "typed" - assert requested_paths(client) == [["target", "value"]] - - -# -- what a proxy says about itself ----------------------------------------- - - -async def test_proxies_describe_themselves(): - backend, _client = make_client(make_objects()) - event_object = make_event( - backend, "evt", snapshot={"type": "click", 3: "not-an-attribute"} - ) - target = event_object.target - - assert event_object.backend is backend - assert event_object.event_id == "evt" - assert event_object.path == () - assert event_object.has_snapshot is True - assert repr(event_object) == ( - "Event(event_id='evt', path=[], " - "snapshot={'type': 'click', 3: 'not-an-attribute'})" - ) - # snapshot keys show up as attributes, but only the string ones - assert "type" in dir(event_object) - assert "3" not in dir(event_object) - - assert target.backend is backend - assert target.path == ("target",) - assert target.has_snapshot is False - assert repr(target) == ("EventProxy(event_id='evt', path=['target'], snapshot={})") - - -async def test_internal_attributes_are_never_resolved_against_the_browser(): - backend, client = make_client(make_objects()) - event_object = make_event(backend, "evt", snapshot={}) - - 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 dict(base) == {} - # with nothing behind it, the snapshot is all there is to describe - assert repr(base) == "{}" - with pytest.raises(EventProxyUnavailable, match="live access"): - await base - - -# -- reading through the snapshot ------------------------------------------- - - -async def test_read_walks_the_whole_dotted_path_at_once(): - backend, client = make_client(make_objects()) - event_object = make_event( - backend, - "evt", - snapshot={ - "target": {"value": "cached"}, - "meta": MappingProxyType({"kind": "mouse", "sub": {"n": 5}}), - "touches": [{"pageX": 7}, {"pageX": 8}], - }, - ) - - assert await event_object.read("target.value") == "cached" - assert await event_object.read("meta.kind") == "mouse" - assert await event_object.read("meta.sub.n") == 5 - assert await event_object.read("touches.1.pageX") == 8 - # a path may be handed over as a sequence too, indices included - assert await event_object.read(["touches", 0, "pageX"]) == 7 - assert requested_paths(client) == [], "the snapshot answered every one of them" - - # what the snapshot lacks costs exactly one request, however deep it goes - assert await event_object.read("deep.a.b.c") == 42 - assert requested_paths(client) == [["deep", "a", "b", "c"]] - - -async def test_read_gives_up_on_paths_the_snapshot_cannot_hold(): - backend, client = make_client(make_objects()) - event_object = make_event( - backend, - "evt", - snapshot={ - "type": "click", - "meta": MappingProxyType({"kind": "mouse"}), - "touches": [{"pageX": 7}], - }, - ) - - # strings are not walked into, out of range and non-numeric indices miss too - assert await event_object.read("type.deeper", default="d1") == "d1" - assert await event_object.read("meta.absent", default="d2") == "d2" - assert await event_object.read("touches.9.pageX", default="d3") == "d3" - assert await event_object.read("touches.first", default="d4") == "d4" - - assert requested_paths(client) == [ - ["type", "deeper"], - ["meta", "absent"], - ["touches", "9", "pageX"], - ["touches", "first"], - ] - # without a default the client's refusal is handed straight back - with pytest.raises(EventProxyNotFound): - await event_object.read("type.deeper") - - -async def test_chained_attributes_are_asked_for_in_one_round_trip(): - objects = { - "evt": { - "chain": Reference( - {"next": Reference({"next": Reference({"answer": 5})})}, - ), - }, - } - backend, client = make_client(objects) - event_object = make_event(backend, "evt", snapshot={}) - - # 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 - client.sent.clear() - assert await event_object.chain.next.next.answer == 5 - assert requested_paths(client) == [["chain", "next", "next", "answer"]] - - -# -- snapshot data which is a reference or a list --------------------------- - - -async def test_references_inside_a_snapshot_become_real_proxies(): - backend, client = make_client(make_objects()) - event_object = make_event( - backend, - "evt", - snapshot={ - "node": { - EVENT_REFERENCE_KEY: { - "path": ["target"], - "callable": True, - "eventId": "other", - } - }, - "self_ref": {EVENT_REFERENCE_KEY: {}}, - }, - ) - - node = event_object.node - assert isinstance(node, EventProxy) - # the path the client handed us wins over where this proxy sits - assert node.path == ("target",) - assert node.event_id == "other" - assert node._reactpy_callable is True, "the client said this one is a function" - assert EVENT_REFERENCE_KEY not in dict(node) - - # a reference which does not say where it points at keeps our own path - self_ref = event_object.self_ref - assert isinstance(self_ref, EventProxy) - assert self_ref.path == ("self_ref",) - assert self_ref.event_id == "evt" - assert self_ref._reactpy_callable is False - assert requested_paths(client) == [] - - -async def test_lists_inside_a_snapshot_are_wrapped_with_child_proxies(): - backend, client = make_client(make_objects()) - event_object = make_event( - backend, - "evt", - snapshot={ - "touches": [{"pageX": 1}, {"pageX": 2}], - "matrix": [[{"x": 1}], [3, "four"], None], - }, - ) - - touches = event_object.touches - assert [touch["pageX"] for touch in touches] == [1, 2] - assert [touch.path for touch in touches] == [ - ("touches", "0"), - ("touches", "1"), - ] - assert all(isinstance(touch, EventProxy) for touch in touches) - - matrix = event_object.matrix - assert isinstance(matrix[0][0], EventProxy) - assert matrix[0][0].path == ("matrix", "0", "0") - assert matrix[0][0]["x"] == 1 - assert matrix[1] == [3, "four"] - assert matrix[2] is None - assert requested_paths(client) == [] - - -# -- the dict protocol of a proxy ------------------------------------------- - - -async def test_async_proxies_refuse_to_be_inspected_while_unknown(): - backend, client = make_client(make_objects()) - target = make_proxy(backend, "evt", ["target"]) - - with pytest.raises(EventProxyAccessError) as measure: - len(target) - assert "cannot measure EventProxy for ['target'] of event 'evt'" in str( - measure.value - ) - assert "await the proxy" in str(measure.value) - - with pytest.raises(EventProxyAccessError, match="cannot iterate over"): - iter(target) - with pytest.raises(EventProxyAccessError, match="cannot test membership of"): - assert "type" in target - - assert requested_paths(client) == [] - - # once the client has told us something the dict protocol is free - event_object = make_event(backend, "evt", snapshot={"type": "click"}) - assert len(event_object) == 1 - assert list(event_object) == ["type"] - assert "type" in event_object - assert "target" not in event_object - assert requested_paths(client) == [] +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) -async def test_proxies_without_a_connection_are_quietly_empty(): - event_object = make_event(EventProxyBackend(), "evt", snapshot={}) + assert backend.disabled + assert not backend.available + with pytest.raises(EventProxyUnavailable): + await event_object.read("key") - assert len(event_object) == 0 - assert list(event_object) == [] - assert "type" not in event_object - # reading through to an attribute nobody can answer for gives nothing at all - assert event_object.type is None - assert event_object.target is None - # whatever the snapshot does hold is handed back without asking anybody - held = make_event( - EventProxyBackend(), "evt", snapshot={"target": {"value": "kept"}} +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" ) - assert held.target["value"] == "kept" + event_object = make_event(backend, "evt") + with pytest.raises(EventProxyTimeout, match="did not answer"): + await event_object.read("target") -# -- what a proxy does when it has to ask the browser ----------------------- - + assert backend.disabled, "a timeout the client reports is still a timeout" -async def test_reads_either_use_the_snapshot_or_ask_the_browser(): - objects = make_objects() - objects["evt"]["detail"] = 7 - backend, client = make_client(objects) - event_object = make_event(backend, "evt", snapshot={"type": "click"}) - cached = await event_object.read("type") - typed = await event_object.read("target.value") - fallback = await event_object.read("nope", default="fallback") - detail = await event_object.read("detail") - resolved = await make_proxy(backend, "evt", ["target"]).resolve() +async def test_failures_the_client_cannot_explain_are_still_reported(): + backend, client = make_canned_client(ok=False) + event_object = make_event(backend, "evt") - assert (cached, typed, fallback, detail) == ("click", "typed", "fallback", 7) - assert type(resolved) is dict - assert resolved["value"] == "typed" - assert requested_paths(client) == [ - ["target", "value"], - ["nope"], - ["detail"], - ["target"], - ] + with pytest.raises(EventProxyAccessError) as info: + await event_object.read("target") - # without a default the client's refusal is raised rather than swallowed - with pytest.raises(EventProxyNotFound): - await event_object.read("nope") + 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 async def test_a_failed_send_switches_live_access_off(): @@ -1103,7 +857,7 @@ async def send(message: dict[str, Any]) -> None: raise ConnectionResetError(msg) backend.set_sender(send) - event_object = make_event(backend, "evt", snapshot={}) + event_object = make_event(backend, "evt") with pytest.raises(EventProxyUnavailable, match="failed to send"): await event_object.read("target") @@ -1138,7 +892,7 @@ def call_from_a_worker() -> None: 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", snapshot={}) + event_object = make_event(backend, "evt") pending = [ asyncio.create_task(event_object.read("target")), asyncio.create_task(event_object.read("type")), @@ -1161,131 +915,236 @@ async def test_closing_the_backend_fails_everything_still_in_flight(): ) -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", snapshot={}) - - with pytest.raises(EventProxyTimeout, match="did not answer"): - await event_object.read("target") +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 - assert backend.disabled, "a timeout the client reports is still a timeout" + 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) -async def test_failures_the_client_cannot_explain_are_still_reported(): - backend, client = make_canned_client(ok=False) - event_object = make_event(backend, "evt", snapshot={}) + with pytest.raises(EventProxyUnavailable, match="no sender is bound"): + await pending - with pytest.raises(EventProxyAccessError) as info: - await event_object.read("target") + assert client.sent == [], "nothing was handed over a loop which no longer runs" - 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 +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 -async def test_requests_made_just_after_the_loop_is_gone_fail_fast(monkeypatch): + 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", snapshot={}) - loop = asyncio.get_running_loop() - # the loop which owned this connection has gone away, the sender has not noticed + event_object = make_event(backend, "evt") monkeypatch.setattr(backend, "_loop", None) - with pytest.raises(EventProxyUnavailable, match="no sender is bound"): + with pytest.raises(EventProxyError): await event_object.read("target") - assert client.sent == [], "nothing was handed over a loop which no longer runs" - monkeypatch.setattr(backend, "_loop", loop) - # the request was dropped rather than left behind waiting for an answer - assert await event_object.read("target.value") == "typed" + assert client.sent == [] -# -- calls the client may or may not have to answer ------------------------- +# -- how event data is recognised and handed out ----------------------------- -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", snapshot={}) +async def test_a_plain_string_marker_names_a_live_event_too(): + backend, client = make_client(make_objects()) - assert event_object.preventDefault() is None - assert event_object.stopPropagation() is None - assert event_object.stopImmediatePropagation() is None + args = build_event_args( + [{EVENT_MARKER_KEY: "evt", "type": "click"}, {"type": "click"}, "extra"], + backend, + ) - # 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 + 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_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", snapshot={}) - event_object.stopImmediatePropagation() +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, + ) - 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 + 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"]] -async def test_client_side_calls_can_be_waited_for(): - objects = make_objects() - backend, client = make_client(objects) - target = make_proxy(backend, "evt", ["target"]) +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) - # ``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_layout_hands_data_which_is_not_a_browser_object_as_is(): + received: dict[str, Any] = {} + async def on_click(event): + received["event"] = event -async def test_the_event_object_itself_cannot_be_called(): - backend, client = make_client(make_objects()) - event_object = make_event(backend, "evt", snapshot={}) + @component + def MyComponent(): + return reactpy.html.div({"onClick": on_click}) - with pytest.raises(EventProxyAccessError, match="cannot be called"): - await event_object() - with pytest.raises(EventProxyAccessError, match="cannot be called"): - await make_proxy(backend, "evt")() + _backend, client = make_client(make_objects()) - assert client.requests == [] + 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) -# -- how event data is recognised and handed out ---------------------------- + # 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_a_plain_string_marker_names_a_live_event_too(): - backend, _client = make_client(make_objects()) +async def test_merged_handlers_all_share_the_same_event(): + """Merged handlers are handed the very same data, which each reads as it likes - args = build_event_args( - [{EVENT_MARKER_KEY: "evt", "type": "click"}, {"type": "click"}, "extra"], - backend, + 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)] ) - assert isinstance(args[0], Event) - assert args[0].event_id == "evt" - assert args[0]["type"] == "click" - assert type(args[1]) is Event - assert args[2] == "extra" + @component + def MyComponent(): + return reactpy.html.div({"onClick": merged}) - marked = build_event_args([{EVENT_MARKER_KEY: {"id": "evt"}}], backend) - assert isinstance(marked[0], Event) - assert marked[0].event_id == "evt" + _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) -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) + 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 1b320c27e..dd70f67f8 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -33,7 +33,7 @@ def test_event_handler_repr(): assert repr(handler) == ( f"EventHandler(function={handler.function}, prevent_default=False, " f"stop_propagation=False, stop_immediate_propagation=False, debounce=None, " - f"target={handler.target!r}, throttle=None, event_proxy=None)" + f"target={handler.target!r}, throttle=None)" ) @@ -219,30 +219,6 @@ async def some_other_func(data): assert calls == ["some_func", "some_other_func"] -def test_merge_event_handlers_event_proxy(): - """Merging never has to settle an argument about live access - - Whether the browser object behind an event should be kept alive is not something - two handlers can contradict each other over - one of them simply wants it, and a - handler which says nothing wants it just as much. The merged handler therefore - refuses only when every original refused, and states nothing at all - which means - the same thing as asking - when none of them said either way. - """ - plain = EventHandler(_noop_handler) - wants = EventHandler(_noop_handler, event_proxy=True) - declines = EventHandler(_noop_handler, event_proxy=False) - other_plain = EventHandler(_noop_handler) - other_declines = EventHandler(_noop_handler, event_proxy=False) - - assert merge_event_handlers([plain, other_plain]).event_proxy is None - assert merge_event_handlers([plain, wants]).event_proxy is True - assert merge_event_handlers([plain, declines]).event_proxy is None, ( - "a handler which never refused lost live access to one which did" - ) - assert merge_event_handlers([declines, wants]).event_proxy is True - assert merge_event_handlers([declines, other_declines]).event_proxy is False - - async def test_can_prevent_event_default_operation(display: DisplayFixture): @reactpy.component def Input(): @@ -588,46 +564,6 @@ async def handler(event: Event): assert eh.prevent_default is False assert eh.stop_propagation is False assert eh.debounce is None - assert eh.event_proxy is None - - -def test_detect_event_proxy_being_refused(): - async def handler(event: Event): - event.eventProxy = False - - assert EventHandler(handler).event_proxy is False - - -def test_detect_event_proxy_being_insisted_on(): - async def handler(event: Event): - event.eventProxy = True - - assert EventHandler(handler).event_proxy is True - - -def test_detected_event_proxy_overrides_the_stated_preference(): - """What the handler says about itself is taken over what was said about it""" - - async def handler(event: Event): - event.eventProxy = False - - assert EventHandler(handler, event_proxy=True).event_proxy is False - - -def test_detect_event_proxy_when_handler_is_partial(): - async def handler(event: Event, *, extra_param): - event.eventProxy = False - - eh = EventHandler(partial(handler, extra_param="extra_value")) - assert eh.event_proxy is False - - -def test_event_decorator_detects_event_proxy(): - @event - async def handler(event: Event): - event.eventProxy = False - - assert handler.event_proxy is False def test_event_decorator_accepts_throttle(): @@ -653,15 +589,6 @@ def test_event_handler_throttle_default_is_none(): 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(): @@ -734,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") @@ -901,7 +829,7 @@ def _ControlledInputWithMirror(): value, set_value = use_state("") async def on_change(event): - set_value(event["target"]["value"]) + set_value(await event.target.value) async def append_marker(event): set_value(lambda old: old + MARKER) @@ -1022,7 +950,7 @@ def _ControlledInputWhichClearsOnKeyDown(): value, set_value = use_state("") async def on_change(event): - set_value(event["target"]["value"]) + set_value(await event.target.value) async def on_key_down(event): set_value("") @@ -1057,17 +985,34 @@ async def test_controlled_input_clear_from_second_handler_reaches_the_dom( inp = await display.page.wait_for_selector("#controlled-input") await inp.click() await inp.type("abc", delay=delay) - await inp.press("Enter") - async def current_value(): + async def field_value(): return await inp.evaluate("node => node.value") - await poll(current_value).until( + 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 display.page.locator("#server-value").text_content() == "" + assert await server_value() == "" async def test_controlled_input_respects_custom_debounce(display: DisplayFixture): @@ -1077,7 +1022,7 @@ def ControlledInput(): 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( { @@ -1112,7 +1057,7 @@ def ControlledInput(): 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( @@ -1297,13 +1242,18 @@ async def test_a_clear_is_not_written_back_over_by_what_came_before_it( ): """A render too old to describe the field cannot resurrect what it held - The keystrokes and the clear are racing. The field is emptied by an event which - reaches the server while the text typed into it is still on its way there, so - renders carrying that text are queued up behind the clear. 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. + 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) @@ -1316,9 +1266,16 @@ async def field_value(): async def server_value(): return await display.page.locator("#server-value").text_content() - # Fill the field and empty it without pausing between, so the clear overtakes - # the keystrokes instead of following them. 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( @@ -1340,6 +1297,52 @@ async def server_value(): ) +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""" @@ -1347,7 +1350,7 @@ def _HeavyPage(): ticks, set_ticks = use_state(0) async def on_change(event): - set_value(event["target"]["value"]) + set_value(await event.target.value) def keep_rendering(): """Re-render on a timer, so renders are always in flight behind the typing""" diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index d5d811104..57b4e62e0 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -248,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}) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index a29b24d4f..df7ced7e3 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -1899,7 +1899,7 @@ def Root(): async def on_change(event): inside_handler.set() await let_handler_finish.wait() - set_value(event["target"]["value"]) + set_value(await event.target.value) return html.input({"value": value, "onChange": on_change}) diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 9c978b625..cd6e08707 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -65,7 +65,6 @@ def make_events_and_expected_model(): "target": STATIC_EVENT_HANDLER.target, "preventDefault": False, "stopPropagation": False, - "eventProxy": True, } }, } @@ -177,7 +176,7 @@ async def test_serve_round_trips_live_event_requests(): def Reader(): @read_handler.use async def read_late_value(event): - seen["value"] = await event.read("target.value") + (seen["value"],) = await event.read("target.value") did_read.set() return reactpy.html.div({"onInput": read_late_value}) From 3fec4852c45a0961f65b80fd84e996e25ea3a5e8 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 18:20:55 -0700 Subject: [PATCH 18/36] Cover the corners live access left behind The coverage runner holds the suite to every line, and the rewrite to live access left nine of them unexecuted. Two of those are the interesting ones: the paths where a read finds another of the same property already on its way and joins it instead of asking the browser twice. Nothing had driven that, so it is now driven twice over - once through a lone read racing a gather, once through a batch which adopts the read a lone await started first - and each test says what the sharing saves rather than merely that it happens. The rest are the flush which reaches for a sender that the connection no longer has, the debounce a handler sets on itself and reads back, and the truthiness of a proxy whose value is still unknown. An answer arriving outside the life of a layout has no request to satisfy, because the backend belongs to the running layout. It is refused rather than raising: the connection cannot know the difference between early and late, and should not be blamed for the timing. The websocket hands answers to the layout directly, on the loop which is waiting for them. EventProxy._reactpy_description had no caller - a proxy describes itself elsewhere - so it is gone. --- src/reactpy/_event_proxy.py | 6 -- src/reactpy/executors/asgi/middleware.py | 8 +- tests/test_core/test_event_proxy.py | 112 +++++++++++++++++++++++ 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 59ee57570..b5f099c25 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -872,12 +872,6 @@ def __call__(self, *args: Any) -> Awaitable[Any]: def __bool__(self) -> bool: return True - def _reactpy_description(self) -> str: - return ( - f"{type(self).__name__} for {list(self._reactpy_path)!r} of event " - f"{self._reactpy_event_id!r}" - ) - if TYPE_CHECKING: _ElementMembers = dict[str, Any] diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index a138553ba..9a45dd22a 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -258,13 +258,11 @@ def handle_event_proxy_response(self, message: dict[str, Any]) -> bool: 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, or the layout - does not recognise it as an answer to one of its own requests. + 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 - if layout is None: - return False - return layout.handle_event_proxy_response(message) + return layout is not None and layout.handle_event_proxy_response(message) async def send_json(self, data: Any) -> None: return await self._send( diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index a7836aee6..0387603d9 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -478,6 +478,47 @@ async def test_sequential_reads_are_not_batched(): 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") @@ -513,6 +554,33 @@ async def test_a_proxy_is_not_a_container(): 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 ----------------------------------------- @@ -802,6 +870,50 @@ def test_backend_without_a_sender_is_unavailable(): 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 + ) + + 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) From 3286b99d6d64e205a5652c0eacea5f0c603265c8 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 19:29:27 -0700 Subject: [PATCH 19/36] docs: align the changelog with the live event proxy --- CHANGELOG.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 713e390eb..8e79f7338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,8 @@ 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 `async def on_click(event: Event): ...`). - Added `asgi` and `jinja` installation extras (for example `pip install reactpy[asgi, jinja]`). @@ -40,10 +40,10 @@ 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 rather than being limited to the snapshot sent along with the event. - - A handler awaits what it needs (`await event.target.value`, `await event.target.focus()`). - - The initial event is a snapshot of serializable event data; whatever the handler reaches past is an `EventProxy` describing where in the browser to look for it. - - Live access is on unless a handler turns it off by assigning `event.eventProxy = False` inside its own body. +- 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. + - 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. - 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`. ### Changed @@ -88,7 +88,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 serializable snapshot it produced is now produced by the client's own serializer, and is what a handler is handed unless it has asked for the live object behind the event (see the live event proxy above). +- 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. @@ -113,6 +113,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 From 1ad56c19c2a1a0ef1cfc10034332fcf20ec6d035 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 22:34:30 -0700 Subject: [PATCH 20/36] Keep what a batch of live reads already answered A batch of live reads gave up the moment one of its paths raised. Whatever the client had answered in that same message was thrown away with it, so the next read of those properties paid again for a round trip the browser had already carried out - and when one path timed out along the way, the timeout was raised even where the client had gone on to explain that another property simply does not exist. Answers which arrived are now cached even when a neighbour fails, and 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. --- src/reactpy/_event_proxy.py | 50 +++++++++++++++++-- tests/test_core/test_event_proxy.py | 77 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index b5f099c25..7ffc2654b 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -128,6 +128,24 @@ 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 @@ -283,6 +301,12 @@ async def resolve_many( Anything already cached is answered on the spot. Everything else is enqueued together, so it leaves the process as a single batched request. + + 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[ @@ -308,14 +332,34 @@ async def resolve_many( *( self._await(request_id, future, event_id, paths[index]) for index, _, (request_id, future) 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): - values[index] = self._to_value(payload, event_id, paths[index]) - self._remember(key, values[index]) + if isinstance(payload, asyncio.CancelledError): + raise payload + 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( diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 0387603d9..7fae579c1 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -959,6 +959,83 @@ async def test_failures_the_client_cannot_explain_are_still_reported(): 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]] = [] From d53680353ff16024141b964d17b25b8faa4901ad Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 22:42:34 -0700 Subject: [PATCH 21/36] Count a dead live event round trip once, not once per property Whether the client still answers at all is what the timeout budget is meant to measure, but every request of a batch which went silent raised its own timeout and every one of those was counted. A handler which read four properties of an event at one time 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. Requests are now stamped with the round trip which carried them - one send of a batch, or one answer which came back - and an unanswered one costs a single strike however many properties it was after. Answering anything still resets the budget, so a client which is slow on one path is no longer mistaken for a client which has gone away. --- src/reactpy/_event_proxy.py | 76 +++++++++++++++++++++-------- src/reactpy/config.py | 6 ++- tests/test_core/test_event_proxy.py | 57 ++++++++++++++++++++++ 3 files changed, 118 insertions(+), 21 deletions(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 7ffc2654b..50e83fd7b 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -161,6 +161,11 @@ def __init__(self) -> None: self._send_tasks: set[Any] = set() self._timeouts = 0 self._disabled = 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] @@ -206,6 +211,7 @@ def _abandon_pending(self, reason: str) -> None: 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 @@ -221,6 +227,9 @@ def handle_response(self, message: Mapping[str, Any]) -> bool: 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: @@ -234,9 +243,7 @@ def handle_response(self, message: Mapping[str, Any]) -> bool: 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._pending.pop(request_id, None) if isinstance(request_id, str) else None - ) + 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. @@ -344,7 +351,7 @@ async def resolve_many( failure: BaseException | None = None for (index, key, _), payload in zip(outstanding, payloads, strict=True): if isinstance(payload, asyncio.CancelledError): - raise payload + raise payload # nocov - nothing cancels a request which is in flight if isinstance(payload, BaseException): failure = _prefer_failure(failure, payload) continue @@ -439,9 +446,14 @@ def _flush(self) -> None: 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 @@ -453,12 +465,7 @@ def _flush(self) -> None: def _abandon(self, batch: Sequence[dict[str, Any]]) -> None: for message in batch: - request_id = message.get("requestId") - future = ( - self._pending.pop(request_id, None) - if isinstance(request_id, str) - else None - ) + 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") @@ -505,25 +512,56 @@ async def _send_message(self, message: dict[str, Any]) -> None: 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 _discard(self, request_id: str) -> None: - self._pending.pop(request_id, None) + 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._discard(request_id) - 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 requests went unanswered" - ) + self._strike(request_id) msg = ( 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" ) 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], diff --git a/src/reactpy/config.py b/src/reactpy/config.py index b56ab3017..07d380bd7 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -161,9 +161,11 @@ def boolean(value: str | bool | int) -> bool: mutable=True, validator=int, ) -"""How many consecutive live event timeouts are tolerated before access is disabled +"""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 timeouts so that event handlers do not stall. +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/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 7fae579c1..4bda5f612 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -914,6 +914,37 @@ def MyComponent(): ) +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) @@ -946,6 +977,32 @@ async def test_client_reported_timeouts_count_against_the_same_budget(monkeypatc 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_failures_the_client_cannot_explain_are_still_reported(): backend, client = make_canned_client(ok=False) event_object = make_event(backend, "evt") From 01ec56f97fa5b100da5662d3cba6ef1f57331cb3 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 22:45:54 -0700 Subject: [PATCH 22/36] Let a client which came back read its events again Being switched off was a statement about the connection which was bound at the time - that the client behind it stopped answering - but it used to last for the life of the layout. A single unanswered message, a page which was reloaded or 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 new connection. Binding a sender which is not the one already in place, which is what a reconnect does, now clears the switch and the timeout budget along with it. Binding the very same sender again does not, and neither does anything at all once the layout has closed. --- src/reactpy/_event_proxy.py | 32 +++++++++++++++++++- src/reactpy/core/layout.py | 4 +++ tests/test_core/test_event_proxy.py | 45 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 50e83fd7b..a488907da 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -161,6 +161,7 @@ def __init__(self) -> None: 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 @@ -189,11 +190,35 @@ def set_sender(self, send: SendCoroutine) -> None: 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: @@ -202,8 +227,13 @@ def disable(self, reason: str) -> None: self._abandon_pending(reason) def close(self) -> None: - """Abandon all in-flight requests (called when the layout shuts down)""" + """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: diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 790982aeb..cef2fc3b5 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -212,6 +212,10 @@ def set_event_proxy_sender( 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) diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 4bda5f612..3fbf70b2b 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -1003,6 +1003,51 @@ async def test_one_silent_batch_costs_one_timeout(monkeypatch): 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") From bea2b51e4c66c2c641313d1e855a1c5188f9cf6d Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 22:48:05 -0700 Subject: [PATCH 23/36] Say how to ask what the browser actually prevented The ``defaultPrevented`` property promised to be readable with ``await event.defaultPrevented``, which is not something a property which hands back a plain ``bool`` can do - the sentence sent handlers straight to a ``TypeError``. What the property answers is what Python asked the browser to do, and it still answers it synchronously. The docstring now says so and points at the read which answers the other question - what the browser settled on, which other scripts on the page may have had a say in. --- src/reactpy/_event_proxy.py | 13 ++++++++++--- tests/test_core/test_event_proxy.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index a488907da..793bc18d9 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -864,9 +864,16 @@ def stopImmediatePropagation(self) -> None: def defaultPrevented(self) -> bool: """Whether :meth:`preventDefault` has been asked for on this proxy - This reports what Python has asked the browser to do, not what the browser - settled on - read the live value with ``await event.defaultPrevented`` if the - distinction matters. + 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")) diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 3fbf70b2b..7416e79bb 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -826,6 +826,33 @@ async def test_stop_immediate_propagation_is_forwarded_to_the_client(): 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 ----------------------- From d5c82a24abf9a13850018c84085c531c406f4ce0 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 22:51:52 -0700 Subject: [PATCH 24/36] Refuse an argument which is deeper than the connection can carry Arguments for a live call were walked recursively on their way out with no bound at all, so a value which went deep enough raised ``RecursionError`` from inside the call. That is not a failure a handler can read, and not one it can recover from by asking for something shallower instead. Arguments are now bounded by the depth reading already uses, and refused with the same kind of error the rest of live access raises. --- src/reactpy/_event_proxy.py | 22 ++++++++++++++++++---- tests/test_core/test_event_proxy.py | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 793bc18d9..a98a799eb 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -629,8 +629,20 @@ async def _immediate(value: Any) -> Any: return value -def serialize_argument(value: Any) -> Any: - """Prepare a Python value to be handed to a client side method""" +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: { @@ -641,9 +653,11 @@ def serialize_argument(value: Any) -> Any: if value is None or isinstance(value, bool | int | float | str): return value if isinstance(value, Mapping): - return {str(key): serialize_argument(item) for key, item in value.items()} + 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) for item in value] + return [serialize_argument(item, depth + 1) for item in value] try: json.dumps(value) diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 7416e79bb..f7eaef067 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -33,6 +33,7 @@ serialize_argument, ) from reactpy.config import ( + REACTPY_EVENT_PROXY_MAX_DEPTH, REACTPY_EVENT_PROXY_MAX_TIMEOUTS, REACTPY_EVENT_PROXY_TIMEOUT, ) @@ -884,6 +885,32 @@ def test_excessively_nested_references_are_refused(): 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 ------------------------- From 7308b5e97708e503f1a632cba499cbca0f6e9878 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 22:53:52 -0700 Subject: [PATCH 25/36] Spell the example the way the formatter wants it The docstring which shows how to ask the browser what it prevented wrote its single element unpacking the way Python lets you, and the formatter wants the parentheses. Nothing about the behaviour changed. --- src/reactpy/_event_proxy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index a98a799eb..98a102f20 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -883,7 +883,7 @@ def defaultPrevented(self) -> bool: 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") + (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 From 74bdcead40202d54c22d462b04150c266a689c78 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 22:57:44 -0700 Subject: [PATCH 26/36] Let a reader which went away take its request with it A handler which was stopped while it was reading - replaced by a re-render, or gone with a component which unmounted - left its request recorded as though somebody still cared, waiting on an answer with nowhere to go and nothing to reclaim it. Nor was there any bound at all on how many requests one connection could have waiting on the client, which only mattered once the client stopped answering. A request is now released as soon as whoever was waiting on it has gone. A client which is being asked beyond a limit is refused in a way a handler can read, rather than buried under requests it will never get round to. --- src/reactpy/_event_proxy.py | 22 +++++++++++ tests/test_core/test_event_proxy.py | 58 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 98a102f20..78500904c 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -104,6 +104,14 @@ async def on_click(event: Event): """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""" @@ -443,6 +451,13 @@ async def _await( ) 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 _enqueue( self, @@ -515,6 +530,13 @@ def _begin( 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] = { diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index f7eaef067..009da5622 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -1235,6 +1235,64 @@ def call_from_a_worker() -> None: 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_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") From 15921a2df59383b60a9408ff9cf6e6cb8c4afe68 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:05:44 -0700 Subject: [PATCH 27/36] Say what is remembered, and let a handler ask the browser again A resolved property is remembered against its event, which is what makes a handler which reads one property twice cost nothing. It also means the second read hands back the first answer however much the page has moved on since, and there was nothing a handler could do about it. The caching is now spelled out where handlers read about reading, and a read which wants what the browser holds by now can say so - ``await event.read("target.value", refresh=True)`` or ``await event.target.value.resolve(refresh=True)`` - which pays for its own round trip and replaces what was remembered. --- CHANGELOG.md | 1 + src/reactpy/_event_proxy.py | 59 +++++++++++++++++++++++------ tests/test_core/test_event_proxy.py | 45 ++++++++++++++++++++++ 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e79f7338..2c10b945e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Don't forget to remove deprecated code on each major release! - 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. - 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)`. - 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`. ### Changed diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 78500904c..46dc2fc68 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -306,19 +306,23 @@ async def resolve( 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. + 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 key in self._cache: + 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 payload = await self._await(*shared, event_id, path) return self._to_value(payload, event_id, path) @@ -340,12 +344,16 @@ async def resolve( return value async def resolve_many( - self, event_id: str, paths: Sequence[Sequence[str | int]] + 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. + 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 @@ -360,7 +368,7 @@ async def resolve_many( ours: list[tuple[str, tuple[str, ...]]] = [] for index, path in enumerate(paths): key = self._cache_key(event_id, path) - if key in self._cache: + if not refresh and key in self._cache: values[index] = self._cache[key] continue shared = self._inflight.get(key) @@ -744,6 +752,14 @@ async def on_input(event: Event): 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`. @@ -944,12 +960,15 @@ def __getattr__(self, name: str) -> Any: # -- resolution ------------------------------------------------------- - def _reactpy_resolve_path(self, call: bool, args: Sequence[Any]) -> Any: + 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: @@ -968,15 +987,21 @@ async def on_click(event: Event): """ return self._reactpy_call_method("composedPath", ()) - def resolve(self) -> Awaitable[_T]: + 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, ()) + return self._reactpy_resolve_path(False, (), refresh) - async def read(self, *paths: str | Sequence[Any]) -> tuple[Any, ...]: + 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 @@ -992,7 +1017,11 @@ async def on_input(event: Event): 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 cached, and a cached answer is not re-fetched. + 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 () @@ -1001,7 +1030,7 @@ async def on_input(event: Event): wanted: list[list[str | int]] = [] wanted_at: list[int] = [] for index, item in enumerate(segments): - if len(item) == 1 and item[0] in self._reactpy_known: + 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: @@ -1009,7 +1038,7 @@ async def on_input(event: Event): wanted_at.append(index) if wanted: resolved = await self._reactpy_backend.resolve_many( - self._reactpy_event_id, wanted + self._reactpy_event_id, wanted, refresh ) for index, value in zip(wanted_at, resolved, strict=True): values[index] = value @@ -1052,6 +1081,12 @@ async def on_key_down(event: Event): 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`. diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 009da5622..dba240948 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -433,6 +433,51 @@ async def test_a_property_is_read_from_the_browser_only_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") From e07346d0c421796350dcadab8eb934e12c3e0a58 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:09:13 -0700 Subject: [PATCH 28/36] Bound a list the way every other collection of members is bound Serializing a live value bounded how many properties of an object came over, and how many items of an iterable - arrays were the one collection with no bound at all, so an event which happened to carry a long one handed the whole of it over in a single message. Whatever the iterable bound did cut off simply vanished, with nothing to say there had been more. One bound now covers properties, list items, and the items of anything which iterates, taken from the same option. What did not fit is pointed at rather than dropped, so the tail of an over-long list comes back as a reference to it and a handler can tell "that is all of it" from "there is more over there". --- .../@reactpy/client/src/event-proxy.ts | 29 ++++++++++++++--- .../@reactpy/client/tests/event-proxy.test.ts | 31 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts index 2520dd3ba..5f35d7419 100644 --- a/src/js/packages/@reactpy/client/src/event-proxy.ts +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -53,7 +53,7 @@ export const EVENT_REFERENCE_KEY = "__reactpy_ref__"; export const MAX_LIVE_OBJECTS = 512; /** How long an object may sit unused before it is forgotten (ms) */ export const LIVE_OBJECT_TTL_MS = 60_000; -/** How many properties of a single object are handed over at most */ +/** How many members of one object, list, or iterable are handed over at most */ export const MAX_PROPERTIES = 100; /** How deep a single resolution may nest before references are used */ export const DEFAULT_MAX_DEPTH = 10; @@ -252,6 +252,10 @@ export function collectProperties( * 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, @@ -285,6 +289,11 @@ export function serializeValue( 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) @@ -294,15 +303,27 @@ export function serializeValue( stack: value.stack ?? null, }; - if (Array.isArray(value)) - return value.map((item, index) => next(item, String(index), depth + 1)); + 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 >= MAX_PROPERTIES) break; + if (index >= maxMembers) { + items.push(reference(eventId, path)); + break; + } items.push(next(item, String(index), depth + 1)); index++; } diff --git a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts index 896c9de45..05ff1d021 100644 --- a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -184,6 +184,37 @@ test("serializeValue reads iterables as lists", () => { expect(serializeValue([1, [2, 3]], "e")).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", [], { 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"], { + 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"])).toEqual(["a", "b"]); +}); + test("serializeValue uses references for cycles and excess depth", () => { const loop: Record = { name: "loop" }; loop["loopInto"] = loop; From 32a8b0422e341473551667b3ccabc0230bdb5695 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:11:09 -0700 Subject: [PATCH 29/36] Let go of idle browser objects while the server is asking Objects are registered so that the server can keep reading them, and nothing else is coming to take them away. Pruning only happened when a new event was dispatched, so a page which went quiet held every object it had ever handed over - detached nodes included - for the whole of its idle time. The registry is now swept whenever the server asks it anything, not only when something is added to it. An object which is being read is not idle, and is kept as before. --- .../@reactpy/client/src/event-proxy.ts | 11 ++++++++ .../@reactpy/client/tests/event-proxy.test.ts | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts index 5f35d7419..061c4aa2e 100644 --- a/src/js/packages/@reactpy/client/src/event-proxy.ts +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -436,6 +436,11 @@ export class EventProxyRegistry { * * Whatever is dropped here is gone for good as far as the server is concerned: the id * it holds keeps working, and keeps answering "not-found". + * + * 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(); @@ -462,6 +467,12 @@ export class EventProxyRegistry { handleMessage(message: EventProxyRequest | EventProxyRequestBatch): void { if (!message || message.type !== EVENT_PROXY_REQUEST_TYPE) return; + // Whatever is registered here is being kept alive for the server's benefit, and + // nobody else is coming to take it away - so the moment the server asks about them + // is the moment to notice which of them have gone stale. Waiting for the next event + // to be dispatched would leave detached objects held for their whole idle time. + this.prune(); + const requests = (message as EventProxyRequestBatch).requests; if (!Array.isArray(requests)) { const request = message as EventProxyRequest; diff --git a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts index 05ff1d021..e241e9b98 100644 --- a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -906,6 +906,31 @@ test("an object which aged out is answered as not-found", () => { expect(replies[0].code).toBe("not-found"); }); +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[] = []; From 3cc449f3d95a6941a3a4d814794d328980098e9f Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:15:53 -0700 Subject: [PATCH 30/36] Hold a request to what the page is willing to take apart Three bounds were missing on the way in. An argument of a call was walked as deep as it happened to be, so the shape of an argument was the server's to choose and the page's to survive. The depth a request asked for was obeyed without being looked at, which left "negative", "not a number", and "one hundred thousand" all as ways past the bound meant to protect the page. And a batched message was carried out however long its list was, spending the page's one turn on an arbitrarily large number of paths. Arguments are now held to the same depth a read is, that depth is read as a suggestion with a ceiling of its own, and whatever a message asks beyond the bound is refused - with an answer, so the server is not left timing out on a client which is wide awake. --- .../@reactpy/client/src/event-proxy.ts | 97 +++++++++++++++++-- .../@reactpy/client/tests/event-proxy.test.ts | 90 +++++++++++++++++ 2 files changed, 178 insertions(+), 9 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts index 061c4aa2e..164f95eca 100644 --- a/src/js/packages/@reactpy/client/src/event-proxy.ts +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -57,6 +57,46 @@ export const LIVE_OBJECT_TTL_MS = 60_000; export const MAX_PROPERTIES = 100; /** How deep a single resolution may nest before references are used */ export const DEFAULT_MAX_DEPTH = 10; +/** + * 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 */ +function allowedDepth(requested: unknown): number { + if (typeof requested !== "number" || !Number.isFinite(requested)) + return DEFAULT_MAX_DEPTH; + 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; @@ -484,11 +524,20 @@ export class EventProxyRegistry { } const responses: EventProxyResponse[] = []; - for (const request of requests) { + 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; - const response = this.resolve(request); + // 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) { @@ -528,11 +577,18 @@ export class EventProxyRegistry { entry.added = this.clock(); const path = Array.isArray(message.path) ? message.path.map(String) : []; - const options: SerializeOptions = { maxDepth: message.maxDepth }; + const maxDepth = allowedDepth(message.maxDepth); + const options: SerializeOptions = { maxDepth }; try { if (message.call) { - const value = this.call(eventId, entry.value, path, message.args || []); + const value = this.call( + eventId, + entry.value, + path, + message.args || [], + maxDepth, + ); return reply(true, { value: serializeValue(value, eventId, path, options), }); @@ -558,7 +614,13 @@ export class EventProxyRegistry { } } - private call(eventId: string, root: any, path: string[], args: any[]): any { + private call( + eventId: string, + root: any, + path: string[], + args: any[], + maxDepth: number, + ): any { if (path.length === 0) throw new ProxyAccessError("the event object itself cannot be called"); const { parent, key } = parentAndKey(root, path); @@ -567,11 +629,26 @@ export class EventProxyRegistry { throw new ProxyAccessError(`${JSON.stringify(key)} is not a function`); return method.apply( parent, - args.map((argument) => this.readArgument(argument)), + args.map((argument) => this.readArgument(argument, maxDepth, 0)), ); } - private readArgument(argument: unknown): unknown { + /** + * 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); @@ -583,11 +660,13 @@ export class EventProxyRegistry { return readPath(entry.value, ref.path); } if (Array.isArray(argument)) - return argument.map((item) => this.readArgument(item)); + 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); + value[key] = this.readArgument(item, maxDepth, depth + 1); return value; } return argument; diff --git a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts index e241e9b98..227736564 100644 --- a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -14,7 +14,9 @@ import { type EventProxyRequestBatch, type EventProxyResponse, LIVE_OBJECT_TTL_MS, + MAX_ALLOWED_DEPTH, MAX_LIVE_OBJECTS, + MAX_REQUESTS_PER_MESSAGE, ProxyAccessError, ProxyNotFoundError, collectProperties, @@ -587,6 +589,94 @@ test("an argument reference to a forgotten object is an error", () => { 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()); From 24461a287d01cd745d058ea759ef031daa2c8cc3 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:21:08 -0700 Subject: [PATCH 31/36] Keep what a call answered, so a path through it leads somewhere A reference which came out of the result of a method call named a place inside that result, and there was nowhere to resolve it: the path was walked again from the event, landed on the method, and was told the property did not exist. The server was left holding a reference to something the page could never reach. An object now keeps what each path called on it answered, for as long as the event itself is kept and up to a small number of the most recent calls. Walking a path which leads through a method steps through that answer rather than the function, and the method is not run again to get it - it may have side effects, and by then it may not even be there. --- CHANGELOG.md | 1 + .../@reactpy/client/src/event-proxy.ts | 101 ++++++++++++++++-- .../@reactpy/client/tests/event-proxy.test.ts | 99 +++++++++++++++++ 3 files changed, 190 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c10b945e..3c197ed96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Don't forget to remove deprecated code on each major release! - 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. - 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`. ### Changed diff --git a/src/js/packages/@reactpy/client/src/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts index 164f95eca..58fe2ccb7 100644 --- a/src/js/packages/@reactpy/client/src/event-proxy.ts +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -406,22 +406,94 @@ export function readProperty(node: any, name: string): any { } } -/** Follow a full property path, refusing anything blocked along the way */ -export function readPath(root: any, path: string[]): any { +/** + * 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 (const segment of path) node = readProperty(node, segment); + for (let index = 0; index < path.length; index++) + node = stepDown(node, path, index, calls); return node; } -function parentAndKey(root: any, path: string[]): { parent: any; key: string } { +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 (const segment of path.slice(0, -1)) node = readProperty(node, segment); + 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; added: number }; +type LiveObject = { + value: any; + added: number; + /** + * 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 @@ -431,6 +503,9 @@ type LiveObject = { value: any; added: number }; * 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". Entries are * still bounded in count and in age, since a page cannot hold on to events forever. + * 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. */ @@ -451,7 +526,7 @@ export class EventProxyRegistry { /** Remember ``value`` and return the id the server will address it by */ register(value: any): string { const eventId = `${this.clock().toString(36)}-${(this.nextId++).toString(36)}`; - this.live.set(eventId, { value, added: this.clock() }); + this.live.set(eventId, { value, added: this.clock(), calls: new Map() }); this.prune(); return eventId; } @@ -588,7 +663,11 @@ export class EventProxyRegistry { 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), }); @@ -599,8 +678,7 @@ export class EventProxyRegistry { value: serializeValue(entry.value, eventId, path, options), }); - const { parent, key } = parentAndKey(entry.value, path); - const value = readProperty(parent, key); + const value = readPath(entry.value, path, entry.calls); if (typeof value === "function") return reply(true, { value: reference(eventId, path, true) }); return reply(true, { @@ -620,10 +698,11 @@ export class EventProxyRegistry { 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); + 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`); @@ -657,7 +736,7 @@ export class EventProxyRegistry { `nothing is registered for event ${JSON.stringify(ref.eventId)}`, ); entry.added = this.clock(); - return readPath(entry.value, ref.path); + return readPath(entry.value, ref.path, entry.calls); } if (Array.isArray(argument)) return argument.map((item) => diff --git a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts index 227736564..00354dc06 100644 --- a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -15,6 +15,7 @@ import { type EventProxyResponse, LIVE_OBJECT_TTL_MS, MAX_ALLOWED_DEPTH, + MAX_CALL_RESULTS_PER_OBJECT, MAX_LIVE_OBJECTS, MAX_REQUESTS_PER_MESSAGE, ProxyAccessError, @@ -540,6 +541,104 @@ test("calling refuses the event itself, non-functions, and blocked members", () ).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[] = []; From 48ddb0704d7817a2523da04a8519169d9d5ffd83 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:22:16 -0700 Subject: [PATCH 32/36] Bring the changelog up to what live access actually does Three things the live event proxy promised were no longer the whole story: what the timeout budget counts (unanswered messages, not properties), what the depth option bounds (arguments on the way in as well as data on the way out), and what happens when a client which went quiet comes back. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c197ed96..318f10233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ Don't forget to remove deprecated code on each major release! - 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 From d1968c63b05abf05ae6e7c01d56ba849a19a6b88 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:45:12 -0700 Subject: [PATCH 33/36] Do not release a shared request when a borrowing reader walks away --- src/reactpy/_event_proxy.py | 57 +++++++++++++------ tests/test_core/test_event_proxy.py | 86 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 18 deletions(-) diff --git a/src/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index 46dc2fc68..c7622d696 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -322,8 +322,10 @@ async def resolve( 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 - payload = await self._await(*shared, event_id, path) + # 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) @@ -363,7 +365,7 @@ async def resolve_many( """ values: list[Any] = [None] * len(paths) outstanding: list[ - tuple[int, tuple[str, tuple[str, ...]], tuple[str, Future | None]] + tuple[int, tuple[str, tuple[str, ...]], tuple[str, Future | None], bool] ] = [] ours: list[tuple[str, tuple[str, ...]]] = [] for index, path in enumerate(paths): @@ -373,18 +375,21 @@ async def resolve_many( continue shared = self._inflight.get(key) if shared is not None: - outstanding.append((index, key, shared)) + # 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)) + outstanding.append((index, key, entry, True)) try: payloads = await asyncio.gather( *( - self._await(request_id, future, event_id, paths[index]) - for index, _, (request_id, future) in outstanding + 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. @@ -395,7 +400,7 @@ async def resolve_many( self._inflight.pop(key, None) failure: BaseException | None = None - for (index, key, _), payload in zip(outstanding, payloads, strict=True): + 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): @@ -450,23 +455,42 @@ async def _await( 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), REACTPY_EVENT_PROXY_TIMEOUT.current - ) + 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. + # 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, @@ -588,10 +612,7 @@ def _handle_timeout( self, request_id: str, event_id: str, path: Sequence[str | int] ) -> NoReturn: self._strike(request_id) - msg = ( - 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" - ) + msg = self._timeout_message(event_id, path) raise EventProxyTimeout(msg) def _strike(self, request_id: str) -> None: diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index dba240948..a7bb13769 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -1311,6 +1311,92 @@ async def test_a_reader_which_went_away_releases_its_request(): 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 From 2ec73f0cc22f7e0715186937b70c8d4e3fbf1e06 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:47:11 -0700 Subject: [PATCH 34/36] Say that a shared request belongs to the reader which asked for it --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 318f10233..e1389f528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ Don't forget to remove deprecated code on each major release! - `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. + - 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. From 6a59bccc5c33e165c3ba58f7826ddd3daf43a9dc Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 13 Oct 2026 23:50:28 -0700 Subject: [PATCH 35/36] Answer the two complaints the checker makes about the tests --- tests/test_core/test_event_proxy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index a7bb13769..5118905a6 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -593,7 +593,7 @@ async def test_a_proxy_is_not_a_container(): with pytest.raises(TypeError): event_object["target"] with pytest.raises(TypeError): - "type" in event_object + assert "type" in event_object with pytest.raises(TypeError): len(event_object) with pytest.raises(TypeError): @@ -1406,7 +1406,7 @@ async def test_requests_may_not_pile_up_on_a_client_which_is_not_answering(monke 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) + backend, _client = make_client(make_objects(), respond=False) event_object = make_event(backend, "evt") waiting = [ From d79634ee2c10cf6661d71ed3c105c0615d713327 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 15 Oct 2026 15:05:37 -0700 Subject: [PATCH 36/36] Drop what the server has gone longest without reading The count bound popped whatever had been dispatched first, so a handler which kept one event object open across an await could come back and find the page had dropped it while unrelated keystrokes piled up behind it. A read now moves an entry to the live end of the queue, so what goes when the registry is full is the object which has gone longest without being asked about. Which bound fired is worth saying out loud. An object which went idle unread answers "not-found" as before, while one dropped to make room answers "evicted" and raises EventProxyEvicted here - still an EventProxyNotFound, so a handler which does not care keeps working. Age takes only the objects the server never asked about, and the sweep runs after a message has been carried out rather than before it. Carrying one out is the proof an object is wanted, so sweeping first used to delete the very object a request had arrived for, and answer a handler which was still working that the page had gone away on its own. Dispatching an object which is already registered now gives back the id the server holds rather than a second copy of the same thing, and DEFAULT_MAX_DEPTH is gone: serializeValue states its own bound, and a request which does not say how deep it may go gets a reference rather than a depth somebody guessed. --- .../@reactpy/client/src/event-proxy.ts | 268 ++++++++++++++--- .../@reactpy/client/tests/event-proxy.test.ts | 275 ++++++++++++++++-- src/reactpy/_event_proxy.py | 18 ++ tests/test_core/test_event_proxy.py | 48 +++ 4 files changed, 541 insertions(+), 68 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/event-proxy.ts b/src/js/packages/@reactpy/client/src/event-proxy.ts index 58fe2ccb7..8b0b22208 100644 --- a/src/js/packages/@reactpy/client/src/event-proxy.ts +++ b/src/js/packages/@reactpy/client/src/event-proxy.ts @@ -45,18 +45,27 @@ export const EVENT_REFERENCE_KEY = "__reactpy_ref__"; * * 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 "not-found" error. The cap therefore has to + * 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 may sit unused before it is forgotten (ms) */ +/** + * 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; -/** How deep a single resolution may nest before references are used */ -export const DEFAULT_MAX_DEPTH = 10; /** * The deepest a request may ask the client to go, however it asks. * @@ -74,10 +83,20 @@ export const MAX_ALLOWED_DEPTH = 128; */ export const MAX_REQUESTS_PER_MESSAGE = 256; -/** Read the depth a request asked for as a sane one, whatever it actually said */ +/** + * 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 DEFAULT_MAX_DEPTH; + if (typeof requested !== "number" || !Number.isFinite(requested)) return 0; return Math.min(Math.max(Math.floor(requested), 0), MAX_ALLOWED_DEPTH); } @@ -115,7 +134,7 @@ export type EventProxyResponse = { ok: boolean; value?: any; error?: string | null; - code?: "not-found" | "access" | null; + code?: "not-found" | "evicted" | "access" | null; }; /** @@ -145,6 +164,14 @@ export type EventMarker = { 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 @@ -224,7 +251,15 @@ export type Reference = { }; export type SerializeOptions = { - maxDepth?: number; + /** + * 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; }; @@ -301,7 +336,7 @@ export function serializeValue( value: unknown, eventId: string, path: string[] = [], - options: SerializeOptions = {}, + options: SerializeOptions, depth = 0, seen: Set = new Set(), ): any { @@ -318,8 +353,7 @@ export function serializeValue( if (kind === "function") return reference(eventId, path, true); if (seen.has(value)) return reference(eventId, path); - if (depth >= (options.maxDepth ?? DEFAULT_MAX_DEPTH)) - 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, @@ -439,7 +473,18 @@ function parentAndKey( 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. * @@ -501,16 +546,41 @@ function stepDown( * * 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". Entries are - * still bounded in count and in age, since a page cannot hold on to events forever. + * 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; @@ -523,14 +593,44 @@ export class EventProxyRegistry { this.clock = clock; } - /** Remember ``value`` and return the id the server will address it by */ + /** + * 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(), calls: new Map() }); + 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); @@ -544,13 +644,45 @@ export class EventProxyRegistry { /** 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 unused for too long, or which no longer fit + * 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 "not-found". + * 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 @@ -560,15 +692,54 @@ export class EventProxyRegistry { prune(): void { const now = this.clock(); for (const [eventId, entry] of this.live) { - if (now - entry.added > LIVE_OBJECT_TTL_MS) this.live.delete(eventId); + 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 * @@ -582,12 +753,26 @@ export class EventProxyRegistry { handleMessage(message: EventProxyRequest | EventProxyRequestBatch): void { if (!message || message.type !== EVENT_PROXY_REQUEST_TYPE) return; - // Whatever is registered here is being kept alive for the server's benefit, and - // nobody else is coming to take it away - so the moment the server asks about them - // is the moment to notice which of them have gone stale. Waiting for the next event - // to be dispatched would leave detached objects held for their whole idle time. - this.prune(); + // 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; @@ -642,14 +827,13 @@ export class EventProxyRegistry { const eventId = typeof message.eventId === "string" ? message.eventId : ""; const entry = this.live.get(eventId); - if (!entry) - return reply(false, { - error: `nothing is registered for event ${JSON.stringify(eventId)}`, - code: "not-found", - }); + if (!entry) return reply(false, this.missing(eventId)); - // keep actively used objects around - entry.added = this.clock(); + // 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); @@ -687,7 +871,12 @@ export class EventProxyRegistry { } catch (error) { return reply(false, { error: error instanceof Error ? error.message : String(error), - code: error instanceof ProxyNotFoundError ? "not-found" : "access", + code: + error instanceof ProxyEvictedError + ? "evicted" + : error instanceof ProxyNotFoundError + ? "not-found" + : "access", }); } } @@ -731,11 +920,14 @@ export class EventProxyRegistry { const ref = readReference(argument); if (ref) { const entry = this.live.get(ref.eventId); - if (!entry) - throw new ProxyNotFoundError( - `nothing is registered for event ${JSON.stringify(ref.eventId)}`, - ); - entry.added = this.clock(); + 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)) diff --git a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts index 00354dc06..cbec65564 100644 --- a/src/js/packages/@reactpy/client/tests/event-proxy.test.ts +++ b/src/js/packages/@reactpy/client/tests/event-proxy.test.ts @@ -4,7 +4,6 @@ // 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 { - DEFAULT_MAX_DEPTH, EVENT_MARKER_KEY, EVENT_PROXY_REQUEST_TYPE, EVENT_PROXY_RESPONSE_TYPE, @@ -19,7 +18,9 @@ import { MAX_LIVE_OBJECTS, MAX_REQUESTS_PER_MESSAGE, ProxyAccessError, + ProxyEvictedError, ProxyNotFoundError, + type SerializeOptions, collectProperties, isBlockedMember, readPath, @@ -144,28 +145,36 @@ test("references round-trip", () => { }); }); +/** + * 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")).toBe("typed"); - expect(serializeValue(true, "e")).toBe(true); - expect(serializeValue(7, "e")).toBe(7); - expect(serializeValue(10n, "e")).toBe(10); - expect(serializeValue(2n ** 100n, "e")).toBe(String(2n ** 100n)); - expect(serializeValue(Symbol("tag"), "e")).toBe("Symbol(tag)"); - expect(serializeValue(null, "e")).toBe(null); - expect(serializeValue(undefined, "e")).toBe(null); + 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")).toBe(null); - expect(serializeValue(Number.POSITIVE_INFINITY, "e")).toBe(null); + 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")).toBe( - "2026-10-09T00:00:00.000Z", - ); - expect(serializeValue(/ab+c/g, "e")).toBe("/ab+c/g"); - expect(serializeValue(new TypeError("bad"), "e")).toEqual({ + 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), @@ -173,7 +182,7 @@ test("serializeValue renders well-known objects readably", () => { }); test("serializeValue turns functions into callable references", () => { - expect(serializeValue(() => 1, "e", ["preventDefault"])).toEqual({ + expect(serializeValue(() => 1, "e", ["preventDefault"], DEEP)).toEqual({ [EVENT_REFERENCE_KEY]: { eventId: "e", path: ["preventDefault"], @@ -183,8 +192,11 @@ test("serializeValue turns functions into callable references", () => { }); test("serializeValue reads iterables as lists", () => { - expect(serializeValue(new Set(["a", "b"]), "e")).toEqual(["a", "b"]); - expect(serializeValue([1, [2, 3]], "e")).toEqual([1, [2, 3]]); + 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", () => { @@ -192,7 +204,10 @@ test("serializeValue bounds a list by the same member count as an object", () => // 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", [], { maxProperties: 5 }); + const serialized = serializeValue(long, "e", [], { + ...DEEP, + maxProperties: 5, + }); expect(serialized).toHaveLength(6); expect(serialized.slice(0, 5)).toEqual([0, 1, 2, 3, 4]); @@ -208,6 +223,7 @@ test("serializeValue says when an iterable had more members than it sent", () => Array.from({ length: 9 }, (_, index) => `item-${index}`), ); const serialized = serializeValue(things, "e", ["things"], { + ...DEEP, maxProperties: 3, }); @@ -215,13 +231,16 @@ test("serializeValue says when an iterable had more members than it sent", () => 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"])).toEqual(["a", "b"]); + 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")).toEqual({ + expect(serializeValue(loop, "e", [], DEEP)).toEqual({ name: "loop", loopInto: { [EVENT_REFERENCE_KEY]: { eventId: "e", path: ["loopInto"] } }, }); @@ -230,15 +249,27 @@ test("serializeValue uses references for cycles and excess depth", () => { expect(serializeValue(deep, "e", [], { maxDepth: 1 })).toEqual({ a: { [EVENT_REFERENCE_KEY]: { eventId: "e", path: ["a"] } }, }); - expect(DEFAULT_MAX_DEPTH).toBeGreaterThan(1); - expect(serializeValue(deep, "e")).toEqual({ a: { b: { c: 1 } } }); + 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")).toEqual({ type: "click", value: 1 }); + expect(serializeValue(event, "e", [], DEEP)).toEqual({ + type: "click", + value: 1, + }); }); test("collectProperties walks the prototype chain", () => { @@ -298,6 +329,9 @@ function make_registry(now: { value: number } = { value: 0 }) { 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 & @@ -309,6 +343,9 @@ function request( 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, }; } @@ -1049,9 +1086,11 @@ test("the blocklist refuses the names which could reach the page", () => { expect(plain.value).toBe("typed"); }); -test("an object evicted by the cap is answered as not-found", () => { +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. + // 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()); @@ -1068,12 +1107,16 @@ test("an object evicted by the cap is answered as not-found", () => { type: EVENT_PROXY_RESPONSE_TYPE, requestId: "gone", ok: false, - error: `nothing is registered for event ${JSON.stringify(eventId)}`, - code: "not-found", + 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 aged out is answered as not-found", () => { +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()); @@ -1093,6 +1136,9 @@ test("an object which aged out is answered as not-found", () => { 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", () => { @@ -1132,3 +1178,172 @@ test("the cap leaves room for every event a busy page can have in flight", () => 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/reactpy/_event_proxy.py b/src/reactpy/_event_proxy.py index c7622d696..9bdf12cd1 100644 --- a/src/reactpy/_event_proxy.py +++ b/src/reactpy/_event_proxy.py @@ -57,6 +57,11 @@ async def on_click(event: Event): {"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": [...]}``. @@ -132,6 +137,17 @@ 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""" @@ -653,6 +669,8 @@ def _to_value( 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": diff --git a/tests/test_core/test_event_proxy.py b/tests/test_core/test_event_proxy.py index 5118905a6..1de97171a 100644 --- a/tests/test_core/test_event_proxy.py +++ b/tests/test_core/test_event_proxy.py @@ -25,6 +25,7 @@ EventProxyAccessError, EventProxyBackend, EventProxyError, + EventProxyEvicted, EventProxyNotFound, EventProxyTimeout, EventProxyUnavailable, @@ -792,6 +793,53 @@ async def test_missing_objects_and_paths_are_reported(): 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")