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

@ -50,8 +50,6 @@ class TestImportLibraries:
}
)
def test_import_libraries(self):
# Ensure we start with a clean state
registry.clear()
all_components = registry.all().copy()
assert "single_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):
# Ensure we start with a clean state
registry.clear()
all_components = registry.all().copy()
assert "single_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.testing import djc_test
from django_components.util.cache import LRUCache
@ -68,16 +66,26 @@ class TestCache:
@djc_test
class TestComponentMediaCache:
@djc_test(components_settings={"cache": "test-cache"})
def test_component_media_caching(self):
test_cache = LocMemCache(
"test-cache",
{
"TIMEOUT": None, # No timeout
"MAX_ENTRIES": None, # No max size
"CULL_FREQUENCY": 3,
@djc_test(
components_settings={"cache": "test-cache"},
django_settings={
"CACHES": {
# See https://docs.djangoproject.com/en/5.2/topics/cache/#local-memory-caching
"test-cache": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"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")
class TestSimpleComponent(Component):
@ -108,14 +116,6 @@ class TestComponentMediaCache:
def get_css_data(self, args, kwargs, slots, context):
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
assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent.class_id}:js")
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}: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`
# NOTE: The hashes is generated from the data.
js_vars_hash = "216ecc"

View file

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

View file

@ -15,7 +15,6 @@ from django.utils.safestring import mark_safe
from pytest_django.asserts import assertHTMLEqual, assertInHTML
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 .testutils import setup_test_config
@ -204,14 +203,18 @@ class TestMainMedia:
class TestComponent(AppLvlCompComponent):
pass
# NOTE: 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.
assert AppLvlCompComponent._component_media.css is UNSET # type: ignore[attr-defined]
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
# NOTE: Currently the components' JS/CSS are loaded eagerly, to make the JS/CSS
# files available via endpoints. If that is no longer true, uncomment the
# following lines to test the lazy loading of the CSS.
#
# # 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.
# assert AppLvlCompComponent._component_media.css is UNSET # type: ignore[attr-defined]
# 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_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.debug_highlight import DebugHighlightExtension
from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.dependencies import DependenciesExtension
from django_components.extensions.view import ViewExtension
from django_components.testing import djc_test
@ -219,12 +220,13 @@ class OverrideAssetExtension(ComponentExtension):
class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]})
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[1], DefaultsExtension)
assert isinstance(app_settings.EXTENSIONS[2], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[3], DebugHighlightExtension)
assert isinstance(app_settings.EXTENSIONS[4], DummyExtension)
assert isinstance(app_settings.EXTENSIONS[2], DependenciesExtension)
assert isinstance(app_settings.EXTENSIONS[3], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[4], DebugHighlightExtension)
assert isinstance(app_settings.EXTENSIONS[5], DummyExtension)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_access_component_from_extension(self):
@ -263,7 +265,7 @@ class TestExtension:
class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
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_deleted"]) == 0
@ -295,7 +297,7 @@ class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
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_deleted"]) == 0
@ -332,7 +334,7 @@ class TestExtensionHooks:
return {"name": kwargs.get("name", "World")}
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
assert len(extension.calls["on_component_registered"]) == 1
@ -370,7 +372,7 @@ class TestExtensionHooks:
test_slots = {"content": "Some content"}
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
assert len(extension.calls["on_component_input"]) == 1
@ -419,7 +421,7 @@ class TestExtensionHooks:
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
assert len(extension.calls["on_component_rendered"]) == 1
@ -448,7 +450,7 @@ class TestExtensionHooks:
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
assert len(extension.calls["on_slot_rendered"]) == 1
@ -516,7 +518,7 @@ class TestExtensionHooks:
# Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# on_template_loaded
assert len(extension.calls["on_template_loaded"]) == 1
@ -559,7 +561,7 @@ class TestExtensionHooks:
# Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
extension = cast(DummyExtension, app_settings.EXTENSIONS[5])
# on_template_loaded
# 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)
assert "slotted_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"]