diff --git a/README.md b/README.md
index 3ebd3faf..75c88223 100644
--- a/README.md
+++ b/README.md
@@ -95,8 +95,8 @@ Additionally, you can pass in `args` and `kwargs` into your component function.
```jinja
-{% load reactpy %}
+{% load reactpy %}
{% component "example_project.my_app.components.hello_world" recipient="World" %}
diff --git a/docs/examples/html/pyscript_component.html b/docs/examples/html/pyscript_component.html
index 3f21e3fa..ab047c2c 100644
--- a/docs/examples/html/pyscript_component.html
+++ b/docs/examples/html/pyscript_component.html
@@ -1,5 +1,5 @@
-{% load reactpy %}
+{% load reactpy %}
diff --git a/docs/examples/html/pyscript_local_import.html b/docs/examples/html/pyscript_local_import.html
index 2d0130fb..cf275ef2 100644
--- a/docs/examples/html/pyscript_local_import.html
+++ b/docs/examples/html/pyscript_local_import.html
@@ -1,5 +1,5 @@
-{% load reactpy %}
+{% load reactpy %}
diff --git a/docs/examples/html/pyscript_multiple_files.html b/docs/examples/html/pyscript_multiple_files.html
index 1f9267a8..a343b35f 100644
--- a/docs/examples/html/pyscript_multiple_files.html
+++ b/docs/examples/html/pyscript_multiple_files.html
@@ -1,5 +1,5 @@
-{% load reactpy %}
+{% load reactpy %}
diff --git a/docs/examples/html/pyscript_setup_extra_js_object.html b/docs/examples/html/pyscript_setup_extra_js_object.html
index 815cb040..06cfd51f 100644
--- a/docs/examples/html/pyscript_setup_extra_js_object.html
+++ b/docs/examples/html/pyscript_setup_extra_js_object.html
@@ -1,5 +1,5 @@
-{% load reactpy %}
+{% load reactpy %}
diff --git a/docs/examples/html/pyscript_setup_extra_js_string.html b/docs/examples/html/pyscript_setup_extra_js_string.html
index 2d0130fb..cf275ef2 100644
--- a/docs/examples/html/pyscript_setup_extra_js_string.html
+++ b/docs/examples/html/pyscript_setup_extra_js_string.html
@@ -1,5 +1,5 @@
-{% load reactpy %}
+{% load reactpy %}
diff --git a/docs/examples/html/pyscript_ssr_parent.html b/docs/examples/html/pyscript_ssr_parent.html
index bf0f47ae..e9a24f09 100644
--- a/docs/examples/html/pyscript_ssr_parent.html
+++ b/docs/examples/html/pyscript_ssr_parent.html
@@ -1,5 +1,5 @@
-{% load reactpy %}
+{% load reactpy %}
diff --git a/docs/examples/html/pyscript_tag.html b/docs/examples/html/pyscript_tag.html
index 6ca71085..f6fde929 100644
--- a/docs/examples/html/pyscript_tag.html
+++ b/docs/examples/html/pyscript_tag.html
@@ -1,5 +1,5 @@
-{% load reactpy %}
+{% load reactpy %}
diff --git a/docs/examples/python/pyscript_component_initial_object.py b/docs/examples/python/pyscript_component_initial_object.py
index d84328a4..a5156d1b 100644
--- a/docs/examples/python/pyscript_component_initial_object.py
+++ b/docs/examples/python/pyscript_component_initial_object.py
@@ -1,6 +1,4 @@
-from reactpy import component, html
-
-from reactpy_django.components import pyscript_component
+from reactpy import component, html, pyscript_component
@component
diff --git a/docs/examples/python/pyscript_component_initial_string.py b/docs/examples/python/pyscript_component_initial_string.py
index bb8f9d17..075f44e0 100644
--- a/docs/examples/python/pyscript_component_initial_string.py
+++ b/docs/examples/python/pyscript_component_initial_string.py
@@ -1,6 +1,4 @@
-from reactpy import component, html
-
-from reactpy_django.components import pyscript_component
+from reactpy import component, html, pyscript_component
@component
diff --git a/docs/examples/python/pyscript_component_multiple_files_root.py b/docs/examples/python/pyscript_component_multiple_files_root.py
index fd826137..03bce026 100644
--- a/docs/examples/python/pyscript_component_multiple_files_root.py
+++ b/docs/examples/python/pyscript_component_multiple_files_root.py
@@ -1,6 +1,4 @@
-from reactpy import component, html
-
-from reactpy_django.components import pyscript_component
+from reactpy import component, html, pyscript_component
@component
diff --git a/docs/examples/python/pyscript_component_root.py b/docs/examples/python/pyscript_component_root.py
index 3d795247..f41d8780 100644
--- a/docs/examples/python/pyscript_component_root.py
+++ b/docs/examples/python/pyscript_component_root.py
@@ -1,6 +1,4 @@
-from reactpy import component, html
-
-from reactpy_django.components import pyscript_component
+from reactpy import component, html, pyscript_component
@component
diff --git a/docs/examples/python/pyscript_ssr_parent.py b/docs/examples/python/pyscript_ssr_parent.py
index 524cdc52..84cc9b5e 100644
--- a/docs/examples/python/pyscript_ssr_parent.py
+++ b/docs/examples/python/pyscript_ssr_parent.py
@@ -1,11 +1,11 @@
-from reactpy import component, html
-
-from reactpy_django.components import pyscript_component
+from reactpy import component, html, pyscript_component
@component
def server_side_component():
return html.div(
"This text is from my server-side component",
- pyscript_component("./example_project/my_app/components/root.py"),
+ pyscript_component(
+ "./example_project/my_app/components/root.py",
+ ),
)
diff --git a/docs/examples/python/pyscript_tag.py b/docs/examples/python/pyscript_tag.py
index a038b267..bb63e601 100644
--- a/docs/examples/python/pyscript_tag.py
+++ b/docs/examples/python/pyscript_tag.py
@@ -1,7 +1,5 @@
from reactpy import component, html
-from reactpy_django.html import pyscript
-
example_source_code = """
import js
@@ -11,6 +9,4 @@
@component
def server_side_component():
- return html.div(
- pyscript(example_source_code.strip()),
- )
+ return html.py_script(example_source_code.strip())
diff --git a/docs/examples/python/use_location.py b/docs/examples/python/use_location.py
index 454da7f6..f7190a7f 100644
--- a/docs/examples/python/use_location.py
+++ b/docs/examples/python/use_location.py
@@ -7,4 +7,4 @@
def my_component():
location = use_location()
- return html.div(location.pathname + location.search)
+ return html.div(location.path + location.query_string)
diff --git a/docs/overrides/homepage_examples/add_interactivity.py b/docs/overrides/homepage_examples/add_interactivity.py
index 9a7bf76f..d183719b 100644
--- a/docs/overrides/homepage_examples/add_interactivity.py
+++ b/docs/overrides/homepage_examples/add_interactivity.py
@@ -17,7 +17,7 @@ def searchable_video_list(videos):
search_text, set_search_text = use_state("")
found_videos = filter_videos(videos, search_text)
- return html._(
+ return html(
search_input(
{"onChange": lambda event: set_search_text(event["target"]["value"])},
value=search_text,
diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md
index 448af463..7fb681fa 100644
--- a/docs/src/reference/components.md
+++ b/docs/src/reference/components.md
@@ -41,7 +41,7 @@ This allows you to embedded any number of client-side PyScript components within
| Name | Type | Description | Default |
| --- | --- | --- | --- |
| `#!python *file_paths` | `#!python str` | File path to your client-side component. If multiple paths are provided, the contents are automatically merged. | N/A |
- | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` |
+ | `#!python initial` | `#!python str | VdomDict | Component` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` |
| `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` |
diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md
index b29990d5..e8c3d8fb 100644
--- a/docs/src/reference/hooks.md
+++ b/docs/src/reference/hooks.md
@@ -591,7 +591,7 @@ Shortcut that returns the browser's current `#!python Location`.
| Type | Description |
| --- | --- |
- | `#!python Location` | An object containing the current URL's `#!python pathname` and `#!python search` query. |
+ | `#!python Location` | An object containing the current URL's `#!python path` and `#!python query_string` query. |
---
diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md
index 146dae4c..1fbf989f 100644
--- a/docs/src/reference/template-tag.md
+++ b/docs/src/reference/template-tag.md
@@ -72,8 +72,8 @@ Each component loaded via this template tag will receive a dedicated WebSocket c
=== "my_template.html"
```jinja
- {% load reactpy %}
+ {% load reactpy %}
{% component "example_project.my_app.components.my_title" %}
@@ -191,7 +191,7 @@ Your Python component file will be directly loaded into the browser. It must hav
| Name | Type | Description | Default |
| --- | --- | --- | --- |
| `#!python *file_paths` | `#!python str` | File path to your client-side component. If multiple paths are provided, the contents are automatically merged. | N/A |
- | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` |
+ | `#!python initial` | `#!python str | VdomDict | Component` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` |
| `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` |
diff --git a/pyproject.toml b/pyproject.toml
index 64d530e7..bb023b79 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,11 +44,10 @@ classifiers = [
dependencies = [
"channels>=4.0.0",
"django>=4.2.0",
- "reactpy>=1.1.0, <2.0.0",
- "reactpy-router>=1.0.3, <2.0.0",
+ "reactpy>=2.0.0b10, <3.0.0",
+ "reactpy-router>2.0.0",
"dill>=0.3.5",
"orjson>=3.6.0",
- "nest_asyncio>=1.5.0",
"typing_extensions",
]
dynamic = ["version"]
@@ -68,6 +67,7 @@ artifacts = ["/src/reactpy_django/static/"]
[tool.hatch.metadata]
license-files = { paths = ["LICENSE.md"] }
+allow-direct-references = true
[tool.hatch.envs.default]
installer = "uv"
@@ -76,8 +76,8 @@ installer = "uv"
commands = [
"bun install --cwd src/js",
'bun build src/js/src/index.ts --outdir="src/reactpy_django/static/reactpy_django/" --minify --sourcemap=linked',
- 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"',
- 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"',
+ 'cd src/scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"',
+ 'cd src/scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"',
]
artifacts = []
@@ -96,18 +96,18 @@ extra-dependencies = [
"servestatic",
"django-bootstrap5",
"decorator",
-
+ "uvicorn[standard]",
]
matrix-name-format = "{variable}-{value}"
# Django 4.2
[[tool.hatch.envs.hatch-test.matrix]]
-python = ["3.9", "3.10", "3.11", "3.12"]
+python = ["3.11", "3.12"]
django = ["4.2"]
# Django 5.0
[[tool.hatch.envs.hatch-test.matrix]]
-python = ["3.10", "3.11", "3.12"]
+python = ["3.11", "3.12"]
django = ["5.0"]
# Django 5.1
@@ -245,6 +245,9 @@ lint.extend-ignore = [
"S403", # `dill` module is possibly insecure
"S301", # `dill` deserialization is possibly insecure unless using trusted data
"RUF029", # Function is declared async but doesn't contain await expression
+ "DOC201", # `return` is not documented in docstring
+ "DOC501", # `Exception` is not documented in docstring
+ "S308", # Django `mark_safe` may expose cross-site scripting vulnerabilities
]
lint.preview = true
lint.isort.known-first-party = ["reactpy_django", "test_app", "example"]
diff --git a/src/js/bun.lockb b/src/js/bun.lockb
index 75a86944..da65f8af 100644
Binary files a/src/js/bun.lockb and b/src/js/bun.lockb differ
diff --git a/src/js/package.json b/src/js/package.json
index 4dbb3b6f..3b9ceb1c 100644
--- a/src/js/package.json
+++ b/src/js/package.json
@@ -1,26 +1,22 @@
{
"dependencies": {
- "@pyscript/core": "^0.6",
- "@reactpy/client": "^0.3.2",
- "event-to-object": "^0.1.2",
- "morphdom": "^2.7.4",
- "preact": "^10.26.9",
- "react": "npm:@preact/compat@17.1.2",
- "react-dom": "npm:@preact/compat@17.1.2"
+ "@pyscript/core": "^0.7.17",
+ "@reactpy/client": "^1.1.0",
+ "morphdom": "^2.7.8"
},
"devDependencies": {
- "@eslint/js": "^9.29.0",
- "bun-types": "^0.5.0",
- "eslint": "^9.13.0",
- "globals": "^16.2.0",
- "prettier": "^3.3.3",
- "typescript": "^5.8.3",
- "typescript-eslint": "^8.34.0"
+ "@eslint/js": "^10.0.1",
+ "bun-types": "^1.3.9",
+ "eslint": "^10.0.0",
+ "globals": "^17.3.0",
+ "prettier": "^3.8.1",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.55.0"
},
"license": "MIT",
+ "name": "@reactpy-django/app",
"scripts": {
"check": "prettier --check . && eslint",
"format": "prettier --write . && eslint --fix"
- },
- "type": "module"
+ }
}
diff --git a/src/js/src/client.ts b/src/js/src/client.ts
index 4a5cfceb..1c637cd1 100644
--- a/src/js/src/client.ts
+++ b/src/js/src/client.ts
@@ -1,34 +1,43 @@
import {
BaseReactPyClient,
- type ReactPyClient,
+ createReconnectingWebSocket,
+ type GenericReactPyClientProps,
+ type ReactPyClientInterface,
type ReactPyModule,
+ type ReactPyUrls,
} from "@reactpy/client";
-import { createReconnectingWebSocket } from "./utils";
-import type { ReactPyDjangoClientProps, ReactPyUrls } from "./types";
export class ReactPyDjangoClient
extends BaseReactPyClient
- implements ReactPyClient
+ implements ReactPyClientInterface
{
urls: ReactPyUrls;
socket: { current?: WebSocket };
- mountElement: HTMLElement | null = null;
+ mountElement: HTMLElement;
prerenderElement: HTMLElement | null = null;
offlineElement: HTMLElement | null = null;
+ private readonly messageQueue: any[] = [];
- constructor(props: ReactPyDjangoClientProps) {
+ constructor(props: GenericReactPyClientProps) {
super();
+
this.urls = props.urls;
this.mountElement = props.mountElement;
- this.prerenderElement = props.prerenderElement;
- this.offlineElement = props.offlineElement;
+ this.prerenderElement = document.getElementById(
+ props.mountElement.id + "-prerender",
+ );
+ this.offlineElement = document.getElementById(
+ props.mountElement.id + "-offline",
+ );
+
this.socket = createReconnectingWebSocket({
url: this.urls.componentUrl,
readyPromise: this.ready,
...props.reconnectOptions,
+ // onMessage: Use standard ReactPy message routing
onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)),
+ // onClose: If offlineElement exists, show it and hide the mountElement/prerenderElement
onClose: () => {
- // If offlineElement exists, show it and hide the mountElement/prerenderElement
if (this.prerenderElement) {
this.prerenderElement.remove();
this.prerenderElement = null;
@@ -38,8 +47,8 @@ export class ReactPyDjangoClient
this.offlineElement.hidden = false;
}
},
+ // onOpen: If offlineElement exists, hide it and show the mountElement
onOpen: () => {
- // If offlineElement exists, hide it and show the mountElement
if (this.offlineElement && this.mountElement) {
this.offlineElement.hidden = true;
this.mountElement.hidden = false;
@@ -49,10 +58,17 @@ export class ReactPyDjangoClient
}
sendMessage(message: any): void {
- this.socket.current?.send(JSON.stringify(message));
+ if (
+ this.socket.current &&
+ this.socket.current.readyState === WebSocket.OPEN
+ ) {
+ this.socket.current.send(JSON.stringify(message));
+ } else {
+ this.messageQueue.push(message);
+ }
}
loadModule(moduleName: string): Promise {
- return import(`${this.urls.jsModules}/${moduleName}`);
+ return import(`${this.urls.jsModulesPath}${moduleName}`);
}
}
diff --git a/src/js/src/components.ts b/src/js/src/components.ts
index 6c265ac2..f77e41a5 100644
--- a/src/js/src/components.ts
+++ b/src/js/src/components.ts
@@ -1,76 +1,44 @@
+import { React } from "@reactpy/client";
import type { DjangoFormProps, HttpRequestProps } from "./types";
-import { useEffect } from "preact/hooks";
-import { type ComponentChildren, render, createElement } from "preact";
-/**
- * Interface used to bind a ReactPy node to React.
- */
-export function bind(node: HTMLElement | Element | Node) {
- return {
- create: (
- type: string,
- props: Record,
- children: ComponentChildren[],
- ) => createElement(type, props, ...children),
- render: (element: HTMLElement | Element | Node) => {
- render(element, node);
- },
- unmount: () => render(null, node),
- };
-}
-export function DjangoForm({
- onSubmitCallback,
- formId,
-}: DjangoFormProps): null {
- useEffect(() => {
+export class DjangoForm extends React.Component {
+ componentDidMount() {
+ const { onSubmitCallback, formId } = this.props;
const form = document.getElementById(formId) as HTMLFormElement;
- // Submission event function
const onSubmitEvent = (event: Event) => {
event.preventDefault();
const formData = new FormData(form);
- // Convert the FormData object to a plain object by iterating through it
- // If duplicate keys are present, convert the value into an array of values
- const entries = formData.entries();
- const formDataArray = Array.from(entries);
- const formDataObject = formDataArray.reduce>(
- (acc, [key, value]) => {
- if (acc[key]) {
- if (Array.isArray(acc[key])) {
- acc[key].push(value);
- } else {
- acc[key] = [acc[key], value];
- }
- } else {
- acc[key] = value;
- }
- return acc;
- },
- {},
- );
+ // Convert the FormData object to a plain object
+ const formObject = Object.fromEntries(formData.entries());
- onSubmitCallback(formDataObject);
+ onSubmitCallback(formObject);
};
- // Bind the event listener
if (form) {
form.addEventListener("submit", onSubmitEvent);
+ // Store cleanup function in instance
+ (this as any)._cleanup = () => {
+ form.removeEventListener("submit", onSubmitEvent);
+ };
}
+ }
- // Unbind the event listener when the component dismounts
- return () => {
- if (form) {
- form.removeEventListener("submit", onSubmitEvent);
- }
- };
- }, []);
+ componentWillUnmount() {
+ if ((this as any)._cleanup) {
+ (this as any)._cleanup();
+ }
+ }
- return null;
+ render() {
+ return null;
+ }
}
-export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
- useEffect(() => {
+export class HttpRequest extends React.Component {
+ componentDidMount() {
+ const { method, url, body, callback } = this.props;
fetch(url, {
method: method,
body: body,
@@ -88,7 +56,9 @@ export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
.catch(() => {
callback(520, "");
});
- }, []);
+ }
- return null;
+ render() {
+ return null;
+ }
}
diff --git a/src/js/src/index.ts b/src/js/src/index.ts
index 01856c7d..b9d59c1e 100644
--- a/src/js/src/index.ts
+++ b/src/js/src/index.ts
@@ -1,2 +1,2 @@
-export { HttpRequest, DjangoForm, bind } from "./components";
+export { HttpRequest, DjangoForm } from "./components";
export { mountComponent } from "./mount";
diff --git a/src/js/src/mount.tsx b/src/js/src/mount.tsx
index 9d2e9a3f..26a02211 100644
--- a/src/js/src/mount.tsx
+++ b/src/js/src/mount.tsx
@@ -1,6 +1,5 @@
import { ReactPyDjangoClient } from "./client";
-import { render } from "preact";
-import { Layout } from "@reactpy/client/src/components";
+import { Layout, React } from "@reactpy/client";
export function mountComponent(
mountElement: HTMLElement,
@@ -8,60 +7,45 @@ export function mountComponent(
urlPrefix: string,
componentPath: string,
resolvedJsModulesPath: string,
- reconnectStartInterval: number,
+ reconnectInterval: number,
reconnectMaxInterval: number,
reconnectMaxRetries: number,
reconnectBackoffMultiplier: number,
) {
- // Protocols
- const httpProtocol = window.location.protocol;
- const wsProtocol = `ws${httpProtocol === "https:" ? "s" : ""}:`;
-
- // WebSocket route (for Python components)
- let wsOrigin: string;
- if (host) {
- wsOrigin = `${wsProtocol}//${host}`;
- } else {
- wsOrigin = `${wsProtocol}//${window.location.host}`;
- }
-
- // HTTP route (for JavaScript modules)
- let httpOrigin: string;
- let jsModulesPath: string;
- if (host) {
- httpOrigin = `${httpProtocol}//${host}`;
- jsModulesPath = `${urlPrefix}/web_module`;
- } else {
- httpOrigin = `${httpProtocol}//${window.location.host}`;
- if (resolvedJsModulesPath) {
- jsModulesPath = resolvedJsModulesPath;
- } else {
- jsModulesPath = `${urlPrefix}/web_module`;
- }
- }
+ // WebSocket route for component rendering
+ const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`;
+ const wsOrigin = host
+ ? `${wsProtocol}//${host}`
+ : `${wsProtocol}//${window.location.host}`;
+ const componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`);
// Embed the initial HTTP path into the WebSocket URL
- const componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`);
- componentUrl.searchParams.append("http_pathname", window.location.pathname);
+ componentUrl.searchParams.append("path", window.location.pathname);
if (window.location.search) {
- componentUrl.searchParams.append("http_search", window.location.search);
+ componentUrl.searchParams.append("qs", window.location.search);
}
+ // HTTP route for JavaScript modules
+ const httpProtocol = window.location.protocol;
+ const httpOrigin: string = host
+ ? `${httpProtocol}//${host}`
+ : `${httpProtocol}//${window.location.host}`;
+ const jsModulesPath: string =
+ resolvedJsModulesPath || `${urlPrefix}/web_module/`;
+
// Configure a new ReactPy client
const client = new ReactPyDjangoClient({
urls: {
componentUrl: componentUrl,
- jsModules: `${httpOrigin}/${jsModulesPath}`,
+ jsModulesPath: `${httpOrigin}${jsModulesPath.startsWith("/") ? "" : "/"}${jsModulesPath}`,
},
reconnectOptions: {
- startInterval: reconnectStartInterval,
+ interval: reconnectInterval,
maxInterval: reconnectMaxInterval,
- backoffMultiplier: reconnectBackoffMultiplier,
maxRetries: reconnectMaxRetries,
+ backoffMultiplier: reconnectBackoffMultiplier,
},
mountElement: mountElement,
- prerenderElement: document.getElementById(mountElement.id + "-prerender"),
- offlineElement: document.getElementById(mountElement.id + "-offline"),
});
// Replace the prerender element with the real element on the first layout update
@@ -76,7 +60,7 @@ export function mountComponent(
// Start rendering the component
if (client.mountElement) {
- render(, client.mountElement);
+ React.render(, client.mountElement);
} else {
console.error(
"ReactPy mount element is undefined, cannot render the component!",
diff --git a/src/js/src/types.ts b/src/js/src/types.ts
index ff930df4..211db4c1 100644
--- a/src/js/src/types.ts
+++ b/src/js/src/types.ts
@@ -1,23 +1,3 @@
-export type ReconnectOptions = {
- startInterval: number;
- maxInterval: number;
- maxRetries: number;
- backoffMultiplier: number;
-};
-
-export type ReactPyUrls = {
- componentUrl: URL;
- jsModules: string;
-};
-
-export type ReactPyDjangoClientProps = {
- urls: ReactPyUrls;
- reconnectOptions: ReconnectOptions;
- mountElement: HTMLElement | null;
- prerenderElement: HTMLElement | null;
- offlineElement: HTMLElement | null;
-};
-
export interface DjangoFormProps {
onSubmitCallback: (data: object) => void;
formId: string;
diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts
deleted file mode 100644
index f0a31b84..00000000
--- a/src/js/src/utils.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-export function createReconnectingWebSocket(props: {
- url: URL;
- readyPromise: Promise;
- onOpen?: () => void;
- onMessage: (message: MessageEvent) => void;
- onClose?: () => void;
- startInterval: number;
- maxInterval: number;
- maxRetries: number;
- backoffMultiplier: number;
-}) {
- const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props;
- let retries = 0;
- let interval = startInterval;
- let everConnected = false;
- const closed = false;
- const socket: { current?: WebSocket } = {};
-
- const connect = () => {
- if (closed) {
- return;
- }
- socket.current = new WebSocket(props.url);
- socket.current.onopen = () => {
- everConnected = true;
- console.info("ReactPy connected!");
- interval = startInterval;
- retries = 0;
- if (props.onOpen) {
- props.onOpen();
- }
- };
- socket.current.onmessage = props.onMessage;
- socket.current.onclose = () => {
- if (props.onClose) {
- props.onClose();
- }
- if (!everConnected) {
- console.info("ReactPy failed to connect!");
- return;
- }
- console.info("ReactPy disconnected!");
- if (retries >= maxRetries) {
- console.info("ReactPy connection max retries exhausted!");
- return;
- }
- console.info(
- `ReactPy reconnecting in ${(interval / 1000).toPrecision(4)} seconds...`,
- );
- setTimeout(connect, interval);
- interval = nextInterval(interval, backoffMultiplier, maxInterval);
- retries++;
- };
- };
-
- props.readyPromise
- .then(() => console.info("Starting ReactPy client..."))
- .then(connect);
-
- return socket;
-}
-
-export function nextInterval(
- currentInterval: number,
- backoffMultiplier: number,
- maxInterval: number,
-): number {
- return Math.min(
- // increase interval by backoff multiplier
- currentInterval * backoffMultiplier,
- // don't exceed max interval
- maxInterval,
- );
-}
diff --git a/src/js/tsconfig.json b/src/js/tsconfig.json
index aa44c202..2ff015f9 100644
--- a/src/js/tsconfig.json
+++ b/src/js/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "composite": true,
"allowJs": true,
"allowSyntheticDefaultImports": true,
"declaration": true,
@@ -13,19 +14,18 @@
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
- "noEmitOnError": true,
- "noUnusedLocals": true,
"paths": {
"react": ["./node_modules/preact/compat/"],
- "react-dom": ["./node_modules/preact/compat/"],
- "react-dom/*": ["./node_modules/preact/compat/*"],
- "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"]
+ "react-dom": ["./node_modules/preact/compat/"]
},
+ "noEmit": false,
+ "noUnusedLocals": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ESNext",
"verbatimModuleSyntax": true
- }
+ },
+ "exclude": ["node_modules", "eslint.config.mjs"]
}
diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py
index c272c9a8..e13b3024 100644
--- a/src/reactpy_django/__init__.py
+++ b/src/reactpy_django/__init__.py
@@ -1,12 +1,7 @@
-import contextlib
-
-import nest_asyncio
-
from reactpy_django import (
components,
decorators,
hooks,
- html,
router,
types,
utils,
@@ -19,14 +14,7 @@
"components",
"decorators",
"hooks",
- "html",
"router",
"types",
"utils",
]
-
-# Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops.
-# Previously, Uvicorn could generate `assert f is self._write_fut` exceptions, and Daphne
-# had jittery rendering behaviors. Demonstrated using our "Renders Per Second" test page.
-with contextlib.suppress(ValueError):
- nest_asyncio.apply()
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index e0a1e065..3aec12c4 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -56,9 +56,9 @@ def setup_asgi_scope():
any relevant actions."""
scope["reactpy"]["synchronize_auth"] = synchronize_auth
- @hooks.use_effect(dependencies=[sync_needed])
+ @hooks.use_async_effect(dependencies=[sync_needed])
async def synchronize_auth_watchdog():
- """Detect if the client has taken too long to request a auth session synchronization.
+ """Detect if the client has taken too long to synchronize WebSocket->HTTP auth sessions.
This effect will automatically be cancelled if the session is successfully
synchronized (via effect dependencies)."""
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index 88001c77..d778e531 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -4,12 +4,12 @@
from uuid import uuid4
from django.contrib.staticfiles.finders import find
-from django.core.checks import Error, Tags, Warning, register
+from django.core import checks
from django.template import loader
from django.urls import NoReverseMatch
-@register(Tags.compatibility)
+@checks.register(checks.Tags.compatibility)
def reactpy_warnings(app_configs, **kwargs):
from django.conf import settings
from django.urls import reverse
@@ -26,7 +26,7 @@ def reactpy_warnings(app_configs, **kwargs):
== ":memory:"
):
warnings.append(
- Warning(
+ checks.Warning(
"Using ReactPy with an in-memory database can cause unexpected behaviors.",
hint="Configure settings.py:DATABASES[REACTPY_DATABASE], to use a "
"multiprocessing and thread safe database.",
@@ -41,7 +41,7 @@ def reactpy_warnings(app_configs, **kwargs):
reverse("reactpy:session_manager", args=[str(uuid4())])
except Exception:
warnings.append(
- Warning(
+ checks.Warning(
"ReactPy URLs have not been registered.",
hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """
"to your application's urlpatterns. If this application does not need "
@@ -55,7 +55,7 @@ def reactpy_warnings(app_configs, **kwargs):
settings, "REACTPY_BACKHAUL_THREAD", False
):
warnings.append(
- Warning(
+ checks.Warning(
"Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled and you running with Daphne.",
hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different web server.",
id="reactpy_django.W003",
@@ -65,7 +65,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if reactpy_django/index.js is available
if not find("reactpy_django/index.js"):
warnings.append(
- Warning(
+ checks.Warning(
"ReactPy index.js could not be found within Django static files!",
hint="Check all static files related Django settings and INSTALLED_APPS.",
id="reactpy_django.W004",
@@ -75,7 +75,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if any components failed to be registered
if REACTPY_FAILED_COMPONENTS:
warnings.append(
- Warning(
+ checks.Warning(
"ReactPy failed to register the following components:\n\t+ " + "\n\t+ ".join(REACTPY_FAILED_COMPONENTS),
hint="Check if these paths are valid, or if an exception is being raised during import.",
id="reactpy_django.W005",
@@ -87,7 +87,7 @@ def reactpy_warnings(app_configs, **kwargs):
loader.get_template("reactpy/component.html")
except Exception:
warnings.append(
- Warning(
+ checks.Warning(
"ReactPy HTML templates could not be found!",
hint="Check your settings.py:TEMPLATES configuration and make sure "
"ReactPy-Django is installed properly.",
@@ -105,7 +105,7 @@ def reactpy_warnings(app_configs, **kwargs):
reactpy_http_prefix = f"{full_path[: full_path.find('web_module/')].strip('/')}"
if reactpy_http_prefix != config.REACTPY_URL_PREFIX:
warnings.append(
- Warning(
+ checks.Warning(
"HTTP paths are not prefixed with REACTPY_URL_PREFIX. "
"Some ReactPy features may not work as expected.",
hint="Use one of the following solutions.\n"
@@ -123,7 +123,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if REACTPY_URL_PREFIX is empty
if not getattr(settings, "REACTPY_URL_PREFIX", "reactpy/"):
warnings.append(
- Warning(
+ checks.Warning(
"REACTPY_URL_PREFIX should not be empty!",
hint="Change your REACTPY_URL_PREFIX to be written in the following format: '/example_url/'",
id="reactpy_django.W011",
@@ -133,7 +133,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if `daphne` is not in installed apps when using `runserver`
if "runserver" in sys.argv and "daphne" not in getattr(settings, "INSTALLED_APPS", []):
warnings.append(
- Warning(
+ checks.Warning(
"You have not configured the `runserver` command to use ASGI. "
"ReactPy will work properly in this configuration.",
hint="Add daphne to settings.py:INSTALLED_APPS.",
@@ -146,7 +146,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_INTERVAL is set to a large value
if isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL > 30000:
warnings.append(
- Warning(
+ checks.Warning(
"REACTPY_RECONNECT_INTERVAL is set to >30 seconds. Are you sure this is intentional? "
"This may cause unexpected delays between reconnection.",
hint="Check your value for REACTPY_RECONNECT_INTERVAL or suppress this warning.",
@@ -157,7 +157,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_MAX_RETRIES is set to a large value
if isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) and config.REACTPY_RECONNECT_MAX_RETRIES > 5000:
warnings.append(
- Warning(
+ checks.Warning(
"REACTPY_RECONNECT_MAX_RETRIES is set to a very large value "
f"{config.REACTPY_RECONNECT_MAX_RETRIES}. Are you sure this is intentional? "
"This may leave your clients attempting reconnections for a long time.",
@@ -172,7 +172,7 @@ def reactpy_warnings(app_configs, **kwargs):
and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 100
):
warnings.append(
- Warning(
+ checks.Warning(
"REACTPY_RECONNECT_BACKOFF_MULTIPLIER is set to a very large value. Are you sure this is intentional?",
hint="Check your value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER or suppress this warning.",
id="reactpy_django.W016",
@@ -198,7 +198,7 @@ def reactpy_warnings(app_configs, **kwargs):
* config.REACTPY_RECONNECT_INTERVAL
)
warnings.append(
- Warning(
+ checks.Warning(
"Your current ReactPy configuration can never reach REACTPY_RECONNECT_MAX_INTERVAL. At most you will reach "
f"{max_value} miliseconds, which is less than {config.REACTPY_RECONNECT_MAX_INTERVAL} (REACTPY_RECONNECT_MAX_INTERVAL).",
hint="Check your ReactPy REACTPY_RECONNECT_* settings.",
@@ -213,7 +213,7 @@ def reactpy_warnings(app_configs, **kwargs):
position_to_beat = installed_apps.index(app)
if "reactpy_django" in installed_apps and installed_apps.index("reactpy_django") < position_to_beat:
warnings.append(
- Warning(
+ checks.Warning(
"The position of 'reactpy_django' in INSTALLED_APPS is suspicious.",
hint="Move 'reactpy_django' below all 'django.contrib.*' apps, or suppress this warning.",
id="reactpy_django.W018",
@@ -223,7 +223,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if user misspelled REACTPY_CLEAN_SESSIONS
if getattr(settings, "REACTPY_CLEAN_SESSION", None):
warnings.append(
- Warning(
+ checks.Warning(
"REACTPY_CLEAN_SESSION is not a valid property value.",
hint="Did you mean to use REACTPY_CLEAN_SESSIONS instead?",
id="reactpy_django.W019",
@@ -234,7 +234,7 @@ def reactpy_warnings(app_configs, **kwargs):
auth_token_timeout = config.REACTPY_AUTH_TOKEN_MAX_AGE
if isinstance(auth_token_timeout, int) and auth_token_timeout > 120:
warnings.append(
- Warning(
+ checks.Warning(
"REACTPY_AUTH_TOKEN_MAX_AGE is set to a very large value.",
hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE under 120 seconds to prevent security risks.",
id="reactpy_django.W020",
@@ -244,7 +244,7 @@ def reactpy_warnings(app_configs, **kwargs):
# Check if REACTPY_AUTH_TOKEN_MAX_AGE is a small value
if isinstance(auth_token_timeout, int) and auth_token_timeout <= 2:
warnings.append(
- Warning(
+ checks.Warning(
"REACTPY_AUTH_TOKEN_MAX_AGE is set to a very low value.",
hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE above 2 seconds to account for client and server latency.",
id="reactpy_django.W021",
@@ -254,7 +254,7 @@ def reactpy_warnings(app_configs, **kwargs):
return warnings
-@register(Tags.compatibility)
+@checks.register(checks.Tags.compatibility)
def reactpy_errors(app_configs, **kwargs):
from django.conf import settings
@@ -265,7 +265,7 @@ def reactpy_errors(app_configs, **kwargs):
# Make sure ASGI is enabled
if not getattr(settings, "ASGI_APPLICATION", None):
errors.append(
- Error(
+ checks.Error(
"ASGI_APPLICATION is not defined, but ReactPy requires ASGI.",
hint="Add ASGI_APPLICATION to settings.py.",
id="reactpy_django.E001",
@@ -277,7 +277,7 @@ def reactpy_errors(app_configs, **kwargs):
settings, "DATABASE_ROUTERS", []
):
errors.append(
- Error(
+ checks.Error(
"ReactPy database has been changed but the database router is not configured.",
hint="Set settings.py:DATABASE_ROUTERS to ['reactpy_django.database.Router', ...]",
id="reactpy_django.E002",
@@ -287,7 +287,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_URL_PREFIX is a valid data type
if not isinstance(getattr(settings, "REACTPY_URL_PREFIX", ""), str):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_URL_PREFIX.",
hint="REACTPY_URL_PREFIX should be a string.",
obj=settings.REACTPY_URL_PREFIX,
@@ -298,7 +298,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_SESSION_MAX_AGE is a valid data type
if not isinstance(getattr(settings, "REACTPY_SESSION_MAX_AGE", 0), int):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_SESSION_MAX_AGE.",
hint="REACTPY_SESSION_MAX_AGE should be an integer.",
obj=settings.REACTPY_SESSION_MAX_AGE,
@@ -309,7 +309,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_CACHE is a valid data type
if not isinstance(getattr(settings, "REACTPY_CACHE", ""), str):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_CACHE.",
hint="REACTPY_CACHE should be a string.",
obj=settings.REACTPY_CACHE,
@@ -320,7 +320,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_DATABASE is a valid data type
if not isinstance(getattr(settings, "REACTPY_DATABASE", ""), str):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_DATABASE.",
hint="REACTPY_DATABASE should be a string.",
obj=settings.REACTPY_DATABASE,
@@ -331,7 +331,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_DEFAULT_QUERY_POSTPROCESSOR is a valid data type
if not isinstance(getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None))):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.",
hint="REACTPY_DEFAULT_QUERY_POSTPROCESSOR should be a string or None.",
obj=settings.REACTPY_DEFAULT_QUERY_POSTPROCESSOR,
@@ -342,7 +342,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_AUTH_BACKEND is a valid data type
if not isinstance(getattr(settings, "REACTPY_AUTH_BACKEND", ""), str):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_AUTH_BACKEND.",
hint="REACTPY_AUTH_BACKEND should be a string.",
obj=settings.REACTPY_AUTH_BACKEND,
@@ -355,7 +355,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_DEFAULT_HOSTS is a valid data type
if not isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", []), list):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_DEFAULT_HOSTS.",
hint="REACTPY_DEFAULT_HOSTS should be a list.",
obj=settings.REACTPY_DEFAULT_HOSTS,
@@ -368,7 +368,7 @@ def reactpy_errors(app_configs, **kwargs):
for host in settings.REACTPY_DEFAULT_HOSTS:
if not isinstance(host, str):
errors.append(
- Error(
+ checks.Error(
f"Invalid type {type(host)} within REACTPY_DEFAULT_HOSTS.",
hint="REACTPY_DEFAULT_HOSTS should be a list of strings.",
obj=settings.REACTPY_DEFAULT_HOSTS,
@@ -380,7 +380,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_INTERVAL is a valid data type
if not isinstance(config.REACTPY_RECONNECT_INTERVAL, int):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_RECONNECT_INTERVAL.",
hint="REACTPY_RECONNECT_INTERVAL should be an integer.",
id="reactpy_django.E012",
@@ -390,7 +390,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_INTERVAL is a positive integer
if isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL < 0:
errors.append(
- Error(
+ checks.Error(
"Invalid value for REACTPY_RECONNECT_INTERVAL.",
hint="REACTPY_RECONNECT_INTERVAL should be a positive integer.",
id="reactpy_django.E013",
@@ -400,7 +400,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_MAX_INTERVAL is a valid data type
if not isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_RECONNECT_MAX_INTERVAL.",
hint="REACTPY_RECONNECT_MAX_INTERVAL should be an integer.",
id="reactpy_django.E014",
@@ -410,7 +410,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_MAX_INTERVAL is a positive integer
if isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) and config.REACTPY_RECONNECT_MAX_INTERVAL < 0:
errors.append(
- Error(
+ checks.Error(
"Invalid value for REACTPY_RECONNECT_MAX_INTERVAL.",
hint="REACTPY_RECONNECT_MAX_INTERVAL should be a positive integer.",
id="reactpy_django.E015",
@@ -424,7 +424,7 @@ def reactpy_errors(app_configs, **kwargs):
and config.REACTPY_RECONNECT_MAX_INTERVAL < config.REACTPY_RECONNECT_INTERVAL
):
errors.append(
- Error(
+ checks.Error(
"REACTPY_RECONNECT_MAX_INTERVAL is less than REACTPY_RECONNECT_INTERVAL.",
hint="REACTPY_RECONNECT_MAX_INTERVAL should be greater than or equal to REACTPY_RECONNECT_INTERVAL.",
id="reactpy_django.E016",
@@ -434,7 +434,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_MAX_RETRIES is a valid data type
if not isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_RECONNECT_MAX_RETRIES.",
hint="REACTPY_RECONNECT_MAX_RETRIES should be an integer.",
id="reactpy_django.E017",
@@ -444,7 +444,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_MAX_RETRIES is a positive integer
if isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) and config.REACTPY_RECONNECT_MAX_RETRIES < 0:
errors.append(
- Error(
+ checks.Error(
"Invalid value for REACTPY_RECONNECT_MAX_RETRIES.",
hint="REACTPY_RECONNECT_MAX_RETRIES should be a positive integer.",
id="reactpy_django.E018",
@@ -454,7 +454,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_RECONNECT_BACKOFF_MULTIPLIER is a valid data type
if not isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.",
hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be an integer or float.",
id="reactpy_django.E019",
@@ -467,7 +467,7 @@ def reactpy_errors(app_configs, **kwargs):
and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER < 1
):
errors.append(
- Error(
+ checks.Error(
"Invalid value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.",
hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be greater than or equal to 1.",
id="reactpy_django.E020",
@@ -477,7 +477,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_PRERENDER is a valid data type
if not isinstance(config.REACTPY_PRERENDER, bool):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_PRERENDER.",
hint="REACTPY_PRERENDER should be a boolean.",
id="reactpy_django.E021",
@@ -487,7 +487,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_AUTO_RELOGIN is a valid data type
if not isinstance(config.REACTPY_AUTO_RELOGIN, bool):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_AUTO_RELOGIN.",
hint="REACTPY_AUTO_RELOGIN should be a boolean.",
id="reactpy_django.E022",
@@ -497,7 +497,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_CLEAN_INTERVAL is a valid data type
if not isinstance(config.REACTPY_CLEAN_INTERVAL, (int, type(None))):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_CLEAN_INTERVAL.",
hint="REACTPY_CLEAN_INTERVAL should be an integer or None.",
id="reactpy_django.E023",
@@ -507,7 +507,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_CLEAN_INTERVAL is a positive integer
if isinstance(config.REACTPY_CLEAN_INTERVAL, int) and config.REACTPY_CLEAN_INTERVAL < 0:
errors.append(
- Error(
+ checks.Error(
"Invalid value for REACTPY_CLEAN_INTERVAL.",
hint="REACTPY_CLEAN_INTERVAL should be a positive integer or None.",
id="reactpy_django.E024",
@@ -517,7 +517,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_CLEAN_SESSIONS is a valid data type
if not isinstance(config.REACTPY_CLEAN_SESSIONS, bool):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_CLEAN_SESSIONS.",
hint="REACTPY_CLEAN_SESSIONS should be a boolean.",
id="reactpy_django.E025",
@@ -527,7 +527,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_CLEAN_USER_DATA is a valid data type
if not isinstance(config.REACTPY_CLEAN_USER_DATA, bool):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_CLEAN_USER_DATA.",
hint="REACTPY_CLEAN_USER_DATA should be a boolean.",
id="reactpy_django.E026",
@@ -537,7 +537,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_CLEAN_AUTH_TOKENS is a valid data type
if not isinstance(config.REACTPY_CLEAN_AUTH_TOKENS, bool):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_CLEAN_AUTH_TOKENS.",
hint="REACTPY_CLEAN_AUTH_TOKENS should be a boolean.",
id="reactpy_django.E027",
@@ -547,7 +547,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_AUTH_TOKEN_MAX_AGE is a valid data type
if not isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int):
errors.append(
- Error(
+ checks.Error(
"Invalid type for REACTPY_AUTH_TOKEN_MAX_AGE.",
hint="REACTPY_AUTH_TOKEN_MAX_AGE should be an integer.",
id="reactpy_django.E028",
@@ -557,7 +557,7 @@ def reactpy_errors(app_configs, **kwargs):
# Check if REACTPY_AUTH_TOKEN_MAX_AGE is a positive integer
if isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int) and config.REACTPY_AUTH_TOKEN_MAX_AGE < 0:
errors.append(
- Error(
+ checks.Error(
"Invalid value for REACTPY_AUTH_TOKEN_MAX_AGE.",
hint="REACTPY_AUTH_TOKEN_MAX_AGE should be a non-negative integer.",
id="reactpy_django.E029",
diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py
index 9234c42e..a25a5adf 100644
--- a/src/reactpy_django/components.py
+++ b/src/reactpy_django/components.py
@@ -9,11 +9,9 @@
from django.http import HttpRequest
from django.urls import reverse
from reactpy import component, hooks, html, utils
-from reactpy.types import ComponentType, Key, VdomDict
from reactpy_django.exceptions import ViewNotRegisteredError
from reactpy_django.forms.components import _django_form
-from reactpy_django.pyscript.components import _pyscript_component
from reactpy_django.utils import (
cached_static_file,
del_html_head_body_transform,
@@ -27,6 +25,7 @@
from django.forms import Form, ModelForm
from django.views import View
+ from reactpy.types import Component, Key, VdomDict
from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor
@@ -54,7 +53,7 @@ def constructor(
*args,
key: Key | None = None,
**kwargs,
- ) -> ComponentType:
+ ) -> Component:
return _view_to_component(
view=view,
transforms=transforms,
@@ -84,13 +83,13 @@ def constructor(
*args,
key: Key | None = None,
**kwargs,
- ) -> ComponentType:
+ ) -> Component:
return _view_to_iframe(view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key)
return constructor
-def django_css(static_path: str, key: Key | None = None) -> ComponentType:
+def django_css(static_path: str, key: Key | None = None) -> Component:
"""Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading.
Args:
@@ -103,7 +102,7 @@ def django_css(static_path: str, key: Key | None = None) -> ComponentType:
return _django_css(static_path=static_path, key=key)
-def django_js(static_path: str, key: Key | None = None) -> ComponentType:
+def django_js(static_path: str, key: Key | None = None) -> Component:
"""Fetches a JS static file for use within ReactPy. This allows for deferred JS loading.
Args:
@@ -131,7 +130,7 @@ def django_form(
top_children: Sequence[Any] = (),
bottom_children: Sequence[Any] = (),
key: Key | None = None,
-) -> ComponentType:
+) -> Component:
"""Converts a Django form to a ReactPy component.
Args:
@@ -174,29 +173,6 @@ def django_form(
)
-def pyscript_component(
- *file_paths: str,
- initial: str | VdomDict | ComponentType = "",
- root: str = "root",
-) -> ComponentType:
- """
- Args:
- file_paths: File path to your client-side component. If multiple paths are \
- provided, the contents are automatically merged.
-
- Kwargs:
- initial: The initial HTML that is displayed prior to the PyScript component \
- loads. This can either be a string containing raw HTML, a \
- `#!python reactpy.html` snippet, or a non-interactive component.
- root: The name of the root component function.
- """
- return _pyscript_component(
- *file_paths,
- initial=initial,
- root=root,
- )
-
-
@component
def _view_to_component(
view: Callable | View | str,
@@ -206,29 +182,29 @@ def _view_to_component(
args: Sequence | None,
kwargs: dict | None,
):
- converted_view, set_converted_view = hooks.use_state(cast(Union[VdomDict, None], None))
- _args: Sequence = args or ()
- _kwargs: dict = kwargs or {}
+ converted_view, set_converted_view = hooks.use_state(cast("Union[VdomDict, None]", None))
+ args_: Sequence = args or ()
+ kwargs_: dict = kwargs or {}
if request:
- _request: HttpRequest = request
+ request_: HttpRequest = request
else:
- _request = HttpRequest()
- _request.method = "GET"
+ request_ = HttpRequest()
+ request_.method = "GET"
resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore
# Render the view render within a hook
- @hooks.use_effect(
+ @hooks.use_async_effect(
dependencies=[
- json.dumps(vars(_request), default=generate_obj_name),
- json.dumps([_args, _kwargs], default=generate_obj_name),
+ json.dumps(vars(request_), default=generate_obj_name),
+ json.dumps([args_, kwargs_], default=generate_obj_name),
]
)
async def _render_view():
"""Render the view in an async hook to avoid blocking the main thread."""
# Render the view
- response = await render_view(resolved_view, _request, _args, _kwargs)
+ response = await render_view(resolved_view, request_, args_, kwargs_)
set_converted_view(
- utils.html_to_vdom(
+ utils.string_to_reactpy(
response.content.decode("utf-8").strip(),
del_html_head_body_transform,
*transforms,
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index cc3ca2fc..0b71ec17 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -7,13 +7,13 @@
from django.core.cache import DEFAULT_CACHE_ALIAS
from django.db import DEFAULT_DB_ALIAS
from reactpy.config import REACTPY_ASYNC_RENDERING as _REACTPY_ASYNC_RENDERING
-from reactpy.config import REACTPY_DEBUG_MODE as _REACTPY_DEBUG_MODE
+from reactpy.config import REACTPY_DEBUG as _REACTPY_DEBUG
from reactpy_django.utils import import_dotted_path
if TYPE_CHECKING:
from django.views import View
- from reactpy.core.types import ComponentConstructor
+ from reactpy.types import ComponentConstructor
from reactpy_django.types import (
AsyncPostprocessor,
@@ -27,7 +27,7 @@
# Configurable through Django settings.py
DJANGO_DEBUG = settings.DEBUG # Snapshot of Django's DEBUG setting
-_REACTPY_DEBUG_MODE.set_current(settings.DEBUG)
+_REACTPY_DEBUG.set_current(settings.DEBUG)
_REACTPY_ASYNC_RENDERING.set_current(getattr(settings, "REACTPY_ASYNC_RENDERING", _REACTPY_ASYNC_RENDERING.current))
REACTPY_URL_PREFIX: str = getattr(
settings,
diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py
index 6b3d220e..0454f5df 100644
--- a/src/reactpy_django/decorators.py
+++ b/src/reactpy_django/decorators.py
@@ -10,7 +10,7 @@
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
- from reactpy.core.types import ComponentConstructor
+ from reactpy.types import ComponentConstructor
def user_passes_test(
diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py
index a10d5c92..6fcf07a2 100644
--- a/src/reactpy_django/forms/components.py
+++ b/src/reactpy_django/forms/components.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Union, cast
from uuid import uuid4
@@ -7,7 +8,7 @@
from django.forms import Form, ModelForm
from reactpy import component, hooks, html, utils
from reactpy.core.events import event
-from reactpy.web import export, module_from_file
+from reactpy.reactjs import component_from_file
from reactpy_django.forms.transforms import (
convert_html_props_to_reactjs,
@@ -24,11 +25,13 @@
if TYPE_CHECKING:
from collections.abc import Sequence
- from reactpy.core.types import VdomDict
+ from reactpy.types import VdomDict
-DjangoForm = export(
- module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "reactpy_django" / "index.js"),
- ("DjangoForm"),
+_logger = getLogger(__name__)
+DjangoForm = component_from_file(
+ Path(__file__).parent.parent / "static" / "reactpy_django" / "index.js",
+ import_names=("DjangoForm"),
+ name="reactpy-django",
)
@@ -53,7 +56,7 @@ def _django_form(
top_children_count = hooks.use_ref(len(top_children))
bottom_children_count = hooks.use_ref(len(bottom_children))
submitted_data, set_submitted_data = hooks.use_state({} or None)
- rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None))
+ rendered_form, set_rendered_form = hooks.use_state(cast("Union[str, None]", None))
# Initialize the form with the provided data
validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form)
@@ -63,27 +66,35 @@ def _django_form(
)
# Validate and render the form
- @hooks.use_effect(dependencies=[str(submitted_data)])
+ @hooks.use_async_effect(dependencies=[str(submitted_data)])
async def render_form():
"""Forms must be rendered in an async loop to allow database fields to execute."""
- if submitted_data:
- await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)()
- success = not initialized_form.errors.as_data()
- if success and on_success:
- await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event)
- if not success and on_error:
- await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event)
- if success and auto_save and isinstance(initialized_form, ModelForm):
- await ensure_async(initialized_form.save)()
- set_submitted_data(None)
-
- set_rendered_form(
- await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE)
- )
+
+ _logger.debug("Rendering form with submitted data: %s", submitted_data)
+ try:
+ if submitted_data:
+ await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)()
+ success = not initialized_form.errors.as_data()
+ if success and on_success:
+ await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event)
+ if not success and on_error:
+ await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event)
+ if success and auto_save and isinstance(initialized_form, ModelForm):
+ await ensure_async(initialized_form.save)()
+ set_submitted_data(None)
+
+ set_rendered_form(
+ await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE)
+ )
+ except Exception:
+ _logger.exception("Error during form processing")
async def on_submit_callback(new_data: dict[str, Any]):
"""Callback function provided directly to the client side listener. This is responsible for transmitting
the submitted form data to the server for processing."""
+ # The client side listener passes a ReactPy Event object which needs to be
+ # converted to a standard dictionary.
+ new_data = dict(new_data)
convert_form_fields(new_data, initialized_form)
if on_receive_data:
@@ -113,7 +124,7 @@ async def _on_change(_event):
},
DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}),
*top_children,
- utils.html_to_vdom(
+ utils.string_to_reactpy(
rendered_form,
convert_html_props_to_reactjs,
convert_textarea_children_to_prop,
diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py
index 1a757b77..141edc37 100644
--- a/src/reactpy_django/forms/transforms.py
+++ b/src/reactpy_django/forms/transforms.py
@@ -7,7 +7,7 @@
from reactpy.core.events import EventHandler, to_event_handler_function
if TYPE_CHECKING:
- from reactpy.core.types import VdomDict
+ from reactpy.types import VdomDict
def convert_html_props_to_reactjs(vdom_tree: VdomDict) -> VdomDict:
@@ -81,7 +81,8 @@ def infer_key_from_attributes(vdom_tree: VdomDict) -> VdomDict:
key = attributes.get("name")
if key:
- vdom_tree["key"] = key
+ attributes["key"] = key
+ vdom_tree["attributes"] = attributes
return vdom_tree
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index de18c190..4ba11df7 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -3,7 +3,6 @@
import asyncio
import logging
from collections import defaultdict
-from collections.abc import Awaitable
from typing import (
TYPE_CHECKING,
Any,
@@ -17,7 +16,7 @@
from channels import DEFAULT_CHANNEL_LAYER
from channels import auth as channels_auth
from channels.layers import InMemoryChannelLayer, get_channel_layer
-from reactpy import use_callback, use_effect, use_memo, use_ref, use_state
+from reactpy import use_async_effect, use_callback, use_effect, use_memo, use_ref, use_state
from reactpy import use_connection as _use_connection
from reactpy import use_location as _use_location
from reactpy import use_scope as _use_scope
@@ -39,11 +38,11 @@
from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk
if TYPE_CHECKING:
- from collections.abc import Sequence
+ from collections.abc import Awaitable, Sequence
from channels_redis.core import RedisChannelLayer
from django.contrib.auth.models import AbstractUser
- from reactpy.backend.types import Location
+ from reactpy.types import Location
_logger = logging.getLogger(__name__)
@@ -124,11 +123,10 @@ def use_query(
"""
should_execute, set_should_execute = use_state(True)
- data, set_data = use_state(cast(Inferred, None))
+ data, set_data = use_state(cast("Inferred", None))
loading, set_loading = use_state(True)
- error, set_error = use_state(cast(Union[Exception, None], None))
+ error, set_error = use_state(cast("Union[Exception, None]", None))
query_ref = use_ref(query)
- async_task_refs = use_ref(set())
kwargs = kwargs or {}
postprocessor_kwargs = postprocessor_kwargs or {}
@@ -141,23 +139,22 @@ async def execute_query() -> None:
try:
# Run the query
query_async = cast(
- Callable[..., Awaitable[Inferred]], ensure_async(query, thread_sensitive=thread_sensitive)
+ "Callable[..., Awaitable[Inferred]]", ensure_async(query, thread_sensitive=thread_sensitive)
)
new_data = await query_async(**kwargs)
# Run the postprocessor
if postprocessor:
async_postprocessor = cast(
- Callable[..., Awaitable[Any]], ensure_async(postprocessor, thread_sensitive=thread_sensitive)
+ "Callable[..., Awaitable[Any]]", ensure_async(postprocessor, thread_sensitive=thread_sensitive)
)
new_data = await async_postprocessor(new_data, **postprocessor_kwargs)
# Log any errors and set the error state
except Exception as e:
- set_data(cast(Inferred, None))
set_loading(False)
set_error(e)
- _logger.exception("Failed to execute query: %s", generate_obj_name(query))
+ _logger.exception("Failed to execute query '%s'", generate_obj_name(query))
return
# Query was successful
@@ -166,20 +163,16 @@ async def execute_query() -> None:
set_loading(False)
set_error(None)
- @use_effect(dependencies=None)
- def schedule_query() -> None:
- """Schedule the query to be run"""
+ @use_async_effect(dependencies=[should_execute])
+ async def schedule_query() -> None:
+ """Execute a query when needed."""
# Make sure we don't re-execute the query unless we're told to
if not should_execute:
return
- set_should_execute(False)
-
- # Execute the query in the background
- task = asyncio.create_task(execute_query())
- # Add the task to a set to prevent it from being garbage collected
- async_task_refs.current.add(task)
- task.add_done_callback(async_task_refs.current.remove)
+ # Execute the query
+ await execute_query()
+ set_should_execute(False)
@use_callback
def refetch() -> None:
@@ -233,7 +226,7 @@ def use_mutation(
"""
loading, set_loading = use_state(False)
- error, set_error = use_state(cast(Union[Exception, None], None))
+ error, set_error = use_state(cast("Union[Exception, None]", None))
async_task_refs = use_ref(set())
# The main "running" function for `use_mutation`
@@ -261,11 +254,10 @@ async def execute_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParam
callback()
# Schedule the mutation to be run when needed
- @use_callback
def schedule_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwargs) -> None:
# Set the loading state.
- # It's okay to re-execute the mutation if we're told to. The user
- # can use the `loading` state to prevent this.
+ # It's okay to re-execute the mutation if we're told to. If desired,
+ # The user could use the `loading` state to prevent double execution.
set_loading(True)
# Execute the mutation in the background
@@ -296,7 +288,7 @@ def use_user() -> AbstractUser:
def use_user_data(
- default_data: (None | dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any]) = None,
+ default_data: (dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any] | None) = None,
save_default_data: bool = False,
) -> UserData:
"""Get or set user data stored within the REACTPY_DATABASE.
@@ -378,7 +370,7 @@ def use_channel_layer(
raise ValueError(msg)
# Add/remove a group's channel during component mount/dismount respectively.
- @use_effect(dependencies=[])
+ @use_async_effect(dependencies=[])
async def group_manager():
if group:
await channel_layer.group_add(group, channel_name)
@@ -386,7 +378,7 @@ async def group_manager():
return None
# Listen for messages on the channel using the provided `receiver` function.
- @use_effect
+ @use_async_effect
async def message_receiver():
if not receiver:
return
@@ -448,7 +440,7 @@ async def logout(rerender: bool = True) -> None:
return UseAuthTuple(login=login, logout=logout)
-async def _get_user_data(user: AbstractUser, default_data: None | dict, save_default_data: bool) -> dict | None:
+async def _get_user_data(user: AbstractUser, default_data: dict | None, save_default_data: bool) -> dict | None:
"""The mutation function for `use_user_data`"""
from reactpy_django.models import UserDataModel
diff --git a/src/reactpy_django/html.py b/src/reactpy_django/html.py
deleted file mode 100644
index d35daf43..00000000
--- a/src/reactpy_django/html.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from reactpy.core.vdom import make_vdom_constructor
-
-pyscript = make_vdom_constructor("py-script")
diff --git a/src/reactpy_django/javascript_components.py b/src/reactpy_django/javascript_components.py
index 80072211..15d12583 100644
--- a/src/reactpy_django/javascript_components.py
+++ b/src/reactpy_django/javascript_components.py
@@ -2,9 +2,10 @@
from pathlib import Path
-from reactpy import web
+from reactpy import reactjs
-HttpRequest = web.export(
- web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "index.js"),
- ("HttpRequest"),
+HttpRequest = reactjs.component_from_file(
+ Path(__file__).parent / "static" / "reactpy_django" / "index.js",
+ "HttpRequest",
+ name="reactpy-django",
)
diff --git a/src/reactpy_django/pyscript/__init__.py b/src/reactpy_django/pyscript/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py
deleted file mode 100644
index 0dfb27b7..00000000
--- a/src/reactpy_django/pyscript/component_template.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# ruff: noqa: TCH004, N802, N816, RUF006
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- import asyncio
-
- from reactpy_django.pyscript.layout_handler import ReactPyLayoutHandler
-
-
-# User component is inserted below by regex replacement
-def user_workspace_UUID():
- """Encapsulate the user's code with a completely unique function (workspace)
- to prevent overlapping imports and variable names between different components.
-
- This code is designed to be run directly by PyScript, and is not intended to be run
- in a normal Python environment.
-
- ReactPy-Django performs string substitutions to turn this file into valid PyScript.
- """
-
- def root(): ...
-
- return root()
-
-
-# Create a task to run the user's component workspace
-task_UUID = asyncio.create_task(ReactPyLayoutHandler("UUID").run(user_workspace_UUID))
diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py
deleted file mode 100644
index 255b6354..00000000
--- a/src/reactpy_django/pyscript/components.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from uuid import uuid4
-
-from reactpy import component, hooks, html
-
-from reactpy_django.html import pyscript
-from reactpy_django.pyscript.utils import render_pyscript_template
-from reactpy_django.utils import reactpy_to_string
-
-if TYPE_CHECKING:
- from reactpy.types import ComponentType, VdomDict
-
-
-@component
-def _pyscript_component(
- *file_paths: str,
- initial: str | VdomDict | ComponentType = "",
- root: str = "root",
-):
- rendered, set_rendered = hooks.use_state(False)
- uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
- initial = reactpy_to_string(initial, uuid=uuid)
- executor = render_pyscript_template(file_paths, uuid, root)
-
- if not rendered:
- # FIXME: This is needed to properly re-render PyScript during a WebSocket
- # disconnection / reconnection. There may be a better way to do this in the future.
- set_rendered(True)
- return None
-
- return html._(
- html.div(
- {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid},
- initial,
- ),
- pyscript({"async": ""}, executor),
- )
diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py
deleted file mode 100644
index 6a7a430b..00000000
--- a/src/reactpy_django/pyscript/layout_handler.py
+++ /dev/null
@@ -1,127 +0,0 @@
-# type: ignore
-import asyncio
-import logging
-
-import js
-from jsonpointer import set_pointer
-from pyodide.ffi.wrappers import add_event_listener
-from pyscript.js_modules import morphdom
-from reactpy.core.layout import Layout
-
-
-class ReactPyLayoutHandler:
- """Encapsulate the entire layout handler with a class to prevent overlapping
- variable names between user code.
-
- This code is designed to be run directly by PyScript, and is not intended to be run
- in a normal Python environment.
- """
-
- def __init__(self, uuid):
- self.uuid = uuid
- self.running_tasks = set()
-
- @staticmethod
- def update_model(update, root_model):
- """Apply an update ReactPy's internal DOM model."""
-
- if update["path"]:
- set_pointer(root_model, update["path"], update["model"])
- else:
- root_model.update(update["model"])
-
- def render_html(self, layout, model):
- """Submit ReactPy's internal DOM model into the HTML DOM."""
-
- # Create a new container to render the layout into
- container = js.document.getElementById(f"pyscript-{self.uuid}")
- temp_root_container = container.cloneNode(False)
- self.build_element_tree(layout, temp_root_container, model)
-
- # Use morphdom to update the DOM
- morphdom.default(container, temp_root_container)
-
- # Remove the cloned container to prevent memory leaks
- temp_root_container.remove()
-
- def build_element_tree(self, layout, parent, model):
- """Recursively build an element tree, starting from the root component."""
- if isinstance(model, str):
- parent.appendChild(js.document.createTextNode(model))
- elif isinstance(model, dict):
- if not model["tagName"]:
- for child in model.get("children", []):
- self.build_element_tree(layout, parent, child)
- return
- tag = model["tagName"]
- attributes = model.get("attributes", {})
- children = model.get("children", [])
- element = js.document.createElement(tag)
- for key, value in attributes.items():
- if key == "style":
- for style_key, style_value in value.items():
- setattr(element.style, style_key, style_value)
- elif key == "className":
- element.className = value
- else:
- element.setAttribute(key, value)
- for event_name, event_handler_model in model.get("eventHandlers", {}).items():
- self.create_event_handler(layout, element, event_name, event_handler_model)
- for child in children:
- self.build_element_tree(layout, element, child)
- parent.appendChild(element)
- else:
- msg = f"Unknown model type: {type(model)}"
- raise TypeError(msg)
-
- def create_event_handler(self, layout, element, event_name, event_handler_model):
- """Create an event handler for an element. This function is used as an
- adapter between ReactPy and browser events."""
- target = event_handler_model["target"]
-
- def event_handler(*args):
- task = asyncio.create_task(layout.deliver({"type": "layout-event", "target": target, "data": args}))
-
- # Add the task to a set to prevent it from being garbage collected
- self.running_tasks.add(task)
- task.add_done_callback(self.running_tasks.remove)
-
- event_name = event_name.lstrip("on_").lower().replace("_", "")
- add_event_listener(element, event_name, event_handler)
-
- @staticmethod
- def delete_old_workspaces():
- """To prevent memory leaks, we must delete all user generated Python code when
- it is no longer in use (removed from the page). To do this, we compare what
- UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global
- interpreter."""
- dom_workspaces = js.document.querySelectorAll(".pyscript")
- dom_uuids = {element.dataset.uuid for element in dom_workspaces}
- python_uuids = {value.split("_")[-1] for value in globals() if value.startswith("user_workspace_")}
-
- # Delete any workspaces that are not being used
- for uuid in python_uuids - dom_uuids:
- task_name = f"task_{uuid}"
- if task_name in globals():
- task: asyncio.Task = globals()[task_name]
- task.cancel()
- del globals()[task_name]
- else:
- logging.error("Could not auto delete PyScript task %s", task_name)
-
- workspace_name = f"user_workspace_{uuid}"
- if workspace_name in globals():
- del globals()[workspace_name]
- else:
- logging.error("Could not auto delete PyScript workspace %s", workspace_name)
-
- async def run(self, workspace_function):
- """Run the layout handler. This function is main executor for all user generated code."""
- self.delete_old_workspaces()
- root_model: dict = {}
-
- async with Layout(workspace_function()) as root_layout:
- while True:
- update = await root_layout.render()
- self.update_model(update, root_model)
- self.render_html(root_layout, root_model)
diff --git a/src/reactpy_django/pyscript/utils.py b/src/reactpy_django/pyscript/utils.py
deleted file mode 100644
index 6484a5dc..00000000
--- a/src/reactpy_django/pyscript/utils.py
+++ /dev/null
@@ -1,89 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-import textwrap
-from copy import deepcopy
-from pathlib import Path
-from typing import TYPE_CHECKING, Any
-
-import jsonpointer
-import orjson
-import reactpy
-from django.templatetags.static import static
-
-from reactpy_django.utils import create_cache_key
-
-if TYPE_CHECKING:
- from collections.abc import Sequence
-
-
-PYSCRIPT_COMPONENT_TEMPLATE = (Path(__file__).parent / "component_template.py").read_text(encoding="utf-8")
-PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "layout_handler.py").read_text(encoding="utf-8")
-PYSCRIPT_DEFAULT_CONFIG: dict[str, Any] = {}
-
-
-def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str):
- """Inserts the user's code into the PyScript template using pattern matching."""
- from django.core.cache import caches
-
- from reactpy_django.config import REACTPY_CACHE
-
- # Create a valid PyScript executor by replacing the template values
- executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid)
- executor = executor.replace("return root()", f"return {root}()")
-
- # Fetch the user's PyScript code
- all_file_contents: list[str] = []
- for file_path in file_paths:
- # Try to get user code from cache
- cache_key = create_cache_key("pyscript", file_path)
- last_modified_time = os.stat(file_path).st_mtime
- file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time))
- if file_contents:
- all_file_contents.append(file_contents)
-
- # If not cached, read from file system
- else:
- file_contents = Path(file_path).read_text(encoding="utf-8").strip()
- all_file_contents.append(file_contents)
- caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time))
-
- # Prepare the PyScript code block
- user_code = "\n".join(all_file_contents) # Combine all user code
- user_code = user_code.replace("\t", " ") # Normalize the text
- user_code = textwrap.indent(user_code, " ") # Add indentation to match template
-
- # Insert the user code into the PyScript template
- return executor.replace(" def root(): ...", user_code)
-
-
-def extend_pyscript_config(extra_py: Sequence, extra_js: dict | str, config: dict | str) -> str:
- """Extends ReactPy's default PyScript config with user provided values."""
- # Lazily set up the initial config in to wait for Django's static file system
- if not PYSCRIPT_DEFAULT_CONFIG:
- PYSCRIPT_DEFAULT_CONFIG.update({
- "packages": [
- f"reactpy=={reactpy.__version__}",
- f"jsonpointer=={jsonpointer.__version__}",
- "ssl",
- ],
- "js_modules": {"main": {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"}},
- })
-
- # Extend the Python dependency list
- pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG)
- pyscript_config["packages"].extend(extra_py)
-
- # Extend the JavaScript dependency list
- if extra_js and isinstance(extra_js, str):
- pyscript_config["js_modules"]["main"].update(json.loads(extra_js))
- elif extra_js and isinstance(extra_js, dict):
- pyscript_config["js_modules"]["main"].update(extra_py)
-
- # Update the config
- if config and isinstance(config, str):
- pyscript_config.update(json.loads(config))
- elif config and isinstance(config, dict):
- pyscript_config.update(config)
- return orjson.dumps(pyscript_config).decode("utf-8")
diff --git a/src/reactpy_django/router/resolvers.py b/src/reactpy_django/router/resolvers.py
index 30bb3f46..891d63dd 100644
--- a/src/reactpy_django/router/resolvers.py
+++ b/src/reactpy_django/router/resolvers.py
@@ -1,26 +1,10 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
-
-from reactpy_router.resolvers import StarletteResolver
+from reactpy_router.resolvers import ReactPyResolver
from reactpy_django.router.converters import CONVERTERS
-if TYPE_CHECKING:
- from reactpy_router.types import ConversionInfo, Route
-
-
-class DjangoResolver(StarletteResolver):
- """A simple route resolver that uses regex to match paths"""
- def __init__(
- self,
- route: Route,
- param_pattern=r"<(?P\w+:)?(?P\w+)>",
- converters: dict[str, ConversionInfo] | None = None,
- ) -> None:
- super().__init__(
- route=route,
- param_pattern=param_pattern,
- converters=converters or CONVERTERS,
- )
+class DjangoResolver(ReactPyResolver):
+ param_pattern = r"<(?P\w+:)?(?P\w+)>"
+ converters = CONVERTERS
diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html
index a4767040..2b0fdda2 100644
--- a/src/reactpy_django/templates/reactpy/pyscript_component.html
+++ b/src/reactpy_django/templates/reactpy/pyscript_component.html
@@ -1,2 +1,2 @@
{{pyscript_initial_html}}
-{{pyscript_executor}}
+
diff --git a/src/reactpy_django/templates/reactpy/pyscript_setup.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html
index 547a672a..85b26c81 100644
--- a/src/reactpy_django/templates/reactpy/pyscript_setup.html
+++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html
@@ -5,4 +5,4 @@
{% endif %}
-{{pyscript_layout_handler}}
+
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index f74b1ffa..f277fda1 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -5,7 +5,10 @@
from uuid import uuid4
from django import template
+from django.templatetags.static import static
from django.urls import NoReverseMatch, reverse
+from django.utils.safestring import mark_safe
+from reactpy.executors.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, pyscript_executor_html
from reactpy_django import config as reactpy_config
from reactpy_django.exceptions import (
@@ -15,8 +18,8 @@
InvalidHostError,
OfflineComponentMissingError,
)
-from reactpy_django.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, render_pyscript_template
from reactpy_django.utils import (
+ fetch_cached_python_file,
prerender_component,
reactpy_to_string,
save_component_params,
@@ -27,7 +30,7 @@
if TYPE_CHECKING:
from django.http import HttpRequest
- from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict
+ from reactpy.types import Component, ComponentConstructor, VdomDict
register = template.Library()
@@ -91,8 +94,8 @@ def component(
class_ = kwargs.pop("class", "")
has_args = bool(args or kwargs)
user_component: ComponentConstructor | None = None
- _prerender_html = ""
- _offline_html = ""
+ prerender_html = ""
+ offline_html = ""
# Validate the host
if host and DJANGO_DEBUG:
@@ -148,7 +151,7 @@ def component(
)
_logger.error(msg)
return failure_context(dotted_path, ComponentCarrierError(msg))
- _prerender_html = prerender_component(user_component, args, kwargs, uuid, request)
+ prerender_html = prerender_component(user_component, args, kwargs, uuid, request)
# Fetch the offline component's HTML, if requested
if offline:
@@ -164,7 +167,7 @@ def component(
)
_logger.error(msg)
return failure_context(dotted_path, ComponentCarrierError(msg))
- _offline_html = prerender_component(offline_component, [], {}, uuid, request)
+ offline_html = prerender_component(offline_component, [], {}, uuid, request)
# Return the template rendering context
return {
@@ -173,13 +176,13 @@ def component(
"reactpy_host": host or perceived_host,
"reactpy_url_prefix": reactpy_config.REACTPY_URL_PREFIX,
"reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/",
- "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH,
+ "reactpy_resolved_web_modules_path": f"/{RESOLVED_WEB_MODULES_PATH.strip('/')}/",
"reactpy_reconnect_interval": reactpy_config.REACTPY_RECONNECT_INTERVAL,
"reactpy_reconnect_max_interval": reactpy_config.REACTPY_RECONNECT_MAX_INTERVAL,
"reactpy_reconnect_backoff_multiplier": reactpy_config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
"reactpy_reconnect_max_retries": reactpy_config.REACTPY_RECONNECT_MAX_RETRIES,
- "reactpy_prerender_html": _prerender_html,
- "reactpy_offline_html": _offline_html,
+ "reactpy_prerender_html": mark_safe(prerender_html),
+ "reactpy_offline_html": mark_safe(offline_html),
}
@@ -187,7 +190,7 @@ def component(
def pyscript_component(
context: template.RequestContext,
*file_paths: str,
- initial: str | VdomDict | ComponentType = "",
+ initial: str | VdomDict | Component = "",
root: str = "root",
):
"""
@@ -208,12 +211,12 @@ def pyscript_component(
uuid = uuid4().hex
request: HttpRequest | None = context.get("request")
initial = reactpy_to_string(initial, request=request, uuid=uuid)
- executor = render_pyscript_template(file_paths, uuid, root)
+ executor = pyscript_executor_html(file_paths, uuid, root, fetch_cached_python_file)
return {
- "pyscript_executor": executor,
+ "pyscript_executor": mark_safe(executor),
"pyscript_uuid": uuid,
- "pyscript_initial_html": initial,
+ "pyscript_initial_html": mark_safe(initial),
}
@@ -239,8 +242,12 @@ def pyscript_setup(
from reactpy_django.config import DJANGO_DEBUG
return {
- "pyscript_config": extend_pyscript_config(extra_py, extra_js, config),
- "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER,
+ "pyscript_config": mark_safe(
+ extend_pyscript_config(
+ extra_py, extra_js, config, {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"}
+ )
+ ),
+ "pyscript_layout_handler": mark_safe(PYSCRIPT_LAYOUT_HANDLER),
"django_debug": DJANGO_DEBUG,
}
diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py
index 2703523d..e71f9c63 100644
--- a/src/reactpy_django/types.py
+++ b/src/reactpy_django/types.py
@@ -13,7 +13,7 @@
)
from django.http import HttpRequest
-from reactpy.types import ComponentType, Connection, Key
+from reactpy.types import Component, Connection, Key
from typing_extensions import ParamSpec
if TYPE_CHECKING:
@@ -104,11 +104,11 @@ async def __call__(self, message: dict) -> None: ...
class ViewToComponentConstructor(Protocol):
def __call__(
self, request: HttpRequest | None = None, *args: Any, key: Key | None = None, **kwargs: Any
- ) -> ComponentType: ...
+ ) -> Component: ...
class ViewToIframeConstructor(Protocol):
- def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ...
+ def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> Component: ...
class UseAuthLogin(Protocol):
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index 775ba0f9..7b19ee12 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -13,6 +13,7 @@
from fnmatch import fnmatch
from functools import wraps
from importlib import import_module
+from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from uuid import UUID, uuid4
@@ -26,10 +27,10 @@
from django.http import HttpRequest, HttpResponse
from django.template import engines
from django.utils.encoding import smart_str
-from reactpy import vdom_to_html
-from reactpy.backend.types import Connection, Location
+from reactpy import reactpy_to_string as _reactpy_to_string
from reactpy.core.hooks import ConnectionContext
from reactpy.core.layout import Layout
+from reactpy.types import Connection, Location
from reactpy_django.exceptions import (
ComponentDoesNotExistError,
@@ -359,7 +360,7 @@ def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
- SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__()).result()
+ SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__(exc_type, exc_val, exc_tb)).result()
self.loop.close()
def sync_render(self):
@@ -405,21 +406,21 @@ def prerender_component(
user_component(*args, **kwargs),
value=Connection(
scope=scope,
- location=Location(pathname=request.path, search=f"?{search}" if search else ""),
+ location=Location(path=request.path, query_string=f"?{search}" if search else ""),
carrier=request,
),
)
) as layout:
vdom_tree = layout.sync_render()["model"]
- return vdom_to_html(vdom_tree) # type: ignore
+ return _reactpy_to_string(vdom_tree) # type: ignore
def reactpy_to_string(vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None) -> str:
"""Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be
automatically returned."""
if isinstance(vdom_or_component, dict):
- return vdom_to_html(vdom_or_component) # type: ignore
+ return _reactpy_to_string(vdom_or_component) # type: ignore
if hasattr(vdom_or_component, "render"):
if not request:
@@ -452,7 +453,7 @@ def save_component_params(args, kwargs, uuid) -> None:
def validate_host(host: str) -> None:
"""Validates the host string to ensure it does not contain a protocol."""
if "://" in host:
- protocol = host.split("://")[0]
+ protocol = host.split("://", maxsplit=1)[0]
msg = f"Invalid host provided to component. Contains a protocol '{protocol}://'."
_logger.error(msg)
raise InvalidHostError(msg)
@@ -520,7 +521,7 @@ def cached_static_file(static_path: str) -> str:
def del_html_head_body_transform(vdom: VdomDict) -> VdomDict:
- """Transform intended for use with `html_to_vdom`.
+ """Transform intended for use with `string_to_reactpy `.
Removes ``, ``, and `` while preserving their children.
@@ -531,3 +532,22 @@ def del_html_head_body_transform(vdom: VdomDict) -> VdomDict:
if vdom["tagName"] in {"html", "body", "head"}:
return {"tagName": "", "children": vdom.setdefault("children", [])}
return vdom
+
+
+def fetch_cached_python_file(file_path: str, minify: bool = True) -> str:
+ from reactpy.executors.pyscript.utils import minify_python
+
+ from reactpy_django.config import REACTPY_CACHE
+
+ # Try to get user code from cache
+ cache_key = create_cache_key("pyscript", file_path)
+ last_modified_time = os.stat(file_path).st_mtime
+ file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time))
+ if file_contents:
+ return file_contents
+
+ file_contents = Path(file_path).read_text(encoding="utf-8").strip()
+ if minify:
+ file_contents = minify_python(file_contents)
+ caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time))
+ return file_contents
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 47ccc717..5b605f6f 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -16,10 +16,10 @@
from channels.auth import login
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.utils import timezone
-from reactpy.backend.types import Connection, Location
from reactpy.core.hooks import ConnectionContext
from reactpy.core.layout import Layout
from reactpy.core.serve import serve_layout
+from reactpy.types import Connection, Location
from reactpy_django.tasks import clean
from reactpy_django.utils import ensure_async
@@ -156,12 +156,12 @@ async def run_dispatcher(self):
has_args = scope["url_route"]["kwargs"].get("has_args")
scope["reactpy"] = {"id": str(uuid)}
query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True)
- http_pathname = query_string.get("http_pathname", [""])[0]
- http_search = query_string.get("http_search", [""])[0]
+ http_path = query_string.get("path", [""])[0]
+ http_query_string = query_string.get("qs", [""])[0]
self.recv_queue = asyncio.Queue()
connection = Connection( # For `use_connection`
scope=scope,
- location=Location(pathname=http_pathname, search=http_search),
+ location=Location(path=http_path, query_string=http_query_string),
carrier=self,
)
now = timezone.now()
@@ -212,7 +212,7 @@ async def run_dispatcher(self):
# Start the ReactPy component rendering loop
with contextlib.suppress(Exception):
await serve_layout(
- Layout( # type: ignore
+ Layout(
ConnectionContext(
auth_manager(),
root_manager(root_component),
diff --git a/src/build_scripts/copy_dir.py b/src/scripts/copy_dir.py
similarity index 76%
rename from src/build_scripts/copy_dir.py
rename to src/scripts/copy_dir.py
index 0a2cafab..bfd4c2db 100644
--- a/src/build_scripts/copy_dir.py
+++ b/src/scripts/copy_dir.py
@@ -1,9 +1,10 @@
-# ruff: noqa: INP001
import logging
import shutil
import sys
from pathlib import Path
+logger = logging.getLogger(__name__)
+
def copy_files(source: Path, destination: Path) -> None:
if destination.exists():
@@ -19,7 +20,7 @@ def copy_files(source: Path, destination: Path) -> None:
if __name__ == "__main__":
if len(sys.argv) != 3:
- logging.error("Script used incorrectly!\nUsage: python copy_dir.py ")
+ logger.error("Script used incorrectly!\nUsage: python copy_dir.py ")
sys.exit(1)
root_dir = Path(__file__).parent.parent.parent
@@ -27,7 +28,7 @@ def copy_files(source: Path, destination: Path) -> None:
dest = Path(root_dir / sys.argv[2])
if not src.exists():
- logging.error("Source directory %s does not exist", src)
+ logger.error("Source directory %s does not exist", src)
sys.exit(1)
copy_files(src, dest)
diff --git a/src/scripts/install_deps.py b/src/scripts/install_deps.py
new file mode 100644
index 00000000..310283b2
--- /dev/null
+++ b/src/scripts/install_deps.py
@@ -0,0 +1,37 @@
+"""
+Development/debug script to parse pyproject.toml to find dependecies then install them in the local
+environment via `uv pip install -U `
+"""
+
+import subprocess
+from pathlib import Path
+
+import toml
+
+DEPENDENCIES = set()
+
+
+def find_deps(data):
+ """Recurse through all categories and find any list with `dependencies` in the name, then combine
+ all dependencies into a single list"""
+ if isinstance(data, dict):
+ for key, value in data.items():
+ if "dependencies" in key and isinstance(value, list) and value and isinstance(value[0], str):
+ DEPENDENCIES.update(value)
+ else:
+ find_deps(value)
+ elif isinstance(data, list):
+ for item in data:
+ find_deps(item)
+
+
+def install_deps():
+ pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
+ pyproject_data = toml.load(pyproject_path)
+ find_deps(pyproject_data)
+ DEPENDENCIES.remove("ruff") # ruff only exists in dev dependencies for CI purposes.
+ subprocess.run(["uv", "pip", "install", "-U", *DEPENDENCIES], check=False) # noqa: S607
+
+
+if __name__ == "__main__":
+ install_deps()
diff --git a/src/scripts/run_django.py b/src/scripts/run_django.py
new file mode 100644
index 00000000..e64b401b
--- /dev/null
+++ b/src/scripts/run_django.py
@@ -0,0 +1,16 @@
+"""
+Development/debug script to run Django's development server in the local environment.
+You should run the `install_deps.py` script before this to ensure all dependencies are installed.
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+if __name__ == "__main__":
+ # Run server and pass through any additional command line arguments (e.g. for specifying a different port)
+ subprocess.run(
+ [sys.executable, "manage.py", "runserver", *sys.argv[1:]],
+ check=True,
+ cwd=Path(__file__).parent.parent.parent / "tests",
+ )
diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py
index fb3ee7d4..df44ad6c 100644
--- a/tests/test_app/__init__.py
+++ b/tests/test_app/__init__.py
@@ -8,14 +8,7 @@
assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0
assert (
subprocess.run(
- [
- "bun",
- "build",
- "./src/index.ts",
- f"--outdir={static_dir}",
- "--minify",
- "--sourcemap=linked",
- ],
+ ["bun", "build", "./src/index.ts", f"--outdir={static_dir}", "--sourcemap=linked"],
cwd=str(js_dir),
check=True,
).returncode
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index bcdf8276..d35cb89e 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -7,7 +7,7 @@
from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
from django.http import HttpRequest
-from reactpy import component, hooks, html, web
+from reactpy import component, hooks, html, reactjs
import reactpy_django
from reactpy_django.components import view_to_component, view_to_iframe
@@ -28,13 +28,13 @@
@component
def hello_world():
- return html._(html.div({"id": "hello-world"}, "Hello World!"))
+ return html(html.div({"id": "hello-world"}, "Hello World!"))
@component
def button():
count, set_count = hooks.use_state(0)
- return html._(
+ return html(
html.div(
"button:",
html.button(
@@ -49,7 +49,7 @@ def button():
@component
def parameterized_component(x, y):
total = x + y
- return html._(
+ return html(
html.div(
{"id": "parametrized-component", "data-value": total},
f"parameterized_component: {total}",
@@ -61,21 +61,17 @@ def parameterized_component(x, y):
def object_in_templatetag(my_object: TestObject):
success = bool(my_object and my_object.value)
co_name = inspect.currentframe().f_code.co_name
- return html._(html.div({"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object)))
+ return html(html.div({"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object)))
-SimpleButtonModule = web.module_from_file(
- "SimpleButton",
- Path(__file__).parent / "tests" / "js" / "button-from-js-module.js",
- resolve_exports=False,
- fallback="...",
+SimpleButton = reactjs.component_from_file(
+ Path(__file__).parent / "tests" / "js" / "button-from-js-module.js", import_names="SimpleButton", fallback="..."
)
-SimpleButton = web.export(SimpleButtonModule, "SimpleButton")
@component
def button_from_js_module():
- return html._("button_from_js_module:", SimpleButton({"id": "button-from-js-module"}))
+ return html("button_from_js_module:", SimpleButton({"id": "button-from-js-module"}))
@component
@@ -125,7 +121,7 @@ def django_css():
@component
def django_js():
success = False
- return html._(
+ return html(
html.div(
{"id": "django-js", "data-success": success},
f"django_js: {success}",
@@ -319,7 +315,7 @@ def on_change(event):
elif items.data is None:
rendered_items = html.h2("Loading...")
else:
- rendered_items = html._(
+ rendered_items = html(
html.h3("Not Done"),
_render_todo_items([i for i in items.data if not i.done], toggle_item),
html.h3("Done"),
@@ -390,7 +386,7 @@ async def on_change(event):
elif items.data is None:
rendered_items = html.h2("Loading...")
else:
- rendered_items = html._(
+ rendered_items = html(
html.h3("Not Done"),
_render_todo_items([i for i in items.data if not i.done], toggle_item),
html.h3("Done"),
@@ -503,7 +499,7 @@ def on_click(_):
post_request.method = "POST"
set_request(post_request)
- return html._(
+ return html(
html.button(
{
"id": f"{inspect.currentframe().f_code.co_name}_btn",
@@ -522,7 +518,7 @@ def view_to_component_args():
def on_click(_):
set_success("")
- return html._(
+ return html(
html.button(
{
"id": f"{inspect.currentframe().f_code.co_name}_btn",
@@ -541,7 +537,7 @@ def view_to_component_kwargs():
def on_click(_):
set_success("")
- return html._(
+ return html(
html.button(
{
"id": f"{inspect.currentframe().f_code.co_name}_btn",
@@ -696,17 +692,17 @@ async def on_submit(event):
@component
def use_auth():
- _login, _logout = reactpy_django.hooks.use_auth()
+ login_, logout_ = reactpy_django.hooks.use_auth()
uuid = hooks.use_ref(str(uuid4())).current
current_user = reactpy_django.hooks.use_user()
connection = reactpy_django.hooks.use_connection()
async def login_user(event):
new_user, _created = await get_user_model().objects.aget_or_create(username="user_4")
- await _login(new_user)
+ await login_(new_user)
async def logout_user(event):
- await _logout()
+ await logout_()
async def disconnect(event):
await connection.carrier.close()
@@ -728,17 +724,17 @@ async def disconnect(event):
@component
def use_auth_no_rerender():
- _login, _logout = reactpy_django.hooks.use_auth()
+ login_, logout_ = reactpy_django.hooks.use_auth()
uuid = hooks.use_ref(str(uuid4())).current
current_user = reactpy_django.hooks.use_user()
connection = reactpy_django.hooks.use_connection()
async def login_user(event):
new_user, _created = await get_user_model().objects.aget_or_create(username="user_5")
- await _login(new_user, rerender=False)
+ await login_(new_user, rerender=False)
async def logout_user(event):
- await _logout(rerender=False)
+ await logout_(rerender=False)
async def disconnect(event):
await connection.carrier.close()
diff --git a/tests/test_app/pyscript/components/multifile_parent.py b/tests/test_app/pyscript/components/multifile_parent.py
index c54d7719..3773e9a4 100644
--- a/tests/test_app/pyscript/components/multifile_parent.py
+++ b/tests/test_app/pyscript/components/multifile_parent.py
@@ -1,4 +1,4 @@
-# ruff: noqa: TCH004
+# ruff: noqa: TC004
from typing import TYPE_CHECKING
from reactpy import component, html
diff --git a/tests/test_app/pyscript/components/server_side.py b/tests/test_app/pyscript/components/server_side.py
index 682411d5..d5a6af4d 100644
--- a/tests/test_app/pyscript/components/server_side.py
+++ b/tests/test_app/pyscript/components/server_side.py
@@ -1,13 +1,13 @@
-from reactpy import component, html, use_state
-
-from reactpy_django.components import pyscript_component
+from reactpy import component, html, pyscript_component, use_state
@component
def parent():
return html.div(
{"id": "parent"},
- pyscript_component("./test_app/pyscript/components/child.py"),
+ pyscript_component(
+ "./test_app/pyscript/components/child.py",
+ ),
)
@@ -30,5 +30,7 @@ def parent_toggle():
{"onClick": lambda _: set_state(not state)},
"Click to show/hide",
),
- pyscript_component("./test_app/pyscript/components/child.py"),
+ pyscript_component(
+ "./test_app/pyscript/components/child.py",
+ ),
)
diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py
index ea95c5f2..81fa135e 100644
--- a/tests/test_app/router/components.py
+++ b/tests/test_app/router/components.py
@@ -1,5 +1,7 @@
-from reactpy import component, html, use_location
-from reactpy_router import route, use_params, use_search_params
+from uuid import uuid4
+
+from reactpy import component, html, use_location, use_state
+from reactpy_router import link, route, use_params, use_search_params
from reactpy_router.types import Route
from reactpy_django.router import django_router
@@ -11,14 +13,14 @@ def display_params(string: str):
search_params = use_search_params()
url_params = use_params()
- return html._(
+ return html(
html.div({"id": "router-string"}, string),
html.div(
- {"id": "router-path", "data-path": location.pathname},
- f"path: {location.pathname}",
+ {"id": "router-path", "data-path": location.path},
+ f"path: {location.path}",
),
html.div(f"url_params: {url_params}"),
- html.div(f"location.search: {location.search}"),
+ html.div(f"location.query_string: {location.query_string}"),
html.div(f"search_params: {search_params}"),
)
@@ -27,6 +29,21 @@ def show_route(path: str, *children: Route) -> Route:
return route(path, display_params(path), *children)
+@component
+def next_page():
+ url_params = use_params()
+ state, set_state = use_state(uuid4)
+ page = url_params.get("page", 0)
+ next_page = page + 1
+ return html.fragment(
+ display_params("/router/next//"),
+ html.div({"id": "router-uuid", "data-uuid": state.hex}, f"UUID: {state.hex}"),
+ html.button(
+ link({"to": f"/router/next/{next_page}/"}, "Next Page"),
+ ),
+ )
+
+
@component
def main():
return django_router(
@@ -39,4 +56,5 @@ def main():
show_route("/router/uuid//"),
show_route("/router/any/"),
show_route("/router/two///"),
+ route("/router/next//", next_page()),
)
diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py
index 514ca1be..162a585a 100644
--- a/tests/test_app/settings_multi_db.py
+++ b/tests/test_app/settings_multi_db.py
@@ -132,12 +132,7 @@
"version": 1,
"disable_existing_loggers": False,
"handlers": {
- "console": {"class": "logging.StreamHandler"},
- },
- "loggers": {
- "reactpy_django": {"handlers": ["console"], "level": LOG_LEVEL},
- "reactpy": {"handlers": ["console"], "level": LOG_LEVEL},
- "django.request": {"handlers": ["console"], "level": LOG_LEVEL},
+ "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL},
},
}
diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py
index 2f7f3e00..7a87b060 100644
--- a/tests/test_app/settings_single_db.py
+++ b/tests/test_app/settings_single_db.py
@@ -118,12 +118,7 @@
"version": 1,
"disable_existing_loggers": False,
"handlers": {
- "console": {"class": "logging.StreamHandler"},
- },
- "loggers": {
- "reactpy_django": {"handlers": ["console"], "level": LOG_LEVEL},
- "reactpy": {"handlers": ["console"], "level": LOG_LEVEL},
- "django.request": {"handlers": ["console"], "level": LOG_LEVEL},
+ "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL},
},
}
diff --git a/tests/test_app/templates/async_event_form.html b/tests/test_app/templates/async_event_form.html
index af77e5c1..6a07c916 100644
--- a/tests/test_app/templates/async_event_form.html
+++ b/tests/test_app/templates/async_event_form.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index aaef6cb7..c2c9dd45 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/bootstrap_form.html b/tests/test_app/templates/bootstrap_form.html
index 0ef218db..f985bbb7 100644
--- a/tests/test_app/templates/bootstrap_form.html
+++ b/tests/test_app/templates/bootstrap_form.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %} {% load django_bootstrap5 %}
+{% load static %} {% load reactpy %} {% load django_bootstrap5 %}
diff --git a/tests/test_app/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html
index 26361861..8720668e 100644
--- a/tests/test_app/templates/channel_layers.html
+++ b/tests/test_app/templates/channel_layers.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/errors.html b/tests/test_app/templates/errors.html
index 0d4ab161..67590666 100644
--- a/tests/test_app/templates/errors.html
+++ b/tests/test_app/templates/errors.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/event_timing.html b/tests/test_app/templates/event_timing.html
index 4f75dafb..f0dc5c3c 100644
--- a/tests/test_app/templates/event_timing.html
+++ b/tests/test_app/templates/event_timing.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
@@ -74,7 +74,7 @@ ReactPy Event Timing Test Page
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
- calculateEventTiming();
+ calculateEventTiming();
diff --git a/tests/test_app/templates/events_renders_per_second.html b/tests/test_app/templates/events_renders_per_second.html
index cb1ed5c8..715340d8 100644
--- a/tests/test_app/templates/events_renders_per_second.html
+++ b/tests/test_app/templates/events_renders_per_second.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/form.html b/tests/test_app/templates/form.html
index ecffc1ac..e6794521 100644
--- a/tests/test_app/templates/form.html
+++ b/tests/test_app/templates/form.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html
index 1eb2be2a..17273987 100644
--- a/tests/test_app/templates/host_port.html
+++ b/tests/test_app/templates/host_port.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/host_port_roundrobin.html b/tests/test_app/templates/host_port_roundrobin.html
index ad2dada0..407a16ae 100644
--- a/tests/test_app/templates/host_port_roundrobin.html
+++ b/tests/test_app/templates/host_port_roundrobin.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/mixed_time_to_load.html b/tests/test_app/templates/mixed_time_to_load.html
index 3b23050c..5fa03438 100644
--- a/tests/test_app/templates/mixed_time_to_load.html
+++ b/tests/test_app/templates/mixed_time_to_load.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/model_form.html b/tests/test_app/templates/model_form.html
index 3c28eb07..ca169a9f 100644
--- a/tests/test_app/templates/model_form.html
+++ b/tests/test_app/templates/model_form.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/net_io_time_to_load.html b/tests/test_app/templates/net_io_time_to_load.html
index c5d79050..45e86dce 100644
--- a/tests/test_app/templates/net_io_time_to_load.html
+++ b/tests/test_app/templates/net_io_time_to_load.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
@@ -37,7 +37,7 @@ ReactPy Network IO Intensive Time To Load Per Second Test Page
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
- calculateTTL();
+ calculateTTL();
diff --git a/tests/test_app/templates/offline.html b/tests/test_app/templates/offline.html
index e7c39106..34e6d903 100644
--- a/tests/test_app/templates/offline.html
+++ b/tests/test_app/templates/offline.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/prerender.html b/tests/test_app/templates/prerender.html
index dab4ba01..6c59042e 100644
--- a/tests/test_app/templates/prerender.html
+++ b/tests/test_app/templates/prerender.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html
index 57a5dd15..1f77f290 100644
--- a/tests/test_app/templates/pyscript.html
+++ b/tests/test_app/templates/pyscript.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/renders_per_second.html b/tests/test_app/templates/renders_per_second.html
index 2ec2f868..9253f543 100644
--- a/tests/test_app/templates/renders_per_second.html
+++ b/tests/test_app/templates/renders_per_second.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/router.html b/tests/test_app/templates/router.html
index ee15fb64..292da720 100644
--- a/tests/test_app/templates/router.html
+++ b/tests/test_app/templates/router.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/sync_event_form.html b/tests/test_app/templates/sync_event_form.html
index c22955d1..5b9d8c00 100644
--- a/tests/test_app/templates/sync_event_form.html
+++ b/tests/test_app/templates/sync_event_form.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
diff --git a/tests/test_app/templates/time_to_load.html b/tests/test_app/templates/time_to_load.html
index 4e30f3eb..411ba2df 100644
--- a/tests/test_app/templates/time_to_load.html
+++ b/tests/test_app/templates/time_to_load.html
@@ -1,5 +1,5 @@
-{% load static %} {% load reactpy %}
+{% load static %} {% load reactpy %}
@@ -37,7 +37,7 @@ ReactPy Time To Load Test Page
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
- calculateTTL();
+ calculateTTL();
diff --git a/tests/test_app/templates/view_to_component_script.html b/tests/test_app/templates/view_to_component_script.html
index c9c5d263..b191179a 100644
--- a/tests/test_app/templates/view_to_component_script.html
+++ b/tests/test_app/templates/view_to_component_script.html
@@ -1,16 +1,11 @@
-{% extends "view_to_component.html" %}
-
-{% block top %}
+{% extends "view_to_component.html" %} {% block top %}
-{% endblock %}
-
-{% block bottom %}
+{% endblock %} {% block bottom %}
{% endblock %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py
index 9f4fc495..db8875cf 100644
--- a/tests/test_app/tests/test_components.py
+++ b/tests/test_app/tests/test_components.py
@@ -1,18 +1,17 @@
# type: ignore
-# ruff: noqa: RUF012, N802
+# ruff: noqa: RUF012
import os
import socket
from uuid import uuid4
import pytest
-from playwright.sync_api import TimeoutError, expect
+from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
+from playwright.sync_api import expect
+from reactpy.testing import DEFAULT_TYPE_DELAY as DELAY
from reactpy_django.models import ComponentSession
-from reactpy_django.utils import str_to_bool
-from .utils import GITHUB_ACTIONS, PlaywrightTestCase, navigate_to_page
-
-CLICK_DELAY = 250 if str_to_bool(GITHUB_ACTIONS) else 25 # Delay in miliseconds.
+from .utils import PlaywrightTestCase, navigate_to_page
class ComponentTests(PlaywrightTestCase):
@@ -30,7 +29,7 @@ def test_component_hello_world(self):
def test_component_counter(self):
for i in range(5):
self.page.locator(f"#counter-num[data-count={i}]")
- self.page.locator("#counter-inc").click(delay=CLICK_DELAY)
+ self.page.locator("#counter-inc").click(delay=DELAY)
@navigate_to_page("/")
def test_component_parametrized_component(self):
@@ -75,13 +74,13 @@ def test_component_static_js(self):
@navigate_to_page("/")
def test_component_unauthorized_user(self):
- with pytest.raises(TimeoutError):
+ with pytest.raises(PlaywrightTimeoutError):
self.page.wait_for_selector("#unauthorized-user", timeout=1)
self.page.wait_for_selector("#unauthorized-user-fallback")
@navigate_to_page("/")
def test_component_authorized_user(self):
- with pytest.raises(TimeoutError):
+ with pytest.raises(PlaywrightTimeoutError):
self.page.wait_for_selector("#authorized-user-fallback", timeout=1)
self.page.wait_for_selector("#authorized-user")
@@ -102,11 +101,11 @@ def test_component_use_query_and_mutation(self):
item_ids = list(range(5))
for i in item_ids:
- todo_input.type(f"sample-{i}", delay=CLICK_DELAY)
- todo_input.press("Enter", delay=CLICK_DELAY)
+ todo_input.type(f"sample-{i}", delay=DELAY)
+ todo_input.press("Enter", delay=DELAY)
self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}")
- self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY)
- with pytest.raises(TimeoutError):
+ self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=DELAY)
+ with pytest.raises(PlaywrightTimeoutError):
self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1)
@navigate_to_page("/")
@@ -116,11 +115,11 @@ def test_component_async_use_query_and_mutation(self):
item_ids = list(range(5))
for i in item_ids:
- todo_input.type(f"sample-{i}", delay=CLICK_DELAY)
- todo_input.press("Enter", delay=CLICK_DELAY)
+ todo_input.type(f"sample-{i}", delay=DELAY)
+ todo_input.press("Enter", delay=DELAY)
self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}")
- self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY)
- with pytest.raises(TimeoutError):
+ self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=DELAY)
+ with pytest.raises(PlaywrightTimeoutError):
self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1)
@navigate_to_page("/")
@@ -146,7 +145,7 @@ def test_component_view_to_component_template_view_class(self):
@navigate_to_page("/")
def _click_btn_and_check_success(self, name):
self.page.locator(f"#{name}:not([data-success=true])").wait_for()
- self.page.wait_for_selector(f"#{name}_btn").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector(f"#{name}_btn").click(delay=DELAY)
self.page.locator(f"#{name}[data-success=true]").wait_for()
@navigate_to_page("/")
@@ -242,42 +241,42 @@ def test_component_use_user_data(self):
assert "Data: None" in user_data_div.text_content()
# Test first user's data
- login_1.click(delay=CLICK_DELAY)
+ login_1.click(delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]"
)
assert "Data: {}" in user_data_div.text_content()
- text_input.type("test", delay=CLICK_DELAY)
- text_input.press("Enter", delay=CLICK_DELAY)
+ text_input.type("test", delay=DELAY)
+ text_input.press("Enter", delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]"
)
assert "Data: {'test': 'test'}" in user_data_div.text_content()
# Test second user's data
- login_2.click(delay=CLICK_DELAY)
+ login_2.click(delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]"
)
assert "Data: {}" in user_data_div.text_content()
- text_input.press("Control+A", delay=CLICK_DELAY)
- text_input.press("Backspace", delay=CLICK_DELAY)
- text_input.type("test 2", delay=CLICK_DELAY)
- text_input.press("Enter", delay=CLICK_DELAY)
+ text_input.press("Control+A", delay=DELAY)
+ text_input.press("Backspace", delay=DELAY)
+ text_input.type("test 2", delay=DELAY)
+ text_input.press("Enter", delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]"
)
assert "Data: {'test 2': 'test 2'}" in user_data_div.text_content()
# Attempt to clear data
- clear.click(delay=CLICK_DELAY)
+ clear.click(delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]"
)
assert "Data: {}" in user_data_div.text_content()
# Attempt to logout
- logout.click(delay=CLICK_DELAY)
+ logout.click(delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]"
)
@@ -296,13 +295,13 @@ def test_component_use_user_data_with_default(self):
assert "Data: None" in user_data_div.text_content()
# Test first user's data
- login_3.click(delay=CLICK_DELAY)
+ login_3.click(delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]"
)
assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content()
- text_input.type("test", delay=CLICK_DELAY)
- text_input.press("Enter", delay=CLICK_DELAY)
+ text_input.type("test", delay=DELAY)
+ text_input.press("Enter", delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]"
)
@@ -312,7 +311,7 @@ def test_component_use_user_data_with_default(self):
)
# Attempt to clear data
- clear.click(delay=CLICK_DELAY)
+ clear.click(delay=DELAY)
user_data_div = self.page.wait_for_selector(
"#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]"
)
@@ -325,25 +324,25 @@ def test_component_use_auth(self):
uuid = self.page.wait_for_selector("#use-auth").get_attribute("data-uuid")
assert len(uuid) == 36
- self.page.wait_for_selector("#use-auth .login").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#use-auth .login").click(delay=DELAY)
# Wait for #use-auth[data-username="user_4"] to appear
self.page.wait_for_selector("#use-auth[data-username='user_4']")
self.page.wait_for_selector(f"#use-auth[data-uuid='{uuid}']")
# Press disconnect and wait for #use-auth[data-uuid=...] to disappear
- self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#use-auth .disconnect").click(delay=DELAY)
expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0)
# Double check that the same user is logged in
self.page.wait_for_selector("#use-auth[data-username='user_4']")
# Press logout and wait for #use-auth[data-username="AnonymousUser"] to appear
- self.page.wait_for_selector("#use-auth .logout").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#use-auth .logout").click(delay=DELAY)
self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']")
# Press disconnect and wait for #use-auth[data-uuid=...] to disappear
- self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#use-auth .disconnect").click(delay=DELAY)
expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0)
# Double check that the user stayed logged out
@@ -357,22 +356,22 @@ def test_component_use_auth(self):
# uuid = self.page.wait_for_selector("#use-auth-no-rerender").get_attribute("data-uuid")
# assert len(uuid) == 36
- # self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=CLICK_DELAY)
+ # self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=DELAY)
# # Make sure #use-auth[data-username="user_5"] does not appear
- # with pytest.raises(TimeoutError):
+ # with pytest.raises(PlaywrightTimeoutError):
# self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']", timeout=1)
# # Press disconnect and see if #use-auth[data-username="user_5"] appears
- # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY)
+ # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=DELAY)
# self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']")
# # Press logout and make sure #use-auth[data-username="AnonymousUser"] does not appear
- # with pytest.raises(TimeoutError):
+ # with pytest.raises(PlaywrightTimeoutError):
# self.page.wait_for_selector("#use-auth-no-rerender[data-username='AnonymousUser']", timeout=1)
# # Press disconnect and see if #use-auth[data-username="AnonymousUser"] appears
- # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY)
+ # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=DELAY)
@navigate_to_page("/")
def test_component_use_rerender(self):
@@ -380,7 +379,7 @@ def test_component_use_rerender(self):
assert len(initial_uuid) == 36
rerender_button = self.page.wait_for_selector("#use-rerender button")
- rerender_button.click(delay=CLICK_DELAY)
+ rerender_button.click(delay=DELAY)
# Wait for #use-rerender[data-uuid=...] to disappear
expect(self.page.locator(f"#use-rerender[data-uuid='{initial_uuid}']")).to_have_count(0)
@@ -536,6 +535,19 @@ def test_url_router_int_and_string(self):
string = self.page.query_selector("#router-string")
assert string.text_content() == "/router/two///"
+ def test_url_router_navigation_state(self):
+ self.page.goto(f"{self.live_server_url}/router/next/1/")
+ uuid1 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid")
+ self.page.locator("button").click()
+ self.page.wait_for_selector(f"#router-path[data-path='/router/next/2/']")
+ uuid2 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid")
+ assert uuid1 == uuid2
+ self.page.go_back()
+ self.page.wait_for_selector(f"#router-path[data-path='/router/next/1/']")
+ uuid3 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid")
+ # When going back, it should also be a new mount if navigation always remounts
+ assert uuid1 == uuid3
+
#######################
# Channel Layer Tests #
#######################
@@ -543,14 +555,14 @@ def test_url_router_int_and_string(self):
@navigate_to_page("/channel-layers/")
def test_channel_layer_components(self):
sender = self.page.wait_for_selector("#sender")
- sender.type("test", delay=CLICK_DELAY)
- sender.press("Enter", delay=CLICK_DELAY)
+ sender.type("test", delay=DELAY)
+ sender.press("Enter", delay=DELAY)
receiver = self.page.wait_for_selector("#receiver[data-message='test']")
assert receiver is not None
sender = self.page.wait_for_selector("#group-sender")
- sender.type("1234", delay=CLICK_DELAY)
- sender.press("Enter", delay=CLICK_DELAY)
+ sender.type("1234", delay=DELAY)
+ sender.press("Enter", delay=DELAY)
receiver_1 = self.page.wait_for_selector("#group-receiver-1[data-message='1234']")
receiver_2 = self.page.wait_for_selector("#group-receiver-2[data-message='1234']")
receiver_3 = self.page.wait_for_selector("#group-receiver-3[data-message='1234']")
@@ -581,11 +593,11 @@ def test_pyscript_1_multifile(self):
def test_pyscript_1_counter(self):
self.page.wait_for_selector("#counter")
self.page.wait_for_selector("#counter pre[data-value='0']")
- self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#counter .plus").click(delay=DELAY)
self.page.wait_for_selector("#counter pre[data-value='1']")
- self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#counter .plus").click(delay=DELAY)
self.page.wait_for_selector("#counter pre[data-value='2']")
- self.page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#counter .minus").click(delay=DELAY)
self.page.wait_for_selector("#counter pre[data-value='1']")
@navigate_to_page("/pyscript/")
@@ -593,24 +605,24 @@ def test_pyscript_1_server_side_parent(self):
self.page.wait_for_selector("#parent")
self.page.wait_for_selector("#child")
self.page.wait_for_selector("#child pre[data-value='0']")
- self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#child .plus").click(delay=DELAY)
self.page.wait_for_selector("#child pre[data-value='1']")
- self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#child .plus").click(delay=DELAY)
self.page.wait_for_selector("#child pre[data-value='2']")
- self.page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#child .minus").click(delay=DELAY)
self.page.wait_for_selector("#child pre[data-value='1']")
@navigate_to_page("/pyscript/")
def test_pyscript_1_server_side_parent_with_toggle(self):
self.page.wait_for_selector("#parent-toggle")
- self.page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#parent-toggle button").click(delay=DELAY)
self.page.wait_for_selector("#parent-toggle")
self.page.wait_for_selector("#parent-toggle pre[data-value='0']")
- self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#parent-toggle .plus").click(delay=DELAY)
self.page.wait_for_selector("#parent-toggle pre[data-value='1']")
- self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#parent-toggle .plus").click(delay=DELAY)
self.page.wait_for_selector("#parent-toggle pre[data-value='2']")
- self.page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#parent-toggle .minus").click(delay=DELAY)
self.page.wait_for_selector("#parent-toggle pre[data-value='1']")
@navigate_to_page("/pyscript/")
@@ -662,7 +674,7 @@ def test_distributed_custom_host_wrong_port(self):
tmp_sock.bind((self._server_process_0.host, 0))
random_port = tmp_sock.getsockname()[1]
self.page.goto(f"{self.live_server_url}/port/{random_port}/")
- with pytest.raises(TimeoutError):
+ with pytest.raises(PlaywrightTimeoutError):
self.page.locator(".custom_host").wait_for(timeout=1000)
#################
@@ -724,7 +736,7 @@ def test_form_basic(self):
self.page.wait_for_selector("#id_password_field")
self.page.wait_for_selector("#id_model_choice_field")
self.page.wait_for_selector("#id_model_multiple_choice_field")
- self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
self.page.wait_for_selector(".errorlist")
# Submitting an empty form should result in 22 error elements.
@@ -732,35 +744,35 @@ def test_form_basic(self):
assert len(self.page.query_selector_all(".errorlist")) == 22
# Fill out the form
- self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#id_boolean_field").click(delay=DELAY)
expect(self.page.locator("#id_boolean_field")).to_be_checked()
- self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY)
+ self.page.locator("#id_char_field").type("test", delay=DELAY)
self.page.locator("#id_choice_field").select_option("2")
- self.page.locator("#id_date_field").type("2021-01-01", delay=CLICK_DELAY)
- self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=CLICK_DELAY)
- self.page.locator("#id_decimal_field").type("0.123", delay=CLICK_DELAY)
- self.page.locator("#id_duration_field").type("1", delay=CLICK_DELAY)
- self.page.locator("#id_email_field").type("test@example.com", delay=CLICK_DELAY)
+ self.page.locator("#id_date_field").type("2021-01-01", delay=DELAY)
+ self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=DELAY)
+ self.page.locator("#id_decimal_field").type("0.123", delay=DELAY)
+ self.page.locator("#id_duration_field").type("1", delay=DELAY)
+ self.page.locator("#id_email_field").type("test@example.com", delay=DELAY)
file_path_field_options = self.page.query_selector_all("#id_file_path_field option")
file_path_field_values: list[str] = [option.get_attribute("value") for option in file_path_field_options]
self.page.locator("#id_file_path_field").select_option(file_path_field_values[1])
- self.page.locator("#id_float_field").type("1.2345", delay=CLICK_DELAY)
- self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=CLICK_DELAY)
- self.page.locator("#id_integer_field").type("123", delay=CLICK_DELAY)
+ self.page.locator("#id_float_field").type("1.2345", delay=DELAY)
+ self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=DELAY)
+ self.page.locator("#id_integer_field").type("123", delay=DELAY)
self.page.locator("#id_json_field").clear()
- self.page.locator("#id_json_field").type('{"key": "value"}', delay=CLICK_DELAY)
+ self.page.locator("#id_json_field").type('{"key": "value"}', delay=DELAY)
self.page.locator("#id_multiple_choice_field").select_option(["2", "3"])
self.page.locator("#id_null_boolean_field").select_option("false")
- self.page.locator("#id_regex_field").type("12", delay=CLICK_DELAY)
- self.page.locator("#id_slug_field").type("my-slug-text", delay=CLICK_DELAY)
- self.page.locator("#id_time_field").type("01:01:00", delay=CLICK_DELAY)
+ self.page.locator("#id_regex_field").type("12", delay=DELAY)
+ self.page.locator("#id_slug_field").type("my-slug-text", delay=DELAY)
+ self.page.locator("#id_time_field").type("01:01:00", delay=DELAY)
self.page.locator("#id_typed_choice_field").select_option("2")
self.page.locator("#id_typed_multiple_choice_field").select_option(["1", "2"])
- self.page.locator("#id_url_field").type("http://example.com", delay=CLICK_DELAY)
- self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=CLICK_DELAY)
- self.page.locator("#id_combo_field").type("test@example.com", delay=CLICK_DELAY)
- self.page.locator("#id_password_field").type("password", delay=CLICK_DELAY)
+ self.page.locator("#id_url_field").type("http://example.com", delay=DELAY)
+ self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=DELAY)
+ self.page.locator("#id_combo_field").type("test@example.com", delay=DELAY)
+ self.page.locator("#id_password_field").type("password", delay=DELAY)
model_choice_field_options = self.page.query_selector_all("#id_model_multiple_choice_field option")
model_choice_field_values: list[str] = [option.get_attribute("value") for option in model_choice_field_options]
self.page.locator("#id_model_choice_field").select_option(model_choice_field_values[0])
@@ -771,7 +783,7 @@ def test_form_basic(self):
# Submit and wait for one of the error messages to disappear (indicating that the form has been re-rendered)
invalid_feedback = self.page.locator(".errorlist").all()[0]
- self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
expect(invalid_feedback).not_to_be_attached()
# Make sure no errors remain
assert len(self.page.query_selector_all(".errorlist")) == 0
@@ -794,7 +806,7 @@ def test_form_bootstrap(self):
self.page.wait_for_selector("#id_boolean_field")
self.page.wait_for_selector("#id_char_field")
self.page.wait_for_selector("#id_choice_field")
- self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("button[type=submit]").click(delay=DELAY)
self.page.wait_for_selector(".invalid-feedback")
# Submitting an empty form should result in 2 error elements.
@@ -802,14 +814,14 @@ def test_form_bootstrap(self):
assert len(self.page.query_selector_all(".invalid-feedback")) == 2
# Fill out the form
- self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("#id_boolean_field").click(delay=DELAY)
expect(self.page.locator("#id_boolean_field")).to_be_checked()
- self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY)
+ self.page.locator("#id_char_field").type("test", delay=DELAY)
self.page.locator("#id_choice_field").select_option("2")
# Submit and wait for one of the error messages to disappear (indicating that the form has been re-rendered)
invalid_feedback = self.page.locator(".invalid-feedback").all()[0]
- self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("button[type=submit]").click(delay=DELAY)
expect(invalid_feedback).not_to_be_attached()
# Make sure no errors remain
assert len(self.page.query_selector_all(".invalid-feedback")) == 0
@@ -818,7 +830,7 @@ def test_form_bootstrap(self):
def test_form_orm_model(self):
uuid = uuid4().hex
self.page.wait_for_selector("form")
- self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
self.page.wait_for_selector(".errorlist")
# Submitting an empty form should result in 1 error element.
@@ -826,10 +838,10 @@ def test_form_orm_model(self):
assert len(error_list) == 1
# Fill out the form
- self.page.locator("#id_text").type(uuid, delay=CLICK_DELAY)
+ self.page.locator("#id_text").type(uuid, delay=DELAY)
# Submit the form
- self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
# Wait for the error message to disappear (indicating that the form has been re-rendered)
expect(error_list[0]).not_to_be_attached()
@@ -861,7 +873,7 @@ def test_form_orm_model(self):
# self.page.wait_for_selector("#change[data-value='false']")
# # Submit empty the form
- # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
# # The empty form was submitted, should result in an error
# self.page.wait_for_selector("#success[data-value='false']")
@@ -870,8 +882,8 @@ def test_form_orm_model(self):
# self.page.wait_for_selector("#change[data-value='false']")
# # Fill out the form and re-submit
- # self.page.wait_for_selector("#id_char_field").type("test", delay=CLICK_DELAY)
- # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY)
+ # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
# # Form should have been successfully submitted
# self.page.wait_for_selector("#success[data-value='true']")
@@ -890,7 +902,7 @@ def test_form_orm_model(self):
# self.page.wait_for_selector("#change[data-value='false']")
# # Submit empty the form
- # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
# # The empty form was submitted, should result in an error
# self.page.wait_for_selector("#success[data-value='false']")
@@ -899,8 +911,8 @@ def test_form_orm_model(self):
# self.page.wait_for_selector("#change[data-value='false']")
# # Fill out the form and re-submit
- # self.page.wait_for_selector("#id_char_field").type("test", delay=CLICK_DELAY)
- # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
+ # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY)
+ # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
# # Form should have been successfully submitted
# self.page.wait_for_selector("#success[data-value='true']")
diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py
index de43d958..5a58ea64 100644
--- a/tests/test_app/tests/utils.py
+++ b/tests/test_app/tests/utils.py
@@ -1,4 +1,4 @@
-# ruff: noqa: N802, RUF012, T201
+# ruff: noqa: RUF012, T201
import asyncio
import os
import sys
@@ -143,10 +143,10 @@ def navigate_to_page(path: str, *, server_num=0):
def _decorator(func: Callable):
@decorator.decorator
def _wrapper(func: Callable, self: PlaywrightTestCase, *args, **kwargs):
- _port = getattr(self, f"_port_{server_num}")
- _path = f"http://{self.host}:{_port}/{path.lstrip('/')}"
- if self.page.url != _path:
- self.page.goto(_path)
+ port = getattr(self, f"_port_{server_num}")
+ path_ = f"http://{self.host}:{port}/{path.lstrip('/')}"
+ if self.page.url != path_:
+ self.page.goto(path_)
return func(self, *args, **kwargs)
return _wrapper(func)