From 5e8e593cf6b9e12b61389e794b611a0c844c053b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 5 Feb 2024 23:49:45 -0800 Subject: [PATCH 01/16] functional only-once implementation --- src/reactpy_django/components.py | 45 ++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 75b0c321..5ac7e61b 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,9 +1,11 @@ from __future__ import annotations +import contextlib import json import os from typing import Any, Callable, Sequence, Union, cast, overload from urllib.parse import urlencode +from uuid import uuid4 from warnings import warn from django.contrib.staticfiles.finders import find @@ -15,6 +17,7 @@ from reactpy.types import Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError +from reactpy_django.hooks import use_scope from reactpy_django.utils import generate_obj_name, import_module, render_view @@ -27,8 +30,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Any: - ... +) -> Any: ... # Type hints for: @@ -39,8 +41,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Callable[[Callable], Any]: - ... +) -> Callable[[Callable], Any]: ... def view_to_component( @@ -122,7 +123,7 @@ def constructor( return constructor -def django_css(static_path: str, key: Key | None = None): +def django_css(static_path: str, only_once: bool = True, key: Key | None = None): """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -132,10 +133,10 @@ def django_css(static_path: str, key: Key | None = None): immediate siblings """ - return _django_css(static_path=static_path, key=key) + return _django_css(static_path=static_path, only_once=only_once, key=key) -def django_js(static_path: str, key: Key | None = None): +def django_js(static_path: str, only_once: bool = True, key: Key | None = None): """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -145,7 +146,7 @@ def django_js(static_path: str, key: Key | None = None): immediate siblings """ - return _django_js(static_path=static_path, key=key) + return _django_js(static_path=static_path, only_once=only_once, key=key) @component @@ -250,12 +251,34 @@ def _view_to_iframe( @component -def _django_css(static_path: str): - return html.style(_cached_static_contents(static_path)) +def _django_css(static_path: str, only_once: bool): + scope = use_scope() + ownership_uuid = hooks.use_memo(lambda: uuid4()) + scope.setdefault("reactpy_css", {}).setdefault(static_path, ownership_uuid) + + # Load the CSS file if no other component has loaded it + @hooks.use_effect(dependencies=None) + async def only_once_manager(): + if not only_once: + return + + # If the CSS file currently isn't rendered, let this component render it + if not scope["reactpy_css"].get(static_path): + scope["reactpy_css"].setdefault(static_path, ownership_uuid) + + # Only the component that loaded the CSS file should remove it from the scope + def unmount(): + if scope["reactpy_css"].get(static_path) == ownership_uuid: + scope["reactpy_css"].pop(static_path) + + return unmount + + if not only_once or (scope["reactpy_css"].get(static_path) == ownership_uuid): + return html.style(_cached_static_contents(static_path)) @component -def _django_js(static_path: str): +def _django_js(static_path: str, only_once: bool): return html.script(_cached_static_contents(static_path)) From fdf399e7b40450a0e1967d9d782db03c4e4663e7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 6 Feb 2024 00:53:46 -0800 Subject: [PATCH 02/16] cleanup --- src/reactpy_django/components.py | 44 ++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 5ac7e61b..d24f10f2 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -254,32 +254,56 @@ def _view_to_iframe( def _django_css(static_path: str, only_once: bool): scope = use_scope() ownership_uuid = hooks.use_memo(lambda: uuid4()) - scope.setdefault("reactpy_css", {}).setdefault(static_path, ownership_uuid) + scope.setdefault("reactpy", {}).setdefault("css", {}) + scope["reactpy"]["css"].setdefault(static_path, ownership_uuid) - # Load the CSS file if no other component has loaded it + # Load the file if no other component has loaded it @hooks.use_effect(dependencies=None) async def only_once_manager(): if not only_once: return - # If the CSS file currently isn't rendered, let this component render it - if not scope["reactpy_css"].get(static_path): - scope["reactpy_css"].setdefault(static_path, ownership_uuid) + # If the file currently isn't rendered, let this component render it + if not scope["reactpy"]["css"].get(static_path): + scope["reactpy"]["css"].setdefault(static_path, ownership_uuid) - # Only the component that loaded the CSS file should remove it from the scope + # Only the component that loaded the file should remove it from the scope def unmount(): - if scope["reactpy_css"].get(static_path) == ownership_uuid: - scope["reactpy_css"].pop(static_path) + if scope["reactpy"]["css"].get(static_path) == ownership_uuid: + scope["reactpy"]["css"].pop(static_path) return unmount - if not only_once or (scope["reactpy_css"].get(static_path) == ownership_uuid): + if not only_once or (scope["reactpy"]["css"].get(static_path) == ownership_uuid): return html.style(_cached_static_contents(static_path)) @component def _django_js(static_path: str, only_once: bool): - return html.script(_cached_static_contents(static_path)) + scope = use_scope() + ownership_uuid = hooks.use_memo(lambda: uuid4()) + scope.setdefault("reactpy", {}).setdefault("js", {}) + scope["reactpy"]["js"].setdefault(static_path, ownership_uuid) + + # Load the file if no other component has loaded it + @hooks.use_effect(dependencies=None) + async def only_once_manager(): + if not only_once: + return + + # If the file currently isn't rendered, let this component render it + if not scope["reactpy"]["js"].get(static_path): + scope["reactpy"]["js"].setdefault(static_path, ownership_uuid) + + # Only the component that loaded the file should remove it from the scope + def unmount(): + if scope["reactpy"]["js"].get(static_path) == ownership_uuid: + scope["reactpy"]["js"].pop(static_path) + + return unmount + + if not only_once or (scope["reactpy"]["js"].get(static_path) == ownership_uuid): + return html.script(_cached_static_contents(static_path)) def _cached_static_contents(static_path: str) -> str: From 1225951c3922f7faa829d5f72cfe062dab71c12e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 6 Feb 2024 00:54:03 -0800 Subject: [PATCH 03/16] css test component --- tests/test_app/components.py | 66 ++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index dbe9bd8f..90064e49 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,6 +1,7 @@ import asyncio import inspect from pathlib import Path +from uuid import uuid4 import reactpy_django from channels.auth import login, logout @@ -135,6 +136,59 @@ def django_css(): ) +@component +def django_css_only_once(): + scope = reactpy_django.hooks.use_scope() + css_files, set_css_files = hooks.use_state( + [reactpy_django.components.django_css("django-css-only-once-test.css")] + ) + + async def add_end_css(event): + set_css_files( + css_files + + [ + reactpy_django.components.django_css( + "django-css-only-once-test.css", key=str(uuid4()) + ) + ] + ) + + async def add_front_css(event): + set_css_files( + [ + reactpy_django.components.django_css( + "django-css-only-once-test.css", key=str(uuid4()) + ) + ] + + css_files + ) + + async def remove_end_css(event): + if css_files: + set_css_files(css_files[:-1]) + + async def remove_front_css(event): + if css_files: + set_css_files(css_files[1:]) + + return html.div( + {"id": "django-css-only-once"}, + html.div({"style": {"display": "inline"}}, "django_css_only_once: "), + html.button( + "This text should be blue. The stylesheet for this component should only be added once." + ), + html.div( + html.button({"on_click": add_end_css}, "Add End File"), + html.button({"on_click": add_front_css}, "Add Front File"), + html.button({"on_click": remove_end_css}, "Remove End File"), + html.button({"on_click": remove_front_css}, "Remove Front File"), + ), + html.div(f'CSS ownership tracked via ASGI scope: {scope.get("reactpy_css")}'), + html.div(f"Components with CSS: {css_files}"), + css_files, + ) + + @component def django_js(): success = False @@ -720,9 +774,9 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": "AnonymousUser" - if current_user.is_anonymous - else current_user.username, + "data-username": ( + "AnonymousUser" if current_user.is_anonymous else current_user.username + ), }, html.div("use_user_data"), html.button({"class": "login-1", "on_click": login_user1}, "Login 1"), @@ -788,9 +842,9 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": "AnonymousUser" - if current_user.is_anonymous - else current_user.username, + "data-username": ( + "AnonymousUser" if current_user.is_anonymous else current_user.username + ), }, html.div("use_user_data_with_default"), html.button({"class": "login-3", "on_click": login_user3}, "Login 3"), From 6cd61549a2c7c7887a03301dd4e58621738b8741 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 6 Feb 2024 00:57:26 -0800 Subject: [PATCH 04/16] Set default to false --- src/reactpy_django/components.py | 5 ++--- tests/test_app/components.py | 10 +++++++--- tests/test_app/static/django-css-only-once-test.css | 3 +++ 3 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 tests/test_app/static/django-css-only-once-test.css diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index d24f10f2..e9b8f9ca 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,6 +1,5 @@ from __future__ import annotations -import contextlib import json import os from typing import Any, Callable, Sequence, Union, cast, overload @@ -123,7 +122,7 @@ def constructor( return constructor -def django_css(static_path: str, only_once: bool = True, key: Key | None = None): +def django_css(static_path: str, only_once: bool = False, key: Key | None = None): """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -136,7 +135,7 @@ def django_css(static_path: str, only_once: bool = True, key: Key | None = None) return _django_css(static_path=static_path, only_once=only_once, key=key) -def django_js(static_path: str, only_once: bool = True, key: Key | None = None): +def django_js(static_path: str, only_once: bool = False, key: Key | None = None): """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 90064e49..64690558 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -140,7 +140,11 @@ def django_css(): def django_css_only_once(): scope = reactpy_django.hooks.use_scope() css_files, set_css_files = hooks.use_state( - [reactpy_django.components.django_css("django-css-only-once-test.css")] + [ + reactpy_django.components.django_css( + "django-css-only-once-test.css", only_once=True + ) + ] ) async def add_end_css(event): @@ -148,7 +152,7 @@ async def add_end_css(event): css_files + [ reactpy_django.components.django_css( - "django-css-only-once-test.css", key=str(uuid4()) + "django-css-only-once-test.css", only_once=True, key=str(uuid4()) ) ] ) @@ -157,7 +161,7 @@ async def add_front_css(event): set_css_files( [ reactpy_django.components.django_css( - "django-css-only-once-test.css", key=str(uuid4()) + "django-css-only-once-test.css", only_once=True, key=str(uuid4()) ) ] + css_files diff --git a/tests/test_app/static/django-css-only-once-test.css b/tests/test_app/static/django-css-only-once-test.css new file mode 100644 index 00000000..15f51822 --- /dev/null +++ b/tests/test_app/static/django-css-only-once-test.css @@ -0,0 +1,3 @@ +#django-css-only-once button { + color: rgb(0, 0, 255); +} From 767aeea20e3794cd858ff81c88b9972a9adf2ece Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 9 Feb 2024 19:17:27 -0800 Subject: [PATCH 05/16] rename only_once to allow_duplicates --- src/reactpy_django/components.py | 34 +++++++++++++++++++------------- tests/test_app/components.py | 14 ++++++++----- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index e9b8f9ca..3f3abf1f 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -122,7 +122,9 @@ def constructor( return constructor -def django_css(static_path: str, only_once: bool = False, key: Key | None = None): +def django_css( + static_path: str, allow_duplicates: bool = False, key: Key | None = None +): """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -132,10 +134,12 @@ def django_css(static_path: str, only_once: bool = False, key: Key | None = None immediate siblings """ - return _django_css(static_path=static_path, only_once=only_once, key=key) + return _django_css( + static_path=static_path, allow_duplicates=allow_duplicates, key=key + ) -def django_js(static_path: str, only_once: bool = False, key: Key | None = None): +def django_js(static_path: str, allow_duplicates: bool = False, key: Key | None = None): """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -145,7 +149,9 @@ def django_js(static_path: str, only_once: bool = False, key: Key | None = None) immediate siblings """ - return _django_js(static_path=static_path, only_once=only_once, key=key) + return _django_js( + static_path=static_path, allow_duplicates=allow_duplicates, key=key + ) @component @@ -250,7 +256,7 @@ def _view_to_iframe( @component -def _django_css(static_path: str, only_once: bool): +def _django_css(static_path: str, allow_duplicates: bool): scope = use_scope() ownership_uuid = hooks.use_memo(lambda: uuid4()) scope.setdefault("reactpy", {}).setdefault("css", {}) @@ -258,27 +264,27 @@ def _django_css(static_path: str, only_once: bool): # Load the file if no other component has loaded it @hooks.use_effect(dependencies=None) - async def only_once_manager(): - if not only_once: + async def duplicate_manager(): + if allow_duplicates: return # If the file currently isn't rendered, let this component render it if not scope["reactpy"]["css"].get(static_path): scope["reactpy"]["css"].setdefault(static_path, ownership_uuid) - # Only the component that loaded the file should remove it from the scope + # The component that loaded the file should notify when it's removed def unmount(): if scope["reactpy"]["css"].get(static_path) == ownership_uuid: scope["reactpy"]["css"].pop(static_path) return unmount - if not only_once or (scope["reactpy"]["css"].get(static_path) == ownership_uuid): + if allow_duplicates or (scope["reactpy"]["css"].get(static_path) == ownership_uuid): return html.style(_cached_static_contents(static_path)) @component -def _django_js(static_path: str, only_once: bool): +def _django_js(static_path: str, allow_duplicates: bool): scope = use_scope() ownership_uuid = hooks.use_memo(lambda: uuid4()) scope.setdefault("reactpy", {}).setdefault("js", {}) @@ -286,22 +292,22 @@ def _django_js(static_path: str, only_once: bool): # Load the file if no other component has loaded it @hooks.use_effect(dependencies=None) - async def only_once_manager(): - if not only_once: + async def duplicate_manager(): + if allow_duplicates: return # If the file currently isn't rendered, let this component render it if not scope["reactpy"]["js"].get(static_path): scope["reactpy"]["js"].setdefault(static_path, ownership_uuid) - # Only the component that loaded the file should remove it from the scope + # The component that loaded the file should notify when it's removed def unmount(): if scope["reactpy"]["js"].get(static_path) == ownership_uuid: scope["reactpy"]["js"].pop(static_path) return unmount - if not only_once or (scope["reactpy"]["js"].get(static_path) == ownership_uuid): + if allow_duplicates or (scope["reactpy"]["js"].get(static_path) == ownership_uuid): return html.script(_cached_static_contents(static_path)) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 64690558..2c5766f0 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -137,12 +137,12 @@ def django_css(): @component -def django_css_only_once(): +def django_css_allow_duplicates(): scope = reactpy_django.hooks.use_scope() css_files, set_css_files = hooks.use_state( [ reactpy_django.components.django_css( - "django-css-only-once-test.css", only_once=True + "django-css-only-once-test.css", allow_duplicates=True ) ] ) @@ -152,7 +152,9 @@ async def add_end_css(event): css_files + [ reactpy_django.components.django_css( - "django-css-only-once-test.css", only_once=True, key=str(uuid4()) + "django-css-only-once-test.css", + allow_duplicates=True, + key=str(uuid4()), ) ] ) @@ -161,7 +163,9 @@ async def add_front_css(event): set_css_files( [ reactpy_django.components.django_css( - "django-css-only-once-test.css", only_once=True, key=str(uuid4()) + "django-css-only-once-test.css", + allow_duplicates=True, + key=str(uuid4()), ) ] + css_files @@ -177,7 +181,7 @@ async def remove_front_css(event): return html.div( {"id": "django-css-only-once"}, - html.div({"style": {"display": "inline"}}, "django_css_only_once: "), + html.div({"style": {"display": "inline"}}, "django_css_allow_duplicates: "), html.button( "This text should be blue. The stylesheet for this component should only be added once." ), From dcd6a55d859a815100ed5eff2d7abf2a2a78d8f7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 11 Feb 2024 23:54:26 -0800 Subject: [PATCH 06/16] Only configure the scope if using `allow_duplicates=false` --- src/reactpy_django/components.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 3f3abf1f..a79eb887 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -259,12 +259,16 @@ def _view_to_iframe( def _django_css(static_path: str, allow_duplicates: bool): scope = use_scope() ownership_uuid = hooks.use_memo(lambda: uuid4()) - scope.setdefault("reactpy", {}).setdefault("css", {}) - scope["reactpy"]["css"].setdefault(static_path, ownership_uuid) + + # Configure the scope to track the file + if not allow_duplicates: + scope.setdefault("reactpy", {}).setdefault("css", {}) + scope["reactpy"]["css"].setdefault(static_path, ownership_uuid) # Load the file if no other component has loaded it @hooks.use_effect(dependencies=None) async def duplicate_manager(): + """Note: This hook runs on every render. This is intentional.""" if allow_duplicates: return @@ -287,12 +291,16 @@ def unmount(): def _django_js(static_path: str, allow_duplicates: bool): scope = use_scope() ownership_uuid = hooks.use_memo(lambda: uuid4()) - scope.setdefault("reactpy", {}).setdefault("js", {}) - scope["reactpy"]["js"].setdefault(static_path, ownership_uuid) + + # Configure the scope to track the file + if not allow_duplicates: + scope.setdefault("reactpy", {}).setdefault("js", {}) + scope["reactpy"]["js"].setdefault(static_path, ownership_uuid) # Load the file if no other component has loaded it @hooks.use_effect(dependencies=None) async def duplicate_manager(): + """Note: This hook runs on every render. This is intentional.""" if allow_duplicates: return From 57b195061737e265bfef13ede7641cb5864571c7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 12 Feb 2024 03:37:21 -0800 Subject: [PATCH 07/16] DRY static file loading --- src/reactpy_django/components.py | 123 +++++++++++-------------------- src/reactpy_django/utils.py | 29 ++++++++ tests/test_app/components.py | 12 +-- 3 files changed, 74 insertions(+), 90 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index a79eb887..09ff397a 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,23 +1,26 @@ from __future__ import annotations import json -import os from typing import Any, Callable, Sequence, Union, cast, overload from urllib.parse import urlencode from uuid import uuid4 from warnings import warn -from django.contrib.staticfiles.finders import find -from django.core.cache import caches from django.http import HttpRequest from django.urls import reverse from django.views import View from reactpy import component, hooks, html, utils +from reactpy.core.types import VdomDictConstructor from reactpy.types import Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError from reactpy_django.hooks import use_scope -from reactpy_django.utils import generate_obj_name, import_module, render_view +from reactpy_django.utils import ( + cached_static_contents, + generate_obj_name, + import_module, + render_view, +) # Type hints for: @@ -123,7 +126,7 @@ def constructor( def django_css( - static_path: str, allow_duplicates: bool = False, key: Key | None = None + static_path: str, prevent_duplicates: bool = True, key: Key | None = None ): """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. @@ -134,12 +137,18 @@ def django_css( immediate siblings """ - return _django_css( - static_path=static_path, allow_duplicates=allow_duplicates, key=key + return _django_static_file( + static_path=static_path, + prevent_duplicates=prevent_duplicates, + file_type="css", + vdom_constructor=html.style, + key=key, ) -def django_js(static_path: str, allow_duplicates: bool = False, key: Key | None = None): +def django_js( + static_path: str, prevent_duplicates: bool = True, key: Key | None = None +): """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -149,8 +158,12 @@ def django_js(static_path: str, allow_duplicates: bool = False, key: Key | None immediate siblings """ - return _django_js( - static_path=static_path, allow_duplicates=allow_duplicates, key=key + return _django_static_file( + static_path=static_path, + prevent_duplicates=prevent_duplicates, + file_type="js", + vdom_constructor=html.script, + key=key, ) @@ -256,91 +269,39 @@ def _view_to_iframe( @component -def _django_css(static_path: str, allow_duplicates: bool): - scope = use_scope() - ownership_uuid = hooks.use_memo(lambda: uuid4()) - - # Configure the scope to track the file - if not allow_duplicates: - scope.setdefault("reactpy", {}).setdefault("css", {}) - scope["reactpy"]["css"].setdefault(static_path, ownership_uuid) - - # Load the file if no other component has loaded it - @hooks.use_effect(dependencies=None) - async def duplicate_manager(): - """Note: This hook runs on every render. This is intentional.""" - if allow_duplicates: - return - - # If the file currently isn't rendered, let this component render it - if not scope["reactpy"]["css"].get(static_path): - scope["reactpy"]["css"].setdefault(static_path, ownership_uuid) - - # The component that loaded the file should notify when it's removed - def unmount(): - if scope["reactpy"]["css"].get(static_path) == ownership_uuid: - scope["reactpy"]["css"].pop(static_path) - - return unmount - - if allow_duplicates or (scope["reactpy"]["css"].get(static_path) == ownership_uuid): - return html.style(_cached_static_contents(static_path)) - - -@component -def _django_js(static_path: str, allow_duplicates: bool): +def _django_static_file( + static_path: str, + prevent_duplicates: bool, + file_type: str, + vdom_constructor: VdomDictConstructor, +): scope = use_scope() ownership_uuid = hooks.use_memo(lambda: uuid4()) - # Configure the scope to track the file - if not allow_duplicates: - scope.setdefault("reactpy", {}).setdefault("js", {}) - scope["reactpy"]["js"].setdefault(static_path, ownership_uuid) + # Configure the ASGI scope to track the file + if prevent_duplicates: + scope.setdefault("reactpy", {}).setdefault(file_type, {}) + scope["reactpy"][file_type].setdefault(static_path, ownership_uuid) # Load the file if no other component has loaded it @hooks.use_effect(dependencies=None) async def duplicate_manager(): """Note: This hook runs on every render. This is intentional.""" - if allow_duplicates: + if not prevent_duplicates: return # If the file currently isn't rendered, let this component render it - if not scope["reactpy"]["js"].get(static_path): - scope["reactpy"]["js"].setdefault(static_path, ownership_uuid) + if not scope["reactpy"][file_type].get(static_path): + scope["reactpy"][file_type].setdefault(static_path, ownership_uuid) # The component that loaded the file should notify when it's removed def unmount(): - if scope["reactpy"]["js"].get(static_path) == ownership_uuid: - scope["reactpy"]["js"].pop(static_path) + if scope["reactpy"][file_type].get(static_path) == ownership_uuid: + scope["reactpy"][file_type].pop(static_path) return unmount - if allow_duplicates or (scope["reactpy"]["js"].get(static_path) == ownership_uuid): - return html.script(_cached_static_contents(static_path)) - - -def _cached_static_contents(static_path: str) -> str: - from reactpy_django.config import REACTPY_CACHE - - # Try to find the file within Django's static files - abs_path = find(static_path) - if not abs_path: - raise FileNotFoundError( - f"Could not find static file {static_path} within Django's static files." - ) - - # Fetch the file from cache, if available - last_modified_time = os.stat(abs_path).st_mtime - cache_key = f"reactpy_django:static_contents:{static_path}" - file_contents: str | None = caches[REACTPY_CACHE].get( - cache_key, version=int(last_modified_time) - ) - if file_contents is None: - with open(abs_path, encoding="utf-8") as static_file: - file_contents = static_file.read() - caches[REACTPY_CACHE].delete(cache_key) - caches[REACTPY_CACHE].set( - cache_key, file_contents, timeout=None, version=int(last_modified_time) - ) - - return file_contents + if not prevent_duplicates or ( + scope["reactpy"][file_type].get(static_path) == ownership_uuid + ): + return vdom_constructor(cached_static_contents(static_path)) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 3ed0e2de..9ecffa7e 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -12,6 +12,8 @@ from asgiref.sync import async_to_sync from channels.db import database_sync_to_async +from django.contrib.staticfiles.finders import find +from django.core.cache import caches from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects from django.db.models.base import Model from django.db.models.query import QuerySet @@ -366,3 +368,30 @@ def render(self): def get_pk(model): """Returns the value of the primary key for a Django model.""" return getattr(model, model._meta.pk.name) + + +def cached_static_contents(static_path: str) -> str: + from reactpy_django.config import REACTPY_CACHE + + # Try to find the file within Django's static files + abs_path = find(static_path) + if not abs_path: + raise FileNotFoundError( + f"Could not find static file {static_path} within Django's static files." + ) + + # Fetch the file from cache, if available + last_modified_time = os.stat(abs_path).st_mtime + cache_key = f"reactpy_django:static_contents:{static_path}" + file_contents: str | None = caches[REACTPY_CACHE].get( + cache_key, version=int(last_modified_time) + ) + if file_contents is None: + with open(abs_path, encoding="utf-8") as static_file: + file_contents = static_file.read() + caches[REACTPY_CACHE].delete(cache_key) + caches[REACTPY_CACHE].set( + cache_key, file_contents, timeout=None, version=int(last_modified_time) + ) + + return file_contents diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 2c5766f0..59f107e9 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -137,14 +137,10 @@ def django_css(): @component -def django_css_allow_duplicates(): +def django_css_prevent_duplicates(): scope = reactpy_django.hooks.use_scope() css_files, set_css_files = hooks.use_state( - [ - reactpy_django.components.django_css( - "django-css-only-once-test.css", allow_duplicates=True - ) - ] + [reactpy_django.components.django_css("django-css-only-once-test.css")] ) async def add_end_css(event): @@ -153,7 +149,6 @@ async def add_end_css(event): + [ reactpy_django.components.django_css( "django-css-only-once-test.css", - allow_duplicates=True, key=str(uuid4()), ) ] @@ -164,7 +159,6 @@ async def add_front_css(event): [ reactpy_django.components.django_css( "django-css-only-once-test.css", - allow_duplicates=True, key=str(uuid4()), ) ] @@ -181,7 +175,7 @@ async def remove_front_css(event): return html.div( {"id": "django-css-only-once"}, - html.div({"style": {"display": "inline"}}, "django_css_allow_duplicates: "), + html.div({"style": {"display": "inline"}}, "django_css_prevent_duplicates: "), html.button( "This text should be blue. The stylesheet for this component should only be added once." ), From 726e24445671a898f4b5411d648e25fe50f6476f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 17 Feb 2024 22:08:04 -0800 Subject: [PATCH 08/16] minor docs syntax highlighting --- docs/src/reference/hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 5ceb2fe6..9b510921 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -369,7 +369,7 @@ This is often used to create chat systems, synchronize data between components, pip install channels-redis ``` - 3. Configure your `settings.py` to use `RedisChannelLayer` as your layer backend. + 3. Configure your `settings.py` to use `#!python RedisChannelLayer` as your layer backend. ```python linenums="0" CHANNEL_LAYERS = { From f8cb34c44e100f88062cd6205f1e186a06da4c06 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 17 Feb 2024 22:11:14 -0800 Subject: [PATCH 09/16] docstrings --- docs/src/reference/template-tag.md | 4 +++- src/reactpy_django/components.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index d81f522a..a8903040 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -12,6 +12,8 @@ Django template tags can be used within your HTML templates to provide ReactPy f This template tag can be used to insert any number of ReactPy components onto your page. +Each component loaded via this template tag will receive a dedicated WebSocket connection to the server. + === "my-template.html" {% include-markdown "../../../README.md" start="" end="" %} @@ -27,7 +29,7 @@ This template tag can be used to insert any number of ReactPy components onto yo | `#!python class` | `#!python str | None` | The HTML class to apply to the top-level component div. | `#!python None` | | `#!python key` | `#!python Any` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `#!python key` within a template tag is effectively useless. | `#!python None` | | `#!python host` | `#!python str | None` | The host to use for the ReactPy connections. If unset, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `#!python None` | - | `#!python prerender` | `#!python str` | If `#!python "true"`, the component will pre-rendered, which enables SEO compatibility and reduces perceived latency. | `#!python "false"` | + | `#!python prerender` | `#!python str` | If `#!python "true"` the component will pre-rendered, which enables SEO compatibility and reduces perceived latency. | `#!python "false"` | | `#!python offline` | `#!python str` | The dotted path to a component that will be displayed if your root component loses connection to the server. Keep in mind, this `offline` component will be non-interactive (hooks won't operate). | `#!python ""` | | `#!python **kwargs` | `#!python Any` | The keyword arguments to provide to the component. | N/A | diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 09ff397a..99b34eca 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -133,6 +133,8 @@ def django_css( Args: static_path: The path to the static file. This path is identical to what you would \ use on Django's `{% static %}` template tag + prevent_duplicates: If True, this component will only load the file if no other \ + component (in your connection's component tree) has already loaded it. key: A key to uniquely identify this component which is unique amongst a component's \ immediate siblings """ @@ -154,6 +156,8 @@ def django_js( Args: static_path: The path to the static file. This path is identical to what you would \ use on Django's `{% static %}` template tag. + prevent_duplicates: If True, this component will only load the file if no other \ + component (in your connection's component tree) has already loaded it. key: A key to uniquely identify this component which is unique amongst a component's \ immediate siblings """ From 6f64d464fd6fdd432412aef2550ff3d9d98daa55 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:39:52 -0800 Subject: [PATCH 10/16] bump deprecated workflows --- .github/workflows/codeql.yml | 105 ++++++++++----------- .github/workflows/publish-develop-docs.yml | 4 +- .github/workflows/publish-py.yml | 4 +- .github/workflows/publish-release-docs.yml | 4 +- .github/workflows/test-docs.yml | 4 +- .github/workflows/test-src.yml | 4 +- 6 files changed, 62 insertions(+), 63 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f26793e..17080b52 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,67 +12,66 @@ name: "CodeQL" on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - # Runs at 22:21 on Monday. - - cron: '21 22 * * 1' + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + # Runs at 22:21 on Monday. + - cron: "21 22 * * 1" jobs: - analyze: - name: Analyze - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - actions: read - contents: read - security-events: write + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + strategy: + fail-fast: false + matrix: + language: ["javascript", "python"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - steps: - - name: Checkout repository - uses: actions/checkout@v3 + steps: + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 09871fa6..b79d3cd2 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -8,10 +8,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 9b07a206..72a04dae 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -11,9 +11,9 @@ jobs: release-package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install dependencies diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index 6fc32336..a98e9869 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -8,10 +8,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 2c38c905..907e1a2c 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -14,10 +14,10 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - name: Check docs build diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index c7de8acc..c450cf9f 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -17,9 +17,9 @@ jobs: matrix: python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies From acf7e6d7d1d693e0571b96fc30563641194ddc2e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:42:13 -0800 Subject: [PATCH 11/16] functional test components --- tests/test_app/components.py | 79 +++++++++++++++++-- .../static/django-css-only-once-test.css | 3 - .../django-css-prevent-duplicates-test.css | 3 + .../django-js-prevent-duplicates-test.js | 15 ++++ tests/test_app/templates/base.html | 4 + 5 files changed, 93 insertions(+), 11 deletions(-) delete mode 100644 tests/test_app/static/django-css-only-once-test.css create mode 100644 tests/test_app/static/django-css-prevent-duplicates-test.css create mode 100644 tests/test_app/static/django-js-prevent-duplicates-test.js diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 59f107e9..f48272d2 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -140,7 +140,7 @@ def django_css(): def django_css_prevent_duplicates(): scope = reactpy_django.hooks.use_scope() css_files, set_css_files = hooks.use_state( - [reactpy_django.components.django_css("django-css-only-once-test.css")] + [reactpy_django.components.django_css("django-css-prevent-duplicates-test.css")] ) async def add_end_css(event): @@ -148,7 +148,7 @@ async def add_end_css(event): css_files + [ reactpy_django.components.django_css( - "django-css-only-once-test.css", + "django-css-prevent-duplicates-test.css", key=str(uuid4()), ) ] @@ -158,7 +158,7 @@ async def add_front_css(event): set_css_files( [ reactpy_django.components.django_css( - "django-css-only-once-test.css", + "django-css-prevent-duplicates-test.css", key=str(uuid4()), ) ] @@ -174,18 +174,18 @@ async def remove_front_css(event): set_css_files(css_files[1:]) return html.div( - {"id": "django-css-only-once"}, + {"id": "django-css-prevent-duplicates"}, html.div({"style": {"display": "inline"}}, "django_css_prevent_duplicates: "), - html.button( - "This text should be blue. The stylesheet for this component should only be added once." - ), + html.button("This text should be blue."), html.div( html.button({"on_click": add_end_css}, "Add End File"), html.button({"on_click": add_front_css}, "Add Front File"), html.button({"on_click": remove_end_css}, "Remove End File"), html.button({"on_click": remove_front_css}, "Remove Front File"), ), - html.div(f'CSS ownership tracked via ASGI scope: {scope.get("reactpy_css")}'), + html.div( + f'CSS ownership tracked via ASGI scope: {scope.get("reactpy",{}).get("css")}' + ), html.div(f"Components with CSS: {css_files}"), css_files, ) @@ -203,6 +203,69 @@ def django_js(): ) +@component +def django_js_prevent_duplicates(): + scope = reactpy_django.hooks.use_scope() + js_files, set_js_files = hooks.use_state( + [reactpy_django.components.django_js("django-js-prevent-duplicates-test.js")] + ) + + async def add_end_js(event): + set_js_files( + js_files + + [ + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", + key=str(uuid4()), + ) + ] + ) + + async def add_front_js(event): + set_js_files( + [ + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", + key=str(uuid4()), + ) + ] + + js_files + ) + + async def remove_end_js(event): + if js_files: + set_js_files(js_files[:-1]) + + async def remove_front_js(event): + if js_files: + set_js_files(js_files[1:]) + + return html.div( + {"id": "django-js-prevent-duplicates"}, + html.div( + {"style": {"display": "inline"}}, + "django_js_prevent_duplicates: ", + html.div( + { + "id": "django-js-prevent-duplicates-value", + "style": {"display": "inline"}, + } + ), + ), + html.div( + html.button({"on_click": add_end_js}, "Add End File"), + html.button({"on_click": add_front_js}, "Add Front File"), + html.button({"on_click": remove_end_js}, "Remove End File"), + html.button({"on_click": remove_front_js}, "Remove Front File"), + ), + html.div( + f'JS ownership tracked via ASGI scope: {scope.get("reactpy",{}).get("js")}' + ), + html.div(f"Components with JS: {js_files}"), + js_files, + ) + + @component @reactpy_django.decorators.auth_required( fallback=html.div( diff --git a/tests/test_app/static/django-css-only-once-test.css b/tests/test_app/static/django-css-only-once-test.css deleted file mode 100644 index 15f51822..00000000 --- a/tests/test_app/static/django-css-only-once-test.css +++ /dev/null @@ -1,3 +0,0 @@ -#django-css-only-once button { - color: rgb(0, 0, 255); -} diff --git a/tests/test_app/static/django-css-prevent-duplicates-test.css b/tests/test_app/static/django-css-prevent-duplicates-test.css new file mode 100644 index 00000000..59d2446b --- /dev/null +++ b/tests/test_app/static/django-css-prevent-duplicates-test.css @@ -0,0 +1,3 @@ +#django-css-prevent-duplicates button { + color: rgb(0, 0, 255); +} diff --git a/tests/test_app/static/django-js-prevent-duplicates-test.js b/tests/test_app/static/django-js-prevent-duplicates-test.js new file mode 100644 index 00000000..c35d997f --- /dev/null +++ b/tests/test_app/static/django-js-prevent-duplicates-test.js @@ -0,0 +1,15 @@ +// This file uses ReactPy's layout that can convert a JavaScript file into ReactJS `useEffect` hook +() => { + // this is run once the script is loaded and each time its content changes + let el = document.body.querySelector("#django-js-prevent-duplicates-value"); + if (el.dataset.django_js === undefined) { + el.dataset.django_js = 0; + } + el.dataset.django_js = Number(el.dataset.django_js) + 1; + el.textContent = "Loaded JS file " + el.dataset.django_js + " time(s)"; + return () => { + // this is run when the script is unloaded (i.e. it's removed from the tree) or just before its content changes + el.dataset.django_js = Number(el.dataset.django_js) - 1; + el.textContent = "Loaded JS file " + el.dataset.django_js + " time(s)"; + }; +}; diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index f15094ac..f686836b 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -41,6 +41,10 @@

