refactor: Fix #1277 + Cache components' JS/CSS scripts at class creation (#1283)

* refactor: Cache components' JS and CSS scripts at class creation time

* refactor: add test for no template_rendered signal for component with no template
This commit is contained in:
Juro Oravec 2025-07-03 12:27:21 +02:00 committed by GitHub
parent 007009a480
commit c692b7a310
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 138 additions and 58 deletions

View file

@ -1,5 +1,23 @@
# Release notes # Release notes
## v0.141.1
#### Fix
- Components' JS and CSS scripts (e.g. from `Component.js` or `Component.js_file`) are now cached at class creation time.
This means that when you now restart the server while having a page opened in the browser,
the JS / CSS files are immediately available.
Previously, the JS/CSS were cached only after the components were rendered. So you had to reload
the page to trigger the rendering, in order to make the JS/CSS files available.
- Fix the default cache for JS / CSS scripts to be unbounded.
Previously, the default cache for the JS/CSS scripts (`LocMemCache`) was accidentally limited to 300 entries (~150 components).
- Do not send `template_rendered` signal when rendering a component with no template. ([#1277](https://github.com/django-components/django-components/issues/1277))
## v0.141.0 ## v0.141.0
#### Feat #### Feat

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "django_components" name = "django_components"
version = "0.141.0" version = "0.141.1"
requires-python = ">=3.8, <4.0" requires-python = ">=3.8, <4.0"
description = "A way to create simple reusable template components in Django." description = "A way to create simple reusable template components in Django."
keywords = ["django", "components", "css", "js", "html"] keywords = ["django", "components", "css", "js", "html"]

View file

@ -800,6 +800,7 @@ class InternalSettings:
from django_components.extensions.cache import CacheExtension from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension from django_components.extensions.debug_highlight import DebugHighlightExtension
from django_components.extensions.defaults import DefaultsExtension from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.dependencies import DependenciesExtension
from django_components.extensions.view import ViewExtension from django_components.extensions.view import ViewExtension
extensions = cast( extensions = cast(
@ -807,6 +808,7 @@ class InternalSettings:
[ [
CacheExtension, CacheExtension,
DefaultsExtension, DefaultsExtension,
DependenciesExtension,
ViewExtension, ViewExtension,
DebugHighlightExtension, DebugHighlightExtension,
], ],

View file

@ -1,3 +1,4 @@
import sys
from typing import Optional from typing import Optional
from django.core.cache import BaseCache, caches from django.core.cache import BaseCache, caches
@ -36,9 +37,14 @@ def get_component_media_cache() -> BaseCache:
component_media_cache = LocMemCache( component_media_cache = LocMemCache(
"django-components-media", "django-components-media",
{ {
"TIMEOUT": None, # No timeout # No max size nor timeout
"MAX_ENTRIES": None, # No max size # NOTE: Implementation of `BaseCache` coerces the `MAX_ENTRIES` value
"CULL_FREQUENCY": 3, # to `int()` so we use exact max size instead of `inf` or `None`.
# See https://github.com/django/django/blob/94ebcf8366d62f6360851b40e9c4dfe3f71d202f/django/core/cache/backends/base.py#L73 # noqa: E501
"TIMEOUT": None,
"OPTIONS": {
"MAX_ENTRIES": sys.maxsize,
},
}, },
) )

View file

@ -3486,11 +3486,12 @@ class Component(metaclass=ComponentMeta):
) )
) )
# Process Component's JS and CSS # Cache component's JS and CSS scripts, in case they have been evicted from the cache.
cache_component_js(comp_cls) cache_component_js(comp_cls, force=False)
js_input_hash = cache_component_js_vars(comp_cls, js_data) if js_data else None cache_component_css(comp_cls, force=False)
cache_component_css(comp_cls) # Create JS/CSS scripts that will load the JS/CSS variables into the page.
js_input_hash = cache_component_js_vars(comp_cls, js_data) if js_data else None
css_input_hash = cache_component_css_vars(comp_cls, css_data) if css_data else None css_input_hash = cache_component_css_vars(comp_cls, css_data) if css_data else None
############################################################################# #############################################################################
@ -3670,7 +3671,7 @@ class Component(metaclass=ComponentMeta):
# ``` # ```
def _gen_component_renderer( def _gen_component_renderer(
self, self,
template: Template, template: Optional[Template],
context: Context, context: Context,
component_path: List[str], component_path: List[str],
css_input_hash: Optional[str], css_input_hash: Optional[str],
@ -3695,7 +3696,8 @@ class Component(metaclass=ComponentMeta):
component.on_render_before(context, template) component.on_render_before(context, template)
# Emit signal that the template is about to be rendered # Emit signal that the template is about to be rendered
template_rendered.send(sender=template, template=template, context=context) if template is not None:
template_rendered.send(sender=template, template=template, context=context)
# Get the component's HTML # Get the component's HTML
# To access the *final* output (with all its children rendered) from within `Component.on_render()`, # To access the *final* output (with all its children rendered) from within `Component.on_render()`,

View file

@ -107,13 +107,16 @@ def _cache_script(
cache.set(cache_key, script.strip()) cache.set(cache_key, script.strip())
def cache_component_js(comp_cls: Type["Component"]) -> None: def cache_component_js(comp_cls: Type["Component"], force: bool) -> None:
""" """
Cache the content from `Component.js`. This is the common JS that's shared Cache the content from `Component.js`. This is the common JS that's shared
among all instances of the same component. So even if the component is rendered multiple among all instances of the same component. So even if the component is rendered multiple
times, this JS is loaded only once. times, this JS is loaded only once.
""" """
if not comp_cls.js or not is_nonempty_str(comp_cls.js) or _is_script_in_cache(comp_cls, "js", None): if not comp_cls.js or not is_nonempty_str(comp_cls.js):
return None
if not force and _is_script_in_cache(comp_cls, "js", None):
return None return None
_cache_script( _cache_script(
@ -167,13 +170,16 @@ def wrap_component_js(comp_cls: Type["Component"], content: str) -> str:
return f"<script>{content}</script>" return f"<script>{content}</script>"
def cache_component_css(comp_cls: Type["Component"]) -> None: def cache_component_css(comp_cls: Type["Component"], force: bool) -> None:
""" """
Cache the content from `Component.css`. This is the common CSS that's shared Cache the content from `Component.css`. This is the common CSS that's shared
among all instances of the same component. So even if the component is rendered multiple among all instances of the same component. So even if the component is rendered multiple
times, this CSS is loaded only once. times, this CSS is loaded only once.
""" """
if not comp_cls.css or not is_nonempty_str(comp_cls.css) or _is_script_in_cache(comp_cls, "css", None): if not comp_cls.css or not is_nonempty_str(comp_cls.css):
return None
if not force and _is_script_in_cache(comp_cls, "css", None):
return None return None
_cache_script( _cache_script(

View file

@ -0,0 +1,21 @@
from django_components.dependencies import cache_component_css, cache_component_js
from django_components.extension import (
ComponentExtension,
OnComponentClassCreatedContext,
)
class DependenciesExtension(ComponentExtension):
"""
This extension adds a nested `Dependencies` class to each `Component`.
This extension is automatically added to all components.
"""
name = "dependencies"
# Cache the component's JS and CSS scripts when the class is created, so that
# components' JS/CSS files are accessible even before having to render the component first.
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
cache_component_js(ctx.component_cls, force=True)
cache_component_css(ctx.component_cls, force=True)

View file

@ -50,8 +50,6 @@ class TestImportLibraries:
} }
) )
def test_import_libraries(self): def test_import_libraries(self):
# Ensure we start with a clean state
registry.clear()
all_components = registry.all().copy() all_components = registry.all().copy()
assert "single_file_component" not in all_components assert "single_file_component" not in all_components
assert "multi_file_component" not in all_components assert "multi_file_component" not in all_components
@ -80,8 +78,6 @@ class TestImportLibraries:
} }
) )
def test_import_libraries_map_modules(self): def test_import_libraries_map_modules(self):
# Ensure we start with a clean state
registry.clear()
all_components = registry.all().copy() all_components = registry.all().copy()
assert "single_file_component" not in all_components assert "single_file_component" not in all_components
assert "multi_file_component" not in all_components assert "multi_file_component" not in all_components

