Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
117747c
Add live event proxy for accessing browser event data
Archmonger Oct 10, 2026
06bfedd
Make the live event proxy opt-in
Archmonger Oct 10, 2026
9d76593
Carry the event ack per event, and finish the proxy opt-in
Archmonger Oct 11, 2026
4719a46
Keep the linter happy where the protocol touches a model's attributes
Archmonger Oct 11, 2026
e805007
Hold a closed layout to what it no longer owes
Archmonger Oct 11, 2026
4219d27
Wait on the client's own calls in the fire-and-forget proxy tests
Archmonger Oct 11, 2026
586b29e
Make every event handler a coroutine
Archmonger Oct 11, 2026
f180c45
Hand the docs examples coroutine event handlers
Archmonger Oct 11, 2026
1ab8b00
Cover the corners the switch to coroutines left untested
Archmonger Oct 11, 2026
9c0e4ee
Tell the docs and changelog that handlers are coroutines now
Archmonger Oct 11, 2026
d25c9cc
Make the last handler in the examples a coroutine too
Archmonger Oct 11, 2026
fd8d68c
Put the docs back the way they were on main
Archmonger Oct 11, 2026
dc0b918
Let a handler turn its own event proxy off
Archmonger Oct 11, 2026
9a47a7c
Drop the global event proxy switch
Archmonger Oct 12, 2026
158ccb4
Let a merged handler ask for live access unless all refused
Archmonger Oct 12, 2026
886a18c
update changelog
Archmonger Oct 13, 2026
96c7691
Read browser events live, and only live
Archmonger Oct 14, 2026
3fec485
Cover the corners live access left behind
Archmonger Oct 14, 2026
3286b99
docs: align the changelog with the live event proxy
Archmonger Oct 14, 2026
1ad56c1
Keep what a batch of live reads already answered
Archmonger Oct 14, 2026
d536803
Count a dead live event round trip once, not once per property
Archmonger Oct 14, 2026
01ec56f
Let a client which came back read its events again
Archmonger Oct 14, 2026
bea2b51
Say how to ask what the browser actually prevented
Archmonger Oct 14, 2026
d5c82a2
Refuse an argument which is deeper than the connection can carry
Archmonger Oct 14, 2026
7308b5e
Spell the example the way the formatter wants it
Archmonger Oct 14, 2026
74bdcea
Let a reader which went away take its request with it
Archmonger Oct 14, 2026
15921a2
Say what is remembered, and let a handler ask the browser again
Archmonger Oct 14, 2026
e07346d
Bound a list the way every other collection of members is bound
Archmonger Oct 14, 2026
32a8b04
Let go of idle browser objects while the server is asking
Archmonger Oct 14, 2026
3cc449f
Hold a request to what the page is willing to take apart
Archmonger Oct 14, 2026
24461a2
Keep what a call answered, so a path through it leads somewhere
Archmonger Oct 14, 2026
48ddb07
Bring the changelog up to what live access actually does
Archmonger Oct 14, 2026
d1968c6
Do not release a shared request when a borrowing reader walks away
Archmonger Oct 14, 2026
2ec73f0
Say that a shared request belongs to the reader which asked for it
Archmonger Oct 14, 2026
6a59bcc
Answer the two complaints the checker makes about the tests
Archmonger Oct 14, 2026
d79634e
Drop what the server has gone longest without reading
Archmonger Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
7 changes: 0 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 17 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ Don't forget to remove deprecated code on each major release!
- Added type hints to `reactpy.html` attributes.
- Added support for nested components in web modules
- Added support for inline JavaScript as event handlers or other attributes that expect a callable via `reactpy.types.InlineJavaScript`
- Event functions can now call `event.preventDefault()` and `event.stopPropagation()` methods directly on the event data object, rather than using the `@event` decorator.
- Event data now supports accessing properties via dot notation (ex. `event.target.value`).
- Event functions can now call `event.preventDefault()`, `event.stopPropagation()`, and `event.stopImmediatePropagation()` methods directly on the event data object, rather than using the `@event` decorator.
- Event data now supports accessing properties via dot notation (ex. `await event.target.value`).
- Added support for partial functions in EventHandler
- Added `reactpy.types.Event` to provide type hints for the standard `data` function argument (for example `def on_click(event: Event): ...`).
- Added `reactpy.types.Event` to provide type hints for the standard `data` function argument (for example `async def on_click(event: Event): ...`).
- Added `asgi` and `jinja` installation extras (for example `pip install reactpy[asgi, jinja]`).
- Added `reactpy.executors.asgi.ReactPy` that can be used to run ReactPy in standalone mode via ASGI.
- Added `reactpy.executors.asgi.ReactPyCsr` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided.
Expand All @@ -40,6 +40,15 @@ Don't forget to remove deprecated code on each major release!
- Events now support `debounce` and `throttle`, configurable per event via `event.debounce = <milliseconds>` and `EventHandler(fn, throttle=<milliseconds>)` respectively.
- `debounce` waits until activity stops, then fires once. Default is 200 ms on `input`/`select`/`textarea`, 0 ms elsewhere.
- `throttle` caps the rate how often an event is allowed to execute. No default; opt in per event.
- Added a live event proxy which lets event handlers read properties from, and call methods on, the browser objects which triggered them (ex. `await event.target.value`, `await event.target.focus()`) rather than being limited to whatever was serializable at the moment the event was dispatched.
- Only an id and the event's `type` travel with the event. Whatever a handler reaches past is fetched from the browser on demand, one round trip at a time, and resolved against the object itself.
- Properties which are in flight at the same moment go out as a single message, and `await event.read("target.value", "key")` reads several at once. Two handlers which want the same property at the same moment share that one message, and one which walks away from an answer it only borrowed leaves the message in place for the one which asked.
- A read belongs to the moment it is awaited: it reports the current state of the browser object, not the state it held when the event was dispatched.
- Whatever a read found is remembered against the event, so reading one property twice costs one round trip - and a handler which wants what the browser holds by now asks again with `await event.read("target.value", refresh=True)`.
- Whatever comes back out of a method call can be walked too: the page keeps what the call answered for as long as it keeps the event, so a reference into a result resolves against that answer rather than against the method.
- Unanswered requests are bounded by `reactpy.config.REACTPY_EVENT_PROXY_TIMEOUT`, `reactpy.config.REACTPY_EVENT_PROXY_MAX_TIMEOUTS`, and `reactpy.config.REACTPY_EVENT_PROXY_MAX_DEPTH`.
- What that budget counts is unanswered messages rather than properties: a batch which asked for four things and got no answer at all costs one of it, not four. The same depth bounds an argument handed to a client side method as well as the data which comes back.
- A client which reconnects is a fresh chance at live access: binding a new sender brings it back if an earlier one was switched off for going quiet.

