diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e7d7e2..ba90787f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # 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 #### Feat diff --git a/pyproject.toml b/pyproject.toml index 9a9645a3..a7a96040 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.141.0" +version = "0.141.1" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index ab26519a..aa1a2d9f 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -800,6 +800,7 @@ class InternalSettings: 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 extensions = cast( @@ -807,6 +808,7 @@ class InternalSettings: [ CacheExtension, DefaultsExtension, + DependenciesExtension, ViewExtension, DebugHighlightExtension, ], diff --git a/src/django_components/cache.py b/src/django_components/cache.py index a14954ed..0b4c443d 100644 --- a/src/django_components/cache.py +++ b/src/django_components/cache.py @@ -1,3 +1,4 @@ +import sys from typing import Optional from django.core.cache import BaseCache, caches @@ -36,9 +37,14 @@ def get_component_media_cache() -> BaseCache: component_media_cache = LocMemCache( "django-components-media", { - "TIMEOUT": None, # No timeout - "MAX_ENTRIES": None, # No max size - "CULL_FREQUENCY": 3, + # No max size nor timeout + # NOTE: Implementation of `BaseCache` coerces the `MAX_ENTRIES` value + # 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, + }, }, ) diff --git a/src/django_components/component.py b/src/django_components/component.py index e0f84310..75c799db 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -3486,11 +3486,12 @@ class Component(metaclass=ComponentMeta): ) ) - # Process Component's JS and CSS - cache_component_js(comp_cls) - js_input_hash = cache_component_js_vars(comp_cls, js_data) if js_data else None + # Cache component's JS and CSS scripts, in case they have been evicted from the cache. + cache_component_js(comp_cls, force=False) + 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 ############################################################################# @@ -3670,7 +3671,7 @@ class Component(metaclass=ComponentMeta): # ``` def _gen_component_renderer( self, - template: Template, + template: Optional[Template], context: Context, component_path: List[str], css_input_hash: Optional[str], @@ -3695,7 +3696,8 @@ class Component(metaclass=ComponentMeta): component.on_render_before(context, template) # 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 # To access the *final* output (with all its children rendered) from within `Component.on_render()`, diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index ffa06e7a..4f4a8830 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -107,13 +107,16 @@ def _cache_script( 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 among all instances of the same component. So even if the component is rendered multiple 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 _cache_script( @@ -167,13 +170,16 @@ def wrap_component_js(comp_cls: Type["Component"], content: str) -> str: return f"" -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 among all instances of the same component. So even if the component is rendered multiple 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 _cache_script( diff --git a/src/django_components/extensions/dependencies.py b/src/django_components/extensions/dependencies.py new file mode 100644 index 00000000..20c4d444 --- /dev/null +++ b/src/django_components/extensions/dependencies.py @@ -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) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 142a0552..03aee405 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -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 diff --git a/tests/test_cache.py b/tests/test_cache.py index 8a7b7bdd..58630a93 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -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" diff --git a/tests/test_command_ext.py b/tests/test_command_ext.py index 8b42e5c4..c43dc54a 100644 --- a/tests/test_command_ext.py +++ b/tests/test_command_ext.py @@ -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" diff --git a/tests/test_component_media.py b/tests/test_component_media.py index fac30466..3bce7716 100644 --- a/tests/test_component_media.py +++ b/tests/test_component_media.py @@ -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] diff --git a/tests/test_extension.py b/tests/test_extension.py index a764e294..cfd1c1cc 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -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', diff --git a/tests/test_signals.py b/tests/test_signals.py index ef629bad..6dcabfe9 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -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"]