From 0d6684c09980bdf9c3e880c2f6d9937d8bfda79b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 23 Aug 2023 17:24:29 -0700 Subject: [PATCH 01/16] functional new client code --- docs/src/features/settings.md | 2 +- requirements/pkg-deps.txt | 2 +- src/js/package-lock.json | 189 +++++++++++------- src/js/package.json | 12 +- src/js/rollup.config.mjs | 4 +- src/js/src/index.js | 59 ------ src/js/src/index.ts | 88 ++++++++ src/js/src/types.ts | 17 ++ src/js/src/utils.ts | 100 +++++++++ src/js/tsconfig.json | 7 + src/reactpy_django/checks.py | 8 +- src/reactpy_django/config.py | 31 ++- ...05_alter_componentsession_last_accessed.py | 17 ++ src/reactpy_django/models.py | 2 +- .../templates/reactpy/component.html | 9 +- src/reactpy_django/templatetags/reactpy.py | 7 +- src/reactpy_django/utils.py | 6 +- src/reactpy_django/websocket/consumer.py | 33 ++- tests/test_app/tests/test_database.py | 4 +- 19 files changed, 432 insertions(+), 165 deletions(-) delete mode 100644 src/js/src/index.js create mode 100644 src/js/src/index.ts create mode 100644 src/js/src/types.ts create mode 100644 src/js/src/utils.ts create mode 100644 src/js/tsconfig.json create mode 100644 src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 29ca81ad..a68ad25c 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -14,7 +14,7 @@ These are ReactPy-Django's default settings values. You can modify these values | --- | --- | --- | --- | | `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | | `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database ReactPy uses to store session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to also configure `DATABASE_ROUTERS` like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | -| `REACTPY_RECONNECT_MAX` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. | +| `REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. | | `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | | `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | | `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. | diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index ee7156c3..8eecf7bc 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,5 +1,5 @@ channels >=4.0.0 -django >=4.1.0 +django >=4.2.0 reactpy >=1.0.2, <1.1.0 aiofile >=3.0 dill >=0.3.5 diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 024084a6..84321d16 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -5,14 +5,16 @@ "packages": { "": { "dependencies": { - "@reactpy/client": "^0.1.0" + "@reactpy/client": "^0.3.1", + "@rollup/plugin-typescript": "^11.1.2", + "tslib": "^2.6.2" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", - "prettier": "^2.8.3", - "rollup": "^3.12.0" + "prettier": "^3.0.2", + "rollup": "^3.28.1" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -22,16 +24,16 @@ "dev": true }, "node_modules/@reactpy/client": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz", - "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz", + "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==", "dependencies": { - "htm": "^3.0.3", + "event-to-object": "^0.1.2", "json-pointer": "^0.6.2" }, "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" + "react": ">=16 <18", + "react-dom": ">=16 <18" } }, "node_modules/@rollup/plugin-commonjs": { @@ -105,11 +107,35 @@ } } }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -130,8 +156,7 @@ "node_modules/@types/estree": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, "node_modules/@types/resolve": { "version": "1.20.2", @@ -184,8 +209,15 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/event-to-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", + "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", + "dependencies": { + "json-pointer": "^0.6.2" + } }, "node_modules/foreach": { "version": "2.0.6", @@ -215,8 +247,7 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "node_modules/glob": { "version": "8.1.0", @@ -241,7 +272,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -249,11 +279,6 @@ "node": ">= 0.4.0" } }, - "node_modules/htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -289,7 +314,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "dependencies": { "has": "^1.0.3" }, @@ -383,14 +407,12 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -399,15 +421,15 @@ } }, "node_modules/prettier": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", - "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -444,7 +466,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "dependencies": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -458,10 +479,10 @@ } }, "node_modules/rollup": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz", - "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==", - "dev": true, + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz", + "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==", + "devOptional": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -487,7 +508,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -495,6 +515,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -510,11 +548,11 @@ "dev": true }, "@reactpy/client": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz", - "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz", + "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==", "requires": { - "htm": "^3.0.3", + "event-to-object": "^0.1.2", "json-pointer": "^0.6.2" } }, @@ -556,11 +594,19 @@ "magic-string": "^0.27.0" } }, + "@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "requires": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + } + }, "@rollup/pluginutils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, "requires": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -570,8 +616,7 @@ "@types/estree": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, "@types/resolve": { "version": "1.20.2", @@ -615,8 +660,15 @@ "estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "event-to-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", + "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", + "requires": { + "json-pointer": "^0.6.2" + } }, "foreach": { "version": "2.0.6", @@ -639,8 +691,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "glob": { "version": "8.1.0", @@ -659,16 +710,10 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } }, - "htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -698,7 +743,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "requires": { "has": "^1.0.3" } @@ -777,19 +821,17 @@ "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, "prettier": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", - "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", "dev": true }, "react": { @@ -817,7 +859,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "requires": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -825,10 +866,10 @@ } }, "rollup": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz", - "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==", - "dev": true, + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz", + "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==", + "devOptional": true, "requires": { "fsevents": "~2.3.2" } @@ -846,8 +887,18 @@ "supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "peer": true }, "wrappy": { "version": "1.0.2", diff --git a/src/js/package.json b/src/js/package.json index 92890671..40596a0d 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -1,6 +1,6 @@ { "description": "reactpy-django client", - "main": "src/index.js", + "main": "src/index.ts", "type": "module", "files": [ "src/**/*.js" @@ -10,13 +10,15 @@ "format": "prettier --ignore-path .gitignore --write ." }, "devDependencies": { - "prettier": "^2.8.3", - "rollup": "^3.12.0", "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", - "@rollup/plugin-replace": "^5.0.2" + "@rollup/plugin-replace": "^5.0.2", + "prettier": "^3.0.2", + "rollup": "^3.28.1" }, "dependencies": { - "@reactpy/client": "^0.1.0" + "@reactpy/client": "^0.3.1", + "@rollup/plugin-typescript": "^11.1.2", + "tslib": "^2.6.2" } } diff --git a/src/js/rollup.config.mjs b/src/js/rollup.config.mjs index 50fbc637..79f93839 100644 --- a/src/js/rollup.config.mjs +++ b/src/js/rollup.config.mjs @@ -1,9 +1,10 @@ import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import replace from "@rollup/plugin-replace"; +import typescript from "@rollup/plugin-typescript"; export default { - input: "src/index.js", + input: "src/index.ts", output: { file: "../reactpy_django/static/reactpy_django/client.js", format: "esm", @@ -14,6 +15,7 @@ export default { replace({ "process.env.NODE_ENV": JSON.stringify("production"), }), + typescript(), ], onwarn: function (warning) { console.warn(warning.message); diff --git a/src/js/src/index.js b/src/js/src/index.js deleted file mode 100644 index 2ee74e07..00000000 --- a/src/js/src/index.js +++ /dev/null @@ -1,59 +0,0 @@ -import { mountLayoutWithWebSocket } from "@reactpy/client"; - -// Set up a websocket at the base endpoint -let HTTP_PROTOCOL = window.location.protocol; -let WS_PROTOCOL = ""; -if (HTTP_PROTOCOL == "https:") { - WS_PROTOCOL = "wss:"; -} else { - WS_PROTOCOL = "ws:"; -} - -export function mountViewToElement( - mountElement, - reactpyHost, - reactpyUrlPrefix, - reactpyReconnectMax, - reactpyComponentPath, - reactpyResolvedWebModulesPath -) { - // Determine the Websocket route - let wsOrigin; - if (reactpyHost) { - wsOrigin = `${WS_PROTOCOL}//${reactpyHost}`; - } else { - wsOrigin = `${WS_PROTOCOL}//${window.location.host}`; - } - const websocketUrl = `${wsOrigin}/${reactpyUrlPrefix}/${reactpyComponentPath}`; - - // Determine the HTTP route - let httpOrigin; - let webModulesPath; - if (reactpyHost) { - httpOrigin = `${HTTP_PROTOCOL}//${reactpyHost}`; - webModulesPath = `${reactpyUrlPrefix}/web_module`; - } else { - httpOrigin = `${HTTP_PROTOCOL}//${window.location.host}`; - if (reactpyResolvedWebModulesPath) { - webModulesPath = reactpyResolvedWebModulesPath; - } else { - webModulesPath = `${reactpyUrlPrefix}/web_module`; - } - } - const webModuleUrl = `${httpOrigin}/${webModulesPath}`; - - // Function that loads the JavaScript web module, if needed - const loadImportSource = (source, sourceType) => { - return import( - sourceType == "NAME" ? `${webModuleUrl}/${source}` : source - ); - }; - - // Start rendering the component - mountLayoutWithWebSocket( - mountElement, - websocketUrl, - loadImportSource, - reactpyReconnectMax - ); -} diff --git a/src/js/src/index.ts b/src/js/src/index.ts new file mode 100644 index 00000000..d19dbf79 --- /dev/null +++ b/src/js/src/index.ts @@ -0,0 +1,88 @@ +import { mount, BaseReactPyClient, ReactPyClient } from "@reactpy/client"; +import { createReconnectingWebSocket } from "./utils"; +import { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; + +export class ReactPyDjangoClient + extends BaseReactPyClient + implements ReactPyClient +{ + urls: ReactPyUrls; + socket: { current?: WebSocket }; + + constructor(props: ReactPyDjangoClientProps) { + super(); + this.urls = props.urls; + this.socket = createReconnectingWebSocket({ + readyPromise: this.ready, + url: this.urls.componentUrl, + onMessage: async ({ data }) => + this.handleIncoming(JSON.parse(data)), + ...props.reconnectOptions, + }); + } + + sendMessage(message: any): void { + this.socket.current?.send(JSON.stringify(message)); + } + + loadModule(moduleName: string) { + return import(`${this.urls.jsModules}/${moduleName}`); + } +} + +export function mountComponent( + mountElement: HTMLElement, + host: string, + urlPrefix: string, + componentPath: string, + resolvedJsModulesPath: string, + reconnectMaxInterval: number, + reconnectJitterMultiplier: number, + reconnectBackoffMultiplier: number, + reconnectMaxRetries: number +) { + // Protocols + let httpProtocol = window.location.protocol; + let wsProtocol = `ws${httpProtocol === "https:" ? "s" : ""}:`; + + // WebSocket route (for Python components) + let wsOrigin: string; + if (host) { + wsOrigin = `${wsProtocol}//${host}`; + } else { + wsOrigin = `${wsProtocol}//${window.location.host}`; + } + + // HTTP route (for JavaScript modules) + let httpOrigin: string; + let jsModulesPath: string; + if (host) { + httpOrigin = `${httpProtocol}//${host}`; + jsModulesPath = `${urlPrefix}/web_module`; + } else { + httpOrigin = `${httpProtocol}//${window.location.host}`; + if (resolvedJsModulesPath) { + jsModulesPath = resolvedJsModulesPath; + } else { + jsModulesPath = `${urlPrefix}/web_module`; + } + } + + // Set up the client + const client = new ReactPyDjangoClient({ + urls: { + componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`, + query: document.location.search, + jsModules: `${httpOrigin}/${jsModulesPath}`, + }, + reconnectOptions: { + maxInterval: reconnectMaxInterval, + intervalJitter: reconnectJitterMultiplier, + backoffRate: reconnectBackoffMultiplier, + maxRetries: reconnectMaxRetries, + }, + }); + + // Start rendering the component + mount(mountElement, client); +} diff --git a/src/js/src/types.ts b/src/js/src/types.ts new file mode 100644 index 00000000..576b1fc7 --- /dev/null +++ b/src/js/src/types.ts @@ -0,0 +1,17 @@ +export type ReconnectOptions = { + maxInterval?: number; + maxRetries?: number; + backoffRate?: number; + intervalJitter?: number; +} + +export type ReactPyUrls = { + componentUrl: string; + query: string; + jsModules: string; +} + +export type ReactPyDjangoClientProps = { + urls: ReactPyUrls; + reconnectOptions: ReconnectOptions; +} diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts new file mode 100644 index 00000000..1bbacc96 --- /dev/null +++ b/src/js/src/utils.ts @@ -0,0 +1,100 @@ +export function createReconnectingWebSocket(props: { + url: string; + readyPromise: Promise; + onOpen?: () => void; + onMessage: (message: MessageEvent) => void; + onClose?: () => void; + maxInterval?: number; + maxRetries?: number; + backoffRate?: number; + intervalJitter?: number; +}) { + const { + maxInterval = 60000, + maxRetries = 50, + backoffRate = 1.1, + intervalJitter = 0.1, + } = props; + + const startInterval = 750; + let retries = 0; + let interval = startInterval; + const closed = false; + let everConnected = false; + const socket: { current?: WebSocket } = {}; + + const connect = () => { + if (closed) { + return; + } + socket.current = new WebSocket(props.url); + socket.current.onopen = () => { + everConnected = true; + console.info("ReactPy connected!"); + interval = startInterval; + retries = 0; + if (props.onOpen) { + props.onOpen(); + } + }; + socket.current.onmessage = props.onMessage; + socket.current.onclose = () => { + if (!everConnected) { + console.info("ReactPy failed to connect!"); + return; + } + + console.info("ReactPy disconnected!"); + if (props.onClose) { + props.onClose(); + } + + if (retries >= maxRetries) { + return; + } + + const thisInterval = addJitter(interval, intervalJitter); + console.info( + `ReactPy reconnecting in ${(thisInterval / 1000).toPrecision( + 4 + )} seconds...` + ); + setTimeout(connect, thisInterval); + interval = nextInterval(interval, backoffRate, maxInterval); + retries++; + }; + }; + + props.readyPromise + .then(() => console.info("Starting ReactPy client...")) + .then(connect); + + return socket; +} + +export function nextInterval( + currentInterval: number, + backoffRate: number, + maxInterval: number +): number { + return Math.min( + currentInterval * + // increase interval by backoff rate + backoffRate, + // don't exceed max interval + maxInterval + ); +} + +export function addJitter(interval: number, jitter: number): number { + return ( + interval + (Math.random() * jitter * interval * 2 - jitter * interval) + ); +} + +type reconnectOptions = { + maxInterval?: number; + maxRetries?: number; + backoffRate?: number; + intervalJitter?: number; +}; diff --git a/src/js/tsconfig.json b/src/js/tsconfig.json new file mode 100644 index 00000000..7da4aa77 --- /dev/null +++ b/src/js/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "node", + }, +} diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 7ab9546e..efb35e35 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -204,12 +204,12 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E003", ) ) - if not isinstance(getattr(settings, "REACTPY_RECONNECT_MAX", 0), int): + if not isinstance(getattr(settings, "REACTPY_SESSION_MAX_AGE", 0), int): errors.append( Error( - "Invalid type for REACTPY_RECONNECT_MAX.", - hint="REACTPY_RECONNECT_MAX should be an integer.", - obj=settings.REACTPY_RECONNECT_MAX, + "Invalid type for REACTPY_SESSION_MAX_AGE.", + hint="REACTPY_SESSION_MAX_AGE should be an integer.", + obj=settings.REACTPY_SESSION_MAX_AGE, id="reactpy_django.E004", ) ) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 24aff5f6..ae8c8306 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -28,6 +28,11 @@ "REACTPY_WEBSOCKET_URL", "reactpy/", ) +REACTPY_RECONNECT_MAX: int = getattr( + settings, + "REACTPY_RECONNECT_MAX", + 259200, # Default to 3 days +) # Configurable through Django settings.py REACTPY_URL_PREFIX: str = getattr( @@ -35,10 +40,10 @@ "REACTPY_URL_PREFIX", REACTPY_WEBSOCKET_URL, ).strip("/") -REACTPY_RECONNECT_MAX: int = getattr( +REACTPY_SESSION_MAX_AGE: int = getattr( settings, - "REACTPY_RECONNECT_MAX", - 259200, # Default to 3 days + "REACTPY_SESSION_MAX_AGE", + REACTPY_RECONNECT_MAX, ) REACTPY_CACHE: str = getattr( settings, @@ -82,3 +87,23 @@ if _default_hosts else None ) +REACTPY_RECONNECT_MAX_INTERVAL: int = getattr( + settings, + "REACTPY_RECONNECT_MAX_INTERVAL", + 60000, # Default to 60 seconds +) +REACTPY_RECONNECT_JITTER_MULTIPLIER: int = getattr( + settings, + "REACTPY_RECONNECT_JITTER_MULTIPLIER", + 0, # Default to no jitter +) +REACTPY_RECONNECT_BACKOFF_MULTIPLIER: int = getattr( + settings, + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", + 1.1, # Default to 10% backoff +) +REACTPY_RECONNECT_MAX_RETRIES: int = getattr( + settings, + "REACTPY_RECONNECT_MAX_RETRIES", + 150, +) diff --git a/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py new file mode 100644 index 00000000..488f660d --- /dev/null +++ b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.3 on 2023-08-23 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reactpy_django", "0004_config"), + ] + + operations = [ + migrations.AlterField( + model_name="componentsession", + name="last_accessed", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 65152126..1fa69d2c 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -8,7 +8,7 @@ class ComponentSession(models.Model): uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore params = models.BinaryField(editable=False) # type: ignore - last_accessed = models.DateTimeField(auto_now_add=True) # type: ignore + last_accessed = models.DateTimeField(auto_now=True) # type: ignore class Config(models.Model): diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 4010b80f..4d98bfd4 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -6,15 +6,18 @@ {% else %}
{% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 1ae88413..4852a52b 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -84,7 +84,7 @@ def component( _logger.error(msg) return failure_context(dotted_path, ComponentDoesNotExistError(msg)) - # Validate the component + # Validate the component args & kwargs if is_local and config.REACTPY_DEBUG_MODE: try: validate_component_args(user_component, *args, **kwargs) @@ -109,11 +109,14 @@ def component( "reactpy_uuid": uuid, "reactpy_host": host or perceived_host, "reactpy_url_prefix": config.REACTPY_URL_PREFIX, - "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX, "reactpy_component_path": f"{dotted_path}/{uuid}/" if component_has_args else f"{dotted_path}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, + "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL, + "reactpy_reconnect_jitter_multiplier": config.REACTPY_RECONNECT_JITTER_MULTIPLIER, + "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, + "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_INTERVAL, } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 0776bc3b..f40ea1ee 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -327,17 +327,17 @@ def create_cache_key(*args): def db_cleanup(immediate: bool = False): """Deletes expired component sessions from the database. This function may be expanded in the future to include additional cleanup tasks.""" - from .config import REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX + from .config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE from .models import ComponentSession, Config config = Config.load() start_time = timezone.now() cleaned_at = config.cleaned_at - clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_RECONNECT_MAX) + clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_SESSION_MAX_AGE) # Delete expired component parameters if immediate or timezone.now() >= clean_needed_by: - expiration_date = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX) + expiration_date = timezone.now() - timedelta(seconds=REACTPY_SESSION_MAX_AGE) ComponentSession.objects.filter(last_accessed__lte=expiration_date).delete() config.cleaned_at = timezone.now() config.save() diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index a2a76cab..b486dc3c 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -42,6 +42,7 @@ class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): async def connect(self) -> None: """The browser has connected.""" + from reactpy_django import models from reactpy_django.config import REACTPY_AUTH_BACKEND, REACTPY_BACKHAUL_THREAD await super().connect() @@ -80,6 +81,7 @@ async def connect(self) -> None: # Start the component dispatcher self.dispatcher: Future | asyncio.Task self.threaded = REACTPY_BACKHAUL_THREAD + self.component_session: models.ComponentSession | None = None if self.threaded: if not backhaul_thread.is_alive(): await asyncio.to_thread( @@ -95,6 +97,17 @@ async def connect(self) -> None: async def disconnect(self, code: int) -> None: """The browser has disconnected.""" self.dispatcher.cancel() + + # Update the last_accessed timestamp + if self.component_session: + try: + await self.component_session.asave() + except Exception: + await asyncio.to_thread( + _logger.exception, + "ReactPy has failed to update component session `last_accessed`!", + ) + await super().disconnect(code) async def receive_json(self, content: Any, **_) -> None: @@ -118,8 +131,8 @@ async def run_dispatcher(self): """Runs the main loop that performs component rendering tasks.""" from reactpy_django import models from reactpy_django.config import ( - REACTPY_RECONNECT_MAX, REACTPY_REGISTERED_COMPONENTS, + REACTPY_SESSION_MAX_AGE, ) scope = self.scope @@ -155,16 +168,14 @@ async def run_dispatcher(self): # Always clean up expired entries first await database_sync_to_async(db_cleanup, thread_sensitive=False)() - # Get the queries from a DB - params_query = await models.ComponentSession.objects.aget( + # Get the component session from the DB + self.component_session = await models.ComponentSession.objects.aget( uuid=uuid, - last_accessed__gt=now - timedelta(seconds=REACTPY_RECONNECT_MAX), + last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE), + ) + component_params: ComponentParamData = pickle.loads( + self.component_session.params ) - params_query.last_accessed = timezone.now() - await database_sync_to_async( - params_query.save, thread_sensitive=False - )() - component_params: ComponentParamData = pickle.loads(params_query.params) component_args = component_params.args component_kwargs = component_params.kwargs @@ -176,7 +187,7 @@ async def run_dispatcher(self): await asyncio.to_thread( _logger.warning, f"Component session for '{dotted_path}:{uuid}' not found. The " - "session may have already expired beyond REACTPY_RECONNECT_MAX. " + "session may have already expired beyond REACTPY_SESSION_MAX_AGE. " "If you are using a custom host, you may have forgotten to provide " "args/kwargs.", ) @@ -185,7 +196,7 @@ async def run_dispatcher(self): await asyncio.to_thread( _logger.exception, f"Failed to construct component {component_constructor} " - f"with parameters {component_kwargs}", + f"with args='{component_args}' kwargs='{component_kwargs}'!", ) return diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 3bd23527..94f75950 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -31,8 +31,8 @@ def test_component_params(self): # Force `params_1` to expire from reactpy_django import config - config.REACTPY_RECONNECT_MAX = 1 - sleep(config.REACTPY_RECONNECT_MAX + 0.1) + config.REACTPY_SESSION_MAX_AGE = 1 + sleep(config.REACTPY_SESSION_MAX_AGE + 0.1) # Create a new, non-expired component params params_2 = self._save_params_to_db(2) From 4d93310b30fd5488765acdc2cc3d67c7fae7e5b2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 23 Aug 2023 17:50:20 -0700 Subject: [PATCH 02/16] refactoring --- src/js/src/client.ts | 31 ++++++++++++++ src/js/src/index.ts | 41 ++++--------------- src/js/src/types.ts | 5 ++- src/js/src/utils.ts | 35 +++++++--------- src/reactpy_django/config.py | 5 +++ .../templates/reactpy/component.html | 1 + src/reactpy_django/templatetags/reactpy.py | 1 + 7 files changed, 62 insertions(+), 57 deletions(-) create mode 100644 src/js/src/client.ts diff --git a/src/js/src/client.ts b/src/js/src/client.ts new file mode 100644 index 00000000..eb8b3929 --- /dev/null +++ b/src/js/src/client.ts @@ -0,0 +1,31 @@ +import { BaseReactPyClient, ReactPyClient } from "@reactpy/client"; +import { createReconnectingWebSocket } from "./utils"; +import { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; + +export class ReactPyDjangoClient + extends BaseReactPyClient + implements ReactPyClient +{ + urls: ReactPyUrls; + socket: { current?: WebSocket }; + + constructor(props: ReactPyDjangoClientProps) { + super(); + this.urls = props.urls; + this.socket = createReconnectingWebSocket({ + readyPromise: this.ready, + url: this.urls.componentUrl, + onMessage: async ({ data }) => + this.handleIncoming(JSON.parse(data)), + ...props.reconnectOptions, + }); + } + + sendMessage(message: any): void { + this.socket.current?.send(JSON.stringify(message)); + } + + loadModule(moduleName: string) { + return import(`${this.urls.jsModules}/${moduleName}`); + } +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts index d19dbf79..8c45da78 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,34 +1,5 @@ -import { mount, BaseReactPyClient, ReactPyClient } from "@reactpy/client"; -import { createReconnectingWebSocket } from "./utils"; -import { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; - -export class ReactPyDjangoClient - extends BaseReactPyClient - implements ReactPyClient -{ - urls: ReactPyUrls; - socket: { current?: WebSocket }; - - constructor(props: ReactPyDjangoClientProps) { - super(); - this.urls = props.urls; - this.socket = createReconnectingWebSocket({ - readyPromise: this.ready, - url: this.urls.componentUrl, - onMessage: async ({ data }) => - this.handleIncoming(JSON.parse(data)), - ...props.reconnectOptions, - }); - } - - sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); - } - - loadModule(moduleName: string) { - return import(`${this.urls.jsModules}/${moduleName}`); - } -} +import { mount } from "@reactpy/client"; +import { ReactPyDjangoClient } from "./client"; export function mountComponent( mountElement: HTMLElement, @@ -36,6 +7,7 @@ export function mountComponent( urlPrefix: string, componentPath: string, resolvedJsModulesPath: string, + reconnectStartInterval: number, reconnectMaxInterval: number, reconnectJitterMultiplier: number, reconnectBackoffMultiplier: number, @@ -68,7 +40,7 @@ export function mountComponent( } } - // Set up the client + // Configure a new ReactPy client const client = new ReactPyDjangoClient({ urls: { componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`, @@ -76,9 +48,10 @@ export function mountComponent( jsModules: `${httpOrigin}/${jsModulesPath}`, }, reconnectOptions: { + startInterval: reconnectStartInterval, maxInterval: reconnectMaxInterval, - intervalJitter: reconnectJitterMultiplier, - backoffRate: reconnectBackoffMultiplier, + jitterMultiplier: reconnectJitterMultiplier, + backoffMultiplier: reconnectBackoffMultiplier, maxRetries: reconnectMaxRetries, }, }); diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 576b1fc7..7394d7e3 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -1,8 +1,9 @@ export type ReconnectOptions = { + startInterval?: number; maxInterval?: number; maxRetries?: number; - backoffRate?: number; - intervalJitter?: number; + backoffMultiplier?: number; + jitterMultiplier?: number; } export type ReactPyUrls = { diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index 1bbacc96..063fc697 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -4,23 +4,23 @@ export function createReconnectingWebSocket(props: { onOpen?: () => void; onMessage: (message: MessageEvent) => void; onClose?: () => void; + startInterval?: number; maxInterval?: number; maxRetries?: number; - backoffRate?: number; - intervalJitter?: number; + backoffMultiplier?: number; + jitterMultiplier?: number; }) { const { - maxInterval = 60000, - maxRetries = 50, - backoffRate = 1.1, - intervalJitter = 0.1, + startInterval, + maxInterval, + maxRetries, + backoffMultiplier, + jitterMultiplier, } = props; - - const startInterval = 750; let retries = 0; let interval = startInterval; - const closed = false; let everConnected = false; + const closed = false; const socket: { current?: WebSocket } = {}; const connect = () => { @@ -53,14 +53,14 @@ export function createReconnectingWebSocket(props: { return; } - const thisInterval = addJitter(interval, intervalJitter); + const thisInterval = addJitter(interval, jitterMultiplier); console.info( `ReactPy reconnecting in ${(thisInterval / 1000).toPrecision( 4 )} seconds...` ); setTimeout(connect, thisInterval); - interval = nextInterval(interval, backoffRate, maxInterval); + interval = nextInterval(interval, backoffMultiplier, maxInterval); retries++; }; }; @@ -74,13 +74,13 @@ export function createReconnectingWebSocket(props: { export function nextInterval( currentInterval: number, - backoffRate: number, + backoffMultiplier: number, maxInterval: number ): number { return Math.min( currentInterval * - // increase interval by backoff rate - backoffRate, + // increase interval by backoff multiplier + backoffMultiplier, // don't exceed max interval maxInterval ); @@ -91,10 +91,3 @@ export function addJitter(interval: number, jitter: number): number { interval + (Math.random() * jitter * interval * 2 - jitter * interval) ); } - -type reconnectOptions = { - maxInterval?: number; - maxRetries?: number; - backoffRate?: number; - intervalJitter?: number; -}; diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index ae8c8306..0045889f 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -87,6 +87,11 @@ if _default_hosts else None ) +REACTPY_RECONNECT_INTERVAL: int = getattr( + settings, + "REACTPY_RECONNECT_INTERVAL", + 750, # Default to 0.75 seconds +) REACTPY_RECONNECT_MAX_INTERVAL: int = getattr( settings, "REACTPY_RECONNECT_MAX_INTERVAL", diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 4d98bfd4..be4950a2 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -14,6 +14,7 @@ "{{ reactpy_url_prefix }}", "{{ reactpy_component_path }}", "{{ reactpy_resolved_web_modules_path }}", + Number("{{ reactpy_reconnect_interval }}"), Number("{{ reactpy_reconnect_max_interval }}"), Number("{{ reactpy_reconnect_jitter_multiplier }}"), Number("{{ reactpy_reconnect_backoff_multiplier }}"), diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 4852a52b..7f6e4e50 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -113,6 +113,7 @@ def component( if component_has_args else f"{dotted_path}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, + "reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL, "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL, "reactpy_reconnect_jitter_multiplier": config.REACTPY_RECONNECT_JITTER_MULTIPLIER, "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, From 93f4f8058260a46ce8165984c301be107b4c411d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 23 Aug 2023 21:49:51 -0700 Subject: [PATCH 03/16] fix types --- pyproject.toml | 1 - src/js/src/types.ts | 10 +++++----- src/js/src/utils.ts | 10 +++++----- src/reactpy_django/config.py | 4 ++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f9e87a3..250a64a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -incremental = true [tool.ruff.isort] known-first-party = ["src", "tests"] diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 7394d7e3..2cb9fa2b 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -1,9 +1,9 @@ export type ReconnectOptions = { - startInterval?: number; - maxInterval?: number; - maxRetries?: number; - backoffMultiplier?: number; - jitterMultiplier?: number; + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; + jitterMultiplier: number; } export type ReactPyUrls = { diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index 063fc697..fb430d01 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -4,11 +4,11 @@ export function createReconnectingWebSocket(props: { onOpen?: () => void; onMessage: (message: MessageEvent) => void; onClose?: () => void; - startInterval?: number; - maxInterval?: number; - maxRetries?: number; - backoffMultiplier?: number; - jitterMultiplier?: number; + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; + jitterMultiplier: number; }) { const { startInterval, diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 0045889f..3c429e9c 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -97,12 +97,12 @@ "REACTPY_RECONNECT_MAX_INTERVAL", 60000, # Default to 60 seconds ) -REACTPY_RECONNECT_JITTER_MULTIPLIER: int = getattr( +REACTPY_RECONNECT_JITTER_MULTIPLIER: float | int = getattr( settings, "REACTPY_RECONNECT_JITTER_MULTIPLIER", 0, # Default to no jitter ) -REACTPY_RECONNECT_BACKOFF_MULTIPLIER: int = getattr( +REACTPY_RECONNECT_BACKOFF_MULTIPLIER: float | int = getattr( settings, "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", 1.1, # Default to 10% backoff From c89f437a80a263759c0e7c8a61550e3df7fdcd44 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 23 Aug 2023 22:29:28 -0700 Subject: [PATCH 04/16] fix max retries --- src/js/src/client.ts | 4 ++-- src/js/src/index.ts | 4 ++-- src/js/src/utils.ts | 1 + src/reactpy_django/templates/reactpy/component.html | 4 ++-- src/reactpy_django/templatetags/reactpy.py | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/js/src/client.ts b/src/js/src/client.ts index eb8b3929..6f79df77 100644 --- a/src/js/src/client.ts +++ b/src/js/src/client.ts @@ -1,4 +1,4 @@ -import { BaseReactPyClient, ReactPyClient } from "@reactpy/client"; +import { BaseReactPyClient, ReactPyClient, ReactPyModule } from "@reactpy/client"; import { createReconnectingWebSocket } from "./utils"; import { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; @@ -25,7 +25,7 @@ export class ReactPyDjangoClient this.socket.current?.send(JSON.stringify(message)); } - loadModule(moduleName: string) { + loadModule(moduleName: string): Promise { return import(`${this.urls.jsModules}/${moduleName}`); } } diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 8c45da78..65ac4611 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -9,9 +9,9 @@ export function mountComponent( resolvedJsModulesPath: string, reconnectStartInterval: number, reconnectMaxInterval: number, - reconnectJitterMultiplier: number, + reconnectMaxRetries: number, reconnectBackoffMultiplier: number, - reconnectMaxRetries: number + reconnectJitterMultiplier: number ) { // Protocols let httpProtocol = window.location.protocol; diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index fb430d01..1d2b7ca1 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -50,6 +50,7 @@ export function createReconnectingWebSocket(props: { } if (retries >= maxRetries) { + console.info("ReactPy connection max retries exhausted!"); return; } diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index be4950a2..f7f9e6a6 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -16,9 +16,9 @@ "{{ reactpy_resolved_web_modules_path }}", Number("{{ reactpy_reconnect_interval }}"), Number("{{ reactpy_reconnect_max_interval }}"), - Number("{{ reactpy_reconnect_jitter_multiplier }}"), - Number("{{ reactpy_reconnect_backoff_multiplier }}"), Number("{{ reactpy_reconnect_max_retries }}"), + Number("{{ reactpy_reconnect_backoff_multiplier }}"), + Number("{{ reactpy_reconnect_jitter_multiplier }}"), ); {% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 7f6e4e50..97d53a17 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -117,7 +117,7 @@ def component( "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL, "reactpy_reconnect_jitter_multiplier": config.REACTPY_RECONNECT_JITTER_MULTIPLIER, "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, - "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_INTERVAL, + "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES, } From 92a9acfe1ddcbe55de83ce277bcef02e5afd128d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 23 Aug 2023 22:58:33 -0700 Subject: [PATCH 05/16] remove jtter --- src/js/src/index.ts | 4 +--- src/js/src/types.ts | 1 - src/js/src/utils.ts | 23 +++---------------- src/reactpy_django/config.py | 13 ++++------- .../templates/reactpy/component.html | 1 - src/reactpy_django/templatetags/reactpy.py | 1 - 6 files changed, 8 insertions(+), 35 deletions(-) diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 65ac4611..53d67c6f 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -10,8 +10,7 @@ export function mountComponent( reconnectStartInterval: number, reconnectMaxInterval: number, reconnectMaxRetries: number, - reconnectBackoffMultiplier: number, - reconnectJitterMultiplier: number + reconnectBackoffMultiplier: number ) { // Protocols let httpProtocol = window.location.protocol; @@ -50,7 +49,6 @@ export function mountComponent( reconnectOptions: { startInterval: reconnectStartInterval, maxInterval: reconnectMaxInterval, - jitterMultiplier: reconnectJitterMultiplier, backoffMultiplier: reconnectBackoffMultiplier, maxRetries: reconnectMaxRetries, }, diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 2cb9fa2b..54a0b604 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -3,7 +3,6 @@ export type ReconnectOptions = { maxInterval: number; maxRetries: number; backoffMultiplier: number; - jitterMultiplier: number; } export type ReactPyUrls = { diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index 1d2b7ca1..a3f653ce 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -8,15 +8,8 @@ export function createReconnectingWebSocket(props: { maxInterval: number; maxRetries: number; backoffMultiplier: number; - jitterMultiplier: number; }) { - const { - startInterval, - maxInterval, - maxRetries, - backoffMultiplier, - jitterMultiplier, - } = props; + const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props; let retries = 0; let interval = startInterval; let everConnected = false; @@ -43,24 +36,20 @@ export function createReconnectingWebSocket(props: { console.info("ReactPy failed to connect!"); return; } - console.info("ReactPy disconnected!"); if (props.onClose) { props.onClose(); } - if (retries >= maxRetries) { console.info("ReactPy connection max retries exhausted!"); return; } - - const thisInterval = addJitter(interval, jitterMultiplier); console.info( - `ReactPy reconnecting in ${(thisInterval / 1000).toPrecision( + `ReactPy reconnecting in ${(interval / 1000).toPrecision( 4 )} seconds...` ); - setTimeout(connect, thisInterval); + setTimeout(connect, interval); interval = nextInterval(interval, backoffMultiplier, maxInterval); retries++; }; @@ -86,9 +75,3 @@ export function nextInterval( maxInterval ); } - -export function addJitter(interval: number, jitter: number): number { - return ( - interval + (Math.random() * jitter * interval * 2 - jitter * interval) - ); -} diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 3c429e9c..695e78f4 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -97,18 +97,13 @@ "REACTPY_RECONNECT_MAX_INTERVAL", 60000, # Default to 60 seconds ) -REACTPY_RECONNECT_JITTER_MULTIPLIER: float | int = getattr( +REACTPY_RECONNECT_MAX_RETRIES: int = getattr( settings, - "REACTPY_RECONNECT_JITTER_MULTIPLIER", - 0, # Default to no jitter + "REACTPY_RECONNECT_MAX_RETRIES", + 150, ) REACTPY_RECONNECT_BACKOFF_MULTIPLIER: float | int = getattr( settings, "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", - 1.1, # Default to 10% backoff -) -REACTPY_RECONNECT_MAX_RETRIES: int = getattr( - settings, - "REACTPY_RECONNECT_MAX_RETRIES", - 150, + 1.25, # Default to 10% backoff ) diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index f7f9e6a6..75a65dbe 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -18,7 +18,6 @@ Number("{{ reactpy_reconnect_max_interval }}"), Number("{{ reactpy_reconnect_max_retries }}"), Number("{{ reactpy_reconnect_backoff_multiplier }}"), - Number("{{ reactpy_reconnect_jitter_multiplier }}"), ); {% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 97d53a17..74d2c2db 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -115,7 +115,6 @@ def component( "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, "reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL, "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL, - "reactpy_reconnect_jitter_multiplier": config.REACTPY_RECONNECT_JITTER_MULTIPLIER, "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES, } From 28570771d61e8df8bc0c72309372109a9ab0785d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 23 Aug 2023 22:58:45 -0700 Subject: [PATCH 06/16] add checks for REACTPY_RECONNECT_MAX --- src/reactpy_django/checks.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index efb35e35..3e5c42b2 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -104,7 +104,7 @@ def reactpy_warnings(app_configs, **kwargs): # DELETED W007: Check if REACTPY_WEBSOCKET_URL doesn't end with a slash # DELETED W008: Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character - # Removed Settings + # Removed REACTPY_WEBSOCKET_URL setting if getattr(settings, "REACTPY_WEBSOCKET_URL", None): warnings.append( Warning( @@ -159,6 +159,16 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Removed REACTPY_RECONNECT_MAX setting + if getattr(settings, "REACTPY_RECONNECT_MAX", None): + warnings.append( + Warning( + "REACTPY_RECONNECT_MAX has been removed.", + hint="Use REACTPY_SESSION_MAX_AGE as an alternative.", + id="reactpy_django.W013", + ) + ) + return warnings From a7c92c3fb1c22c07f1b2180fd4fc6b68cf63ce36 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 23 Aug 2023 23:06:47 -0700 Subject: [PATCH 07/16] add docs --- docs/src/features/settings.md | 6 +++++- src/reactpy_django/config.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index a68ad25c..288f3a68 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -14,12 +14,16 @@ These are ReactPy-Django's default settings values. You can modify these values | --- | --- | --- | --- | | `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | | `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database ReactPy uses to store session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to also configure `DATABASE_ROUTERS` like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | -| `REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. | +| `REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `args` and `kwargs` passed into your component template tag.
Use `#!python 0` to not store any session data. | | `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | | `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | | `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. | | `REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | | `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | Default host(s) to use for ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) to override this default. | +| `REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. | +| `REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. | +| `REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. | +| `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. | diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 695e78f4..d8ca67b4 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -105,5 +105,5 @@ REACTPY_RECONNECT_BACKOFF_MULTIPLIER: float | int = getattr( settings, "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", - 1.25, # Default to 10% backoff + 1.25, # Default to 25% backoff per connection attempt ) From d2c7b56dd6fa14bd178184865a224746cd6235c5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 24 Aug 2023 00:18:37 -0700 Subject: [PATCH 08/16] checks for all new settings --- src/reactpy_django/checks.py | 171 +++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 3e5c42b2..2fc90f77 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -1,4 +1,5 @@ import contextlib +import math import sys from django.contrib.staticfiles.finders import find @@ -169,6 +170,77 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + if ( + isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_INTERVAL > 30000 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_INTERVAL is set to >30 seconds. Are you sure this is intentional? " + "This may cause unexpected delays between reconnection.", + hint="Check your value for REACTPY_RECONNECT_INTERVAL or suppress this warning.", + id="reactpy_django.W014", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and config.REACTPY_RECONNECT_MAX_RETRIES > 5000 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_MAX_RETRIES is set to a very large value. Are you sure this is intentional? " + "This may leave your clients attempting reconnections for a long time.", + hint="Check your value for REACTPY_RECONNECT_MAX_RETRIES or suppress this warning.", + id="reactpy_django.W015", + ) + ) + + # Check if the value is too large (greater than 50) + if ( + isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 100 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER is set to a very large value. Are you sure this is intentional?", + hint="Check your value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER or suppress this warning.", + id="reactpy_django.W016", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_INTERVAL > 0 + and config.REACTPY_RECONNECT_MAX_INTERVAL > 0 + and config.REACTPY_RECONNECT_MAX_RETRIES > 0 + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 1 + and ( + config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER + ** config.REACTPY_RECONNECT_MAX_RETRIES + ) + * config.REACTPY_RECONNECT_INTERVAL + < config.REACTPY_RECONNECT_MAX_INTERVAL + ): + max_value = math.floor( + ( + config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER + ** config.REACTPY_RECONNECT_MAX_RETRIES + ) + * config.REACTPY_RECONNECT_INTERVAL + ) + warnings.append( + Warning( + "Your current ReactPy configuration can never reach REACTPY_RECONNECT_MAX_INTERVAL. At most you will reach " + f"{max_value} miliseconds, which is less than {config.REACTPY_RECONNECT_MAX_INTERVAL} (REACTPY_RECONNECT_MAX_INTERVAL).", + hint="Check your ReactPy REACTPY_RECONNECT_* settings.", + id="reactpy_django.W017", + ) + ) + return warnings @@ -176,6 +248,8 @@ def reactpy_warnings(app_configs, **kwargs): def reactpy_errors(app_configs, **kwargs): from django.conf import settings + from reactpy_django import config + errors = [] # Make sure ASGI is enabled @@ -288,4 +362,101 @@ def reactpy_errors(app_configs, **kwargs): ) break + if not isinstance(config.REACTPY_RECONNECT_INTERVAL, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_INTERVAL should be an integer.", + id="reactpy_django.E012", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_INTERVAL < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_INTERVAL should be a positive integer.", + id="reactpy_django.E013", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_MAX_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be an integer.", + id="reactpy_django.E014", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and config.REACTPY_RECONNECT_MAX_INTERVAL < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_MAX_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be a positive integer.", + id="reactpy_django.E015", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_MAX_INTERVAL < config.REACTPY_RECONNECT_INTERVAL + ): + errors.append( + Error( + "REACTPY_RECONNECT_MAX_INTERVAL is less than REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be greater than or equal to REACTPY_RECONNECT_INTERVAL.", + id="reactpy_django.E016", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_MAX_RETRIES.", + hint="REACTPY_RECONNECT_MAX_RETRIES should be an integer.", + id="reactpy_django.E017", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and config.REACTPY_RECONNECT_MAX_RETRIES < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_MAX_RETRIES.", + hint="REACTPY_RECONNECT_MAX_RETRIES should be a positive integer.", + id="reactpy_django.E018", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", + hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be an integer or float.", + id="reactpy_django.E019", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER < 1 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", + hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be greater than or equal to 1.", + id="reactpy_django.E020", + ) + ) + return errors From 40511f404b76fad4e8cb76155f59c1bb484e3cb9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 24 Aug 2023 00:46:02 -0700 Subject: [PATCH 09/16] changelog and tweaks --- CHANGELOG.md | 16 ++++++++++++++++ src/reactpy_django/checks.py | 2 +- src/reactpy_django/config.py | 7 +------ src/reactpy_django/websocket/consumer.py | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe62c373..7f179f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,11 +34,27 @@ Using the following categories, list your changes in this order: ## [Unreleased] +## Added + +- More reconnection behavior customizability through new settings! + - `REACTPY_RECONNECT_INTERVAL` + - `REACTPY_RECONNECT_MAX_INTERVAL` + - `REACTPY_RECONNECT_MAX_RETRIES` + - `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` + ### Changed - Bumped the minimum ReactPy version to `1.0.2`. - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. +- Bumped the minimum `@reactpy/client` version to `0.3.1` +- Use TypeScript instead of JavaScript for this repo. +- Bumped minimum Django version to `4.2`. + - Note: Moving forward, ReactPy-Django will only support Django's latest versions to increase async support (and performance). This latest-only trend will continue until Django has all async features that ReactPy requires. + +### Removed + +- `settings.py:REACTPY_RECONNECT_MAX` is removed. See the docs for the new `REACTPY_RECONNECT_*` settings. ## [3.4.0] - 2023-08-18 diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 2fc90f77..754b81be 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -165,7 +165,7 @@ def reactpy_warnings(app_configs, **kwargs): warnings.append( Warning( "REACTPY_RECONNECT_MAX has been removed.", - hint="Use REACTPY_SESSION_MAX_AGE as an alternative.", + hint="See the docs for the new REACTPY_RECONNECT_* settings.", id="reactpy_django.W013", ) ) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index d8ca67b4..d811f7dc 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -28,11 +28,6 @@ "REACTPY_WEBSOCKET_URL", "reactpy/", ) -REACTPY_RECONNECT_MAX: int = getattr( - settings, - "REACTPY_RECONNECT_MAX", - 259200, # Default to 3 days -) # Configurable through Django settings.py REACTPY_URL_PREFIX: str = getattr( @@ -43,7 +38,7 @@ REACTPY_SESSION_MAX_AGE: int = getattr( settings, "REACTPY_SESSION_MAX_AGE", - REACTPY_RECONNECT_MAX, + 259200, # Default to 3 days ) REACTPY_CACHE: str = getattr( settings, diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index b486dc3c..e40bc68e 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -105,7 +105,7 @@ async def disconnect(self, code: int) -> None: except Exception: await asyncio.to_thread( _logger.exception, - "ReactPy has failed to update component session `last_accessed`!", + "ReactPy has failed to save component session!", ) await super().disconnect(code) From dcc280ec9d261ff4baf95842d90d7eabba94c5a8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 24 Aug 2023 00:55:47 -0700 Subject: [PATCH 10/16] fix typos --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f179f70..671dd500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Using the following categories, list your changes in this order: ## Added -- More reconnection behavior customizability through new settings! +- More customization for reconnection behavior through new settings! - `REACTPY_RECONNECT_INTERVAL` - `REACTPY_RECONNECT_MAX_INTERVAL` - `REACTPY_RECONNECT_MAX_RETRIES` @@ -48,7 +48,7 @@ Using the following categories, list your changes in this order: - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. - Bumped the minimum `@reactpy/client` version to `0.3.1` -- Use TypeScript instead of JavaScript for this repo. +- Use TypeScript instead of JavaScript for this repository. - Bumped minimum Django version to `4.2`. - Note: Moving forward, ReactPy-Django will only support Django's latest versions to increase async support (and performance). This latest-only trend will continue until Django has all async features that ReactPy requires. From d4424b9c0870499c2e1e939fa0198cef173ba745 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 26 Aug 2023 14:28:46 -0700 Subject: [PATCH 11/16] fix changelog "added" title --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a53a2a..1424a0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ Using the following categories, list your changes in this order: ## [Unreleased] -## Added +### Added - More customization for reconnection behavior through new settings! - `REACTPY_RECONNECT_INTERVAL` From eb37320a54dba47e176259bdefc17e81d3f75c4a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 26 Aug 2023 19:41:12 -0700 Subject: [PATCH 12/16] minor refactoring --- src/reactpy_django/templatetags/reactpy.py | 4 +-- src/reactpy_django/types.py | 4 +-- src/reactpy_django/utils.py | 20 ++++++------ src/reactpy_django/websocket/consumer.py | 36 +++++++++++++--------- tests/test_app/tests/test_database.py | 10 +++--- 5 files changed, 41 insertions(+), 33 deletions(-) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index e49b3769..82640ced 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -15,7 +15,7 @@ ComponentParamError, InvalidHostError, ) -from reactpy_django.types import ComponentParamData +from reactpy_django.types import ComponentParams from reactpy_django.utils import validate_component_args try: @@ -129,7 +129,7 @@ def failure_context(dotted_path: str, error: Exception): def save_component_params(args, kwargs, uuid): - params = ComponentParamData(args, kwargs) + params = ComponentParams(args, kwargs) model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() model.save() diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 02fdec6e..ac6205e0 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -33,7 +33,7 @@ "SyncPostprocessor", "QueryOptions", "MutationOptions", - "ComponentParamData", + "ComponentParams", ] _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) @@ -127,7 +127,7 @@ class MutationOptions: @dataclass -class ComponentParamData: +class ComponentParams: """Container used for serializing component parameters. This dataclass is pickled & stored in the database, then unpickled when needed.""" diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index f40ea1ee..755b3a05 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -324,9 +324,10 @@ def create_cache_key(*args): return f"reactpy_django:{':'.join(str(arg) for arg in args)}" -def db_cleanup(immediate: bool = False): +def delete_expired_sessions(immediate: bool = False): """Deletes expired component sessions from the database. - This function may be expanded in the future to include additional cleanup tasks.""" + As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds. + """ from .config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE from .models import ComponentSession, Config @@ -343,10 +344,11 @@ def db_cleanup(immediate: bool = False): config.save() # Check if cleaning took abnormally long - clean_duration = timezone.now() - start_time - if REACTPY_DEBUG_MODE and clean_duration.total_seconds() > 1: - _logger.warning( - "ReactPy has taken %s seconds to clean up expired component sessions. " - "This may indicate a performance issue with your system, cache, or database.", - clean_duration.total_seconds(), - ) + if REACTPY_DEBUG_MODE: + clean_duration = timezone.now() - start_time + if clean_duration.total_seconds() > 1: + _logger.warning( + "ReactPy has taken %s seconds to clean up expired component sessions. " + "This may indicate a performance issue with your system, cache, or database.", + clean_duration.total_seconds(), + ) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index e40bc68e..c6a47c27 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -21,8 +21,8 @@ from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy_django.types import ComponentParamData, ComponentWebsocket -from reactpy_django.utils import db_cleanup +from reactpy_django.types import ComponentParams, ComponentWebsocket +from reactpy_django.utils import delete_expired_sessions _logger = logging.getLogger(__name__) backhaul_loop = asyncio.new_event_loop() @@ -98,8 +98,19 @@ async def disconnect(self, code: int) -> None: """The browser has disconnected.""" self.dispatcher.cancel() - # Update the last_accessed timestamp if self.component_session: + # Clean up expired component sessions + try: + await database_sync_to_async( + delete_expired_sessions, thread_sensitive=False + )() + except Exception: + await asyncio.to_thread( + _logger.exception, + "ReactPy has failed to delete expired component sessions!", + ) + + # Update the last_accessed timestamp try: await self.component_session.asave() except Exception: @@ -149,8 +160,8 @@ async def run_dispatcher(self): carrier=ComponentWebsocket(self.close, self.disconnect, dotted_path), ) now = timezone.now() - component_args: Sequence[Any] = () - component_kwargs: MutableMapping[str, Any] = {} + component_session_args: Sequence[Any] = () + component_session_kwargs: MutableMapping[str, Any] = {} # Verify the component has already been registered try: @@ -165,23 +176,18 @@ async def run_dispatcher(self): # Fetch the component's args/kwargs from the database, if needed try: if uuid: - # Always clean up expired entries first - await database_sync_to_async(db_cleanup, thread_sensitive=False)() - # Get the component session from the DB self.component_session = await models.ComponentSession.objects.aget( uuid=uuid, last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE), ) - component_params: ComponentParamData = pickle.loads( - self.component_session.params - ) - component_args = component_params.args - component_kwargs = component_params.kwargs + params: ComponentParams = pickle.loads(self.component_session.params) + component_session_args = params.args + component_session_kwargs = params.kwargs # Generate the initial component instance component_instance = component_constructor( - *component_args, **component_kwargs + *component_session_args, **component_session_kwargs ) except models.ComponentSession.DoesNotExist: await asyncio.to_thread( @@ -196,7 +202,7 @@ async def run_dispatcher(self): await asyncio.to_thread( _logger.exception, f"Failed to construct component {component_constructor} " - f"with args='{component_args}' kwargs='{component_kwargs}'!", + f"with args='{component_session_args}' kwargs='{component_session_kwargs}'!", ) return diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 94f75950..0c9a1b84 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -6,7 +6,7 @@ from django.test import TransactionTestCase from reactpy_django import utils from reactpy_django.models import ComponentSession -from reactpy_django.types import ComponentParamData +from reactpy_django.types import ComponentParams class RoutedDatabaseTests(TransactionTestCase): @@ -15,7 +15,7 @@ class RoutedDatabaseTests(TransactionTestCase): @classmethod def setUpClass(cls): super().setUpClass() - utils.db_cleanup(immediate=True) + utils.delete_expired_sessions(immediate=True) def test_component_params(self): # Make sure the ComponentParams table is empty @@ -39,7 +39,7 @@ def test_component_params(self): self.assertEqual(ComponentSession.objects.count(), 2) # Delete the first component params based on expiration time - utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic + utils.delete_expired_sessions() # Don't use `immediate` to test timestamping logic # Make sure `params_1` has expired self.assertEqual(ComponentSession.objects.count(), 1) @@ -47,9 +47,9 @@ def test_component_params(self): pickle.loads(ComponentSession.objects.first().params), params_2 # type: ignore ) - def _save_params_to_db(self, value: Any) -> ComponentParamData: + def _save_params_to_db(self, value: Any) -> ComponentParams: db = list(self.databases)[0] - param_data = ComponentParamData((value,), {"test_value": value}) + param_data = ComponentParams((value,), {"test_value": value}) model = ComponentSession(uuid4().hex, params=pickle.dumps(param_data)) model.clean_fields() model.clean() From 88464c4995a424fa87d9b65ccf7889abd5453e01 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 26 Aug 2023 19:59:04 -0700 Subject: [PATCH 13/16] remove mike set-default --- .github/workflows/publish-release-docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index b58cb0ed..a0c8861d 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -20,4 +20,3 @@ jobs: git config user.name github-actions git config user.email github-actions@github.com mike deploy --push --update-aliases ${{ github.event.release.name }} latest - mike set-default --push latest From 86fdbbea3f50401f6f962bfbb4bde4efb8fc22d1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 26 Aug 2023 20:09:52 -0700 Subject: [PATCH 14/16] tweak changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1424a0e5..da060bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ Using the following categories, list your changes in this order: - Bumped the minimum `@reactpy/client` version to `0.3.1` - Use TypeScript instead of JavaScript for this repository. - Bumped minimum Django version to `4.2`. - - Note: Moving forward, ReactPy-Django will only support Django's latest versions to increase async support (and performance). This latest-only trend will continue until Django has all async features that ReactPy requires. + - Note: ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support. This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions. ### Removed From 3b8f0feefb3bb4a74a6255d9fb165fdbb3236d1f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 26 Aug 2023 20:20:19 -0700 Subject: [PATCH 15/16] bump node and npm --- .github/workflows/publish-py.yml | 56 ++++++++++++++++---------------- .github/workflows/test-src.yml | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 4439cb5e..09be8866 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -4,33 +4,33 @@ name: Publish Python on: - release: - types: [published] + release: + types: [published] jobs: - release-package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: "14.x" - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: "3.x" - - name: Install NPM - run: | - npm install -g npm@7.22.0 - npm --version - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/build-pkg.txt - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python -m build --sdist --wheel --outdir dist . - twine upload dist/* + release-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "20.x" + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install NPM + run: | + npm install -g npm@latest + npm --version + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements/build-pkg.txt + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build --sdist --wheel --outdir dist . + twine upload dist/* diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index f945ea9d..4a03c49c 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "14.x" + node-version: "20.x" - name: Use Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: From 69aee026bc0c86afd6d131edeb0178b99bc28403 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 26 Aug 2023 20:31:37 -0700 Subject: [PATCH 16/16] add hyperlinks to localhost --- docs/src/contribute/code.md | 2 +- docs/src/contribute/docs.md | 2 +- docs/src/get-started/run-webserver.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/contribute/code.md b/docs/src/contribute/code.md index cc5b8e58..a5b52955 100644 --- a/docs/src/contribute/code.md +++ b/docs/src/contribute/code.md @@ -45,7 +45,7 @@ cd tests python manage.py runserver ``` -Navigate to `http://127.0.0.1:8000` to see if the tests are rendering correctly. +Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to see if the tests are rendering correctly. ## GitHub Pull Request diff --git a/docs/src/contribute/docs.md b/docs/src/contribute/docs.md index 33b83fb7..913bc0a6 100644 --- a/docs/src/contribute/docs.md +++ b/docs/src/contribute/docs.md @@ -37,7 +37,7 @@ Finally, to verify that everything is working properly, you can manually run the mkdocs serve ``` -Navigate to `http://127.0.0.1:8000` to view a preview of the documentation. +Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to view a preview of the documentation. ## GitHub Pull Request diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md index cefeafc3..cb4f87f1 100644 --- a/docs/src/get-started/run-webserver.md +++ b/docs/src/get-started/run-webserver.md @@ -16,7 +16,7 @@ To test your new Django view, run the following command to start up a developmen python manage.py runserver ``` -Now you can navigate to your **Django project** URL that contains a ReactPy component, such as `http://127.0.0.1:8000/example/` ([_from the previous step_](./register-view.md)). +Now you can navigate to your **Django project** URL that contains a ReactPy component, such as [`http://127.0.0.1:8000/example/`](http://127.0.0.1:8000/example/) ([_from the previous step_](./register-view.md)). If you copy-pasted our example component, you will now see your component display "Hello World".