Skip to content

Commit 3f0ce61

Browse files
committed
docs(reference): fix executor and client-api accuracy
- reactpy.md: executor **settings write global config Options via process_settings/set_current, so the last constructor wins process-wide; tests_default_timeout is mutable=False and raises TypeError; debug is server-side validation + error text, not a client switch. - reactpy-middleware.md: match_extra_paths returns None -- no user-registered route row; the route registry belongs to standalone ReactPy. - reactpy-csr.md: file_paths are functools.cache'd, not re-read per request; the second package is jsonpointer==3.* and morphdom is a js_modules entry. - client-api.md / manually-register-a-client.md: the NotImplementedError keeps its %s placeholders and the path (it is raised as an unformatted tuple); the 'internal bindings' line comes from bind.tsx, not the Python bootstrap; the react probe runs only when a module lacks bind, which is optional. - examples: import_map page used componentPath import_map.counter against root_components main.counter -- the app could not mount (verified: server raised 'unregistered root component'). Now main.counter/ and it works.
1 parent 384201b commit 3f0ce61

6 files changed

Lines changed: 46 additions & 17 deletions

File tree

‎docs/examples/reference_client_api/import_map/page.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
mountReactPy({
2222
mountElement: document.getElementById("app"),
2323
pathPrefix: "/reactpy/",
24-
componentPath: "import_map.counter/",
24+
componentPath: "main.counter/",
2525
});
2626
</script>
2727
</body>

‎docs/src/learn/manually-register-a-client.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Three details are load-bearing, all verified in a real browser:
4646
- **`#!js componentPath` carries a trailing slash** and must name a component the server knows. `#!js "main.counter/"`, not `#!js "main.counter"`.
4747
- **The import must be `#!html <script type="module">`.** The bundle statically imports two sibling chunks; a classic script fails with `#!python SyntaxError: Cannot use import statement outside a module` before anything ReactPy-related runs.
4848