### Changed

Expand All @@ -53,7 +62,6 @@ Don't forget to remove deprecated code on each major release!
- Renamed the `use_location` hook's `pathname` attribute to `path`.
- Renamed `reactpy.config.REACTPY_DEBUG_MODE` to `reactpy.config.REACTPY_DEBUG`.
- ReactPy no longer auto-converts `snake_case` props to `camelCase`. It is now the responsibility of the user to ensure that props are in the correct format.
- Rewrite the `event-to-object` package to be more robust at handling properties on events.
- Custom JS components will now automatically assume you are using ReactJS in the absence of a `bind` function.
- Refactor layout rendering logic to improve readability and maintainability.
- The JavaScript package `@reactpy/client` now exports `React` and `ReactDOM`, which allows third-party components to re-use the same React instance as ReactPy.
Expand All @@ -65,6 +73,7 @@ Don't forget to remove deprecated code on each major release!
- `reactpy.types.VdomDictConstructor` has been renamed to `reactpy.types.VdomConstructor`.
- `REACTPY_ASYNC_RENDERING` can now de-duplicate renders where necessary.
- `REACTPY_ASYNC_RENDERING` is now defaulted to `True` for up to 40x performance improvements in environments with high concurrency.
- Event handlers must be coroutines. Whatever is handed to `EventHandler`, `@event`, or an `on_*` attribute has to be awaitable, and anything which is not is refused with a `TypeError` as it is made into a handler. This is what makes it safe for a handler to read live event data without stalling the layout, and to call `set_state()` while it waits.

