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 @@