View file

@ -1,5 +1,3 @@
from django.core.cache.backends.locmem import LocMemCache
from django_components import Component, register from django_components import Component, register
from django_components.testing import djc_test from django_components.testing import djc_test
from django_components.util.cache import LRUCache from django_components.util.cache import LRUCache
@ -68,16 +66,26 @@ class TestCache:
@djc_test @djc_test
class TestComponentMediaCache: class TestComponentMediaCache:
@djc_test(components_settings={"cache": "test-cache"}) @djc_test(
def test_component_media_caching(self): components_settings={"cache": "test-cache"},
test_cache = LocMemCache( django_settings={
"test-cache", "CACHES": {
{ # See https://docs.djangoproject.com/en/5.2/topics/cache/#local-memory-caching
"TIMEOUT": None, # No timeout "test-cache": {
"MAX_ENTRIES": None, # No max size "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"CULL_FREQUENCY": 3, "LOCATION": "test-cache",
"TIMEOUT": None, # No timeout
"OPTIONS": {
"MAX_ENTRIES": 10_000,
},
},
}, },
) },
)
def test_component_media_caching(self):
from django.core.cache import caches
test_cache = caches["test-cache"]
@register("test_simple") @register("test_simple")
class TestSimpleComponent(Component): class TestSimpleComponent(Component):
@ -108,14 +116,6 @@ class TestComponentMediaCache:
def get_css_data(self, args, kwargs, slots, context): def get_css_data(self, args, kwargs, slots, context):
return {"color": "blue"} return {"color": "blue"}
# Register our test cache
from django.core.cache import caches
caches["test-cache"] = test_cache
# Render the components to trigger caching
TestMediaAndVarsComponent.render()
# Check that JS/CSS is cached for components that have them # Check that JS/CSS is cached for components that have them
assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent.class_id}:js") assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent.class_id}:js")
assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent.class_id}:css") assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent.class_id}:css")
@ -128,6 +128,9 @@ class TestComponentMediaCache:
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:js").strip() == "console.log('Hello from JS');" # noqa: E501 assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:js").strip() == "console.log('Hello from JS');" # noqa: E501
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:css").strip() == ".novars-component { color: blue; }" # noqa: E501 assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:css").strip() == ".novars-component { color: blue; }" # noqa: E501
# Render the components to trigger caching of JS/CSS variables from `get_js_data` / `get_css_data`
TestMediaAndVarsComponent.render()
# Check that we cache JS / CSS scripts generated from `get_js_data` / `get_css_data` # Check that we cache JS / CSS scripts generated from `get_js_data` / `get_css_data`
# NOTE: The hashes is generated from the data. # NOTE: The hashes is generated from the data.
js_vars_hash = "216ecc" js_vars_hash = "216ecc"

