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
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.
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 = {
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 75b0c321..43bad660 100644
--- a/src/reactpy_django/components.py
+++ b/src/reactpy_django/components.py
@@ -1,21 +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.utils import generate_obj_name, import_module, render_view
+from reactpy_django.hooks import use_scope
+from reactpy_django.utils import (
+ cached_static_contents,
+ generate_obj_name,
+ import_module,
+ render_view,
+)
# Type hints for:
@@ -27,8 +32,7 @@ def view_to_component(
compatibility: bool = False,
transforms: Sequence[Callable[[VdomDict], Any]] = (),
strict_parsing: bool = True,
-) -> Any:
- ...
+) -> Any: ...
# Type hints for:
@@ -39,8 +43,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,30 +125,50 @@ def constructor(
return constructor
-def django_css(static_path: str, key: Key | None = None):
+def django_css(
+ 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.
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
"""
- return _django_css(static_path=static_path, 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, 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:
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
"""
- return _django_js(static_path=static_path, key=key)
+ return _django_static_file(
+ static_path=static_path,
+ prevent_duplicates=prevent_duplicates,
+ file_type="js",
+ vdom_constructor=html.script,
+ key=key,
+ )
@component
@@ -250,37 +273,47 @@ def _view_to_iframe(
@component
-def _django_css(static_path: str):
- return html.style(_cached_static_contents(static_path))
-
-
-@component
-def _django_js(static_path: str):
- return html.script(_cached_static_contents(static_path))
-
+def _django_static_file(
+ static_path: str,
+ prevent_duplicates: bool,
+ file_type: str,
+ 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
+ if prevent_duplicates:
+ scope.setdefault("reactpy", {}).setdefault(file_type, {})
+ scope["reactpy"][file_type].setdefault(static_path, ownership_uuid)
+
+ # Check if other _django_static_file components have unmounted
+ @hooks.use_effect(dependencies=None)
+ 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")
-def _cached_static_contents(static_path: str) -> str:
- from reactpy_django.config import REACTPY_CACHE
+ 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)
- # 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."
- )
+ return unmount
- # 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
+ # Render the component, if needed
+ 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 dbe9bd8f..f355a0fe 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,63 @@ def django_css():
)
+@component
+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", 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", key=str(uuid4())
+ )
+ )
+ set_uuid(uuid4())
+
+ async def add_front_css(event):
+ components.current.insert(
+ 0,
+ reactpy_django.components.django_css(
+ "django-css-prevent-duplicates-test.css", key=str(uuid4())
+ ),
+ )
+ set_uuid(uuid4())
+
+ async def remove_end_css(event):
+ if components.current:
+ components.current.pop()
+ set_uuid(uuid4())
+
+ async def remove_front_css(event):
+ if components.current:
+ components.current.pop(0)
+ set_uuid(uuid4())
+
+ return html.div(
+ {"id": "django-css-prevent-duplicates"},
+ html.div({"style": {"display": "inline"}}, "django_css_prevent_duplicates: "),
+ 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",{}).get("css")}'
+ ),
+ html.div(f"Components with CSS: {components.current}"),
+ components.current,
+ )
+
+
@component
def django_js():
success = False
@@ -147,6 +205,65 @@ def django_js():
)
+@component
+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", key=str(uuid4())
+ )
+ ]
+ )
+ uuid, set_uuid = hooks.use_state(uuid4())
+
+ async def add_end_js(event):
+ 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):
+ 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 components.current:
+ components.current.pop()
+ set_uuid(uuid4())
+
+ async def remove_front_js(event):
+ if components.current:
+ components.current.pop(0)
+ set_uuid(uuid4())
+
+ return html.div(
+ {"id": "django-js-prevent-duplicates"},
+ html.div(
+ "django_js_prevent_duplicates: ",
+ html.div({"id": "django-js-prevent-duplicates-value"}),
+ ),
+ 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: {components.current}"),
+ components.current,
+ )
+
+
@component
@reactpy_django.decorators.auth_required(
fallback=html.div(
@@ -720,9 +837,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 +905,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"),
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..c52328ff
--- /dev/null
+++ b/tests/test_app/static/django-js-prevent-duplicates-test.js
@@ -0,0 +1,17 @@
+// 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 =
+ "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 =
+ "Currently loaded by " + el.dataset.django_js + " component(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 @@