diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c387b5d..4c0b43db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -486,6 +486,42 @@ - If `Slot` was created from string via `Slot("...")`, `Slot.contents` will contain that string. - If `Slot` was created from a function, `Slot.contents` will contain that function. +- Component caching can now take slots into account, by setting `Component.Cache.include_slots` to `True`. + + ```py + class MyComponent(Component): + class Cache: + enabled = True + include_slots = True + ``` + + In which case the following two calls will generate separate cache entries: + + ```django + {% component "my_component" position="left" %} + Hello, Alice + {% endcomponent %} + + {% component "my_component" position="left" %} + Hello, Bob + {% endcomponent %} + ``` + + Same applies to `Component.render()` with string slots: + + ```py + MyComponent.render( + kwargs={"position": "left"}, + slots={"content": "Hello, Alice"} + ) + MyComponent.render( + kwargs={"position": "left"}, + slots={"content": "Hello, Bob"} + ) + ``` + + Read more on [Component caching](https://django-components.github.io/django-components/0.140/concepts/advanced/component_caching/). + #### Fix - Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)). diff --git a/docs/concepts/advanced/component_caching.md b/docs/concepts/advanced/component_caching.md index 5ee84072..8418c0cb 100644 --- a/docs/concepts/advanced/component_caching.md +++ b/docs/concepts/advanced/component_caching.md @@ -5,7 +5,8 @@ This is particularly useful for components that are expensive to render or do no !!! info - Component caching uses Django's cache framework, so you can use any cache backend that is supported by Django. + Component caching uses [Django's cache framework](https://docs.djangoproject.com/en/5.2/topics/cache/), + so you can use any cache backend that is supported by Django. ### Enabling caching @@ -83,6 +84,81 @@ For even more control, you can override other methods available on the [`Compone The default implementation of `Cache.hash()` simply serializes the input into a string. As such, it might not be suitable if you need to hash complex objects like Models. +### Caching slots + +By default, the cache key is generated based ONLY on the args and kwargs. + +To cache the component based on the slots, set [`Component.Cache.include_slots`](../../reference/api.md#django_components.ComponentCache.include_slots) to `True`: + +```python +class MyComponent(Component): + class Cache: + enabled = True + include_slots = True +``` + +with `include_slots = True`, the cache key will be generated also based on the given slots. + +As such, the following two calls would generate separate entries in the cache: + +```django +{% component "my_component" position="left" %} + Hello, Alice +{% endcomponent %} + +{% component "my_component" position="left" %} + Hello, Bob +{% endcomponent %} +``` + +Same when using [`Component.render()`](../../reference/api.md#django_components.Component.render) with string slots: + +```py +MyComponent.render( + kwargs={"position": "left"}, + slots={"content": "Hello, Alice"} +) +MyComponent.render( + kwargs={"position": "left"}, + slots={"content": "Hello, Bob"} +) +``` + +!!! warning + + Passing slots as functions to cached components with `include_slots=True` will raise an error. + + ```py + MyComponent.render( + kwargs={"position": "left"}, + slots={"content": lambda *a, **kwa: "Hello, Alice"} + ) + ``` + +!!! warning + + Slot caching DOES NOT account for context variables within + the [`{% fill %}`](../../reference/template_tags.md#fill) tag. + + For example, the following two cases will be treated as the same entry: + + ```django + {% with my_var="foo" %} + {% component "mycomponent" name="foo" %} + {{ my_var }} + {% endcomponent %} + {% endwith %} + + {% with my_var="bar" %} + {% component "mycomponent" name="bar" %} + {{ my_var }} + {% endcomponent %} + {% endwith %} + ``` + + Currently it's impossible to capture used variables. This will be addressed in v2. + Read more about it in [django-components/#1164](https://github.com/django-components/django-components/issues/1164). + ### Example Here's a complete example of a component with caching enabled: diff --git a/src/django_components/extensions/cache.py b/src/django_components/extensions/cache.py index b432115c..9f26c789 100644 --- a/src/django_components/extensions/cache.py +++ b/src/django_components/extensions/cache.py @@ -1,3 +1,4 @@ +from hashlib import md5 from typing import Any, Dict, List, Optional from django.core.cache import BaseCache, caches @@ -7,6 +8,7 @@ from django_components.extension import ( OnComponentInputContext, OnComponentRenderedContext, ) +from django_components.slots import Slot # NOTE: We allow users to override cache key generation, but then we internally # still prefix their key with our own prefix, so it's clear where it comes from. @@ -38,6 +40,49 @@ class ComponentCache(ComponentExtension.ExtensionClass): # type: ignore """ Whether this Component should be cached. Defaults to `False`. """ + include_slots: bool = False + """ + Whether the slots should be hashed into the cache key. + + If enabled, the following two cases will be treated as different entries: + + ```django + {% component "mycomponent" name="foo" %} + FILL ONE + {% endcomponent %} + + {% component "mycomponent" name="foo" %} + FILL TWO + {% endcomponent %} + ``` + + !!! warning + + Passing slots as functions to cached components with `include_slots=True` will raise an error. + + !!! warning + + Slot caching DOES NOT account for context variables within the `{% fill %}` tag. + + For example, the following two cases will be treated as the same entry: + + ```django + {% with my_var="foo" %} + {% component "mycomponent" name="foo" %} + {{ my_var }} + {% endcomponent %} + {% endwith %} + + {% with my_var="bar" %} + {% component "mycomponent" name="bar" %} + {{ my_var }} + {% endcomponent %} + {% endwith %} + ``` + + Currently it's impossible to capture used variables. This will be addressed in v2. + Read more about it in https://github.com/django-components/django-components/issues/1164. + """ ttl: Optional[int] = None """ @@ -71,7 +116,10 @@ class ComponentCache(ComponentExtension.ExtensionClass): # type: ignore # Allow user to override how the input is hashed into a cache key with `hash()`, # but then still prefix it wih our own prefix, so it's clear where it comes from. cache_key = self.hash(args, kwargs) - cache_key = CACHE_KEY_PREFIX + self.component._class_hash + ":" + cache_key + if self.include_slots: + cache_key += ":" + self.hash_slots(slots) + cache_key = self.component._class_hash + ":" + cache_key + cache_key = CACHE_KEY_PREFIX + md5(cache_key.encode()).hexdigest() return cache_key def hash(self, args: List, kwargs: Dict) -> str: @@ -87,6 +135,19 @@ class ComponentCache(ComponentExtension.ExtensionClass): # type: ignore kwargs_hash = ",".join(f"{k}-{v}" for k, v in sorted_items) return f"{args_hash}:{kwargs_hash}" + def hash_slots(self, slots: Dict[str, Slot]) -> str: + sorted_items = sorted(slots.items()) + hash_parts = [] + for key, slot in sorted_items: + if callable(slot.contents): + raise ValueError( + f"Cannot hash slot '{key}' of component '{self.component.name}' - Slot functions are unhashable." + " Instead define the slot as a string or `{% fill %}` tag, or disable slot caching" + " with `Cache.include_slots=False`." + ) + hash_parts.append(f"{key}-{slot.contents}") + return ",".join(hash_parts) + class CacheExtension(ComponentExtension): """ diff --git a/tests/test_component_cache.py b/tests/test_component_cache.py index 97ab1fcb..606e3861 100644 --- a/tests/test_component_cache.py +++ b/tests/test_component_cache.py @@ -1,8 +1,10 @@ +import re import time from django.core.cache import caches from django.template import Template from django.template.context import Context +import pytest from django_components import Component, register from django_components.testing import djc_test @@ -45,7 +47,7 @@ class TestComponentCache: # Check if the cache entry is set cache_key = component.cache.get_cache_key([], {}, {}) - assert cache_key == "components:cache:TestComponent_c9770f::" + assert cache_key == "components:cache:c98bf483e9a1937732d4542c714462ac" assert component.cache.get_entry(cache_key) == "Hello" assert caches["default"].get(cache_key) == "Hello" @@ -137,9 +139,9 @@ class TestComponentCache: assert component.cache.get_cache() is caches["custom"] assert ( - component.cache.get_entry("components:cache:TestComponent_90ef7a::") + component.cache.get_entry("components:cache:bcb4b049d8556e06871b39e0e584e452") == "Hello" - ) # noqa: E501 + ) def test_cache_by_input(self): class TestComponent(Component): @@ -162,15 +164,16 @@ class TestComponentCache: # Check if the cache entry is set cache = caches["default"] + assert len(cache._cache) == 2 assert ( - component.cache.get_entry("components:cache:TestComponent_648b95::input-world") + component.cache.get_entry("components:cache:3535e1d1e5f6fa5bc521e7fe203a68d0") == "Hello world" - ) # noqa: E501 + ) assert ( - component.cache.get_entry("components:cache:TestComponent_648b95::input-cake") + component.cache.get_entry("components:cache:a98a8bd5e72a544d7601798d5e777a77") == "Hello cake" - ) # noqa: E501 + ) def test_cache_input_hashing(self): class TestComponent(Component): @@ -201,8 +204,9 @@ class TestComponentCache: component.render(args=(1, 2), kwargs={"key": "value"}) # The key should use the custom hash methods - expected_key = "components:cache:TestComponent_28880f:custom-args-and-kwargs" + expected_key = "components:cache:3d54974c467a578c509efec189b0d14b" assert component.cache.get_cache_key([1, 2], {"key": "value"}, {}) == expected_key + assert component.cache.get_entry(expected_key) == "Hello" def test_cached_component_inside_include(self): @@ -213,12 +217,14 @@ class TestComponentCache: class Cache: enabled = True - template = Template(""" + template = Template( + """ {% extends "test_cached_component_inside_include_base.html" %} {% block content %} THIS_IS_IN_ACTUAL_TEMPLATE_SO_SHOULD_NOT_BE_OVERRIDDEN {% endblock %} - """) + """ + ) result = template.render(Context({})) assert "THIS_IS_IN_BASE_TEMPLATE_SO_SHOULD_BE_OVERRIDDEN" not in result @@ -227,3 +233,118 @@ class TestComponentCache: result_cached = template.render(Context({})) assert "THIS_IS_IN_BASE_TEMPLATE_SO_SHOULD_BE_OVERRIDDEN" not in result_cached assert "THIS_IS_IN_ACTUAL_TEMPLATE_SO_SHOULD_NOT_BE_OVERRIDDEN" in result_cached + + def test_cache_slots__fills(self): + @register("test_component") + class TestComponent(Component): + template = "Hello {{ input }}
{% slot 'content' default / %}
" + + class Cache: + enabled = True + include_slots = True + + def get_template_data(self, args, kwargs, slots, context): + return {"input": kwargs["input"]} + + Template( + """ + {% component "test_component" input="cake" %} + ONE + {% endcomponent %} + """ + ).render(Context({})) + + Template( + """ + {% component "test_component" input="cake" %} + ONE + {% endcomponent %} + """ + ).render(Context({})) + + # Check if the cache entry is set + component = TestComponent() + cache = caches["default"] + + assert len(cache._cache) == 1 + assert ( + component.cache.get_entry("components:cache:87b9e27abdd3c6ef70982d065fc836a9") + == 'Hello cake
\n ONE\n
' # noqa: E501 + ) + + Template( + """ + {% component "test_component" input="cake" %} + TWO + {% endcomponent %} + """ + ).render(Context({})) + + assert len(cache._cache) == 2 + assert ( + component.cache.get_entry("components:cache:1d7e3a58972550cf9bec18f457fb1a61") + == 'Hello cake
\n TWO\n
' # noqa: E501 + ) + + def test_cache_slots__strings(self): + class TestComponent(Component): + template = "Hello {{ input }}
{% slot 'content' default / %}
" + + class Cache: + enabled = True + include_slots = True + + def get_template_data(self, args, kwargs, slots, context): + return {"input": kwargs["input"]} + + TestComponent.render( + kwargs={"input": "cake"}, + slots={"content": "ONE"}, + ) + TestComponent.render( + kwargs={"input": "cake"}, + slots={"content": "ONE"}, + ) + + # Check if the cache entry is set + component = TestComponent() + cache = caches["default"] + + assert len(cache._cache) == 1 + assert ( + component.cache.get_entry("components:cache:362766726cd0e991f33b0527ef8a513c") + == 'Hello cake
ONE
' + ) + + TestComponent.render( + kwargs={"input": "cake"}, + slots={"content": "TWO"}, + ) + + assert len(cache._cache) == 2 + assert ( + component.cache.get_entry("components:cache:468e3f122ac305cff5d9096a3c548faf") + == 'Hello cake
TWO
' + ) + + def test_cache_slots_raises_on_func(self): + class TestComponent(Component): + template = "Hello {{ input }}
{% slot 'content' default / %}
" + + class Cache: + enabled = True + include_slots = True + + def get_template_data(self, args, kwargs, slots, context): + return {"input": kwargs["input"]} + + with pytest.raises( + ValueError, + match=re.escape( + "Cannot hash slot 'content' of component 'TestComponent' - Slot functions are unhashable." + ), + ): + TestComponent.render( + kwargs={"input": "cake"}, + slots={"content": lambda *a, **kwa: "ONE"}, + )