View file

@ -103,6 +103,7 @@ class TestExtensionsListCommand:
"===============\n" "===============\n"
"cache \n" "cache \n"
"defaults \n" "defaults \n"
"dependencies \n"
"view \n" "view \n"
"debug_highlight" "debug_highlight"
) )
@ -121,6 +122,7 @@ class TestExtensionsListCommand:
"===============\n" "===============\n"
"cache \n" "cache \n"
"defaults \n" "defaults \n"
"dependencies \n"
"view \n" "view \n"
"debug_highlight\n" "debug_highlight\n"
"empty \n" "empty \n"
@ -141,6 +143,7 @@ class TestExtensionsListCommand:
"===============\n" "===============\n"
"cache \n" "cache \n"
"defaults \n" "defaults \n"
"dependencies \n"
"view \n" "view \n"
"debug_highlight\n" "debug_highlight\n"
"empty \n" "empty \n"
@ -161,6 +164,7 @@ class TestExtensionsListCommand:
"===============\n" "===============\n"
"cache \n" "cache \n"
"defaults \n" "defaults \n"
"dependencies \n"
"view \n" "view \n"
"debug_highlight\n" "debug_highlight\n"
"empty \n" "empty \n"
@ -179,6 +183,7 @@ class TestExtensionsListCommand:
assert output.strip() == ( assert output.strip() == (
"cache \n" "cache \n"
"defaults \n" "defaults \n"
"dependencies \n"
"view \n" "view \n"
"debug_highlight\n" "debug_highlight\n"
"empty \n" "empty \n"

View file

@ -15,7 +15,6 @@ from django.utils.safestring import mark_safe
from pytest_django.asserts import assertHTMLEqual, assertInHTML from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, autodiscover, registry, render_dependencies, types from django_components import Component, autodiscover, registry, render_dependencies, types
from django_components.component_media import UNSET
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
@ -204,14 +203,18 @@ class TestMainMedia:
class TestComponent(AppLvlCompComponent): class TestComponent(AppLvlCompComponent):
pass pass
# NOTE: Since this is a subclass, actual CSS is defined on the parent class, and thus # NOTE: Currently the components' JS/CSS are loaded eagerly, to make the JS/CSS
# the corresponding ComponentMedia instance is also on the parent class. # files available via endpoints. If that is no longer true, uncomment the
assert AppLvlCompComponent._component_media.css is UNSET # type: ignore[attr-defined] # following lines to test the lazy loading of the CSS.
assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp.css" # type: ignore[attr-defined] #
assert AppLvlCompComponent._component_media._template is UNSET # type: ignore[attr-defined] # # Since this is a subclass, actual CSS is defined on the parent class, and thus
# # the corresponding ComponentMedia instance is also on the parent class.
# Access the property to load the CSS # assert AppLvlCompComponent._component_media.css is UNSET # type: ignore[attr-defined]
_ = TestComponent.css # assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp.css" # type: ignore[attr-defined]
# assert AppLvlCompComponent._component_media._template is UNSET # type: ignore[attr-defined]
#
# # Access the property to load the CSS
# _ = TestComponent.css
assert AppLvlCompComponent._component_media.css == (".html-css-only {\n" " color: blue;\n" "}\n") # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.css == (".html-css-only {\n" " color: blue;\n" "}\n") # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp/app_lvl_comp.css" # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp/app_lvl_comp.css" # type: ignore[attr-defined]

View file

@ -31,6 +31,7 @@ from django_components.extension import (
from django_components.extensions.cache import CacheExtension from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension from django_components.extensions.debug_highlight import DebugHighlightExtension
from django_components.extensions.defaults import DefaultsExtension from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.dependencies import DependenciesExtension
from django_components.extensions.view import ViewExtension from django_components.extensions.view import ViewExtension
from django_components.testing import djc_test from django_components.testing import djc_test
@ -219,12 +220,13 @@ class OverrideAssetExtension(ComponentExtension):
class TestExtension: class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensions_setting(self): def test_extensions_setting(self):
assert len(app_settings.EXTENSIONS) == 5 assert len(app_settings.EXTENSIONS) == 6
assert isinstance(app_settings.EXTENSIONS[0], CacheExtension) assert isinstance(app_settings.EXTENSIONS[0], CacheExtension)
assert isinstance(app_settings.EXTENSIONS[1], DefaultsExtension) assert isinstance(app_settings.EXTENSIONS[1], DefaultsExtension)
assert isinstance(app_settings.EXTENSIONS[2], ViewExtension) assert isinstance(app_settings.EXTENSIONS[2], DependenciesExtension)
assert isinstance(app_settings.EXTENSIONS[3], DebugHighlightExtension) assert isinstance(app_settings.EXTENSIONS[3], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[4], DummyExtension) assert isinstance(app_settings.EXTENSIONS[4], DebugHighlightExtension)
assert isinstance(app_settings.EXTENSIONS[5], DummyExtension)
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_access_component_from_extension(self): def test_access_component_from_extension(self):
@ -263,7 +265,7 @@ class TestExtension:
class TestExtensionHooks: class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_class_lifecycle_hooks(self): def test_component_class_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
assert len(extension.calls["on_component_class_created"]) == 0 assert len(extension.calls["on_component_class_created"]) == 0
assert len(extension.calls["on_component_class_deleted"]) == 0 assert len(extension.calls["on_component_class_deleted"]) == 0
@ -295,7 +297,7 @@ class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_registry_lifecycle_hooks(self): def test_registry_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
assert len(extension.calls["on_registry_created"]) == 0 assert len(extension.calls["on_registry_created"]) == 0
assert len(extension.calls["on_registry_deleted"]) == 0 assert len(extension.calls["on_registry_deleted"]) == 0
@ -332,7 +334,7 @@ class TestExtensionHooks:
return {"name": kwargs.get("name", "World")} return {"name": kwargs.get("name", "World")}
registry.register("test_comp", TestComponent) registry.register("test_comp", TestComponent)
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# Verify on_component_registered was called # Verify on_component_registered was called
assert len(extension.calls["on_component_registered"]) == 1 assert len(extension.calls["on_component_registered"]) == 1
@ -370,7 +372,7 @@ class TestExtensionHooks:
test_slots = {"content": "Some content"} test_slots = {"content": "Some content"}
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots) TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# Verify on_component_input was called with correct args # Verify on_component_input was called with correct args
assert len(extension.calls["on_component_input"]) == 1 assert len(extension.calls["on_component_input"]) == 1
@ -419,7 +421,7 @@ class TestExtensionHooks:
slots={"content": "Some content"}, slots={"content": "Some content"},
) )
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# Verify on_component_rendered was called with correct args # Verify on_component_rendered was called with correct args
assert len(extension.calls["on_component_rendered"]) == 1 assert len(extension.calls["on_component_rendered"]) == 1
@ -448,7 +450,7 @@ class TestExtensionHooks:
assert rendered == "Hello Some content!" assert rendered == "Hello Some content!"
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# Verify on_slot_rendered was called with correct args # Verify on_slot_rendered was called with correct args
assert len(extension.calls["on_slot_rendered"]) == 1 assert len(extension.calls["on_slot_rendered"]) == 1
@ -516,7 +518,7 @@ class TestExtensionHooks:
# Render the component to trigger all hooks # Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"}) TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# on_template_loaded # on_template_loaded
assert len(extension.calls["on_template_loaded"]) == 1 assert len(extension.calls["on_template_loaded"]) == 1
@ -559,7 +561,7 @@ class TestExtensionHooks:
# Render the component to trigger all hooks # Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"}) TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# on_template_loaded # on_template_loaded
# NOTE: The template file gets picked up by 'django.template.loaders.filesystem.Loader', # NOTE: The template file gets picked up by 'django.template.loaders.filesystem.Loader',

View file

@ -96,3 +96,19 @@ class TestTemplateSignal:
templates_used = _get_templates_used_to_render(template) templates_used = _get_templates_used_to_render(template)
assert "slotted_template.html" in templates_used assert "slotted_template.html" in templates_used
assert "simple_template.html" in templates_used assert "simple_template.html" in templates_used
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
@with_template_signal
def test_template_rendered_skipped_when_no_template(self, components_settings):
class EmptyComponent(Component):
pass
registry.register("empty", EmptyComponent)
template_str: types.django_html = """
{% load component_tags %}
{% component 'empty' / %}
"""
template = Template(template_str, name="root")
templates_used = _get_templates_used_to_render(template)
assert templates_used == ["root"]