diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index d762951b3..a55532008 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,14 +1,14 @@
-By submitting this pull request you agree that all contributions to this project are made under the MIT license.
+## Description
-## Issues
+
-
-
-## Solution
+## Checklist
-
+Please update this checklist as you complete each item:
-## Checklist
+- [ ] Tests have been developed for bug fixes or new functionality.
+- [ ] The changelog has been updated, if necessary.
+- [ ] Documentation has been updated, if necessary.
+- [ ] GitHub Issues closed by this PR have been linked.
-- [ ] Tests have been included for all bug fixes or added functionality.
-- [ ] The `changelog.rst` has been updated with any significant changes.
+By submitting this pull request I agree that all contributions comply with this project's open source license(s).
diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml
index b312869e4..1b21e4202 100644
--- a/.github/workflows/.hatch-run.yml
+++ b/.github/workflows/.hatch-run.yml
@@ -1,59 +1,59 @@
name: hatch-run
on:
- workflow_call:
- inputs:
- job-name:
- required: true
- type: string
- hatch-run:
- required: true
- type: string
- runs-on-array:
- required: false
- type: string
- default: '["ubuntu-latest"]'
- python-version-array:
- required: false
- type: string
- default: '["3.x"]'
- node-registry-url:
- required: false
- type: string
- default: ""
- secrets:
- node-auth-token:
- required: false
- pypi-username:
- required: false
- pypi-password:
- required: false
+ workflow_call:
+ inputs:
+ job-name:
+ required: true
+ type: string
+ hatch-run:
+ required: true
+ type: string
+ runs-on-array:
+ required: false
+ type: string
+ default: '["ubuntu-latest"]'
+ python-version-array:
+ required: false
+ type: string
+ default: '["3.x"]'
+ node-registry-url:
+ required: false
+ type: string
+ default: ""
+ secrets:
+ node-auth-token:
+ required: false
+ pypi-username:
+ required: false
+ pypi-password:
+ required: false
jobs:
- hatch:
- name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }}
- strategy:
- matrix:
- python-version: ${{ fromJson(inputs.python-version-array) }}
- runs-on: ${{ fromJson(inputs.runs-on-array) }}
- runs-on: ${{ matrix.runs-on }}
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-node@v2
- with:
- node-version: "14.x"
- registry-url: ${{ inputs.node-registry-url }}
- - name: Pin NPM Version
- run: npm install -g npm@8.19.3
- - name: Use Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install Python Dependencies
- run: pip install hatch poetry
- - name: Run Scripts
- env:
- NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }}
- PYPI_USERNAME: ${{ secrets.pypi-username }}
- PYPI_PASSWORD: ${{ secrets.pypi-password }}
- run: hatch run ${{ inputs.hatch-run }}
+ hatch:
+ name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }}
+ strategy:
+ matrix:
+ python-version: ${{ fromJson(inputs.python-version-array) }}
+ runs-on: ${{ fromJson(inputs.runs-on-array) }}
+ runs-on: ${{ matrix.runs-on }}
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v2
+ with:
+ node-version: "14.x"
+ registry-url: ${{ inputs.node-registry-url }}
+ - name: Pin NPM Version
+ run: npm install -g npm@8.19.3
+ - name: Use Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install Python Dependencies
+ run: pip install hatch poetry
+ - name: Run Scripts
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }}
+ PYPI_USERNAME: ${{ secrets.pypi-username }}
+ PYPI_PASSWORD: ${{ secrets.pypi-password }}
+ run: hatch run ${{ inputs.hatch-run }}
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index af768579c..d370ea129 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -1,45 +1,48 @@
name: check
on:
- push:
- branches:
- - main
- pull_request:
- branches:
- - main
- schedule:
- - cron: "0 0 * * 0"
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+ schedule:
+ - cron: "0 0 * * 0"
jobs:
- test-py-cov:
- uses: ./.github/workflows/.hatch-run.yml
- with:
- job-name: "python-{0}"
- hatch-run: "test-py"
- lint-py:
- uses: ./.github/workflows/.hatch-run.yml
- with:
- job-name: "python-{0}"
- hatch-run: "lint-py"
- test-py-matrix:
- uses: ./.github/workflows/.hatch-run.yml
- with:
- job-name: "python-{0} {1}"
- hatch-run: "test-py --no-cov"
- runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]'
- python-version-array: '["3.9", "3.10", "3.11"]'
- test-docs:
- uses: ./.github/workflows/.hatch-run.yml
- with:
- job-name: "python-{0}"
- hatch-run: "test-docs"
- test-js:
- uses: ./.github/workflows/.hatch-run.yml
- with:
- job-name: "{1}"
- hatch-run: "test-js"
- lint-js:
- uses: ./.github/workflows/.hatch-run.yml
- with:
- job-name: "{1}"
- hatch-run: "lint-js"
+ test-py-cov:
+ uses: ./.github/workflows/.hatch-run.yml
+ with:
+ job-name: "python-{0}"
+ hatch-run: "test-py"
+ lint-py:
+ uses: ./.github/workflows/.hatch-run.yml
+ with:
+ job-name: "python-{0}"
+ hatch-run: "lint-py"
+ test-py-matrix:
+ uses: ./.github/workflows/.hatch-run.yml
+ with:
+ job-name: "python-{0} {1}"
+ hatch-run: "test-py --no-cov"
+ runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]'
+ python-version-array: '["3.9", "3.10", "3.11"]'
+ test-docs:
+ uses: ./.github/workflows/.hatch-run.yml
+ with:
+ job-name: "python-{0}"
+ hatch-run: "test-docs"
+ # as of Dec 2023 lxml does have wheels for 3.12
+ # https://bugs.launchpad.net/lxml/+bug/2040440
+ python-version-array: '["3.11"]'
+ test-js:
+ uses: ./.github/workflows/.hatch-run.yml
+ with:
+ job-name: "{1}"
+ hatch-run: "test-js"
+ lint-js:
+ uses: ./.github/workflows/.hatch-run.yml
+ with:
+ job-name: "{1}"
+ hatch-run: "lint-js"
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 000000000..7471953dc
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,12 @@
+{
+ "recommendations": [
+ "wholroyd.jinja",
+ "esbenp.prettier-vscode",
+ "ms-python.vscode-pylance",
+ "ms-python.python",
+ "charliermarsh.ruff",
+ "dbaeumer.vscode-eslint",
+ "ms-python.black-formatter",
+ "ms-python.mypy-type-checker"
+ ]
+}
diff --git a/docs/Dockerfile b/docs/Dockerfile
index 39b9c51be..7a5d49b7b 100644
--- a/docs/Dockerfile
+++ b/docs/Dockerfile
@@ -1,12 +1,14 @@
FROM python:3.9
-
WORKDIR /app/
+RUN apt-get update
+
# Install NodeJS
# --------------
-RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
-RUN apt-get install -y build-essential nodejs npm
-RUN npm install -g npm@8.5.0
+RUN curl -SLO https://deb.nodesource.com/nsolid_setup_deb.sh
+RUN chmod 500 nsolid_setup_deb.sh
+RUN ./nsolid_setup_deb.sh 20
+RUN apt-get install nodejs -y
# Install Poetry
# --------------
diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst
index 9535d0b67..9fc13e015 100644
--- a/docs/source/about/changelog.rst
+++ b/docs/source/about/changelog.rst
@@ -3,15 +3,10 @@ Changelog
.. note::
- The ReactPy team manages their short and long term plans with `GitHub Projects
- `__. If you have questions about what
- the team are working on, or have feedback on how issues should be prioritized, feel
- free to :discussion-type:`open up a discussion `.
-
-All notable changes to this project will be recorded in this document. The style of
-which is based on `Keep a Changelog `__. The versioning
-scheme for the project adheres to `Semantic Versioning `__. For
-more info, see the :ref:`Contributor Guide `.
+ All notable changes to this project will be recorded in this document. The style of
+ which is based on `Keep a Changelog `__. The versioning
+ scheme for the project adheres to `Semantic Versioning `__. For
+ more info, see the :ref:`Contributor Guide `.
.. INSTRUCTIONS FOR CHANGELOG CONTRIBUTORS
@@ -26,6 +21,31 @@ Unreleased
**Fixed**
- :pull:`1118` - `module_from_template` is broken with a recent release of `requests`
+- :pull:`1131` - `module_from_template` did not work when using Flask backend
+- :pull:`1200` - Fixed `UnicodeDecodeError` when using `reactpy.web.export`
+
+**Added**
+
+- :pull:`1165` - Allow concurrently rendering discrete component trees - enable this
+ experimental feature by setting `REACTPY_ASYNC_RENDERING=true`. This improves
+ the overall responsiveness of your app in situations where larger renders would
+ otherwise block smaller renders from executing.
+
+**Changed**
+
+- :pull:`1171` - Previously ``None``, when present in an HTML element, would render as
+ the string ``"None"``. Now ``None`` will not render at all. This is consistent with
+ how ``None`` is handled when returned from components. It also makes it easier to
+ conditionally render elements. For example, previously you would have needed to use a
+ fragment to conditionally render an element by writing
+ ``something if condition else html._()``. Now you can simply write
+ ``something if condition else None``.
+
+**Deprecated**
+
+- :pull:`1171` - The ``Stop`` exception. Recent releases of ``anyio`` have made this
+ exception difficult to use since it now raises an ``ExceptionGroup``. This exception
+ was primarily used for internal testing purposes and so is now deprecated.
v1.0.2
diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py
index be5366cb2..abe55a918 100644
--- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py
+++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py
@@ -24,9 +24,9 @@ def handle_click(event):
"style": {
"height": "30px",
"width": "30px",
- "background_color": "black"
- if index in selected_indices
- else "white",
+ "background_color": (
+ "black" if index in selected_indices else "white"
+ ),
"outline": "1px solid grey",
"cursor": "pointer",
},
diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py
index 8ff2e1ca4..27f170a42 100644
--- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py
+++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py
@@ -21,9 +21,9 @@ def handle_click(event):
"style": {
"height": "30px",
"width": "30px",
- "background_color": "black"
- if index in selected_indices
- else "white",
+ "background_color": (
+ "black" if index in selected_indices else "white"
+ ),
"outline": "1px solid grey",
"cursor": "pointer",
},
diff --git a/pyproject.toml b/pyproject.toml
index ee120a181..775ab01a2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,10 +12,10 @@ detached = true
dependencies = [
"invoke",
# lint
- "black",
- "ruff==0.0.278", # Ruff is moving really fast, so pinning for now.
+ "black==24.1.1", # Pin lint tools we don't control to avoid breaking changes
+ "ruff==0.0.278", # Pin lint tools we don't control to avoid breaking changes
"toml",
- "flake8",
+ "flake8==7.0.0", # Pin lint tools we don't control to avoid breaking changes
"flake8-pyproject",
"reactpy-flake8 >=0.7",
# types
@@ -32,9 +32,11 @@ publish = "invoke publish {args}"
docs = "invoke docs {args}"
check = ["lint-py", "lint-js", "test-py", "test-js", "test-docs"]
+lint = ["lint-py", "lint-js"]
lint-py = "invoke lint-py {args}"
lint-js = "invoke lint-js {args}"
+test = ["test-py", "test-js", "test-docs"]
test-py = "invoke test-py {args}"
test-js = "invoke test-js"
test-docs = "invoke test-docs"
@@ -56,7 +58,7 @@ warn_unused_ignores = true
# --- Flake8 ---------------------------------------------------------------------------
[tool.flake8]
-select = ["RPY"] # only need to check with reactpy-flake8
+select = ["RPY"] # only need to check with reactpy-flake8
exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"]
# --- Ruff -----------------------------------------------------------------------------
@@ -95,7 +97,8 @@ select = [
]
ignore = [
# TODO: turn this on later
- "N802", "N806", # allow TitleCase functions/variables
+ "N802",
+ "N806", # allow TitleCase functions/variables
# We're not any cryptography
"S311",
# For loop variable re-assignment seems like an uncommon mistake
@@ -103,9 +106,12 @@ ignore = [
# Let Black deal with line-length
"E501",
# Allow args/attrs to shadow built-ins
- "A002", "A003",
+ "A002",
+ "A003",
# Allow unused args (useful for documenting what the parameter is for later)
- "ARG001", "ARG002", "ARG005",
+ "ARG001",
+ "ARG002",
+ "ARG005",
# Allow non-abstract empty methods in abstract base classes
"B027",
# Allow boolean positional values in function calls, like `dict.get(... True)`
@@ -113,9 +119,15 @@ ignore = [
# If we're making an explicit comparison to a falsy value it was probably intentional
"PLC1901",
# Ignore checks for possible passwords
- "S105", "S106", "S107",
+ "S105",
+ "S106",
+ "S107",
# Ignore complexity
- "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915",
+ "C901",
+ "PLR0911",
+ "PLR0912",
+ "PLR0913",
+ "PLR0915",
]
unfixable = [
# Don't touch unused imports
diff --git a/src/js/app/package-lock.json b/src/js/app/package-lock.json
index 9794c53d6..adc398279 100644
--- a/src/js/app/package-lock.json
+++ b/src/js/app/package-lock.json
@@ -13,7 +13,7 @@
"@types/react": "^17.0",
"@types/react-dom": "^17.0",
"typescript": "^4.9.5",
- "vite": "^3.2.7"
+ "vite": "^3.2.8"
}
},
"node_modules/@esbuild/android-arm": {
@@ -540,9 +540,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
- "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
@@ -579,9 +579,9 @@
"dev": true
},
"node_modules/postcss": {
- "version": "8.4.21",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
- "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
+ "version": "8.4.35",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
+ "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"dev": true,
"funding": [
{
@@ -591,10 +591,14 @@
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
- "nanoid": "^3.3.4",
+ "nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -715,9 +719,9 @@
}
},
"node_modules/vite": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz",
- "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==",
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz",
+ "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==",
"dev": true,
"dependencies": {
"esbuild": "^0.15.9",
@@ -1064,9 +1068,9 @@
}
},
"nanoid": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
- "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true
},
"object-assign": {
@@ -1088,12 +1092,12 @@
"dev": true
},
"postcss": {
- "version": "8.4.21",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
- "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
+ "version": "8.4.35",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
+ "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"dev": true,
"requires": {
- "nanoid": "^3.3.4",
+ "nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
}
@@ -1173,9 +1177,9 @@
"dev": true
},
"vite": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz",
- "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==",
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz",
+ "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==",
"dev": true,
"requires": {
"esbuild": "^0.15.9",
diff --git a/src/js/app/package.json b/src/js/app/package.json
index 40ce94739..55a42fd66 100644
--- a/src/js/app/package.json
+++ b/src/js/app/package.json
@@ -12,7 +12,7 @@
"@types/react": "^17.0",
"@types/react-dom": "^17.0",
"typescript": "^4.9.5",
- "vite": "^3.2.7"
+ "vite": "^3.2.8"
},
"repository": {
"type": "git",
diff --git a/src/js/package-lock.json b/src/js/package-lock.json
index 2edfdd260..91b7f302c 100644
--- a/src/js/package-lock.json
+++ b/src/js/package-lock.json
@@ -28,7 +28,7 @@
"@types/react": "^17.0",
"@types/react-dom": "^17.0",
"typescript": "^4.9.5",
- "vite": "^3.1.8"
+ "vite": "^3.2.8"
}
},
"app/node_modules/@reactpy/client": {
@@ -2429,9 +2429,9 @@
"dev": true
},
"node_modules/nanoid": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
- "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
@@ -2712,9 +2712,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.24",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
- "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
+ "version": "8.4.35",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
+ "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"dev": true,
"funding": [
{
@@ -2731,7 +2731,7 @@
}
],
"dependencies": {
- "nanoid": "^3.3.6",
+ "nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -3328,9 +3328,9 @@
}
},
"node_modules/vite": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz",
- "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==",
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz",
+ "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==",
"dev": true,
"dependencies": {
"esbuild": "^0.15.9",
@@ -3474,9 +3474,9 @@
}
},
"node_modules/word-wrap": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
- "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -3955,7 +3955,7 @@
"@types/react-dom": "^17.0",
"preact": "^10.7.0",
"typescript": "^4.9.5",
- "vite": "^3.1.8"
+ "vite": "^3.2.8"
},
"dependencies": {
"@reactpy/client": {
@@ -5285,9 +5285,9 @@
"dev": true
},
"nanoid": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
- "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true
},
"natural-compare": {
@@ -5476,12 +5476,12 @@
"dev": true
},
"postcss": {
- "version": "8.4.24",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
- "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
+ "version": "8.4.35",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
+ "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"dev": true,
"requires": {
- "nanoid": "^3.3.6",
+ "nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
}
@@ -5888,9 +5888,9 @@
}
},
"vite": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz",
- "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==",
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz",
+ "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==",
"dev": true,
"requires": {
"esbuild": "^0.15.9",
@@ -5976,9 +5976,9 @@
}
},
"word-wrap": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
- "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true
},
"wrappy": {
diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml
index 87fa7e036..309248507 100644
--- a/src/py/reactpy/pyproject.toml
+++ b/src/py/reactpy/pyproject.toml
@@ -25,6 +25,7 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
+ "exceptiongroup >=1.0",
"typing-extensions >=3.10",
"mypy-extensions >=0.4.3",
"anyio >=3",
@@ -45,6 +46,8 @@ starlette = [
sanic = [
"sanic >=21",
"sanic-cors",
+ "tracerite>=1.1.1",
+ "setuptools",
"uvicorn[standard] >=0.19.0",
]
fastapi = [
@@ -80,7 +83,7 @@ pre-install-command = "hatch build --hooks-only"
dependencies = [
"coverage[toml]>=6.5",
"pytest",
- "pytest-asyncio>=0.17",
+ "pytest-asyncio>=0.23",
"pytest-mock",
"pytest-rerunfailures",
"pytest-timeout",
diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py
index 63a8550cc..49e357441 100644
--- a/src/py/reactpy/reactpy/__init__.py
+++ b/src/py/reactpy/reactpy/__init__.py
@@ -16,7 +16,6 @@
use_state,
)
from reactpy.core.layout import Layout
-from reactpy.core.serve import Stop
from reactpy.core.vdom import vdom
from reactpy.utils import Ref, html_to_vdom, vdom_to_html
diff --git a/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py b/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py
index e5d1860c2..d706adecf 100644
--- a/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py
+++ b/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py
@@ -29,7 +29,7 @@ def rewrite_camel_case_props(paths: list[str]) -> None:
for p in map(Path, paths):
for f in [p] if p.is_file() else p.rglob("*.py"):
- result = generate_rewrite(file=f, source=f.read_text())
+ result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8"))
if result is not None:
f.write_text(result)
diff --git a/src/py/reactpy/reactpy/_console/rewrite_keys.py b/src/py/reactpy/reactpy/_console/rewrite_keys.py
index 64ed42f33..08db9e227 100644
--- a/src/py/reactpy/reactpy/_console/rewrite_keys.py
+++ b/src/py/reactpy/reactpy/_console/rewrite_keys.py
@@ -51,7 +51,7 @@ def rewrite_keys(paths: list[str]) -> None:
for p in map(Path, paths):
for f in [p] if p.is_file() else p.rglob("*.py"):
- result = generate_rewrite(file=f, source=f.read_text())
+ result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8"))
if result is not None:
f.write_text(result)
diff --git a/src/py/reactpy/reactpy/_option.py b/src/py/reactpy/reactpy/_option.py
index 09d0304a9..1db0857e3 100644
--- a/src/py/reactpy/reactpy/_option.py
+++ b/src/py/reactpy/reactpy/_option.py
@@ -68,6 +68,10 @@ def current(self) -> _O:
def current(self, new: _O) -> None:
self.set_current(new)
+ @current.deleter
+ def current(self) -> None:
+ self.unset()
+
def subscribe(self, handler: Callable[[_O], None]) -> Callable[[_O], None]:
"""Register a callback that will be triggered when this option changes"""
if not self.mutable:
@@ -123,7 +127,8 @@ def unset(self) -> None:
msg = f"{self} cannot be modified after initial load"
raise TypeError(msg)
old = self.current
- delattr(self, "_current")
+ if hasattr(self, "_current"):
+ delattr(self, "_current")
if self.current != old:
for sub_func in self._subscribers:
sub_func(self.current)
diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py
index 2e00e8f64..faa979aa9 100644
--- a/src/py/reactpy/reactpy/backend/flask.py
+++ b/src/py/reactpy/reactpy/backend/flask.py
@@ -165,7 +165,7 @@ def send_assets_dir(path: str = "") -> Any:
@api_blueprint.route(f"/{MODULES_PATH.name}/")
def send_modules_dir(path: str = "") -> Any:
- return send_file(safe_web_modules_dir_path(path))
+ return send_file(safe_web_modules_dir_path(path), mimetype="text/javascript")
index_html = read_client_index_html(options)
diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py
index 19ad114ed..ee4ce1b5c 100644
--- a/src/py/reactpy/reactpy/backend/hooks.py
+++ b/src/py/reactpy/reactpy/backend/hooks.py
@@ -4,7 +4,8 @@
from typing import Any
from reactpy.backend.types import Connection, Location
-from reactpy.core.hooks import Context, create_context, use_context
+from reactpy.core.hooks import create_context, use_context
+from reactpy.core.types import Context
# backend implementations should establish this context at the root of an app
ConnectionContext: Context[Connection[Any] | None] = create_context(None)
diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py
index 3fd48db85..76eb0423e 100644
--- a/src/py/reactpy/reactpy/backend/sanic.py
+++ b/src/py/reactpy/reactpy/backend/sanic.py
@@ -48,7 +48,9 @@ class Options(CommonOptions):
# BackendType.configure
def configure(
- app: Sanic, component: RootComponentConstructor, options: Options | None = None
+ app: Sanic[Any, Any],
+ component: RootComponentConstructor,
+ options: Options | None = None,
) -> None:
"""Configure an application instance to display the given component"""
options = options or Options()
@@ -63,7 +65,7 @@ def configure(
# BackendType.create_development_app
-def create_development_app() -> Sanic:
+def create_development_app() -> Sanic[Any, Any]:
"""Return a :class:`Sanic` app instance in test mode"""
Sanic.test_mode = True
logger.warning("Sanic.test_mode is now active")
@@ -72,7 +74,7 @@ def create_development_app() -> Sanic:
# BackendType.serve_development_app
async def serve_development_app(
- app: Sanic,
+ app: Sanic[Any, Any],
host: str,
port: int,
started: asyncio.Event | None = None,
@@ -81,7 +83,7 @@ async def serve_development_app(
await serve_with_uvicorn(app, host, port, started)
-def use_request() -> request.Request:
+def use_request() -> request.Request[Any, Any]:
"""Get the current ``Request``"""
return use_connection().carrier.request
@@ -113,7 +115,7 @@ def _setup_common_routes(
index_html = read_client_index_html(options)
async def single_page_app_files(
- request: request.Request,
+ request: request.Request[Any, Any],
_: str = "",
) -> response.HTTPResponse:
return response.html(index_html)
@@ -131,7 +133,7 @@ async def single_page_app_files(
)
async def asset_files(
- request: request.Request,
+ request: request.Request[Any, Any],
path: str = "",
) -> response.HTTPResponse:
path = urllib_parse.unquote(path)
@@ -140,7 +142,7 @@ async def asset_files(
api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/")
async def web_module_files(
- request: request.Request,
+ request: request.Request[Any, Any],
path: str,
_: str = "", # this is not used
) -> response.HTTPResponse:
@@ -159,7 +161,9 @@ def _setup_single_view_dispatcher_route(
options: Options,
) -> None:
async def model_stream(
- request: request.Request, socket: WebSocketConnection, path: str = ""
+ request: request.Request[Any, Any],
+ socket: WebSocketConnection,
+ path: str = "",
) -> None:
asgi_app = getattr(request.app, "_asgi_app", None)
scope = asgi_app.transport.scope if asgi_app else {}
@@ -220,7 +224,7 @@ async def sock_recv() -> Any:
class _SanicCarrier:
"""A simple wrapper for holding connection information"""
- request: request.Request
+ request: request.Request[Sanic[Any, Any], Any]
"""The current request object"""
websocket: WebSocketConnection
diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py
index 2953b97b3..9bc68db47 100644
--- a/src/py/reactpy/reactpy/backend/starlette.py
+++ b/src/py/reactpy/reactpy/backend/starlette.py
@@ -7,6 +7,7 @@
from dataclasses import dataclass
from typing import Any, Callable
+from exceptiongroup import BaseExceptionGroup
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
@@ -137,8 +138,6 @@ async def serve_index(request: Request) -> HTMLResponse:
def _setup_single_view_dispatcher_route(
options: Options, app: Starlette, component: RootComponentConstructor
) -> None:
- @app.websocket_route(str(STREAM_PATH))
- @app.websocket_route(f"{STREAM_PATH}/{{path:path}}")
async def model_stream(socket: WebSocket) -> None:
await socket.accept()
send, recv = _make_send_recv_callbacks(socket)
@@ -162,8 +161,16 @@ async def model_stream(socket: WebSocket) -> None:
send,
recv,
)
- except WebSocketDisconnect as error:
- logger.info(f"WebSocket disconnect: {error.code}")
+ except BaseExceptionGroup as egroup:
+ for e in egroup.exceptions:
+ if isinstance(e, WebSocketDisconnect):
+ logger.info(f"WebSocket disconnect: {e.code}")
+ break
+ else: # nocov
+ raise
+
+ app.add_websocket_route(str(STREAM_PATH), model_stream)
+ app.add_websocket_route(f"{STREAM_PATH}/{{path:path}}", model_stream)
def _make_send_recv_callbacks(
diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py
index 8371e6d08..d08cdc218 100644
--- a/src/py/reactpy/reactpy/config.py
+++ b/src/py/reactpy/reactpy/config.py
@@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool:
validator=float,
)
"""A default timeout for testing utilities in ReactPy"""
+
+REACTPY_ASYNC_RENDERING = Option(
+ "REACTPY_ASYNC_RENDERING",
+ default=False,
+ mutable=True,
+ validator=boolean,
+)
+"""Whether to render components asynchronously. This is currently an experimental feature."""
diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py
new file mode 100644
index 000000000..88d3386a8
--- /dev/null
+++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+import logging
+from asyncio import Event, Task, create_task, gather
+from typing import Any, Callable, Protocol, TypeVar
+
+from anyio import Semaphore
+
+from reactpy.core._thread_local import ThreadLocal
+from reactpy.core.types import ComponentType, Context, ContextProviderType
+
+T = TypeVar("T")
+
+
+class EffectFunc(Protocol):
+ async def __call__(self, stop: Event) -> None: ...
+
+
+logger = logging.getLogger(__name__)
+
+_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
+
+
+def current_hook() -> LifeCycleHook:
+ """Get the current :class:`LifeCycleHook`"""
+ hook_stack = _HOOK_STATE.get()
+ if not hook_stack:
+ msg = "No life cycle hook is active. Are you rendering in a layout?"
+ raise RuntimeError(msg)
+ return hook_stack[-1]
+
+
+class LifeCycleHook:
+ """An object which manages the "life cycle" of a layout component.
+
+ The "life cycle" of a component is the set of events which occur from the time
+ a component is first rendered until it is removed from the layout. The life cycle
+ is ultimately driven by the layout itself, but components can "hook" into those
+ events to perform actions. Components gain access to their own life cycle hook
+ by calling :func:`current_hook`. They can then perform actions such as:
+
+ 1. Adding state via :meth:`use_state`
+ 2. Adding effects via :meth:`add_effect`
+ 3. Setting or getting context providers via
+ :meth:`LifeCycleHook.set_context_provider` and
+ :meth:`get_context_provider` respectively.
+
+ Components can request access to their own life cycle events and state through hooks
+ while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle
+ forward by triggering events and rendering view changes.
+
+ Example:
+
+ If removed from the complexities of a layout, a very simplified full life cycle
+ for a single component with no child components would look a bit like this:
+
+ .. testcode::
+
+ from reactpy.core._life_cycle_hook import LifeCycleHook
+ from reactpy.core.hooks import current_hook
+
+ # this function will come from a layout implementation
+ schedule_render = lambda: ...
+
+ # --- start life cycle ---
+
+ hook = LifeCycleHook(schedule_render)
+
+ # --- start render cycle ---
+
+ component = ...
+ await hook.affect_component_will_render(component)
+ try:
+ # render the component
+ ...
+
+ # the component may access the current hook
+ assert current_hook() is hook
+
+ # and save state or add effects
+ current_hook().use_state(lambda: ...)
+
+ async def my_effect(stop_event):
+ ...
+
+ current_hook().add_effect(my_effect)
+ finally:
+ await hook.affect_component_did_render()
+
+ # This should only be called after the full set of changes associated with a
+ # given render have been completed.
+ await hook.affect_layout_did_render()
+
+ # Typically an event occurs and a new render is scheduled, thus beginning
+ # the render cycle anew.
+ hook.schedule_render()
+
+
+ # --- end render cycle ---
+
+ hook.affect_component_will_unmount()
+ del hook
+
+ # --- end render cycle ---
+ """
+
+ __slots__ = (
+ "__weakref__",
+ "_context_providers",
+ "_current_state_index",
+ "_effect_funcs",
+ "_effect_stops",
+ "_effect_tasks",
+ "_render_access",
+ "_rendered_atleast_once",
+ "_schedule_render_callback",
+ "_scheduled_render",
+ "_state",
+ "component",
+ )
+
+ component: ComponentType
+
+ def __init__(
+ self,
+ schedule_render: Callable[[], None],
+ ) -> None:
+ self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {}
+ self._schedule_render_callback = schedule_render
+ self._scheduled_render = False
+ self._rendered_atleast_once = False
+ self._current_state_index = 0
+ self._state: tuple[Any, ...] = ()
+ self._effect_funcs: list[EffectFunc] = []
+ self._effect_tasks: list[Task[None]] = []
+ self._effect_stops: list[Event] = []
+ self._render_access = Semaphore(1) # ensure only one render at a time
+
+ def schedule_render(self) -> None:
+ if self._scheduled_render:
+ return None
+ try:
+ self._schedule_render_callback()
+ except Exception:
+ msg = f"Failed to schedule render via {self._schedule_render_callback}"
+ logger.exception(msg)
+ else:
+ self._scheduled_render = True
+
+ def use_state(self, function: Callable[[], T]) -> T:
+ """Add state to this hook
+
+ If this hook has not yet rendered, the state is appended to the state tuple.
+ Otherwise, the state is retrieved from the tuple. This allows state to be
+ preserved across renders.
+ """
+ if not self._rendered_atleast_once:
+ # since we're not initialized yet we're just appending state
+ result = function()
+ self._state += (result,)
+ else:
+ # once finalized we iterate over each succesively used piece of state
+ result = self._state[self._current_state_index]
+ self._current_state_index += 1
+ return result
+
+ def add_effect(self, effect_func: EffectFunc) -> None:
+ """Add an effect to this hook
+
+ A task to run the effect is created when the component is done rendering.
+ When the component will be unmounted, the event passed to the effect is
+ triggered and the task is awaited. The effect should eventually halt after
+ the event is triggered.
+ """
+ self._effect_funcs.append(effect_func)
+
+ def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
+ """Set a context provider for this hook
+
+ The context provider will be used to provide state to any child components
+ of this hook's component which request a context provider of the same type.
+ """
+ self._context_providers[provider.type] = provider
+
+ def get_context_provider(
+ self, context: Context[T]
+ ) -> ContextProviderType[T] | None:
+ """Get a context provider for this hook of the given type
+
+ The context provider will have been set by a parent component. If no provider
+ is found, ``None`` is returned.
+ """
+ return self._context_providers.get(context)
+
+ async def affect_component_will_render(self, component: ComponentType) -> None:
+ """The component is about to render"""
+ await self._render_access.acquire()
+ self._scheduled_render = False
+ self.component = component
+ self.set_current()
+
+ async def affect_component_did_render(self) -> None:
+ """The component completed a render"""
+ self.unset_current()
+ self._rendered_atleast_once = True
+ self._current_state_index = 0
+ self._render_access.release()
+ del self.component
+
+ async def affect_layout_did_render(self) -> None:
+ """The layout completed a render"""
+ stop = Event()
+ self._effect_stops.append(stop)
+ self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs)
+ self._effect_funcs.clear()
+
+ async def affect_component_will_unmount(self) -> None:
+ """The component is about to be removed from the layout"""
+ for stop in self._effect_stops:
+ stop.set()
+ self._effect_stops.clear()
+ try:
+ await gather(*self._effect_tasks)
+ except Exception:
+ logger.exception("Error in effect")
+ finally:
+ self._effect_tasks.clear()
+
+ def set_current(self) -> None:
+ """Set this hook as the active hook in this thread
+
+ This method is called by a layout before entering the render method
+ of this hook's associated component.
+ """
+ hook_stack = _HOOK_STATE.get()
+ if hook_stack:
+ parent = hook_stack[-1]
+ self._context_providers.update(parent._context_providers)
+ hook_stack.append(self)
+
+ def unset_current(self) -> None:
+ """Unset this hook as the active hook in this thread"""
+ if _HOOK_STATE.get().pop() is not self:
+ raise RuntimeError("Hook stack is in an invalid state") # nocov
diff --git a/src/py/reactpy/reactpy/core/events.py b/src/py/reactpy/reactpy/core/events.py
index cd5de3228..f715b7e9d 100644
--- a/src/py/reactpy/reactpy/core/events.py
+++ b/src/py/reactpy/reactpy/core/events.py
@@ -15,8 +15,7 @@ def event(
*,
stop_propagation: bool = ...,
prevent_default: bool = ...,
-) -> EventHandler:
- ...
+) -> EventHandler: ...
@overload
@@ -25,8 +24,7 @@ def event(
*,
stop_propagation: bool = ...,
prevent_default: bool = ...,
-) -> Callable[[Callable[..., Any]], EventHandler]:
- ...
+) -> Callable[[Callable[..., Any]], EventHandler]: ...
def event(
diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py
index a8334458b..640cbf14c 100644
--- a/src/py/reactpy/reactpy/core/hooks.py
+++ b/src/py/reactpy/reactpy/core/hooks.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
-from collections.abc import Awaitable, Sequence
+from collections.abc import Coroutine, Sequence
from logging import getLogger
from types import FunctionType
from typing import (
@@ -9,7 +9,6 @@
Any,
Callable,
Generic,
- NewType,
Protocol,
TypeVar,
cast,
@@ -19,8 +18,8 @@
from typing_extensions import TypeAlias
from reactpy.config import REACTPY_DEBUG_MODE
-from reactpy.core._thread_local import ThreadLocal
-from reactpy.core.types import ComponentType, Key, State, VdomDict
+from reactpy.core._life_cycle_hook import current_hook
+from reactpy.core.types import Context, Key, State, VdomDict
from reactpy.utils import Ref
if not TYPE_CHECKING:
@@ -43,13 +42,11 @@
@overload
-def use_state(initial_value: Callable[[], _Type]) -> State[_Type]:
- ...
+def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: ...
@overload
-def use_state(initial_value: _Type) -> State[_Type]:
- ...
+def use_state(initial_value: _Type) -> State[_Type]: ...
def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]:
@@ -96,7 +93,9 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
_EffectCleanFunc: TypeAlias = "Callable[[], None]"
_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]"
-_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]"
+_AsyncEffectFunc: TypeAlias = (
+ "Callable[[], Coroutine[None, None, _EffectCleanFunc | None]]"
+)
_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
@@ -104,16 +103,14 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
def use_effect(
function: None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
-) -> Callable[[_EffectApplyFunc], None]:
- ...
+) -> Callable[[_EffectApplyFunc], None]: ...
@overload
def use_effect(
function: _EffectApplyFunc,
dependencies: Sequence[Any] | ellipsis | None = ...,
-) -> None:
- ...
+) -> None: ...
def use_effect(
@@ -147,25 +144,30 @@ def add_effect(function: _EffectApplyFunc) -> None:
async_function = cast(_AsyncEffectFunc, function)
def sync_function() -> _EffectCleanFunc | None:
- future = asyncio.ensure_future(async_function())
+ task = asyncio.create_task(async_function())
def clean_future() -> None:
- if not future.cancel():
- clean = future.result()
- if clean is not None:
- clean()
+ if not task.cancel():
+ try:
+ clean = task.result()
+ except asyncio.CancelledError:
+ pass
+ else:
+ if clean is not None:
+ clean()
return clean_future
- def effect() -> None:
+ async def effect(stop: asyncio.Event) -> None:
if last_clean_callback.current is not None:
last_clean_callback.current()
-
+ last_clean_callback.current = None
clean = last_clean_callback.current = sync_function()
+ await stop.wait()
if clean is not None:
- hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean)
+ clean()
- return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect))
+ return memoize(lambda: hook.add_effect(effect))
if function is not None:
add_effect(function)
@@ -212,8 +214,8 @@ def context(
*children: Any,
value: _Type = default_value,
key: Key | None = None,
- ) -> ContextProvider[_Type]:
- return ContextProvider(
+ ) -> _ContextProvider[_Type]:
+ return _ContextProvider(
*children,
value=value,
key=key,
@@ -225,18 +227,6 @@ def context(
return context
-class Context(Protocol[_Type]):
- """Returns a :class:`ContextProvider` component"""
-
- def __call__(
- self,
- *children: Any,
- value: _Type = ...,
- key: Key | None = ...,
- ) -> ContextProvider[_Type]:
- ...
-
-
def use_context(context: Context[_Type]) -> _Type:
"""Get the current value for the given context type.
@@ -255,10 +245,10 @@ def use_context(context: Context[_Type]) -> _Type:
raise TypeError(f"{context} has no 'value' kwarg") # nocov
return cast(_Type, context.__kwdefaults__["value"])
- return provider._value
+ return provider.value
-class ContextProvider(Generic[_Type]):
+class _ContextProvider(Generic[_Type]):
def __init__(
self,
*children: Any,
@@ -269,14 +259,14 @@ def __init__(
self.children = children
self.key = key
self.type = type
- self._value = value
+ self.value = value
def render(self) -> VdomDict:
current_hook().set_context_provider(self)
return {"tagName": "", "children": self.children}
def __repr__(self) -> str:
- return f"{type(self).__name__}({self.type})"
+ return f"ContextProvider({self.type})"
_ActionType = TypeVar("_ActionType")
@@ -319,16 +309,14 @@ def dispatch(action: _ActionType) -> None:
def use_callback(
function: None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
-) -> Callable[[_CallbackFunc], _CallbackFunc]:
- ...
+) -> Callable[[_CallbackFunc], _CallbackFunc]: ...
@overload
def use_callback(
function: _CallbackFunc,
dependencies: Sequence[Any] | ellipsis | None = ...,
-) -> _CallbackFunc:
- ...
+) -> _CallbackFunc: ...
def use_callback(
@@ -364,24 +352,21 @@ def setup(function: _CallbackFunc) -> _CallbackFunc:
class _LambdaCaller(Protocol):
"""MyPy doesn't know how to deal with TypeVars only used in function return"""
- def __call__(self, func: Callable[[], _Type]) -> _Type:
- ...
+ def __call__(self, func: Callable[[], _Type]) -> _Type: ...
@overload
def use_memo(
function: None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
-) -> _LambdaCaller:
- ...
+) -> _LambdaCaller: ...
@overload
def use_memo(
function: Callable[[], _Type],
dependencies: Sequence[Any] | ellipsis | None = ...,
-) -> _Type:
- ...
+) -> _Type: ...
def use_memo(
@@ -495,231 +480,6 @@ def _try_to_infer_closure_values(
return values
-def current_hook() -> LifeCycleHook:
- """Get the current :class:`LifeCycleHook`"""
- hook_stack = _hook_stack.get()
- if not hook_stack:
- msg = "No life cycle hook is active. Are you rendering in a layout?"
- raise RuntimeError(msg)
- return hook_stack[-1]
-
-
-_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
-
-
-EffectType = NewType("EffectType", str)
-"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved"""
-
-COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER")
-"""An effect that will be triggered each time a component renders"""
-
-LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER")
-"""An effect that will be triggered each time a layout renders"""
-
-COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT")
-"""An effect that will be triggered just before the component is unmounted"""
-
-
-class LifeCycleHook:
- """Defines the life cycle of a layout component.
-
- Components can request access to their own life cycle events and state through hooks
- while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle
- forward by triggering events and rendering view changes.
-
- Example:
-
- If removed from the complexities of a layout, a very simplified full life cycle
- for a single component with no child components would look a bit like this:
-
- .. testcode::
-
- from reactpy.core.hooks import (
- current_hook,
- LifeCycleHook,
- COMPONENT_DID_RENDER_EFFECT,
- )
-
-
- # this function will come from a layout implementation
- schedule_render = lambda: ...
-
- # --- start life cycle ---
-
- hook = LifeCycleHook(schedule_render)
-
- # --- start render cycle ---
-
- hook.affect_component_will_render(...)
-
- hook.set_current()
-
- try:
- # render the component
- ...
-
- # the component may access the current hook
- assert current_hook() is hook
-
- # and save state or add effects
- current_hook().use_state(lambda: ...)
- current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...)
- finally:
- hook.unset_current()
-
- hook.affect_component_did_render()
-
- # This should only be called after the full set of changes associated with a
- # given render have been completed.
- hook.affect_layout_did_render()
-
- # Typically an event occurs and a new render is scheduled, thus beginning
- # the render cycle anew.
- hook.schedule_render()
-
-
- # --- end render cycle ---
-
- hook.affect_component_will_unmount()
- del hook
-
- # --- end render cycle ---
- """
-
- __slots__ = (
- "__weakref__",
- "_context_providers",
- "_current_state_index",
- "_event_effects",
- "_is_rendering",
- "_rendered_atleast_once",
- "_schedule_render_callback",
- "_schedule_render_later",
- "_state",
- "component",
- )
-
- component: ComponentType
-
- def __init__(
- self,
- schedule_render: Callable[[], None],
- ) -> None:
- self._context_providers: dict[Context[Any], ContextProvider[Any]] = {}
- self._schedule_render_callback = schedule_render
- self._schedule_render_later = False
- self._is_rendering = False
- self._rendered_atleast_once = False
- self._current_state_index = 0
- self._state: tuple[Any, ...] = ()
- self._event_effects: dict[EffectType, list[Callable[[], None]]] = {
- COMPONENT_DID_RENDER_EFFECT: [],
- LAYOUT_DID_RENDER_EFFECT: [],
- COMPONENT_WILL_UNMOUNT_EFFECT: [],
- }
-
- def schedule_render(self) -> None:
- if self._is_rendering:
- self._schedule_render_later = True
- else:
- self._schedule_render()
-
- def use_state(self, function: Callable[[], _Type]) -> _Type:
- if not self._rendered_atleast_once:
- # since we're not initialized yet we're just appending state
- result = function()
- self._state += (result,)
- else:
- # once finalized we iterate over each succesively used piece of state
- result = self._state[self._current_state_index]
- self._current_state_index += 1
- return result
-
- def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None:
- """Trigger a function on the occurrence of the given effect type"""
- self._event_effects[effect_type].append(function)
-
- def set_context_provider(self, provider: ContextProvider[Any]) -> None:
- self._context_providers[provider.type] = provider
-
- def get_context_provider(
- self, context: Context[_Type]
- ) -> ContextProvider[_Type] | None:
- return self._context_providers.get(context)
-
- def affect_component_will_render(self, component: ComponentType) -> None:
- """The component is about to render"""
- self.component = component
-
- self._is_rendering = True
- self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear()
-
- def affect_component_did_render(self) -> None:
- """The component completed a render"""
- del self.component
-
- component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT]
- for effect in component_did_render_effects:
- try:
- effect()
- except Exception:
- logger.exception(f"Component post-render effect {effect} failed")
- component_did_render_effects.clear()
-
- self._is_rendering = False
- self._rendered_atleast_once = True
- self._current_state_index = 0
-
- def affect_layout_did_render(self) -> None:
- """The layout completed a render"""
- layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT]
- for effect in layout_did_render_effects:
- try:
- effect()
- except Exception:
- logger.exception(f"Layout post-render effect {effect} failed")
- layout_did_render_effects.clear()
-
- if self._schedule_render_later:
- self._schedule_render()
- self._schedule_render_later = False
-
- def affect_component_will_unmount(self) -> None:
- """The component is about to be removed from the layout"""
- will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT]
- for effect in will_unmount_effects:
- try:
- effect()
- except Exception:
- logger.exception(f"Pre-unmount effect {effect} failed")
- will_unmount_effects.clear()
-
- def set_current(self) -> None:
- """Set this hook as the active hook in this thread
-
- This method is called by a layout before entering the render method
- of this hook's associated component.
- """
- hook_stack = _hook_stack.get()
- if hook_stack:
- parent = hook_stack[-1]
- self._context_providers.update(parent._context_providers)
- hook_stack.append(self)
-
- def unset_current(self) -> None:
- """Unset this hook as the active hook in this thread"""
- if _hook_stack.get().pop() is not self:
- raise RuntimeError("Hook stack is in an invalid state") # nocov
-
- def _schedule_render(self) -> None:
- try:
- self._schedule_render_callback()
- except Exception:
- logger.exception(
- f"Failed to schedule render via {self._schedule_render_callback}"
- )
-
-
def strictly_equal(x: Any, y: Any) -> bool:
"""Check if two values are identical or, for a limited set or types, equal.
diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py
index f84cb104e..f45becf7a 100644
--- a/src/py/reactpy/reactpy/core/layout.py
+++ b/src/py/reactpy/reactpy/core/layout.py
@@ -1,10 +1,18 @@
from __future__ import annotations
import abc
-import asyncio
+from asyncio import (
+ FIRST_COMPLETED,
+ CancelledError,
+ Queue,
+ Task,
+ create_task,
+ get_running_loop,
+ wait,
+)
from collections import Counter
-from collections.abc import Iterator
-from contextlib import ExitStack
+from collections.abc import Sequence
+from contextlib import AsyncExitStack
from logging import getLogger
from typing import (
Any,
@@ -18,13 +26,22 @@
from uuid import uuid4
from weakref import ref as weakref
-from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE
-from reactpy.core.hooks import LifeCycleHook
+from anyio import Semaphore
+from typing_extensions import TypeAlias
+
+from reactpy.config import (
+ REACTPY_ASYNC_RENDERING,
+ REACTPY_CHECK_VDOM_SPEC,
+ REACTPY_DEBUG_MODE,
+)
+from reactpy.core._life_cycle_hook import LifeCycleHook
from reactpy.core.types import (
ComponentType,
EventHandlerDict,
+ Key,
LayoutEventMessage,
LayoutUpdateMessage,
+ VdomChild,
VdomDict,
VdomJson,
)
@@ -41,6 +58,8 @@ class Layout:
"root",
"_event_handlers",
"_rendering_queue",
+ "_render_tasks",
+ "_render_tasks_ready",
"_root_life_cycle_state_id",
"_model_states_by_life_cycle_state_id",
)
@@ -58,21 +77,30 @@ def __init__(self, root: ComponentType) -> None:
async def __aenter__(self) -> Layout:
# create attributes here to avoid access before entering context manager
self._event_handlers: EventHandlerDict = {}
+ self._render_tasks: set[Task[LayoutUpdateMessage]] = set()
+ self._render_tasks_ready: Semaphore = Semaphore(0)
self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue()
- root_model_state = _new_root_model_state(self.root, self._rendering_queue.put)
+ root_model_state = _new_root_model_state(self.root, self._schedule_render_task)
self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id
- self._rendering_queue.put(root_id)
-
self._model_states_by_life_cycle_state_id = {root_id: root_model_state}
+ self._schedule_render_task(root_id)
return self
async def __aexit__(self, *exc: Any) -> None:
root_csid = self._root_life_cycle_state_id
root_model_state = self._model_states_by_life_cycle_state_id[root_csid]
- self._unmount_model_states([root_model_state])
+
+ for t in self._render_tasks:
+ t.cancel()
+ try:
+ await t
+ except CancelledError:
+ pass
+
+ await self._unmount_model_states([root_model_state])
# delete attributes here to avoid access after exiting context manager
del self._event_handlers
@@ -100,6 +128,12 @@ async def deliver(self, event: LayoutEventMessage) -> None:
)
async def render(self) -> LayoutUpdateMessage:
+ if REACTPY_ASYNC_RENDERING.current:
+ return await self._parallel_render()
+ else: # nocov
+ return await self._serial_render()
+
+ async def _serial_render(self) -> LayoutUpdateMessage: # nocov
"""Await the next available render. This will block until a component is updated"""
while True:
model_state_id = await self._rendering_queue.get()
@@ -111,19 +145,29 @@ async def render(self) -> LayoutUpdateMessage:
f"{model_state_id!r} - component already unmounted"
)
else:
- update = self._create_layout_update(model_state)
- if REACTPY_CHECK_VDOM_SPEC.current:
- root_id = self._root_life_cycle_state_id
- root_model = self._model_states_by_life_cycle_state_id[root_id]
- validate_vdom_json(root_model.model.current)
- return update
-
- def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage:
+ return await self._create_layout_update(model_state)
+
+ async def _parallel_render(self) -> LayoutUpdateMessage:
+ """Await to fetch the first completed render within our asyncio task group.
+ We use the `asyncio.tasks.wait` API in order to return the first completed task.
+ """
+ await self._render_tasks_ready.acquire()
+ done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED)
+ update_task: Task[LayoutUpdateMessage] = done.pop()
+ self._render_tasks.remove(update_task)
+ return update_task.result()
+
+ async def _create_layout_update(
+ self, old_state: _ModelState
+ ) -> LayoutUpdateMessage:
new_state = _copy_component_model_state(old_state)
component = new_state.life_cycle_state.component
- with ExitStack() as exit_stack:
- self._render_component(exit_stack, old_state, new_state, component)
+ async with AsyncExitStack() as exit_stack:
+ await self._render_component(exit_stack, old_state, new_state, component)
+
+ if REACTPY_CHECK_VDOM_SPEC.current:
+ validate_vdom_json(new_state.model.current)
return {
"type": "layout-update",
@@ -131,9 +175,9 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage:
"model": new_state.model.current,
}
- def _render_component(
+ async def _render_component(
self,
- exit_stack: ExitStack,
+ exit_stack: AsyncExitStack,
old_state: _ModelState | None,
new_state: _ModelState,
component: ComponentType,
@@ -143,18 +187,15 @@ def _render_component(
self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state
- life_cycle_hook.affect_component_will_render(component)
- exit_stack.callback(life_cycle_hook.affect_layout_did_render)
- life_cycle_hook.set_current()
+ await life_cycle_hook.affect_component_will_render(component)
+ exit_stack.push_async_callback(life_cycle_hook.affect_layout_did_render)
try:
raw_model = component.render()
# wrap the model in a fragment (i.e. tagName="") to ensure components have
# a separate node in the model state tree. This could be removed if this
# components are given a node in the tree some other way
- wrapper_model: VdomDict = {"tagName": ""}
- if raw_model is not None:
- wrapper_model["children"] = [raw_model]
- self._render_model(exit_stack, old_state, new_state, wrapper_model)
+ wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]}
+ await self._render_model(exit_stack, old_state, new_state, wrapper_model)
except Exception as error:
logger.exception(f"Failed to render {component}")
new_state.model.current = {
@@ -166,8 +207,7 @@ def _render_component(
),
}
finally:
- life_cycle_hook.unset_current()
- life_cycle_hook.affect_component_did_render()
+ await life_cycle_hook.affect_component_did_render()
try:
parent = new_state.parent
@@ -180,7 +220,7 @@ def _render_component(
old_parent_model = parent.model.current
old_parent_children = old_parent_model["children"]
parent.model.current = {
- **old_parent_model, # type: ignore[misc]
+ **old_parent_model,
"children": [
*old_parent_children[:index],
new_state.model.current,
@@ -188,9 +228,9 @@ def _render_component(
],
}
- def _render_model(
+ async def _render_model(
self,
- exit_stack: ExitStack,
+ exit_stack: AsyncExitStack,
old_state: _ModelState | None,
new_state: _ModelState,
raw_model: Any,
@@ -205,7 +245,7 @@ def _render_model(
if "importSource" in raw_model:
new_state.model.current["importSource"] = raw_model["importSource"]
self._render_model_attributes(old_state, new_state, raw_model)
- self._render_model_children(
+ await self._render_model_children(
exit_stack, old_state, new_state, raw_model.get("children", [])
)
@@ -272,9 +312,9 @@ def _render_model_event_handlers_without_old_state(
return None
- def _render_model_children(
+ async def _render_model_children(
self,
- exit_stack: ExitStack,
+ exit_stack: AsyncExitStack,
old_state: _ModelState | None,
new_state: _ModelState,
raw_children: Any,
@@ -284,31 +324,31 @@ def _render_model_children(
if old_state is None:
if raw_children:
- self._render_model_children_without_old_state(
+ await self._render_model_children_without_old_state(
exit_stack, new_state, raw_children
)
return None
elif not raw_children:
- self._unmount_model_states(list(old_state.children_by_key.values()))
+ await self._unmount_model_states(list(old_state.children_by_key.values()))
return None
- child_type_key_tuples = list(_process_child_type_and_key(raw_children))
+ children_info = _get_children_info(raw_children)
- new_keys = {item[2] for item in child_type_key_tuples}
- if len(new_keys) != len(raw_children):
- key_counter = Counter(item[2] for item in child_type_key_tuples)
+ new_keys = {k for _, _, k in children_info}
+ if len(new_keys) != len(children_info):
+ key_counter = Counter(item[2] for item in children_info)
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
raise ValueError(msg)
old_keys = set(old_state.children_by_key).difference(new_keys)
if old_keys:
- self._unmount_model_states(
+ await self._unmount_model_states(
[old_state.children_by_key[key] for key in old_keys]
)
new_state.model.current["children"] = []
- for index, (child, child_type, key) in enumerate(child_type_key_tuples):
+ for index, (child, child_type, key) in enumerate(children_info):
old_child_state = old_state.children_by_key.get(key)
if child_type is _DICT_TYPE:
old_child_state = old_state.children_by_key.get(key)
@@ -319,7 +359,7 @@ def _render_model_children(
key,
)
elif old_child_state.is_component_state:
- self._unmount_model_states([old_child_state])
+ await self._unmount_model_states([old_child_state])
new_child_state = _make_element_model_state(
new_state,
index,
@@ -332,7 +372,9 @@ def _render_model_children(
new_state,
index,
)
- self._render_model(exit_stack, old_child_state, new_child_state, child)
+ await self._render_model(
+ exit_stack, old_child_state, new_child_state, child
+ )
new_state.append_child(new_child_state.model.current)
new_state.children_by_key[key] = new_child_state
elif child_type is _COMPONENT_TYPE:
@@ -344,19 +386,19 @@ def _render_model_children(
index,
key,
child,
- self._rendering_queue.put,
+ self._schedule_render_task,
)
elif old_child_state.is_component_state and (
old_child_state.life_cycle_state.component.type != child.type
):
- self._unmount_model_states([old_child_state])
+ await self._unmount_model_states([old_child_state])
old_child_state = None
new_child_state = _make_component_model_state(
new_state,
index,
key,
child,
- self._rendering_queue.put,
+ self._schedule_render_task,
)
else:
new_child_state = _update_component_model_state(
@@ -364,48 +406,48 @@ def _render_model_children(
new_state,
index,
child,
- self._rendering_queue.put,
+ self._schedule_render_task,
)
- self._render_component(
+ await self._render_component(
exit_stack, old_child_state, new_child_state, child
)
else:
old_child_state = old_state.children_by_key.get(key)
if old_child_state is not None:
- self._unmount_model_states([old_child_state])
+ await self._unmount_model_states([old_child_state])
new_state.append_child(child)
- def _render_model_children_without_old_state(
+ async def _render_model_children_without_old_state(
self,
- exit_stack: ExitStack,
+ exit_stack: AsyncExitStack,
new_state: _ModelState,
raw_children: list[Any],
) -> None:
- child_type_key_tuples = list(_process_child_type_and_key(raw_children))
+ children_info = _get_children_info(raw_children)
- new_keys = {item[2] for item in child_type_key_tuples}
- if len(new_keys) != len(raw_children):
- key_counter = Counter(item[2] for item in child_type_key_tuples)
+ new_keys = {k for _, _, k in children_info}
+ if len(new_keys) != len(children_info):
+ key_counter = Counter(k for _, _, k in children_info)
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
raise ValueError(msg)
new_state.model.current["children"] = []
- for index, (child, child_type, key) in enumerate(child_type_key_tuples):
+ for index, (child, child_type, key) in enumerate(children_info):
if child_type is _DICT_TYPE:
child_state = _make_element_model_state(new_state, index, key)
- self._render_model(exit_stack, None, child_state, child)
+ await self._render_model(exit_stack, None, child_state, child)
new_state.append_child(child_state.model.current)
new_state.children_by_key[key] = child_state
elif child_type is _COMPONENT_TYPE:
child_state = _make_component_model_state(
- new_state, index, key, child, self._rendering_queue.put
+ new_state, index, key, child, self._schedule_render_task
)
- self._render_component(exit_stack, None, child_state, child)
+ await self._render_component(exit_stack, None, child_state, child)
else:
new_state.append_child(child)
- def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
+ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
to_unmount = old_states[::-1] # unmount in reversed order of rendering
while to_unmount:
model_state = to_unmount.pop()
@@ -416,10 +458,25 @@ def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
if model_state.is_component_state:
life_cycle_state = model_state.life_cycle_state
del self._model_states_by_life_cycle_state_id[life_cycle_state.id]
- life_cycle_state.hook.affect_component_will_unmount()
+ await life_cycle_state.hook.affect_component_will_unmount()
to_unmount.extend(model_state.children_by_key.values())
+ def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None:
+ if not REACTPY_ASYNC_RENDERING.current:
+ self._rendering_queue.put(lcs_id)
+ return None
+ try:
+ model_state = self._model_states_by_life_cycle_state_id[lcs_id]
+ except KeyError:
+ logger.debug(
+ "Did not render component with model state ID "
+ f"{lcs_id!r} - component already unmounted"
+ )
+ else:
+ self._render_tasks.add(create_task(self._create_layout_update(model_state)))
+ self._render_tasks_ready.release()
+
def __repr__(self) -> str:
return f"{type(self).__name__}({self.root})"
@@ -538,6 +595,7 @@ class _ModelState:
__slots__ = (
"__weakref__",
"_parent_ref",
+ "_render_semaphore",
"children_by_key",
"index",
"key",
@@ -554,7 +612,7 @@ def __init__(
key: Any,
model: Ref[VdomJson],
patch_path: str,
- children_by_key: dict[str, _ModelState],
+ children_by_key: dict[Key, _ModelState],
targets_by_event: dict[str, str],
life_cycle_state: _LifeCycleState | None = None,
):
@@ -649,11 +707,9 @@ class _LifeCycleState(NamedTuple):
class _ThreadSafeQueue(Generic[_Type]):
- __slots__ = "_loop", "_queue", "_pending"
-
def __init__(self) -> None:
- self._loop = asyncio.get_running_loop()
- self._queue: asyncio.Queue[_Type] = asyncio.Queue()
+ self._loop = get_running_loop()
+ self._queue: Queue[_Type] = Queue()
self._pending: set[_Type] = set()
def put(self, value: _Type) -> None:
@@ -662,24 +718,22 @@ def put(self, value: _Type) -> None:
self._loop.call_soon_threadsafe(self._queue.put_nowait, value)
async def get(self) -> _Type:
- while True:
- value = await self._queue.get()
- if value in self._pending:
- break
+ value = await self._queue.get()
self._pending.remove(value)
return value
-def _process_child_type_and_key(
- children: list[Any],
-) -> Iterator[tuple[Any, _ElementType, Any]]:
+def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]:
+ infos: list[_ChildInfo] = []
for index, child in enumerate(children):
- if isinstance(child, dict):
+ if child is None:
+ continue
+ elif isinstance(child, dict):
child_type = _DICT_TYPE
key = child.get("key")
elif isinstance(child, ComponentType):
child_type = _COMPONENT_TYPE
- key = getattr(child, "key", None)
+ key = child.key
else:
child = f"{child}"
child_type = _STRING_TYPE
@@ -688,8 +742,12 @@ def _process_child_type_and_key(
if key is None:
key = index
- yield (child, child_type, key)
+ infos.append((child, child_type, key))
+
+ return infos
+
+_ChildInfo: TypeAlias = tuple[Any, "_ElementType", Key]
# used in _process_child_type_and_key
_ElementType = NewType("_ElementType", int)
diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py
index 3a530e854..3a540af59 100644
--- a/src/py/reactpy/reactpy/core/serve.py
+++ b/src/py/reactpy/reactpy/core/serve.py
@@ -3,6 +3,7 @@
from collections.abc import Awaitable
from logging import getLogger
from typing import Callable
+from warnings import warn
from anyio import create_task_group
from anyio.abc import TaskGroup
@@ -24,7 +25,9 @@
class Stop(BaseException):
- """Stop serving changes and events
+ """Deprecated
+
+ Stop serving changes and events
Raising this error will tell dispatchers to gracefully exit. Typically this is
called by code running inside a layout to tell it to stop rendering.
@@ -42,7 +45,12 @@ async def serve_layout(
async with create_task_group() as task_group:
task_group.start_soon(_single_outgoing_loop, layout, send)
task_group.start_soon(_single_incoming_loop, task_group, layout, recv)
- except Stop:
+ except Stop: # nocov
+ warn(
+ "The Stop exception is deprecated and will be removed in a future version",
+ UserWarning,
+ stacklevel=1,
+ )
logger.info(f"Stopped serving {layout}")
diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py
index 194706c6e..b451be30a 100644
--- a/src/py/reactpy/reactpy/core/types.py
+++ b/src/py/reactpy/reactpy/core/types.py
@@ -91,7 +91,7 @@ async def __aexit__(
VdomAttributes = Mapping[str, Any]
"""Describes the attributes of a :class:`VdomDict`"""
-VdomChild: TypeAlias = "ComponentType | VdomDict | str"
+VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any"
"""A single child element of a :class:`VdomDict`"""
VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild"
@@ -100,14 +100,7 @@ async def __aexit__(
class _VdomDictOptional(TypedDict, total=False):
key: Key | None
- children: Sequence[
- # recursive types are not allowed yet:
- # https://github.com/python/mypy/issues/731
- ComponentType
- | dict[str, Any]
- | str
- | Any
- ]
+ children: Sequence[ComponentType | VdomChild]
attributes: VdomAttributes
eventHandlers: EventHandlerDict
importSource: ImportSourceDict
@@ -166,8 +159,7 @@ class _JsonImportSource(TypedDict):
class EventHandlerFunc(Protocol):
"""A coroutine which can handle event data"""
- async def __call__(self, data: Sequence[Any]) -> None:
- ...
+ async def __call__(self, data: Sequence[Any]) -> None: ...
@runtime_checkable
@@ -199,18 +191,17 @@ class VdomDictConstructor(Protocol):
"""Standard function for constructing a :class:`VdomDict`"""
@overload
- def __call__(self, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict:
- ...
+ def __call__(
+ self, attributes: VdomAttributes, *children: VdomChildren
+ ) -> VdomDict: ...
@overload
- def __call__(self, *children: VdomChildren) -> VdomDict:
- ...
+ def __call__(self, *children: VdomChildren) -> VdomDict: ...
@overload
def __call__(
self, *attributes_and_children: VdomAttributes | VdomChildren
- ) -> VdomDict:
- ...
+ ) -> VdomDict: ...
class LayoutUpdateMessage(TypedDict):
@@ -233,3 +224,25 @@ class LayoutEventMessage(TypedDict):
"""The ID of the event handler."""
data: Sequence[Any]
"""A list of event data passed to the event handler."""
+
+
+class Context(Protocol[_Type]):
+ """Returns a :class:`ContextProvider` component"""
+
+ def __call__(
+ self,
+ *children: Any,
+ value: _Type = ...,
+ key: Key | None = ...,
+ ) -> ContextProviderType[_Type]: ...
+
+
+class ContextProviderType(ComponentType, Protocol[_Type]):
+ """A component which provides a context value to its children"""
+
+ type: Context[_Type]
+ """The context type"""
+
+ @property
+ def value(self) -> _Type:
+ "Current context value"
diff --git a/src/py/reactpy/reactpy/core/vdom.py b/src/py/reactpy/reactpy/core/vdom.py
index 840a09c7c..e494b5269 100644
--- a/src/py/reactpy/reactpy/core/vdom.py
+++ b/src/py/reactpy/reactpy/core/vdom.py
@@ -125,13 +125,11 @@ def is_vdom(value: Any) -> bool:
@overload
-def vdom(tag: str, *children: VdomChildren) -> VdomDict:
- ...
+def vdom(tag: str, *children: VdomChildren) -> VdomDict: ...
@overload
-def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict:
- ...
+def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: ...
def vdom(
@@ -345,8 +343,7 @@ def __call__(
children: Sequence[VdomChild],
key: Key | None,
event_handlers: EventHandlerDict,
- ) -> VdomDict:
- ...
+ ) -> VdomDict: ...
class _EllipsisRepr:
diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py
index 6d126fd2e..c1eb18ba5 100644
--- a/src/py/reactpy/reactpy/testing/common.py
+++ b/src/py/reactpy/reactpy/testing/common.py
@@ -13,8 +13,8 @@
from typing_extensions import ParamSpec
from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR
+from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook
from reactpy.core.events import EventHandler, to_event_handler_function
-from reactpy.core.hooks import LifeCycleHook, current_hook
def clear_reactpy_web_modules_dir() -> None:
@@ -67,7 +67,7 @@ async def until(
break
elif (time.time() - started_at) > timeout: # nocov
msg = f"Expected {description} after {timeout} seconds - last value was {result!r}"
- raise TimeoutError(msg)
+ raise asyncio.TimeoutError(msg)
async def until_is(
self,
diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py
index 4766fe801..1ac04395a 100644
--- a/src/py/reactpy/reactpy/types.py
+++ b/src/py/reactpy/reactpy/types.py
@@ -6,10 +6,10 @@
from reactpy.backend.types import BackendType, Connection, Location
from reactpy.core.component import Component
-from reactpy.core.hooks import Context
from reactpy.core.types import (
ComponentConstructor,
ComponentType,
+ Context,
EventHandlerDict,
EventHandlerFunc,
EventHandlerMapping,
diff --git a/src/py/reactpy/reactpy/web/module.py b/src/py/reactpy/reactpy/web/module.py
index 48322fe24..e1a5db82f 100644
--- a/src/py/reactpy/reactpy/web/module.py
+++ b/src/py/reactpy/reactpy/web/module.py
@@ -145,7 +145,7 @@ def module_from_template(
raise ValueError(msg)
variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version}
- content = Template(template_file.read_text()).substitute(variables)
+ content = Template(template_file.read_text(encoding="utf-8")).substitute(variables)
return module_from_string(
_FROM_TEMPLATE_DIR + "/" + package_name,
@@ -270,7 +270,7 @@ def module_from_string(
target_file = _web_module_path(name)
- if target_file.exists() and target_file.read_text() != content:
+ if target_file.exists() and target_file.read_text(encoding="utf-8") != content:
logger.info(
f"Existing web module {name!r} will "
f"be replaced with {target_file.resolve()}"
@@ -314,8 +314,7 @@ def export(
export_names: str,
fallback: Any | None = ...,
allow_children: bool = ...,
-) -> VdomDictConstructor:
- ...
+) -> VdomDictConstructor: ...
@overload
@@ -324,8 +323,7 @@ def export(
export_names: list[str] | tuple[str, ...],
fallback: Any | None = ...,
allow_children: bool = ...,
-) -> list[VdomDictConstructor]:
- ...
+) -> list[VdomDictConstructor]: ...
def export(
diff --git a/src/py/reactpy/reactpy/web/utils.py b/src/py/reactpy/reactpy/web/utils.py
index 295559496..338fa504a 100644
--- a/src/py/reactpy/reactpy/web/utils.py
+++ b/src/py/reactpy/reactpy/web/utils.py
@@ -29,7 +29,7 @@ def resolve_module_exports_from_file(
return set()
export_names, references = resolve_module_exports_from_source(
- file.read_text(), exclude_default=is_re_export
+ file.read_text(encoding="utf-8"), exclude_default=is_re_export
)
for ref in references:
diff --git a/src/py/reactpy/reactpy/widgets.py b/src/py/reactpy/reactpy/widgets.py
index 29f941447..63b45a7e0 100644
--- a/src/py/reactpy/reactpy/widgets.py
+++ b/src/py/reactpy/reactpy/widgets.py
@@ -82,8 +82,7 @@ def sync_inputs(event: dict[str, Any]) -> None:
class _CastFunc(Protocol[_CastTo_co]):
- def __call__(self, value: str) -> _CastTo_co:
- ...
+ def __call__(self, value: str) -> _CastTo_co: ...
if TYPE_CHECKING:
diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py
index 21b23c12e..743d67f02 100644
--- a/src/py/reactpy/tests/conftest.py
+++ b/src/py/reactpy/tests/conftest.py
@@ -8,14 +8,18 @@
from _pytest.config.argparsing import Parser
from playwright.async_api import async_playwright
-from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
+from reactpy.config import (
+ REACTPY_ASYNC_RENDERING,
+ REACTPY_TESTING_DEFAULT_TIMEOUT,
+)
from reactpy.testing import (
BackendFixture,
DisplayFixture,
capture_reactpy_logs,
clear_reactpy_web_modules_dir,
)
-from tests.tooling.loop import open_event_loop
+
+REACTPY_ASYNC_RENDERING.current = True
def pytest_addoption(parser: Parser) -> None:
@@ -33,13 +37,13 @@ async def display(server, page):
yield display
-@pytest.fixture(scope="session")
+@pytest.fixture
async def server():
async with BackendFixture() as server:
yield server
-@pytest.fixture(scope="session")
+@pytest.fixture
async def page(browser):
pg = await browser.new_page()
pg.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000)
@@ -49,18 +53,18 @@ async def page(browser):
await pg.close()
-@pytest.fixture(scope="session")
+@pytest.fixture
async def browser(pytestconfig: Config):
async with async_playwright() as pw:
yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed))
@pytest.fixture(scope="session")
-def event_loop():
+def event_loop_policy():
if os.name == "nt": # nocov
- asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
- with open_event_loop() as loop:
- yield loop
+ return asyncio.WindowsProactorEventLoopPolicy()
+ else:
+ return asyncio.DefaultEventLoopPolicy()
@pytest.fixture(autouse=True)
diff --git a/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py b/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py
index 47b8baabc..ca928cf3b 100644
--- a/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py
+++ b/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py
@@ -106,9 +106,9 @@ def test_rewrite_camel_case_props_declarations_no_files():
None,
),
],
- ids=lambda item: " ".join(map(str.strip, item.split()))
- if isinstance(item, str)
- else item,
+ ids=lambda item: (
+ " ".join(map(str.strip, item.split())) if isinstance(item, str) else item
+ ),
)
def test_generate_rewrite(source, expected):
actual = generate_rewrite(Path("test.py"), dedent(source).strip())
diff --git a/src/py/reactpy/tests/test__console/test_rewrite_keys.py b/src/py/reactpy/tests/test__console/test_rewrite_keys.py
index da0b26c4f..95c49a019 100644
--- a/src/py/reactpy/tests/test__console/test_rewrite_keys.py
+++ b/src/py/reactpy/tests/test__console/test_rewrite_keys.py
@@ -225,9 +225,9 @@ def func():
None,
),
],
- ids=lambda item: " ".join(map(str.strip, item.split()))
- if isinstance(item, str)
- else item,
+ ids=lambda item: (
+ " ".join(map(str.strip, item.split())) if isinstance(item, str) else item
+ ),
)
def test_generate_rewrite(source, expected):
actual = generate_rewrite(Path("test.py"), dedent(source).strip())
diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py
index d697e5d3f..dc8ec1284 100644
--- a/src/py/reactpy/tests/test_backend/test_all.py
+++ b/src/py/reactpy/tests/test_backend/test_all.py
@@ -14,7 +14,6 @@
@pytest.fixture(
params=[*list(all_implementations()), default_implementation],
ids=lambda imp: imp.__name__,
- scope="module",
)
async def display(page, request):
imp: BackendType = request.param
diff --git a/src/py/reactpy/tests/test_client.py b/src/py/reactpy/tests/test_client.py
index 3c7250e48..a9ff10a89 100644
--- a/src/py/reactpy/tests/test_client.py
+++ b/src/py/reactpy/tests/test_client.py
@@ -30,6 +30,11 @@ def SomeComponent():
),
)
+ async def get_count():
+ # need to refetch element because may unmount on reconnect
+ count = await page.wait_for_selector("#count")
+ return await count.get_attribute("data-count")
+
async with AsyncExitStack() as exit_stack:
server = await exit_stack.enter_async_context(BackendFixture(port=port))
display = await exit_stack.enter_async_context(
@@ -38,11 +43,10 @@ def SomeComponent():
await display.show(SomeComponent)
- count = await page.wait_for_selector("#count")
incr = await page.wait_for_selector("#incr")
for i in range(3):
- assert (await count.get_attribute("data-count")) == str(i)
+ await poll(get_count).until_equals(str(i))
await incr.click()
# the server is disconnected but the last view state is still shown
@@ -57,13 +61,7 @@ def SomeComponent():
# use mount instead of show to avoid a page refresh
display.backend.mount(SomeComponent)
- async def get_count():
- # need to refetch element because may unmount on reconnect
- count = await page.wait_for_selector("#count")
- return await count.get_attribute("data-count")
-
for i in range(3):
- # it may take a moment for the websocket to reconnect so need to poll
await poll(get_count).until_equals(str(i))
# need to refetch element because may unmount on reconnect
@@ -98,11 +96,15 @@ def ButtonWithChangingColor():
button = await display.page.wait_for_selector("#my-button")
- assert (await _get_style(button))["background-color"] == "red"
+ await poll(_get_style, button).until(
+ lambda style: style["background-color"] == "red"
+ )
for color in ["blue", "red"] * 2:
await button.click()
- assert (await _get_style(button))["background-color"] == color
+ await poll(_get_style, button).until(
+ lambda style, c=color: style["background-color"] == c
+ )
async def _get_style(element):
diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py
index 453d07c99..5b8f71c62 100644
--- a/src/py/reactpy/tests/test_core/test_hooks.py
+++ b/src/py/reactpy/tests/test_core/test_hooks.py
@@ -5,12 +5,8 @@
import reactpy
from reactpy import html
from reactpy.config import REACTPY_DEBUG_MODE
-from reactpy.core.hooks import (
- COMPONENT_DID_RENDER_EFFECT,
- LifeCycleHook,
- current_hook,
- strictly_equal,
-)
+from reactpy.core._life_cycle_hook import LifeCycleHook
+from reactpy.core.hooks import strictly_equal, use_effect
from reactpy.core.layout import Layout
from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll
from reactpy.testing.logs import assert_reactpy_did_not_log
@@ -32,10 +28,15 @@ def SimpleComponentWithHook():
async def test_simple_stateful_component():
+ index = 0
+
+ def set_index(x):
+ return None
+
@reactpy.component
def SimpleStatefulComponent():
+ nonlocal index, set_index
index, set_index = reactpy.hooks.use_state(0)
- set_index(index + 1)
return reactpy.html.div(index)
sse = SimpleStatefulComponent()
@@ -49,6 +50,7 @@ def SimpleStatefulComponent():
"children": [{"tagName": "div", "children": ["0"]}],
},
)
+ set_index(index + 1)
update_2 = await layout.render()
assert update_2 == update_message(
@@ -58,6 +60,7 @@ def SimpleStatefulComponent():
"children": [{"tagName": "div", "children": ["1"]}],
},
)
+ set_index(index + 1)
update_3 = await layout.render()
assert update_3 == update_message(
@@ -278,18 +281,18 @@ def double_set_state(event):
first = await display.page.wait_for_selector("#first")
second = await display.page.wait_for_selector("#second")
- assert (await first.get_attribute("data-value")) == "0"
- assert (await second.get_attribute("data-value")) == "0"
+ await poll(first.get_attribute, "data-value").until_equals("0")
+ await poll(second.get_attribute, "data-value").until_equals("0")
await button.click()
- assert (await first.get_attribute("data-value")) == "1"
- assert (await second.get_attribute("data-value")) == "1"
+ await poll(first.get_attribute, "data-value").until_equals("1")
+ await poll(second.get_attribute, "data-value").until_equals("1")
await button.click()
- assert (await first.get_attribute("data-value")) == "2"
- assert (await second.get_attribute("data-value")) == "2"
+ await poll(first.get_attribute, "data-value").until_equals("2")
+ await poll(second.get_attribute, "data-value").until_equals("2")
async def test_use_effect_callback_occurs_after_full_render_is_complete():
@@ -562,7 +565,7 @@ def bad_effect():
return reactpy.html.div()
- with assert_reactpy_did_log(match_message=r"Layout post-render effect .* failed"):
+ with assert_reactpy_did_log(match_message=r"Error in effect"):
async with reactpy.Layout(ComponentWithEffect()) as layout:
await layout.render() # no error
@@ -588,7 +591,7 @@ def bad_cleanup():
return reactpy.html.div()
with assert_reactpy_did_log(
- match_message=r"Pre-unmount effect .*? failed",
+ match_message=r"Error in effect",
error_type=ValueError,
):
async with reactpy.Layout(OuterComponent()) as layout:
@@ -1007,7 +1010,7 @@ def bad_effect():
return reactpy.html.div()
with assert_reactpy_did_log(
- match_message=r"post-render effect .*? failed",
+ match_message=r"Error in effect",
error_type=ValueError,
match_error="The error message",
):
@@ -1030,13 +1033,15 @@ def SetStateDuringRender():
async with Layout(SetStateDuringRender()) as layout:
await layout.render()
- assert render_count.current == 1
- await layout.render()
- assert render_count.current == 2
- # there should be no more renders to perform
- with pytest.raises(asyncio.TimeoutError):
- await asyncio.wait_for(layout.render(), timeout=0.1)
+ # we expect a second render to be triggered in the background
+ await poll(lambda: render_count.current).until_equals(2)
+
+ # give an opportunity for a render to happen if it were to.
+ await asyncio.sleep(0.1)
+
+ # however, we don't expect any more renders
+ assert render_count.current == 2
@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode")
@@ -1199,7 +1204,7 @@ def SomeComponent():
@pytest.mark.parametrize("get_value", STRICT_EQUALITY_VALUE_CONSTRUCTORS)
async def test_use_effect_compares_with_strict_equality(get_value):
effect_count = reactpy.Ref(0)
- value = reactpy.Ref("string")
+ value = reactpy.Ref(get_value())
hook = HookCatcher()
@reactpy.component
@@ -1212,7 +1217,7 @@ def incr_effect_count():
async with reactpy.Layout(SomeComponent()) as layout:
await layout.render()
assert effect_count.current == 1
- value.current = "string" # new string instance but same value
+ value.current = get_value()
hook.latest.schedule_render()
await layout.render()
# effect does not trigger
@@ -1240,16 +1245,17 @@ async def test_error_in_component_effect_cleanup_is_gracefully_handled():
@reactpy.component
@component_hook.capture
def ComponentWithEffect():
- hook = current_hook()
+ @use_effect
+ def effect():
+ def bad_cleanup():
+ raise ValueError("The error message")
- def bad_effect():
- raise ValueError("The error message")
+ return bad_cleanup
- hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect)
return reactpy.html.div()
with assert_reactpy_did_log(
- match_message="Component post-render effect .*? failed",
+ match_message="Error in effect",
error_type=ValueError,
match_error="The error message",
):
diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py
index 215e89137..f93ffeb3d 100644
--- a/src/py/reactpy/tests/test_core/test_layout.py
+++ b/src/py/reactpy/tests/test_core/test_layout.py
@@ -2,6 +2,7 @@
import gc
import random
import re
+from unittest.mock import patch
from weakref import finalize
from weakref import ref as weakref
@@ -9,7 +10,7 @@
import reactpy
from reactpy import html
-from reactpy.config import REACTPY_DEBUG_MODE
+from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG_MODE
from reactpy.core.component import component
from reactpy.core.hooks import use_effect, use_state
from reactpy.core.layout import Layout
@@ -20,14 +21,22 @@
assert_reactpy_did_log,
capture_reactpy_logs,
)
+from reactpy.testing.common import poll
from reactpy.utils import Ref
from tests.tooling import select
+from tests.tooling.aio import Event
from tests.tooling.common import event_message, update_message
from tests.tooling.hooks import use_force_render, use_toggle
from tests.tooling.layout import layout_runner
from tests.tooling.select import element_exists, find_element
+@pytest.fixture(autouse=True, params=[True, False])
+def async_rendering(request):
+ with patch.object(REACTPY_ASYNC_RENDERING, "current", request.param):
+ yield request.param
+
+
@pytest.fixture(autouse=True)
def no_logged_errors():
with capture_reactpy_logs() as logs:
@@ -39,8 +48,7 @@ def no_logged_errors():
def test_layout_repr():
@reactpy.component
- def MyComponent():
- ...
+ def MyComponent(): ...
my_component = MyComponent()
layout = reactpy.Layout(my_component)
@@ -56,8 +64,7 @@ def test_layout_expects_abstract_component():
async def test_layout_cannot_be_used_outside_context_manager(caplog):
@reactpy.component
- def Component():
- ...
+ def Component(): ...
component = Component()
layout = reactpy.Layout(component)
@@ -93,15 +100,6 @@ def SimpleComponent():
)
-async def test_component_can_return_none():
- @reactpy.component
- def SomeComponent():
- return None
-
- async with reactpy.Layout(SomeComponent()) as layout:
- assert (await layout.render())["model"] == {"tagName": ""}
-
-
async def test_nested_component_layout():
parent_set_state = reactpy.Ref(None)
child_set_state = reactpy.Ref(None)
@@ -164,7 +162,7 @@ def make_child_model(state):
async def test_layout_render_error_has_partial_update_with_error_message():
@reactpy.component
def Main():
- return reactpy.html.div([OkChild(), BadChild(), OkChild()])
+ return reactpy.html.div(OkChild(), BadChild(), OkChild())
@reactpy.component
def OkChild():
@@ -622,7 +620,7 @@ async def test_hooks_for_keyed_components_get_garbage_collected():
def Outer():
items, set_items = reactpy.hooks.use_state([1, 2, 3])
pop_item.current = lambda: set_items(items[:-1])
- return reactpy.html.div(Inner(key=k, finalizer_id=k) for k in items)
+ return reactpy.html.div([Inner(key=k, finalizer_id=k) for k in items])
@reactpy.component
def Inner(finalizer_id):
@@ -831,17 +829,19 @@ def some_effect():
async with reactpy.Layout(Root()) as layout:
await layout.render()
- assert effects == ["mount x"]
+ await poll(lambda: effects).until_equals(["mount x"])
set_toggle.current()
await layout.render()
- assert effects == ["mount x", "unmount x", "mount y"]
+ await poll(lambda: effects).until_equals(["mount x", "unmount x", "mount y"])
set_toggle.current()
await layout.render()
- assert effects == ["mount x", "unmount x", "mount y", "unmount y", "mount x"]
+ await poll(lambda: effects).until_equals(
+ ["mount x", "unmount x", "mount y", "unmount y", "mount x"]
+ )
async def test_layout_does_not_copy_element_children_by_key():
@@ -1250,3 +1250,94 @@ def App():
c, c_info = find_element(tree, select.id_equals("C"))
assert c_info.path == (0, 1, 0)
assert c["attributes"]["color"] == "blue"
+
+
+async def test_async_renders(async_rendering):
+ if not async_rendering:
+ raise pytest.skip("Async rendering not enabled")
+
+ child_1_hook = HookCatcher()
+ child_2_hook = HookCatcher()
+ child_1_rendered = Event()
+ child_2_rendered = Event()
+ child_1_render_count = Ref(0)
+ child_2_render_count = Ref(0)
+
+ @component
+ def outer():
+ return html._(child_1(), child_2())
+
+ @component
+ @child_1_hook.capture
+ def child_1():
+ child_1_rendered.set()
+ child_1_render_count.current += 1
+
+ @component
+ @child_2_hook.capture
+ def child_2():
+ child_2_rendered.set()
+ child_2_render_count.current += 1
+
+ async with Layout(outer()) as layout:
+ await layout.render()
+
+ # clear render events and counts
+ child_1_rendered.clear()
+ child_2_rendered.clear()
+ child_1_render_count.current = 0
+ child_2_render_count.current = 0
+
+ # we schedule two renders but expect only one
+ child_1_hook.latest.schedule_render()
+ child_1_hook.latest.schedule_render()
+ child_2_hook.latest.schedule_render()
+ child_2_hook.latest.schedule_render()
+
+ await child_1_rendered.wait()
+ await child_2_rendered.wait()
+
+ assert child_1_render_count.current == 1
+ assert child_2_render_count.current == 1
+
+
+async def test_none_does_not_render():
+ @component
+ def Root():
+ return html.div(None, Child())
+
+ @component
+ def Child():
+ return None
+
+ async with layout_runner(Layout(Root())) as runner:
+ tree = await runner.render()
+ assert tree == {
+ "tagName": "",
+ "children": [
+ {"tagName": "div", "children": [{"tagName": "", "children": []}]}
+ ],
+ }
+
+
+async def test_conditionally_render_none_does_not_trigger_state_change_in_siblings():
+ toggle_condition = Ref()
+ effect_run_count = Ref(0)
+
+ @component
+ def Root():
+ condition, toggle_condition.current = use_toggle(True)
+ return html.div("text" if condition else None, Child())
+
+ @component
+ def Child():
+ @reactpy.use_effect
+ def effect():
+ effect_run_count.current += 1
+
+ async with layout_runner(Layout(Root())) as runner:
+ await runner.render()
+ poll(lambda: effect_run_count.current).until_equals(1)
+ toggle_condition.current()
+ await runner.render()
+ assert effect_run_count.current == 1
diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py
index 64be0ec8b..bae3c1e01 100644
--- a/src/py/reactpy/tests/test_core/test_serve.py
+++ b/src/py/reactpy/tests/test_core/test_serve.py
@@ -1,14 +1,18 @@
import asyncio
+import sys
from collections.abc import Sequence
from typing import Any
+import pytest
from jsonpointer import set_pointer
import reactpy
+from reactpy.core.hooks import use_effect
from reactpy.core.layout import Layout
from reactpy.core.serve import serve_layout
from reactpy.core.types import LayoutUpdateMessage
from reactpy.testing import StaticEventHandler
+from tests.tooling.aio import Event
from tests.tooling.common import event_message
EVENT_NAME = "on_event"
@@ -29,7 +33,7 @@ async def send(patch):
changes.append(patch)
sem.release()
if not events_to_inject:
- raise reactpy.Stop()
+ raise Exception("Stop running")
async def recv():
await sem.acquire()
@@ -88,17 +92,20 @@ def Counter():
return reactpy.html.div({EVENT_NAME: handler, "count": count})
+@pytest.mark.skipif(sys.version_info < (3, 11), reason="ExceptionGroup not available")
async def test_dispatch():
events, expected_model = make_events_and_expected_model()
changes, send, recv = make_send_recv_callbacks(events)
- await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1)
+ with pytest.raises(ExceptionGroup):
+ await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1)
assert_changes_produce_expected_model(changes, expected_model)
async def test_dispatcher_handles_more_than_one_event_at_a_time():
- block_and_never_set = asyncio.Event()
- will_block = asyncio.Event()
- second_event_did_execute = asyncio.Event()
+ did_render = Event()
+ block_and_never_set = Event()
+ will_block = Event()
+ second_event_did_execute = Event()
blocked_handler = StaticEventHandler()
non_blocked_handler = StaticEventHandler()
@@ -114,6 +121,10 @@ async def block_forever():
async def handle_event():
second_event_did_execute.set()
+ @use_effect
+ def set_did_render():
+ did_render.set()
+
return reactpy.html.div(
reactpy.html.button({"on_click": block_forever}),
reactpy.html.button({"on_click": handle_event}),
@@ -129,11 +140,12 @@ async def handle_event():
recv_queue.get,
)
)
-
- await recv_queue.put(event_message(blocked_handler.target))
- await will_block.wait()
-
- await recv_queue.put(event_message(non_blocked_handler.target))
- await second_event_did_execute.wait()
-
- task.cancel()
+ try:
+ await did_render.wait()
+ await recv_queue.put(event_message(blocked_handler.target))
+ await will_block.wait()
+
+ await recv_queue.put(event_message(non_blocked_handler.target))
+ await second_event_did_execute.wait()
+ finally:
+ task.cancel()
diff --git a/src/py/reactpy/tests/test_html.py b/src/py/reactpy/tests/test_html.py
index f16d1beed..334fcab03 100644
--- a/src/py/reactpy/tests/test_html.py
+++ b/src/py/reactpy/tests/test_html.py
@@ -122,6 +122,7 @@ def HasScript():
"""
)
+ await poll(lambda: hasattr(incr_src_id, "current")).until_is(True)
incr_src_id.current()
run_count = await display.page.wait_for_selector("#run-count", state="attached")
diff --git a/src/py/reactpy/tests/tooling/aio.py b/src/py/reactpy/tests/tooling/aio.py
new file mode 100644
index 000000000..b0f719400
--- /dev/null
+++ b/src/py/reactpy/tests/tooling/aio.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from asyncio import Event as _Event
+from asyncio import wait_for
+
+from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
+
+
+class Event(_Event):
+ """An event with a ``wait_for`` method."""
+
+ async def wait(self, timeout: float | None = None):
+ return await wait_for(
+ super().wait(),
+ timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current,
+ )
diff --git a/src/py/reactpy/tests/tooling/loop.py b/src/py/reactpy/tests/tooling/loop.py
deleted file mode 100644
index f9e100981..000000000
--- a/src/py/reactpy/tests/tooling/loop.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import asyncio
-import threading
-import time
-from asyncio import wait_for
-from collections.abc import Iterator
-from contextlib import contextmanager
-
-from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
-
-
-@contextmanager
-def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]:
- """Open a new event loop and cleanly stop it
-
- Args:
- as_current: whether to make this loop the current loop in this thread
- """
- loop = asyncio.new_event_loop()
- try:
- if as_current:
- asyncio.set_event_loop(loop)
- loop.set_debug(True)
- yield loop
- finally:
- try:
- _cancel_all_tasks(loop, as_current)
- if as_current:
- loop.run_until_complete(
- wait_for(
- loop.shutdown_asyncgens(),
- REACTPY_TESTING_DEFAULT_TIMEOUT.current,
- )
- )
- loop.run_until_complete(
- wait_for(
- loop.shutdown_default_executor(),
- REACTPY_TESTING_DEFAULT_TIMEOUT.current,
- )
- )
- finally:
- if as_current:
- asyncio.set_event_loop(None)
- start = time.time()
- while loop.is_running():
- if (time.time() - start) > REACTPY_TESTING_DEFAULT_TIMEOUT.current:
- msg = f"Failed to stop loop after {REACTPY_TESTING_DEFAULT_TIMEOUT.current} seconds"
- raise TimeoutError(msg)
- time.sleep(0.1)
- loop.close()
-
-
-def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None:
- to_cancel = asyncio.all_tasks(loop)
- if not to_cancel:
- return
-
- done = threading.Event()
- count = len(to_cancel)
-
- def one_task_finished(future):
- nonlocal count
- count -= 1
- if count == 0:
- done.set()
-
- for task in to_cancel:
- loop.call_soon_threadsafe(task.cancel)
- task.add_done_callback(one_task_finished)
-
- if is_current:
- loop.run_until_complete(
- wait_for(
- asyncio.gather(*to_cancel, return_exceptions=True),
- REACTPY_TESTING_DEFAULT_TIMEOUT.current,
- )
- )
- elif not done.wait(timeout=3): # user was responsible for cancelling all tasks
- msg = "Could not stop event loop in time"
- raise TimeoutError(msg)
-
- for task in to_cancel:
- if task.cancelled():
- continue
- if task.exception() is not None:
- loop.call_exception_handler(
- {
- "message": "unhandled exception during event loop shutdown",
- "exception": task.exception(),
- "task": task,
- }
- )
diff --git a/tasks.py b/tasks.py
index 65f75b208..e11d291e3 100644
--- a/tasks.py
+++ b/tasks.py
@@ -28,8 +28,7 @@
class ReleasePrepFunc(Protocol):
def __call__(
self, context: Context, package: PackageInfo
- ) -> Callable[[bool], None]:
- ...
+ ) -> Callable[[bool], None]: ...
LanguageName: TypeAlias = "Literal['py', 'js']"