49-
The component itself lives in a Python module the backend can import. Under `#!python ReactPyMiddleware`, `#!python componentPath` is the dotted path you listed in `#!python root_components` plus a slash — the middleware matches `#!python ^{path_prefix}(?P<dotted_path>[a-zA-Z0-9_.]+)/$`. Under `#!python ReactPy` there is one root and `#!python componentPath` must be **omitted**: the client builds the URL as `#!js pathPrefix + componentPath` (`static/index.js`) and `ReactPy.match_dispatch_path` compares the result against `#!python dispatcher_path` exactly, so a stray `#!js "/"` produces `#!python /reactpy//`, matches nothing, and is rejected with `#!python NotImplementedError: ReactPy app received unsupported request of type 'websocket'`. See [The executors route differently](../reference/client-api.md#the-executors-route-differently).
49+
The component itself lives in a Python module the backend can import. Under `#!python ReactPyMiddleware`, `#!python componentPath` is the dotted path you listed in `#!python root_components` plus a slash — the middleware matches `#!python ^{path_prefix}(?P<dotted_path>[a-zA-Z0-9_.]+)/$`. Under `#!python ReactPy` there is one root and `#!python componentPath` must be **omitted**: the client builds the URL as `#!js pathPrefix + componentPath` (`static/index.js`) and `ReactPy.match_dispatch_path` compares the result against `#!python dispatcher_path` exactly, so a stray `#!js "/"` produces `#!python /reactpy//`, matches nothing, and the socket dies while the server logs a `NotImplementedError` — one whose `%s` placeholders were never filled in, so the line reads `("ReactPy app received unsupported request of type '%s' at path '%s'", 'websocket', '/reactpy//')`. Grep for `unsupported request of type`, not for the literal you expect. See [The executors route differently](../reference/client-api.md#the-executors-route-differently).
5050

5151
!!! warning "Nothing stops you from mounting twice into the same element"
5252

@@ -126,7 +126,17 @@ app = ReactPy(
126126

127127
## Registering a JavaScript component
128128

129-
Python describes; the browser runs. A JavaScript component is any ES module that exports the names you ask for, and the module needs one extra export — a `#!python bind` function that tells the client how to create, render, and unmount nodes in a container the client owns:
129+
Python describes; the browser runs. A JavaScript component is any ES module that exports the names you ask for. It *can* also export a `#!python bind` function that tells the client how to create, render, and unmount nodes in a container the client owns — and that is what you want, because the alternative is silent:
130+
131+
```js linenums="0"
132+
// vdom.tsx, loadImportSource
133+
let { bind } = module;
134+
if (typeof bind !== "function") {
135+
bind = await infer_bind_from_environment();
136+
}
137+
```
138+
139+
`#!js bind` is therefore **optional**. Leave it out and the client infers one — first trying real React through the import map, then falling back to its bundled preact — and logs `#!js ReactPy will render JavaScript components using internal bindings for 'react'` at `console.debug`, with no `[ReactPy]` prefix. Your module still mounts and appears to work, so a missing or misnamed `#!js bind` is easy to miss. Ship it explicitly:
130140

131141
=== "badge.js"
132142

‎docs/src/reference/client-api.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,19 @@ So the two want opposite things:
126126

127127
| | `#!js componentPath` | Unmatched dotted path |
128128
| --- | --- | --- |
129-
| `#!python ReactPyMiddleware` | **required**, with trailing slash | Falls through to your app (commonly `403`) |
130-
| `#!python ReactPy` | **must be omitted** — the bare `{pathPrefix}` is the route | `500`, from `NotImplementedError: ReactPy app received unsupported request of type 'websocket'` |
129+
| `#!python ReactPyMiddleware` | **required**, with trailing slash | Falls through to your app (with Starlette's websocket handler, a `403` handshake rejection) |
130+
| `#!python ReactPy` | **must be omitted** — the bare `{pathPrefix}` is the route | socket error; the server logs `NotImplementedError` (see below) |
131131

132132
Verified against both executors on the same build: the middleware accepts `#!js /reactpy/main.counter/` and refuses `#!js /reactpy/`; the standalone app does exactly the reverse. A mount snippet copied from one executor to the other therefore produces a socket that never opens.
133133

134+
The standalone rejection does not read like a normal exception. `executors/asgi/standalone.py`, `ReactPyApp.__call__` builds `msg` as a **tuple** of a `%`-format template and its arguments and raises `NotImplementedError(msg)` without ever formatting it, so the server logs it verbatim — the placeholders survive and the path is appended:
135+
136+
```text linenums="0"
137+
NotImplementedError: ("ReactPy app received unsupported request of type '%s' at path '%s'", 'websocket', '/reactpy//')
138+
```
139+
140+
Do not search the log for `...type 'websocket'`; search for `unsupported request of type`. The same tuple is written by `_logger.warning` immediately before the raise.
141+
134142
### The socket does not open immediately
135143

136144
Constructing the client does not connect. The websocket waits on a `#!js ready` promise, and that promise is resolved by the *first* `#!js onMessage` registration — nothing else in the codebase resolves it. The layout registers its handler from an effect, so the client connects only after that effect runs, which is why `#!js [ReactPy] Starting client...` always precedes `#!js Connected!` and never follows it.
@@ -150,7 +158,7 @@ Note that `#!js path` and `#!js qs` are query parameters, not path segments. The
150158

151159
For a server-rendered component, no import map is needed. The bundle imports its own two chunks by relative path and has preact inlined, so nothing it loads statically is a bare specifier. Verified: a page with no map at all mounted, connected, and the server never received a single request for `#!js preact.js`.
152160

153-
The map becomes load-bearing only when the model contains `#!json importSource`. Rendering such an element runs `#!js await import("react")` and `#!js await import("react-dom/client")` — *dynamic* imports, which are exactly what an import map resolves. If they succeed, real React renders the subtree; if they fail, the client falls back to its own preact bindings and says so:
161+
The map becomes load-bearing only when the model contains `#!json importSource` **and** the resolved module does not export its own `#!js bind`. Rendering such an element goes through `#!js loadImportSource`, which uses the module's `#!js bind` when it is a function and only otherwise calls `#!js infer_bind_from_environment()` — the function that runs `#!js await import("react")` and `#!js await import("react-dom/client")`. Those are *dynamic* imports, which are exactly what an import map resolves. If they succeed, real React renders the subtree; if they fail, the client falls back to its own preact bindings and says so:
154162

155163
```text linenums="0"
156164
ReactPy will render JavaScript components using internal bindings for 'react'.
@@ -172,7 +180,7 @@ The **id is the trigger, not the content**. Verified across three pages, all ser
172180
| Your own map, any other id | Yes — a second map appears, alongside yours | The same `did not detect` line, plus the browser's own `An import map rule for specifier 'react' was removed, as it conflicted with an existing rule.` |
173181
| Your own map with `id="reactpy-importmap"` | No | Nothing at all. |
174182

175-
Both of those messages come from the Python side of the bootstrap (`executors/utils.py`, `server_side_component_html`), emitted as unprefixed `#!js console.debug`, not from the client bundle.
183+
The two messages have different authors, which matters when you search a minified bundle. The `#!js did not detect` line is written by the **Python** side of the bootstrap — `executors/utils.py`, `server_side_component_html` emits it inside the inline `#!html <script>` it generates, so it is in the served HTML, not in the bundle. The `#!js internal bindings for 'react'` line comes from the **client** — `#!js infer_bind_from_environment` in `bind.tsx`, reached from `loadImportSource` in `#!js vdom.tsx`. Both are unprefixed `#!js console.debug`, so neither shows up under a `[ReactPy]` filter.
176184

177185
To control resolution yourself, use that exact id. Two maps is worse than untidy: the browser keeps the **first** rule it sees for a specifier and discards the later one, so with two maps the winner is decided by document order — which is why the warning above names a rule being *removed*.
178186

@@ -257,7 +265,7 @@ Nothing is required beyond an element and the call. Both files below were run to
257265
# TODO
258266
```
259267

260-
The component is registered with `#!python root_components`, and the `#!js componentPath` in the page — `#!python "hand_written.counter/"` — is that dotted path with a slash appended.
268+
The component is registered with `#!python root_components`, and the `#!js componentPath` in the page — `#!python "main.counter/"` — is that dotted path with a slash appended.
261269

262270
!!! warning "The 404 in the console is usually not yours"
263271

‎docs/src/reference/reactpy-csr.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ from reactpy.executors.asgi import ReactPyCsr
2424

2525
#### Parameters
2626

27-
- **`*file_paths`**: One or more paths to Python files containing components. Each is read at request time and its **source is inlined into the served HTML**. Accepts `#!python str` or `#!python pathlib.Path`.
27+
- **`*file_paths`**: One or more paths to Python files containing components. Their **source is inlined into the served HTML**. Accepts `#!python str` or `#!python pathlib.Path`.
28+
29+
Read once, then cached for the life of the process: `executors/pyscript/utils.py`, `fetch_cached_python_file` is decorated with `#!python functools.cache`, keyed on the path. Editing the file does not change the page on reload — restart the server. The same cache strips comments and docstrings before inlining (see [Jinja](./jinja.md#pyscript_component-tag)), so what reaches the browser is not quite what you wrote.
2830

2931
- **`root_name`** *(optional)*: The name of the root component inside your Python file. Defaults to `#!python "root"`. The file may define any number of components; this one is mounted.
3032

@@ -89,10 +91,10 @@ Serve with `#!python uvicorn main:my_app`. The served page carries the `#!python
8991
The page ReactPy generates does three things:
9092

9193
1. Links PyScript's CSS and module script from `{path_prefix}static/`.
92-
2. Declares the packages to install, starting with ReactPy's own wheel and `#!python morphdom`.
94+
2. Declares what PyScript should fetch: a `#!python packages` list for Python — ReactPy's own wheel from `{path_prefix}static/wheels/` and `#!python jsonpointer==3.*` — and a separate `#!python js_modules` entry for `#!python morphdom`, served from `{path_prefix}static/morphdom/morphdom-esm.js`. They are different lists with different installers; `#!python morphdom` is not a `#!python packages` entry.
9395
3. Inlines your Python, then runs it under PyScript.
9496

95-
Verified against a running server: the initial response contains the PyScript `#!python core.js`, the `#!python packages` list pointing at the ReactPy wheel, and the `#!python initial` placeholder inside a `#!python <div class="pyscript">`.
97+
Verified against a running server: the initial response carries the PyScript `#!python core.js`, `"packages": ["/reactpy/static/wheels/reactpy-2.0.0b13-py3-none-any.whl", "jsonpointer==3.*"]`, `"js_modules": {"main": {"/reactpy/static/morphdom/morphdom-esm.js": "morphdom"}}`, and the `#!python initial` placeholder inside a `#!python <div class="pyscript">`. `#!python pyscript_config` lets you replace either list.
9698

9799
!!! info "The first load is slow"
98100

‎docs/src/reference/reactpy-middleware.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,17 @@ The wrapped app is untouched. Verified with Starlette: a plain `#!python /health
5656

5757
### What the middleware actually claims
5858

59-
`ReactPyMiddleware.__call__` handles exactly four kinds of request and forwards everything else:
59+
`ReactPyMiddleware.__call__` handles exactly three kinds of request and forwards everything else:
6060

6161
| Path | Handled by |
6262
| --- | --- |
6363
| websocket matching `{path_prefix}{dotted_path}/` | ReactPy render stream |
6464
| `{path_prefix}static/...` | ReactPy client bundle |
6565
| `{path_prefix}modules/...` | built web modules |
66-
| user-registered routes | your handlers |
6766
| **anything else** | **your app** |
6867

68+
There is no fourth row. `ReactPyMiddleware.match_extra_paths` is a hook that returns `#!python None` — its own comment says custom routes are "unused by default to encourage users to handle routing within your ASGI framework of choice". The `#!python @my_app.route(...)` registry people expect belongs to the standalone `#!python ReactPy` executor, which overrides that method to match `#!python extra_http_routes` / `#!python extra_ws_routes`. Under the middleware, every route is your app's.
69+
6970
!!! warning "The middleware never serves your HTML"
7071

7172
This is the part that surprises people. Registering a dotted path does **not** create a page. Requesting `#!python "/"` falls through to your app, and there is no `{path_prefix}{dotted_path}/` HTTP route either — that pattern is matched only when the ASGI scope type is `#!python "websocket"`. Verified: requesting `#!python /reactpy/main.counter/` over HTTP returns `404`, while the websocket on the same path streams the model.

‎docs/src/reference/reactpy.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ from reactpy.executors.asgi import ReactPy
5858

5959
- **`pyscript_options`** *(optional)*: A `#!python PyScriptOptions` configuring that behaviour.
6060

61-
- **`**settings`**: Any key from `#!python ReactPyConfig`, applied to this app instance. See [Configuration](#configuration).
61+
- **`**settings`**: Any key from `#!python ReactPyConfig`. Despite appearances these are **not** scoped to this app instance — each one is written to the matching global in `#!python reactpy.config`, so the last executor constructed wins process-wide. See [Configuration](#configuration).
6262

6363
#### Returns
6464

@@ -125,13 +125,21 @@ Settings are ordinary keyword arguments, and they reach the browser-side client:
125125
| `reconnect_max_retries` | `150` | Attempts before giving up. |
126126
| `reconnect_backoff_multiplier` | `1.25` | Growth factor between attempts. |
127127
| `async_rendering` | `True` | Allow renders to overlap. |
128-
| `debug` | `False` | Extra client-side checks and messages. |
128+
| `debug` | `False` | Server-side checks and richer error text — see the note below. |
129129
| `max_queue_size` | `1000` | Bound on the pending event queue per connection. |
130-
| `tests_default_timeout` | `15.0` | Default timeout for `reactpy.testing`. |
130+
| `tests_default_timeout` | `15.0` | Default timeout for `reactpy.testing`. **Not settable this way** — see the note below. |
131131

132132
Verified: setting `#!python path_prefix="/apps/reactpy/"` moves asset serving to that prefix, and the reconnect values appear in the generated mount call.
133133

134-
Each has an environment-variable twin in `reactpy.config` — `#!python REACTPY_PATH_PREFIX`, `#!python REACTPY_RECONNECT_INTERVAL`, and so on. Passing the keyword is the per-app form; the environment variable is process-wide.
134+
!!! warning "These keywords are process-wide, not per-app"
135+
136+
`executors/utils.py`, `process_settings` walks the keywords and calls `set_current()` on the matching `#!python reactpy.config` `Option` object. Those objects are module-level globals, so the *last constructor to run wins for the whole process* — including for any app built before it. Verified: two `#!python ReactPy` instances created with `#!python reconnect_interval=111` and `#!python reconnect_interval=999` leave `#!python REACTPY_RECONNECT_INTERVAL.current` at `#!python 999` for both. There is no per-app configuration layer; do not host two apps that need different values.
137+
138+
`#!python tests_default_timeout` is worse than ineffective. Its `Option` is declared `#!python mutable=False` in `reactpy/config.py`, and `#!python _option.py`, `set_current` raises `TypeError` on an immutable option — so passing it raises `Option(REACTPY_TESTS_DEFAULT_TIMEOUT=15.0) cannot be modified after initial load` out of the constructor. Set it via the `#!bash REACTPY_TESTS_DEFAULT_TIMEOUT` environment variable before import, or not at all.
139+
140+
`#!python debug` is not a client switch. `#!python REACTPY_DEBUG` turns on server-side VDOM and JSON-attribute validation, populates the model's `#!python error` text (without it, a failed component ships `#!python "error": ""` and renders as nothing), and changes a few asset URLs to their `#!js ?dev` forms. See [Common Props](common-props.md#invalid-dictionaries).
141+
142+
Each setting also has an environment-variable twin in `#!python reactpy.config` — `#!python REACTPY_PATH_PREFIX`, `#!python REACTPY_RECONNECT_INTERVAL`, and so on. Since the keyword already writes to that same global option, the two differ only in timing: the environment variable is read when the module is first imported, the keyword whenever an executor is constructed.
135143

136144
---
137145

0 commit comments

Comments
 (0)