feat: slot caching (#1196)

* feat: slot caching

Closes #1164

* refactor: fix linter
This commit is contained in:
Juro Oravec 2025-05-19 19:26:57 +02:00 committed by GitHub
parent b6b574d875
commit 79c42da2f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 306 additions and 12 deletions

View file

@ -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)).

View file

@ -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:

View file

@ -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):
"""

View file

@ -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) == "<!-- _RENDERED TestComponent_c9770f,ca1bc3e,, -->Hello"
assert caches["default"].get(cache_key) == "<!-- _RENDERED TestComponent_c9770f,ca1bc3e,, -->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")
== "<!-- _RENDERED TestComponent_90ef7a,ca1bc3e,, -->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")
== "<!-- _RENDERED TestComponent_648b95,ca1bc3e,, -->Hello world"
) # noqa: E501
)
assert (
component.cache.get_entry("components:cache:TestComponent_648b95::input-cake")
component.cache.get_entry("components:cache:a98a8bd5e72a544d7601798d5e777a77")
== "<!-- _RENDERED TestComponent_648b95,ca1bc3f,, -->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) == "<!-- _RENDERED TestComponent_28880f,ca1bc3e,, -->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 }} <div>{% slot 'content' default / %}</div>"
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")
== '<!-- _RENDERED TestComponent_dd1dee,ca1bc3f,, -->Hello cake <div data-djc-id-ca1bc3f="">\n ONE\n </div>' # 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")
== '<!-- _RENDERED TestComponent_dd1dee,ca1bc44,, -->Hello cake <div data-djc-id-ca1bc44="">\n TWO\n </div>' # noqa: E501
)
def test_cache_slots__strings(self):
class TestComponent(Component):
template = "Hello {{ input }} <div>{% slot 'content' default / %}</div>"
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")
== '<!-- _RENDERED TestComponent_34b6d1,ca1bc3e,, -->Hello cake <div data-djc-id-ca1bc3e="">ONE</div>'
)
TestComponent.render(
kwargs={"input": "cake"},
slots={"content": "TWO"},
)
assert len(cache._cache) == 2
assert (
component.cache.get_entry("components:cache:468e3f122ac305cff5d9096a3c548faf")
== '<!-- _RENDERED TestComponent_34b6d1,ca1bc41,, -->Hello cake <div data-djc-id-ca1bc41="">TWO</div>'
)
def test_cache_slots_raises_on_func(self):
class TestComponent(Component):
template = "Hello {{ input }} <div>{% slot 'content' default / %}</div>"
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"},
)