### Deprecated

Expand All @@ -83,6 +92,7 @@ Don't forget to remove deprecated code on each major release!
- Removed deprecated function `module_from_template`.
- Removed deprecated exception type `reactpy.core.serve.Stop`.
- Removed deprecated component `reactpy.widgets.hotswap`.
- Removed the `event-to-object` package. The client no longer serializes an event when it dispatches one, so nothing was producing that snapshot any more (see the live event proxy above).
- Removed `reactpy.sample` module.
- Removed `reactpy.svg` module. Contents previously within `reactpy.svg.*` can now be accessed via `reactpy.html.svg.*`.
- Removed `reactpy.html._` function. Use `reactpy.html(...)` or `reactpy.html.fragment(...)` instead.
Expand All @@ -107,6 +117,9 @@ Don't forget to remove deprecated code on each major release!
- Fixed a bug where the `key` property provided within server-side ReactPy code was failing to propagate to the front-end JavaScript components.
- Fixed a bug where `RuntimeError("Hook stack is in an invalid state")` errors could be generated when using a webserver that reuses threads.
- Fixed a bug where events on controlled inputs (e.g. `html.input({"onChange": ...})`) could be lost during rapid actions.
- Fixed a bug where a render which happened while an event handler was still running could write a stale value back over a controlled input, because the event which prompted it had been acknowledged before the handler was out of the way.
- Fixed a bug where `event-proxy-response` messages were dropped by `reactpy.executors.asgi.ReactPyMiddleware` rather than being routed to the layout which asked for them, leaving every live event read to hang until it timed out.
- Fixed a bug where a component could stop re-rendering for the rest of its life after a scheduled render was cancelled, since the internal 'a render is already scheduled' latch was never released.
- Allow for ReactPy and ReactJS components to be arbitrarily inserted onto the page with any possible hierarchy.

## [1.1.0] - 2024-11-24
Expand Down
19 changes: 7 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -190,38 +190,27 @@ 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',
]
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`
Expand Down Expand Up @@ -338,6 +327,12 @@ known-third-party = ["js"]
[tool.ruff.lint.per-file-ignores]
# Tests can use magic values, assertions, and relative imports
"**/tests/**/*" = ["PLR2004", "S101", "TID252"]
"src/reactpy/_event_proxy.py" = [
# Live browser object members keep their original camelCase JavaScript names
"N815",
# These exception names describe the failure rather than ending in "Error"
"N818",
]
"docs/**/*.py" = [
# Examples require some extra setup before import
"E402",
Expand Down
36 changes: 0 additions & 36 deletions src/build_scripts/build_js_event_to_object.py

This file was deleted.

Binary file modified src/js/bun.lockb
100644 → 100755
Binary file not shown.
4 changes: 1 addition & 3 deletions src/js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"workspaces": [
"packages/event-to-object",
"packages/@reactpy/app",
"packages/@reactpy/client"
],
Expand All @@ -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",
Expand Down
1 change: 0 additions & 1 deletion src/js/packages/@reactpy/app/package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
3 changes: 1 addition & 2 deletions src/js/packages/@reactpy/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
17 changes: 17 additions & 0 deletions src/js/packages/@reactpy/client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logger from "./logger";
import { EVENT_PROXY_REQUEST_TYPE, EventProxyRegistry } from "./event-proxy";
import type {
ReactPyClientInterface,
ReactPyModule,
Expand All @@ -12,9 +13,18 @@ export abstract class BaseReactPyClient implements ReactPyClientInterface {
protected readonly ready: Promise<void>;
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 {
Expand All @@ -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) {
Expand Down
Loading
Loading