ReactPy Test Page


{% component "test_app.components.django_js" %}
+ {% component "test_app.components.django_css_prevent_duplicates" %} +
+ {% component "test_app.components.django_js_prevent_duplicates" %} +
{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %} From b5bfe4ba5bf7fb4fbbd4f06956422eb08f281d39 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 18 Feb 2024 21:44:04 -0800 Subject: [PATCH 12/16] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a63334..de10fc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Using the following categories, list your changes in this order: ### Added - Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook. +- `django_css` and `django_js` components now support de-duplication via the `prevent_duplicates=...` parameter. - More robust control over ReactPy clean up tasks! - `settings.py:REACTPY_CLEAN_INTERVAL` to control how often ReactPy automatically performs cleaning tasks. - `settings.py:REACTPY_CLEAN_SESSIONS` to control whether ReactPy automatically cleans up expired sessions. From d04fdab1704a95bd75107b41c08349c55f78e7ed Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 18 Feb 2024 23:55:56 -0800 Subject: [PATCH 13/16] test stubs --- tests/test_app/tests/test_components.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 241e5659..816e04e6 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -135,7 +135,7 @@ def test_use_location(self): def test_use_origin(self): self.page.locator("#use-origin[data-success=true]").wait_for() - def test_static_css(self): + def test_django_css(self): self.assertEqual( self.page.wait_for_selector("#django-css button").evaluate( "e => window.getComputedStyle(e).getPropertyValue('color')" @@ -143,9 +143,13 @@ def test_static_css(self): "rgb(0, 0, 255)", ) - def test_static_js(self): + def test_django_css_prevent_duplicates(self): ... + + def test_django_js(self): self.page.locator("#django-js[data-success=true]").wait_for() + def test_django_js_prevent_duplicates(self): ... + def test_unauthorized_user(self): self.assertRaises( TimeoutError, From d5ff8fa0fd8f9ed7d6d11b074bb5f67d746db680 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 18 Feb 2024 23:56:25 -0800 Subject: [PATCH 14/16] Redo how we set up lists for the test --- tests/test_app/components.py | 96 +++++++++---------- .../django-js-prevent-duplicates-test.js | 6 +- 2 files changed, 47 insertions(+), 55 deletions(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f48272d2..48804c91 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -139,39 +139,37 @@ def django_css(): @component def django_css_prevent_duplicates(): scope = reactpy_django.hooks.use_scope() - css_files, set_css_files = hooks.use_state( + components = hooks.use_ref( [reactpy_django.components.django_css("django-css-prevent-duplicates-test.css")] ) + uuid, set_uuid = hooks.use_state(uuid4()) async def add_end_css(event): - set_css_files( - css_files - + [ - reactpy_django.components.django_css( - "django-css-prevent-duplicates-test.css", - key=str(uuid4()), - ) - ] + components.current.append( + reactpy_django.components.django_css( + "django-css-prevent-duplicates-test.css" + ) ) + set_uuid(uuid4()) async def add_front_css(event): - set_css_files( - [ - reactpy_django.components.django_css( - "django-css-prevent-duplicates-test.css", - key=str(uuid4()), - ) - ] - + css_files + components.current.insert( + 0, + reactpy_django.components.django_css( + "django-css-prevent-duplicates-test.css" + ), ) + set_uuid(uuid4()) async def remove_end_css(event): - if css_files: - set_css_files(css_files[:-1]) + if components.current: + components.current.pop() + set_uuid(uuid4()) async def remove_front_css(event): - if css_files: - set_css_files(css_files[1:]) + if components.current: + components.current.pop(0) + set_uuid(uuid4()) return html.div( {"id": "django-css-prevent-duplicates"}, @@ -186,8 +184,8 @@ async def remove_front_css(event): html.div( f'CSS ownership tracked via ASGI scope: {scope.get("reactpy",{}).get("css")}' ), - html.div(f"Components with CSS: {css_files}"), - css_files, + html.div(f"Components with CSS: {components.current}"), + components.current, ) @@ -206,51 +204,43 @@ def django_js(): @component def django_js_prevent_duplicates(): scope = reactpy_django.hooks.use_scope() - js_files, set_js_files = hooks.use_state( + components = hooks.use_ref( [reactpy_django.components.django_js("django-js-prevent-duplicates-test.js")] ) + uuid, set_uuid = hooks.use_state(uuid4()) async def add_end_js(event): - set_js_files( - js_files - + [ - reactpy_django.components.django_js( - "django-js-prevent-duplicates-test.js", - key=str(uuid4()), - ) - ] + components.current.append( + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", key=str(uuid4()) + ) ) + set_uuid(uuid4()) async def add_front_js(event): - set_js_files( - [ - reactpy_django.components.django_js( - "django-js-prevent-duplicates-test.js", - key=str(uuid4()), - ) - ] - + js_files + components.current.insert( + 0, + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", key=str(uuid4()) + ), ) + set_uuid(uuid4()) async def remove_end_js(event): - if js_files: - set_js_files(js_files[:-1]) + if components.current: + components.current.pop() + set_uuid(uuid4()) async def remove_front_js(event): - if js_files: - set_js_files(js_files[1:]) + if components.current: + components.current.pop(0) + set_uuid(uuid4()) return html.div( {"id": "django-js-prevent-duplicates"}, html.div( - {"style": {"display": "inline"}}, "django_js_prevent_duplicates: ", - html.div( - { - "id": "django-js-prevent-duplicates-value", - "style": {"display": "inline"}, - } - ), + html.div({"id": "django-js-prevent-duplicates-value"}), ), html.div( html.button({"on_click": add_end_js}, "Add End File"), @@ -261,8 +251,8 @@ async def remove_front_js(event): html.div( f'JS ownership tracked via ASGI scope: {scope.get("reactpy",{}).get("js")}' ), - html.div(f"Components with JS: {js_files}"), - js_files, + html.div(f"Components with JS: {components.current}"), + components.current, ) diff --git a/tests/test_app/static/django-js-prevent-duplicates-test.js b/tests/test_app/static/django-js-prevent-duplicates-test.js index c35d997f..c52328ff 100644 --- a/tests/test_app/static/django-js-prevent-duplicates-test.js +++ b/tests/test_app/static/django-js-prevent-duplicates-test.js @@ -6,10 +6,12 @@ el.dataset.django_js = 0; } el.dataset.django_js = Number(el.dataset.django_js) + 1; - el.textContent = "Loaded JS file " + el.dataset.django_js + " time(s)"; + el.textContent = + "Currently loaded by " + el.dataset.django_js + " component(s)"; return () => { // this is run when the script is unloaded (i.e. it's removed from the tree) or just before its content changes el.dataset.django_js = Number(el.dataset.django_js) - 1; - el.textContent = "Loaded JS file " + el.dataset.django_js + " time(s)"; + el.textContent = + "Currently loaded by " + el.dataset.django_js + " component(s)"; }; }; From 999a46968a8c7cf71d27be85a8beadefec406799 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 18 Feb 2024 23:57:56 -0800 Subject: [PATCH 15/16] try using discrete mount/unmount managers --- src/reactpy_django/components.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 99b34eca..43bad660 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -280,6 +280,7 @@ def _django_static_file( vdom_constructor: VdomDictConstructor, ): scope = use_scope() + mount_trigger, set_mount_trigger = hooks.use_state(True) ownership_uuid = hooks.use_memo(lambda: uuid4()) # Configure the ASGI scope to track the file @@ -287,24 +288,31 @@ def _django_static_file( scope.setdefault("reactpy", {}).setdefault(file_type, {}) scope["reactpy"][file_type].setdefault(static_path, ownership_uuid) - # Load the file if no other component has loaded it + # Check if other _django_static_file components have unmounted @hooks.use_effect(dependencies=None) - async def duplicate_manager(): - """Note: This hook runs on every render. This is intentional.""" + async def mount_manager(): + if prevent_duplicates and not scope["reactpy"][file_type].get(static_path): + print("new mount host: ", ownership_uuid) + scope["reactpy"][file_type].setdefault(static_path, ownership_uuid) + set_mount_trigger(not mount_trigger) + + # Notify other components that we've unmounted + @hooks.use_effect(dependencies=[]) + async def unmount_manager(): + # FIXME: This is not working as expected. Dismount is not being called when the component is removed from a list. if not prevent_duplicates: return + print("registering new unmount func") - # If the file currently isn't rendered, let this component render it - if not scope["reactpy"][file_type].get(static_path): - scope["reactpy"][file_type].setdefault(static_path, ownership_uuid) - - # The component that loaded the file should notify when it's removed def unmount(): + print("unmount func called") if scope["reactpy"][file_type].get(static_path) == ownership_uuid: + print("unmounting") scope["reactpy"][file_type].pop(static_path) return unmount + # Render the component, if needed if not prevent_duplicates or ( scope["reactpy"][file_type].get(static_path) == ownership_uuid ): From d5b16dd14dd055cb0b8a77f9ba7aadbf98fa138d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 19 Feb 2024 00:22:48 -0800 Subject: [PATCH 16/16] Make sure test elements always have keys --- tests/test_app/components.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 48804c91..f355a0fe 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -140,14 +140,18 @@ def django_css(): def django_css_prevent_duplicates(): scope = reactpy_django.hooks.use_scope() components = hooks.use_ref( - [reactpy_django.components.django_css("django-css-prevent-duplicates-test.css")] + [ + reactpy_django.components.django_css( + "django-css-prevent-duplicates-test.css", key=str(uuid4()) + ) + ] ) uuid, set_uuid = hooks.use_state(uuid4()) async def add_end_css(event): components.current.append( reactpy_django.components.django_css( - "django-css-prevent-duplicates-test.css" + "django-css-prevent-duplicates-test.css", key=str(uuid4()) ) ) set_uuid(uuid4()) @@ -156,7 +160,7 @@ async def add_front_css(event): components.current.insert( 0, reactpy_django.components.django_css( - "django-css-prevent-duplicates-test.css" + "django-css-prevent-duplicates-test.css", key=str(uuid4()) ), ) set_uuid(uuid4()) @@ -205,7 +209,11 @@ def django_js(): def django_js_prevent_duplicates(): scope = reactpy_django.hooks.use_scope() components = hooks.use_ref( - [reactpy_django.components.django_js("django-js-prevent-duplicates-test.js")] + [ + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", key=str(uuid4()) + ) + ] ) uuid, set_uuid = hooks.use_state(uuid4())