refactor: allow to call Component.inject() outside of render (#1414)

This commit is contained in:
Juro Oravec 2025-09-29 14:53:01 +02:00 committed by GitHub
parent 1578996b21
commit b3ea50572d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 614 additions and 236 deletions

View file

@ -1,5 +1,58 @@
# Release notes # Release notes
## v0.141.6
#### Fix
- Fix error that occured when calling `Component.inject()` inside loops:
```py
class MyComponent(Component):
def get_template_data(self, args, kwargs, slots, context):
data = self.inject("my_provide")
return {"data": data}
```
```django
{% load component_tags %}
{% provide "my_provide" key="hi" data=data %}
{% for i in range(10) %}
{% component "my_component" / %}
{% endfor %}
{% endprovide %}
```
- Allow to call `Component.inject()` outside of the rendering:
```py
comp = None
class MyComponent(Component):
def get_template_data(self, args, kwargs, slots, context):
nonlocal comp
comp = self
template_str = """
{% load component_tags %}
{% provide "my_provide" key="hi" data=data %}
{% component "my_component" / %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert comp is not None
injected = comp.inject("my_provide")
assert injected.key == "hi"
assert injected.data == "data"
```
#### Refactor
- Removed circular references to the Component instances. Component instances
are now garbage collected unless you keep a reference to them.
## v0.141.5 ## v0.141.5
#### Fix #### Fix

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "django_components" name = "django_components"
version = "0.141.5" version = "0.141.6"
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"]
@ -180,8 +180,9 @@ known-first-party = ["django_components"]
check_untyped_defs = true check_untyped_defs = true
ignore_missing_imports = true ignore_missing_imports = true
exclude = [ exclude = [
"test_structures",
"build", "build",
"sampleproject",
"test_structures",
] ]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]

View file

@ -19,7 +19,7 @@ from typing import (
Union, Union,
cast, cast,
) )
from weakref import ReferenceType, WeakValueDictionary, finalize from weakref import ReferenceType, WeakValueDictionary, finalize, ref
from django.forms.widgets import Media as MediaCls from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
@ -61,9 +61,10 @@ from django_components.perfutil.component import (
ComponentRenderer, ComponentRenderer,
OnComponentRenderedResult, OnComponentRenderedResult,
component_context_cache, component_context_cache,
component_instance_cache,
component_post_render, component_post_render,
) )
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference from django_components.perfutil.provide import register_provide_reference, unlink_component_from_provide_on_gc
from django_components.provide import get_injected_context_var from django_components.provide import get_injected_context_var
from django_components.slots import ( from django_components.slots import (
Slot, Slot,
@ -102,9 +103,11 @@ COMP_ONLY_FLAG = "only"
if sys.version_info >= (3, 9): if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType[Type["Component"]]] AllComponents = List[ReferenceType[Type["Component"]]]
CompHashMapping = WeakValueDictionary[str, Type["Component"]] CompHashMapping = WeakValueDictionary[str, Type["Component"]]
ComponentRef = ReferenceType["Component"]
else: else:
AllComponents = List[ReferenceType] AllComponents = List[ReferenceType]
CompHashMapping = WeakValueDictionary CompHashMapping = WeakValueDictionary
ComponentRef = ReferenceType
OnRenderGenerator = Generator[ OnRenderGenerator = Generator[
@ -516,7 +519,7 @@ class ComponentMeta(ComponentMediaMeta):
# Internal data that are made available within the component's template # Internal data that are made available within the component's template
@dataclass @dataclass
class ComponentContext: class ComponentContext:
component: "Component" component: ComponentRef
component_path: List[str] component_path: List[str]
template_name: Optional[str] template_name: Optional[str]
default_slot: Optional[str] default_slot: Optional[str]
@ -527,6 +530,12 @@ class ComponentContext:
post_render_callbacks: Dict[str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]] post_render_callbacks: Dict[str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]]
def on_component_garbage_collected(component_id: str) -> None:
"""Finalizer function to be called when a Component object is garbage collected."""
unlink_component_from_provide_on_gc(component_id)
component_context_cache.pop(component_id, None)
class Component(metaclass=ComponentMeta): class Component(metaclass=ComponentMeta):
# ##################################### # #####################################
# PUBLIC API (Configurable by users) # PUBLIC API (Configurable by users)
@ -2314,6 +2323,9 @@ class Component(metaclass=ComponentMeta):
self.registry = default(registry, registry_) self.registry = default(registry, registry_)
self.node = node self.node = node
# Run finalizer when component is garbage collected
finalize(self, on_component_garbage_collected, self.id)
extensions._init_component_instance(self) extensions._init_component_instance(self)
def __init_subclass__(cls, **kwargs: Any) -> None: def __init_subclass__(cls, **kwargs: Any) -> None:
@ -2940,7 +2952,7 @@ class Component(metaclass=ComponentMeta):
As the `{{ message }}` is taken from the "my_provide" provider. As the `{{ message }}` is taken from the "my_provide" provider.
""" """
return get_injected_context_var(self.name, self.context, key, default) return get_injected_context_var(self.id, self.name, key, default)
@classmethod @classmethod
def as_view(cls, **initkwargs: Any) -> ViewFn: def as_view(cls, **initkwargs: Any) -> ViewFn:
@ -3302,10 +3314,13 @@ class Component(metaclass=ComponentMeta):
node: Optional["ComponentNode"] = None, node: Optional["ComponentNode"] = None,
) -> str: ) -> str:
component_name = _get_component_name(cls, registered_name) component_name = _get_component_name(cls, registered_name)
render_id = _gen_component_id()
# Modify the error to display full component path (incl. slots) # Modify the error to display full component path (incl. slots)
with component_error_message([component_name]): with component_error_message([component_name]):
try:
return cls._render_impl( return cls._render_impl(
render_id=render_id,
context=context, context=context,
args=args, args=args,
kwargs=kwargs, kwargs=kwargs,
@ -3318,10 +3333,15 @@ class Component(metaclass=ComponentMeta):
registered_name=registered_name, registered_name=registered_name,
node=node, node=node,
) )
except Exception as e:
# Clean up if rendering fails
component_instance_cache.pop(render_id, None)
raise e from None
@classmethod @classmethod
def _render_impl( def _render_impl(
comp_cls, comp_cls,
render_id: str,
context: Optional[Union[Dict[str, Any], Context]] = None, context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[Any] = None, args: Optional[Any] = None,
kwargs: Optional[Any] = None, kwargs: Optional[Any] = None,
@ -3348,7 +3368,8 @@ class Component(metaclass=ComponentMeta):
if request is None: if request is None:
_, parent_comp_ctx = _get_parent_component_context(context) _, parent_comp_ctx = _get_parent_component_context(context)
if parent_comp_ctx: if parent_comp_ctx:
request = parent_comp_ctx.component.request parent_comp = parent_comp_ctx.component()
request = parent_comp and parent_comp.request
component_name = _get_component_name(comp_cls, registered_name) component_name = _get_component_name(comp_cls, registered_name)
@ -3371,8 +3392,6 @@ class Component(metaclass=ComponentMeta):
if not isinstance(context, (Context, RequestContext)): if not isinstance(context, (Context, RequestContext)):
context = RequestContext(request, context) if request else Context(context) context = RequestContext(request, context) if request else Context(context)
render_id = _gen_component_id()
component = comp_cls( component = comp_cls(
id=render_id, id=render_id,
args=args_list, args=args_list,
@ -3446,11 +3465,14 @@ class Component(metaclass=ComponentMeta):
) )
# Register the component to provide # Register the component to provide
register_provide_reference(context, render_id) register_provide_reference(context, component)
# This is data that will be accessible (internally) from within the component's template # This is data that will be accessible (internally) from within the component's template.
# NOTE: Be careful with the context - Do not store a strong reference to the component,
# because that would prevent the component from being garbage collected.
# TODO: Test that ComponentContext and Component are garbage collected after render.
component_ctx = ComponentContext( component_ctx = ComponentContext(
component=component, component=ref(component),
component_path=component_path, component_path=component_path,
# Template name is set only once we've resolved the component's Template instance. # Template name is set only once we've resolved the component's Template instance.
template_name=None, template_name=None,
@ -3477,7 +3499,7 @@ class Component(metaclass=ComponentMeta):
# 3. Call data methods # 3. Call data methods
###################################### ######################################
template_data, js_data, css_data = component._call_data_methods(context, args_list, kwargs_dict) template_data, js_data, css_data = component._call_data_methods(args_list, kwargs_dict)
extensions.on_component_data( extensions.on_component_data(
OnComponentDataContext( OnComponentDataContext(
@ -3589,12 +3611,25 @@ class Component(metaclass=ComponentMeta):
js_input_hash=js_input_hash, js_input_hash=js_input_hash,
) )
# This is triggered when a component is rendered, but the component's parents # `on_component_rendered` is triggered when a component is rendered.
# may not have been rendered yet. # The component's parent(s) may not be fully rendered yet.
#
# NOTE: Inside `on_component_rendered`, we access the component indirectly via `component_instance_cache`.
# This is so that the function does not directly hold a strong reference to the component instance,
# so that the component instance can be garbage collected.
component_instance_cache[render_id] = component
def on_component_rendered( def on_component_rendered(
html: Optional[str], html: Optional[str],
error: Optional[Exception], error: Optional[Exception],
) -> OnComponentRenderedResult: ) -> OnComponentRenderedResult:
# NOTE: We expect `on_component_rendered` to be called only once,
# so we can release the strong reference to the component instance.
# This way, the component instance will persist only if the user keeps a reference to it.
component = component_instance_cache.pop(render_id, None)
if component is None:
raise RuntimeError("Component has been garbage collected")
# Allow the user to either: # Allow the user to either:
# - Override/modify the rendered HTML by returning new value # - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error # - Raise an exception to discard the HTML and bubble up error
@ -3608,10 +3643,6 @@ class Component(metaclass=ComponentMeta):
error = new_error error = new_error
html = None html = None
# Remove component from caches
del component_context_cache[render_id]
unregister_provide_reference(render_id)
# Allow extensions to either: # Allow extensions to either:
# - Override/modify the rendered HTML by returning new value # - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error # - Raise an exception to discard the HTML and bubble up error
@ -3759,7 +3790,6 @@ class Component(metaclass=ComponentMeta):
def _call_data_methods( def _call_data_methods(
self, self,
context: Context,
# TODO_V2 - Remove `raw_args` and `raw_kwargs` in v2 # TODO_V2 - Remove `raw_args` and `raw_kwargs` in v2
raw_args: List, raw_args: List,
raw_kwargs: Dict, raw_kwargs: Dict,
@ -3779,11 +3809,11 @@ class Component(metaclass=ComponentMeta):
# TODO - Enable JS and CSS vars - expose, and document # TODO - Enable JS and CSS vars - expose, and document
# JS data # JS data
maybe_js_data = self.get_js_data(self.args, self.kwargs, self.slots, context) maybe_js_data = self.get_js_data(self.args, self.kwargs, self.slots, self.context)
js_data = to_dict(default(maybe_js_data, {})) js_data = to_dict(default(maybe_js_data, {}))
# CSS data # CSS data
maybe_css_data = self.get_css_data(self.args, self.kwargs, self.slots, context) maybe_css_data = self.get_css_data(self.args, self.kwargs, self.slots, self.context)
css_data = to_dict(default(maybe_css_data, {})) css_data = to_dict(default(maybe_css_data, {}))
# Validate outputs # Validate outputs
@ -4005,7 +4035,9 @@ class ComponentNode(BaseNode):
return output return output
def _get_parent_component_context(context: Context) -> Union[Tuple[None, None], Tuple[str, ComponentContext]]: def _get_parent_component_context(
context: Union[Context, Mapping],
) -> Union[Tuple[None, None], Tuple[str, ComponentContext]]:
parent_id = context.get(_COMPONENT_CONTEXT_KEY, None) parent_id = context.get(_COMPONENT_CONTEXT_KEY, None)
if parent_id is None: if parent_id is None:
return None, None return None, None

View file

@ -14,6 +14,7 @@ from typing import (
TypeVar, TypeVar,
Union, Union,
) )
from weakref import ref
import django.urls import django.urls
from django.template import Context, Origin, Template from django.template import Context, Origin, Template
@ -255,7 +256,8 @@ class ExtensionComponentConfig:
component_class: Type["Component"] component_class: Type["Component"]
"""The [`Component`](./api.md#django_components.Component) class that this extension is defined on.""" """The [`Component`](./api.md#django_components.Component) class that this extension is defined on."""
component: "Component" @property
def component(self) -> "Component":
""" """
When a [`Component`](./api.md#django_components.Component) is instantiated, When a [`Component`](./api.md#django_components.Component) is instantiated,
also the nested extension classes (such as `Component.View`) are instantiated, also the nested extension classes (such as `Component.View`) are instantiated,
@ -264,9 +266,15 @@ class ExtensionComponentConfig:
This attribute holds the owner [`Component`](./api.md#django_components.Component) instance This attribute holds the owner [`Component`](./api.md#django_components.Component) instance
that this extension is defined on. that this extension is defined on.
""" """
component = self._component_ref()
if component is None:
raise RuntimeError("Component has been garbage collected")
return component
def __init__(self, component: "Component") -> None: def __init__(self, component: "Component") -> None:
self.component = component # NOTE: Use weak reference to avoid a circular reference between the component instance
# and the extension class.
self._component_ref = ref(component)
# TODO_v1 - Delete # TODO_v1 - Delete

View file

@ -73,6 +73,9 @@ def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
default_field = getattr(defaults, default_field_key) default_field = getattr(defaults, default_field_key)
if isinstance(default_field, property):
continue
# If the field was defined with dataclass.field(), take the default / factory from there. # If the field was defined with dataclass.field(), take the default / factory from there.
if isinstance(default_field, Field): if isinstance(default_field, Field):
if default_field.default is not MISSING: if default_field.default is not MISSING:

View file

@ -161,6 +161,9 @@ class ComponentView(ExtensionComponentConfig, View):
ComponentExtension.ComponentConfig.__init__(self, component) ComponentExtension.ComponentConfig.__init__(self, component)
View.__init__(self, **kwargs) View.__init__(self, **kwargs)
# TODO_v1 - Remove. Superseded by `component_cls`. This was used for backwards compatibility.
self.component = component
@property @property
def url(self) -> str: def url(self) -> str:
""" """

View file

@ -8,7 +8,7 @@ from django_components.constants import COMP_ID_LENGTH
from django_components.util.exception import component_error_message from django_components.util.exception import component_error_message
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import ComponentContext, OnRenderGenerator from django_components.component import Component, ComponentContext, OnRenderGenerator
OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]] OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
@ -30,6 +30,15 @@ OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
# is only a key to this dictionary. # is only a key to this dictionary.
component_context_cache: Dict[str, "ComponentContext"] = {} component_context_cache: Dict[str, "ComponentContext"] = {}
# ComponentID -> Component instance mapping
# This is used so that we can access the component instance from inside `on_component_rendered()`,
# to call `Component.on_render_after()`.
# These are strong references to ensure that the Component instance stays alive until after
# `on_component_rendered()` has been called.
# After that, we release the reference. If user does not keep a reference to the component,
# it will be garbage collected.
component_instance_cache: Dict[str, "Component"] = {}
class ComponentPart(NamedTuple): class ComponentPart(NamedTuple):
"""Queue item where a component is nested in another component.""" """Queue item where a component is nested in another component."""

View file

@ -1,12 +1,16 @@
"""This module contains optimizations for the `{% provide %}` feature.""" """This module contains optimizations for the `{% provide %}` feature."""
from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from typing import Dict, Generator, NamedTuple, Set from typing import TYPE_CHECKING, Dict, Generator, NamedTuple, Set, cast
from django.template import Context from django.template import Context
from django_components.context import _INJECT_CONTEXT_KEY_PREFIX from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
if TYPE_CHECKING:
from django_components.component import Component
# Originally, when `{% provide %}` was used, the provided data was passed down # Originally, when `{% provide %}` was used, the provided data was passed down
# through the Context object. # through the Context object.
# #
@ -77,77 +81,103 @@ from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
# outside of the Context object, to make it easier to debug the data flow. # outside of the Context object, to make it easier to debug the data flow.
provide_cache: Dict[str, NamedTuple] = {} provide_cache: Dict[str, NamedTuple] = {}
# Keep track of how many components are referencing each provided data. # Given a `{% provide %}` instance, keep track of which components are referencing it.
provide_references: Dict[str, Set[str]] = {} # ProvideID -> Component[]
# NOTE: We manually clean up the entries when either:
# - `{% provide %}` ends and there are no more references to it
# - The last component that referenced it is garbage collected
provide_references: Dict[str, Set[str]] = defaultdict(set)
# Keep track of all the listeners that are referencing any provided data. # The opposite - Given a component, keep track of which `{% provide %}` instances it is referencing.
all_reference_ids: Set[str] = set() # Component -> ProvideID[]
# NOTE: We manually clean up the entries when components are garbage collected.
component_provides: Dict[str, Dict[str, str]] = defaultdict(dict)
@contextmanager @contextmanager
def managed_provide_cache(provide_id: str) -> Generator[None, None, None]: def managed_provide_cache(provide_id: str) -> Generator[None, None, None]:
all_reference_ids_before = all_reference_ids.copy() try:
yield
except Exception as e:
# NOTE: In case of an error in within the `{% provide %}` block (e.g. when rendering a component),
# we rely on the component finalizer to remove the references.
# But we still want to call cleanup in case `{% provide %}` contained no components.
_cache_cleanup(provide_id)
# Forward the error
raise e from None
def cache_cleanup() -> None: # Cleanup on success
# Lastly, remove provided data from the cache that was generated during this run, _cache_cleanup(provide_id)
# IF there are no more references to it.
def _cache_cleanup(provide_id: str) -> None:
# Remove provided data from the cache, IF there are no more references to it.
# A `{% provide %}` will have no reference if:
# - It contains no components in its body
# - It contained components, but those components were already garbage collected
if provide_id in provide_references and not provide_references[provide_id]: if provide_id in provide_references and not provide_references[provide_id]:
provide_references.pop(provide_id) provide_references.pop(provide_id)
provide_cache.pop(provide_id) provide_cache.pop(provide_id, None)
# Case: `{% provide %}` contained no components in its body. # Case: `{% provide %}` contained no components in its body.
# The provided data was not referenced by any components, but it's still in the cache. # The provided data was not referenced by any components, but it's still in the cache.
elif provide_id not in provide_references and provide_id in provide_cache: elif provide_id not in provide_references and provide_id in provide_cache:
provide_cache.pop(provide_id) provide_cache.pop(provide_id)
try:
yield
except Exception as e:
# In case of an error in `Component.render()`, there may be some
# references left hanging, so we remove them.
new_reference_ids = all_reference_ids - all_reference_ids_before
for reference_id in new_reference_ids:
unregister_provide_reference(reference_id)
# Cleanup # TODO - Once components can access their parents:
cache_cleanup() # Do NOT pass provide keys through components in isolated mode.
# Forward the error # Instead get parent's provide keys by getting the parent's id, `component.parent.id`
raise e from None # and then accessing `component_provides[component.parent.id]`.
# The logic below would still remain, as that defines the `{% provide %}`
# Cleanup # instances defined INSIDE the parent component.
cache_cleanup() # And we would combine the two sources, and set that to `component_provides[component.id]`.
def register_provide_reference(context: Context, component: "Component") -> None:
def register_provide_reference(context: Context, reference_id: str) -> None:
# No `{% provide %}` among the ancestors, nothing to register to # No `{% provide %}` among the ancestors, nothing to register to
if not provide_cache: if not provide_cache:
return return
all_reference_ids.add(reference_id) # For all instances of `{% provide %}` that the current component is within,
# make note that this component has access to them.
for key, provide_id in context.flatten().items(): for key, value in context.flatten().items():
# NOTE: Provided data is stored on the Context object as e.g.
# `{"_DJC_INJECT__my_provide": "a1b3c3"}`
# Where "a1b3c3" is the ID of the provided data.
if not key.startswith(_INJECT_CONTEXT_KEY_PREFIX): if not key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
continue continue
if provide_id not in provide_references: provide_id = cast("str", value)
provide_references[provide_id] = set() provide_key = key.split(_INJECT_CONTEXT_KEY_PREFIX, 1)[1]
provide_references[provide_id].add(reference_id)
# Update the Provide -> Component[] mapping.
provide_references[provide_id].add(component.id)
# Update the Component -> Provide[] mapping.
component_provides[component.id][provide_key] = provide_id
def unregister_provide_reference(reference_id: str) -> None: def unregister_provide_reference(component_id: str) -> None:
# No registered references, nothing to unregister # List of `{% provide %}` IDs that the component had access to.
if reference_id not in all_reference_ids: component_provides_ids = component_provides.get(component_id)
if not component_provides_ids:
return return
all_reference_ids.remove(reference_id) # Remove this component from all provide references it was subscribed to
for provide_id in component_provides_ids.values():
references_to_this_provide = provide_references.get(provide_id)
if references_to_this_provide:
references_to_this_provide.discard(component_id)
for provide_id in list(provide_references.keys()):
if reference_id not in provide_references[provide_id]:
continue
provide_references[provide_id].remove(reference_id) def unlink_component_from_provide_on_gc(component_id: str) -> None:
"""
Finalizer function to be called when a Component object is garbage collected.
# There are no more references to the provided data, so we can delete it. Unlinking the component at this point ensures that one can call `Component.inject()`
if not provide_references[provide_id]: even after the component was rendered, as long as one keeps the reference to the component object.
provide_cache.pop(provide_id) """
provide_references.pop(provide_id) unregister_provide_reference(component_id)
provide_ids = component_provides.pop(component_id, None)
if provide_ids:
for provide_id in provide_ids.values():
_cache_cleanup(provide_id)

View file

@ -5,7 +5,7 @@ from django.utils.safestring import SafeString
from django_components.context import _INJECT_CONTEXT_KEY_PREFIX from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.perfutil.provide import managed_provide_cache, provide_cache from django_components.perfutil.provide import component_provides, managed_provide_cache, provide_cache
from django_components.util.misc import gen_id from django_components.util.misc import gen_id
@ -102,8 +102,8 @@ class ProvideNode(BaseNode):
def get_injected_context_var( def get_injected_context_var(
component_id: str,
component_name: str, component_name: str,
context: Context,
key: str, key: str,
default: Optional[Any] = None, default: Optional[Any] = None,
) -> Any: ) -> Any:
@ -111,15 +111,13 @@ def get_injected_context_var(
Retrieve a 'provided' field. The field MUST have been previously 'provided' Retrieve a 'provided' field. The field MUST have been previously 'provided'
by the component's ancestors using the `{% provide %}` template tag. by the component's ancestors using the `{% provide %}` template tag.
""" """
# NOTE: For simplicity, we keep the provided values directly on the context. # NOTE: `component_provides` is defaultdict. Use `.get()` to avoid making an empty dictionary.
# This plays nicely with Django's Context, which behaves like a stack, so "newer" providers = component_provides.get(component_id)
# values overshadow the "older" ones.
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
# Return provided value if found # Return provided value if found
if internal_key in context: if providers and key in providers:
cache_key = context[internal_key] provide_id = providers[key]
return provide_cache[cache_key] return provide_cache[provide_id]
# If a default was given, return that # If a default was given, return that
if default is not None: if default is not None:
@ -133,6 +131,8 @@ def get_injected_context_var(
) )
# TODO_v2 - Once we wrap all executions of Django's Template as our Components,
# we'll be able to store the provided data on ComponentContext instead of on Context.
def set_provided_context_var( def set_provided_context_var(
context: Context, context: Context,
key: str, key: str,
@ -161,8 +161,12 @@ def set_provided_context_var(
tuple_cls = NamedTuple("DepInject", fields) # type: ignore[misc] tuple_cls = NamedTuple("DepInject", fields) # type: ignore[misc]
payload = tuple_cls(**provided_kwargs) payload = tuple_cls(**provided_kwargs)
# Instead of storing the provided data on the Context object, we store it # To allow the components nested inside `{% provide %}` to access the provided data,
# in a separate dictionary, and we set only the key to the data on the Context. # we pass the data through the Context.
# But instead of storing the data directly on the Context object, we store it
# in a separate dictionary, and we only set a key to the data on the Context.
# This helps with debugging as the Context is easier to inspect. It also helps
# with testing and garbage collection, as we can easily access/modify the provided data.
context_key = _INJECT_CONTEXT_KEY_PREFIX + key context_key = _INJECT_CONTEXT_KEY_PREFIX + key
provide_id = gen_id() provide_id = gen_id()
context[context_key] = provide_id context[context_key] = provide_id

View file

@ -680,7 +680,11 @@ class SlotNode(BaseNode):
# Component info # Component info
component_id: str = context[_COMPONENT_CONTEXT_KEY] component_id: str = context[_COMPONENT_CONTEXT_KEY]
component_ctx = component_context_cache[component_id] component_ctx = component_context_cache[component_id]
component = component_ctx.component component = component_ctx.component()
if component is None:
raise RuntimeError(
f"Component with id '{component_id}' was garbage collected before its slots could be rendered."
)
component_name = component.name component_name = component.name
component_path = component_ctx.component_path component_path = component_ctx.component_path
is_dynamic_component = getattr(component, "_is_dynamic_component", False) is_dynamic_component = getattr(component, "_is_dynamic_component", False)
@ -828,7 +832,12 @@ class SlotNode(BaseNode):
if parent_index is not None: if parent_index is not None:
ctx_id_with_fills = context.dicts[parent_index][_COMPONENT_CONTEXT_KEY] ctx_id_with_fills = context.dicts[parent_index][_COMPONENT_CONTEXT_KEY]
ctx_with_fills = component_context_cache[ctx_id_with_fills] ctx_with_fills = component_context_cache[ctx_id_with_fills]
slot_fills = ctx_with_fills.component.raw_slots parent_component = ctx_with_fills.component()
if parent_component is None:
raise RuntimeError(
f"Component with id '{component_id}' was garbage collected before its slots could be rendered."
)
slot_fills = parent_component.raw_slots
# Add trace message when slot_fills are overwritten # Add trace message when slot_fills are overwritten
trace_component_msg( trace_component_msg(

View file

@ -1,5 +1,5 @@
{% load component_tags %} {% load component_tags %}
<div> <div>
{% component "injectee" %} {% component "injectee17" %}
{% endcomponent %} {% endcomponent %}
</div> </div>

View file

@ -736,8 +736,18 @@ class TestSlot:
assert len(seen_slots) == 3 assert len(seen_slots) == 3
results = [slot().strip() for slot in seen_slots] results = [slot().strip() for slot in seen_slots]
if components_settings["context_behavior"] == "django":
assert results == [ assert results == [
"<!-- _RENDERED MyInnerComponent_fb676b,ca1bc49,, -->Hello!", "<!-- _RENDERED MyInnerComponent_fb676b,ca1bc49,, -->Hello!",
"<!-- _RENDERED MyInnerComponent_fb676b,ca1bc4a,, -->Hello!", "<!-- _RENDERED MyInnerComponent_fb676b,ca1bc4a,, -->Hello!",
"<!-- _RENDERED MyInnerComponent_fb676b,ca1bc4b,, -->Hello!", "<!-- _RENDERED MyInnerComponent_fb676b,ca1bc4b,, -->Hello!",
] ]
else:
# TODO - Incorrect for slots!
# To be fixed in https://github.com/django-components/django-components/issues/1259
assert results == [
'<template djc-render-id="ca1bc49"></template>',
'<template djc-render-id="ca1bc4a"></template>',
'<template djc-render-id="ca1bc4b"></template>',
]

View file

@ -1,11 +1,15 @@
import gc
import re import re
from weakref import ref
import pytest import pytest
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
from pytest_django.asserts import assertHTMLEqual from pytest_django.asserts import assertHTMLEqual
from django_components import Component, register, types from django_components import Component, register, types
from django_components.perfutil.provide import all_reference_ids, provide_cache, provide_references from django_components.component import ComponentContext
from django_components.perfutil.component import component_context_cache, component_instance_cache
from django_components.perfutil.provide import component_provides, provide_cache, provide_references
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
@ -13,16 +17,24 @@ from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@djc_test # NOTE: By running garbage collection and then checking for empty caches,
class TestProvideTemplateTag: # we ensure that we are not introducing any memory leaks.
def _assert_clear_cache(self): def _assert_clear_cache():
# Ensure that finalizers have run
gc.collect()
assert provide_cache == {} assert provide_cache == {}
assert provide_references == {} assert provide_references == {}
assert all_reference_ids == set() assert component_provides == {}
assert component_instance_cache == {}
assert component_context_cache == {}
@djc_test
class TestProvideTemplateTag:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_basic(self, components_settings): def test_provide_basic(self, components_settings):
@register("injectee") @register("injectee1")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -35,7 +47,7 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=1 %} {% provide "my_provide" key="hi" another=1 %}
{% component "injectee" %} {% component "injectee1" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
""" """
@ -48,7 +60,7 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc41> injected: DepInject(key='hi', another=1) </div> <div data-djc-id-ca1bc41> injected: DepInject(key='hi', another=1) </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_basic_self_closing(self, components_settings): def test_provide_basic_self_closing(self, components_settings):
@ -67,11 +79,11 @@ class TestProvideTemplateTag:
<div></div> <div></div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_access_keys_in_python(self, components_settings): def test_provide_access_keys_in_python(self, components_settings):
@register("injectee") @register("injectee2")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> key: {{ key }} </div> <div> key: {{ key }} </div>
@ -88,7 +100,7 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=3 %} {% provide "my_provide" key="hi" another=3 %}
{% component "injectee" %} {% component "injectee2" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
""" """
@ -102,11 +114,11 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc41> another: 3 </div> <div data-djc-id-ca1bc41> another: 3 </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_access_keys_in_django(self, components_settings): def test_provide_access_keys_in_django(self, components_settings):
@register("injectee") @register("injectee3")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> key: {{ my_provide.key }} </div> <div> key: {{ my_provide.key }} </div>
@ -122,7 +134,7 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=4 %} {% provide "my_provide" key="hi" another=4 %}
{% component "injectee" %} {% component "injectee3" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
""" """
@ -136,11 +148,11 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc41> another: 4 </div> <div data-djc-id-ca1bc41> another: 4 </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_does_not_leak(self, components_settings): def test_provide_does_not_leak(self, components_settings):
@register("injectee") @register("injectee4")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -154,7 +166,7 @@ class TestProvideTemplateTag:
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=5 %} {% provide "my_provide" key="hi" another=5 %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee4" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -166,13 +178,13 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc41> injected: default </div> <div data-djc-id-ca1bc41> injected: default </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_empty(self, components_settings): def test_provide_empty(self, components_settings):
"""Check provide tag with no kwargs""" """Check provide tag with no kwargs"""
@register("injectee") @register("injectee5")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -185,10 +197,10 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" %} {% provide "my_provide" %}
{% component "injectee" %} {% component "injectee5" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee5" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -201,13 +213,13 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc43> injected: default </div> <div data-djc-id-ca1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(components_settings={"context_behavior": "django"}) @djc_test(components_settings={"context_behavior": "django"})
def test_provide_no_inject(self): def test_provide_no_inject(self):
"""Check that nothing breaks if we do NOT inject even if some data is provided""" """Check that nothing breaks if we do NOT inject even if some data is provided"""
@register("injectee") @register("injectee6")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div></div> <div></div>
@ -216,10 +228,10 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=6 %} {% provide "my_provide" key="hi" another=6 %}
{% component "injectee" %} {% component "injectee6" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee6" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -232,11 +244,11 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc43></div> <div data-djc-id-ca1bc43></div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_name_single_quotes(self, components_settings): def test_provide_name_single_quotes(self, components_settings):
@register("injectee") @register("injectee7")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -249,10 +261,10 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide 'my_provide' key="hi" another=7 %} {% provide 'my_provide' key="hi" another=7 %}
{% component "injectee" %} {% component "injectee7" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee7" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -265,11 +277,11 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc43> injected: default </div> <div data-djc-id-ca1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_name_as_var(self, components_settings): def test_provide_name_as_var(self, components_settings):
@register("injectee") @register("injectee8")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -282,10 +294,10 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide var_a key="hi" another=8 %} {% provide var_a key="hi" another=8 %}
{% component "injectee" %} {% component "injectee8" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee8" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -304,11 +316,11 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc43> injected: default </div> <div data-djc-id-ca1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_name_as_spread(self, components_settings): def test_provide_name_as_spread(self, components_settings):
@register("injectee") @register("injectee9")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -321,10 +333,10 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide ...provide_props %} {% provide ...provide_props %}
{% component "injectee" %} {% component "injectee9" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee9" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -347,11 +359,11 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc43> injected: default </div> <div data-djc-id-ca1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_no_name_raises(self, components_settings): def test_provide_no_name_raises(self, components_settings):
@register("injectee") @register("injectee10")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -364,10 +376,10 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide key="hi" another=10 %} {% provide key="hi" another=10 %}
{% component "injectee" %} {% component "injectee10" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee10" %}
{% endcomponent %} {% endcomponent %}
""" """
with pytest.raises( with pytest.raises(
@ -376,11 +388,11 @@ class TestProvideTemplateTag:
): ):
Template(template_str).render(Context({})) Template(template_str).render(Context({}))
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_name_must_be_string_literal(self, components_settings): def test_provide_name_must_be_string_literal(self, components_settings):
@register("injectee") @register("injectee11")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -393,10 +405,10 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide my_var key="hi" another=11 %} {% provide my_var key="hi" another=11 %}
{% component "injectee" %} {% component "injectee11" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee11" %}
{% endcomponent %} {% endcomponent %}
""" """
with pytest.raises( with pytest.raises(
@ -405,11 +417,11 @@ class TestProvideTemplateTag:
): ):
Template(template_str).render(Context({})) Template(template_str).render(Context({}))
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_name_must_be_identifier(self, components_settings): def test_provide_name_must_be_identifier(self, components_settings):
@register("injectee") @register("injectee12")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -422,21 +434,21 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "%heya%" key="hi" another=12 %} {% provide "%heya%" key="hi" another=12 %}
{% component "injectee" %} {% component "injectee12" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee12" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
with pytest.raises(TemplateSyntaxError): with pytest.raises(TemplateSyntaxError):
template.render(Context({})) template.render(Context({}))
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_aggregate_dics(self, components_settings): def test_provide_aggregate_dics(self, components_settings):
@register("injectee") @register("injectee13")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -449,7 +461,7 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" var1:key="hi" var1:another=13 var2:x="y" %} {% provide "my_provide" var1:key="hi" var1:another=13 var2:x="y" %}
{% component "injectee" %} {% component "injectee13" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
""" """
@ -462,13 +474,13 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc41> injected: DepInject(var1={'key': 'hi', 'another': 13}, var2={'x': 'y'}) </div> <div data-djc-id-ca1bc41> injected: DepInject(var1={'key': 'hi', 'another': 13}, var2={'x': 'y'}) </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_does_not_expose_kwargs_to_context(self, components_settings): def test_provide_does_not_expose_kwargs_to_context(self, components_settings):
"""Check that `provide` tag doesn't assign the keys to the context like `with` tag does""" """Check that `provide` tag doesn't assign the keys to the context like `with` tag does"""
@register("injectee") @register("injectee14")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -499,13 +511,13 @@ class TestProvideTemplateTag:
key_in: key_in:
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_nested_in_provide_same_key(self, components_settings): def test_provide_nested_in_provide_same_key(self, components_settings):
"""Check that inner `provide` with same key overshadows outer `provide`""" """Check that inner `provide` with same key overshadows outer `provide`"""
@register("injectee") @register("injectee15")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -519,14 +531,14 @@ class TestProvideTemplateTag:
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=15 lost=0 %} {% provide "my_provide" key="hi" another=15 lost=0 %}
{% provide "my_provide" key="hi1" another=16 new=3 %} {% provide "my_provide" key="hi1" another=16 new=3 %}
{% component "injectee" %} {% component "injectee15" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee15" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee15" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -541,13 +553,13 @@ class TestProvideTemplateTag:
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_nested_in_provide_different_key(self, components_settings): def test_provide_nested_in_provide_different_key(self, components_settings):
"""Check that `provide` tag with different keys don't affect each other""" """Check that `provide` tag with different keys don't affect each other"""
@register("injectee") @register("injectee16")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> first_provide: {{ first_provide|safe }} </div> <div> first_provide: {{ first_provide|safe }} </div>
@ -566,7 +578,7 @@ class TestProvideTemplateTag:
{% load component_tags %} {% load component_tags %}
{% provide "first_provide" key="hi" another=17 lost=0 %} {% provide "first_provide" key="hi" another=17 lost=0 %}
{% provide "second_provide" key="hi1" another=18 new=3 %} {% provide "second_provide" key="hi1" another=18 new=3 %}
{% component "injectee" %} {% component "injectee16" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% endprovide %} {% endprovide %}
@ -581,11 +593,11 @@ class TestProvideTemplateTag:
<div data-djc-id-ca1bc43> second_provide: DepInject(key='hi1', another=18, new=3) </div> <div data-djc-id-ca1bc43> second_provide: DepInject(key='hi1', another=18, new=3) </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_in_include(self, components_settings): def test_provide_in_include(self, components_settings):
@register("injectee") @register("injectee17")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -612,11 +624,11 @@ class TestProvideTemplateTag:
</div> </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_slot_in_provide(self, components_settings): def test_slot_in_provide(self, components_settings):
@register("injectee") @register("injectee18")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -638,7 +650,7 @@ class TestProvideTemplateTag:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "parent" %} {% component "parent" %}
{% component "injectee" %}{% endcomponent %} {% component "injectee18" %}{% endcomponent %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -652,19 +664,143 @@ class TestProvideTemplateTag:
</div> </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
# TODO - Enable once globals and finalizers are scoped to a single DJC instance")
# See https://github.com/django-components/django-components/issues/1413
@pytest.mark.skip("#TODO")
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_component_inside_forloop(self, components_settings):
@register("loop_component")
class LoopComponent(Component):
template: types.django_html = """
<div>Item {{ item_num }}: {{ provided_value }}</div>
"""
def get_template_data(self, args, kwargs, slots, context):
provided_data = self.inject("loop_provide")
return {
"item_num": kwargs["item_num"],
"provided_value": provided_data.shared_value,
}
template_str: types.django_html = """
{% load component_tags %}
{% provide "loop_provide" shared_value="shared_data" %}
{% for i in items %}
{% component "loop_component" item_num=i / %}
{% endfor %}
{% endprovide %}
"""
template = Template(template_str)
context = Context({"items": [1, 2, 3, 4, 5]})
rendered = template.render(context)
assertHTMLEqual(
rendered,
"""
<div data-djc-id-ca1bc41>Item 1: shared_data</div>
<div data-djc-id-ca1bc42>Item 2: shared_data</div>
<div data-djc-id-ca1bc43>Item 3: shared_data</div>
<div data-djc-id-ca1bc44>Item 4: shared_data</div>
<div data-djc-id-ca1bc45>Item 5: shared_data</div>
""",
)
# Ensure that finalizers have run
gc.collect()
# Ensure all caches are properly cleaned up even with multiple component instances
_assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_component_inside_nested_forloop(self, components_settings):
@register("nested_loop_component")
class NestedLoopComponent(Component):
template: types.django_html = """
<span>{{ outer }}-{{ inner }}: {{ provided_value }}</span>
"""
def get_template_data(self, args, kwargs, slots, context):
provided_data = self.inject("nested_provide")
return {
"outer": kwargs["outer"],
"inner": kwargs["inner"],
"provided_value": provided_data.nested_value,
}
template_str: types.django_html = """
{% load component_tags %}
{% provide "nested_provide" nested_value="nested_data" %}
{% for outer in outer_items %}
{% for inner in inner_items %}
{% component "nested_loop_component" outer=outer inner=inner / %}
{% endfor %}
{% endfor %}
{% endprovide %}
"""
template = Template(template_str)
context = Context({"outer_items": ["A", "B"], "inner_items": [1, 2]})
rendered = template.render(context)
assertHTMLEqual(
rendered,
"""
<span data-djc-id-ca1bc41>A-1: nested_data</span>
<span data-djc-id-ca1bc42>A-2: nested_data</span>
<span data-djc-id-ca1bc43>B-1: nested_data</span>
<span data-djc-id-ca1bc44>B-2: nested_data</span>
""",
)
# Ensure all caches are properly cleaned up even with many component instances
_assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_provide_component_forloop_with_error(self, components_settings):
@register("error_loop_component")
class ErrorLoopComponent(Component):
template = ""
def get_template_data(self, args, kwargs, slots, context):
provided_data = self.inject("error_provide")
item_num = kwargs["item_num"]
# Throw error on the third item
if item_num == 3:
raise ValueError(f"Error on item {item_num}")
return {
"item_num": item_num,
"provided_value": provided_data.error_value,
}
template_str: types.django_html = """
{% load component_tags %}
{% provide "error_provide" error_value="error_data" %}
{% for i in items %}
{% component "error_loop_component" item_num=i / %}
{% endfor %}
{% endprovide %}
"""
template = Template(template_str)
context = Context({"items": [1, 2, 3, 4, 5]})
with pytest.raises(ValueError, match=re.escape("Error on item 3")):
template.render(context)
# Ensure all caches are properly cleaned up even when errors occur
_assert_clear_cache()
@djc_test @djc_test
class TestInject: class TestInject:
def _assert_clear_cache(self):
assert provide_cache == {}
assert provide_references == {}
assert all_reference_ids == set()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_basic(self, components_settings): def test_inject_basic(self, components_settings):
@register("injectee") @register("injectee19")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -677,7 +813,7 @@ class TestInject:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=21 %} {% provide "my_provide" key="hi" another=21 %}
{% component "injectee" %} {% component "injectee19" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
""" """
@ -690,11 +826,11 @@ class TestInject:
<div data-djc-id-ca1bc41> injected: DepInject(key='hi', another=21) </div> <div data-djc-id-ca1bc41> injected: DepInject(key='hi', another=21) </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_missing_key_raises_without_default(self, components_settings): def test_inject_missing_key_raises_without_default(self, components_settings):
@register("injectee") @register("injectee20")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -706,7 +842,7 @@ class TestInject:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "injectee" %} {% component "injectee20" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -714,11 +850,11 @@ class TestInject:
with pytest.raises(KeyError): with pytest.raises(KeyError):
template.render(Context({})) template.render(Context({}))
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_missing_key_ok_with_default(self, components_settings): def test_inject_missing_key_ok_with_default(self, components_settings):
@register("injectee") @register("injectee21")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -730,7 +866,7 @@ class TestInject:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "injectee" %} {% component "injectee21" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -741,11 +877,11 @@ class TestInject:
<div data-djc-id-ca1bc3f> injected: default </div> <div data-djc-id-ca1bc3f> injected: default </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_empty_string(self, components_settings): def test_inject_empty_string(self, components_settings):
@register("injectee") @register("injectee22")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
@ -758,10 +894,10 @@ class TestInject:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=22 %} {% provide "my_provide" key="hi" another=22 %}
{% component "injectee" %} {% component "injectee22" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee22" %}
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
@ -769,29 +905,114 @@ class TestInject:
with pytest.raises(KeyError): with pytest.raises(KeyError):
template.render(Context({})) template.render(Context({}))
self._assert_clear_cache() _assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) # TODO - Enable once globals and finalizers are scoped to a single DJC instance")
def test_inject_called_outside_rendering(self, components_settings): # See https://github.com/django-components/django-components/issues/1413
@register("injectee") # @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
@djc_test(
parametrize=(
["components_settings"],
[
[{"context_behavior": "isolated"}],
],
["isolated"],
)
)
def test_inject_called_outside_rendering__persisted_ref(self, components_settings):
comp = None
@register("injectee23")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
<div> injected: {{ var|safe }} </div> <div> injected: {{ var|safe }} </div>
""" """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
var = self.inject("abc", "default") nonlocal comp
comp = self
var = self.inject(key="my_provide")
return {"var": var} return {"var": var}
comp = InjectComponent() template_str: types.django_html = """
comp.inject("abc", "def") {% load component_tags %}
{% provide "my_provide" key="hi" value=23 %}
{% component "injectee23" / %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self._assert_clear_cache() assertHTMLEqual(
rendered,
"""
<div data-djc-id-ca1bc41> injected: DepInject(key='hi', value=23) </div>
""",
)
assert comp is not None
# Check that we can inject the data even after the component was rendered.
injected = comp.inject(key="my_provide", default="def")
assert isinstance(injected, tuple)
assert injected.key == "hi" # type: ignore[attr-defined]
assert injected.value == 23 # type: ignore[attr-defined]
# NOTE: Because we kept the reference to the component, it's not garbage collected yet.
gc.collect()
assert provide_cache == {"a1bc40": ("hi", 23)}
assert provide_references == {"a1bc40": {"ca1bc41"}}
assert component_provides == {"ca1bc41": {"my_provide": "a1bc40"}}
assert component_instance_cache == {}
assert len(component_context_cache) == 1
assert isinstance(component_context_cache["ca1bc41"], ComponentContext)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_called_outside_rendering__not_persisted(self, components_settings):
comp = None
@register("injectee24")
class InjectComponent(Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_template_data(self, args, kwargs, slots, context):
nonlocal comp
comp = ref(self)
var = self.inject(key="my_provide")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" value=23 %}
{% component "injectee24" / %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
<div data-djc-id-ca1bc41> injected: DepInject(key='hi', value=23) </div>
""",
)
gc.collect()
# We didn't keep the reference, so the caches should be cleared.
assert comp is not None
assert comp() is None
_assert_clear_cache()
# See https://github.com/django-components/django-components/pull/778 # See https://github.com/django-components/django-components/pull/778
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_in_fill(self, components_settings): def test_inject_in_fill(self, components_settings):
@register("injectee") @register("injectee25")
class Injectee(Component): class Injectee(Component):
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
@ -825,7 +1046,7 @@ class TestInject:
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "provider" data=data %} {% component "provider" data=data %}
{% component "injectee" %} {% component "injectee25" %}
{% slot "content" default / %} {% slot "content" default / %}
{% endcomponent %} {% endcomponent %}
{% endcomponent %} {% endcomponent %}
@ -855,12 +1076,12 @@ class TestInject:
</main> </main>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
# See https://github.com/django-components/django-components/pull/786 # See https://github.com/django-components/django-components/pull/786
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_in_slot_in_fill(self, components_settings): def test_inject_in_slot_in_fill(self, components_settings):
@register("injectee") @register("injectee26")
class Injectee(Component): class Injectee(Component):
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
@ -903,7 +1124,7 @@ class TestInject:
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "parent" data=123 %} {% component "parent" data=123 %}
{% component "injectee" / %} {% component "injectee26" / %}
{% endcomponent %} {% endcomponent %}
""" """
@ -919,7 +1140,7 @@ class TestInject:
</main> </main>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
# When there is `{% component %}` that's a descendant of `{% provide %}`, # When there is `{% component %}` that's a descendant of `{% provide %}`,
@ -930,13 +1151,8 @@ class TestInject:
# when the component rendered is done. # when the component rendered is done.
@djc_test @djc_test
class TestProvideCache: class TestProvideCache:
def _assert_clear_cache(self):
assert provide_cache == {}
assert provide_references == {}
assert all_reference_ids == set()
def test_provide_outside_component(self): def test_provide_outside_component(self):
@register("injectee") @register("injectee27")
class Injectee(Component): class Injectee(Component):
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
@ -953,14 +1169,14 @@ class TestProvideCache:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=23 %} {% provide "my_provide" key="hi" another=23 %}
{% component "injectee" / %} {% component "injectee27" / %}
{% endprovide %} {% endprovide %}
""" """
self._assert_clear_cache() _assert_clear_cache()
template = Template(template_str) template = Template(template_str)
self._assert_clear_cache() _assert_clear_cache()
rendered = template.render(Context({})) rendered = template.render(Context({}))
@ -975,11 +1191,11 @@ class TestProvideCache:
</div> </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
# Cache should be cleared even if there is an error. # Cache should be cleared even if there is an error.
def test_provide_outside_component_with_error(self): def test_provide_outside_component_with_error(self):
@register("injectee") @register("injectee28")
class Injectee(Component): class Injectee(Component):
template = "" template = ""
@ -993,22 +1209,22 @@ class TestProvideCache:
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=24 %} {% provide "my_provide" key="hi" another=24 %}
{% component "injectee" / %} {% component "injectee28" / %}
{% endprovide %} {% endprovide %}
""" """
self._assert_clear_cache() _assert_clear_cache()
template = Template(template_str) template = Template(template_str)
self._assert_clear_cache() _assert_clear_cache()
with pytest.raises(ValueError, match=re.escape("Oops")): with pytest.raises(ValueError, match=re.escape("Oops")):
template.render(Context({})) template.render(Context({}))
self._assert_clear_cache() _assert_clear_cache()
def test_provide_inside_component(self): def test_provide_inside_component(self):
@register("injectee") @register("injectee29")
class Injectee(Component): class Injectee(Component):
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
@ -1027,11 +1243,11 @@ class TestProvideCache:
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=25 %} {% provide "my_provide" key="hi" another=25 %}
{% component "injectee" / %} {% component "injectee29" / %}
{% endprovide %} {% endprovide %}
""" """
self._assert_clear_cache() _assert_clear_cache()
rendered = Root.render() rendered = Root.render()
@ -1046,10 +1262,10 @@ class TestProvideCache:
</div> </div>
""", """,
) )
self._assert_clear_cache() _assert_clear_cache()
def test_provide_inside_component_with_error(self): def test_provide_inside_component_with_error(self):
@register("injectee") @register("injectee30")
class Injectee(Component): class Injectee(Component):
template = "" template = ""
@ -1065,13 +1281,13 @@ class TestProvideCache:
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=26 %} {% provide "my_provide" key="hi" another=26 %}
{% component "injectee" / %} {% component "injectee30" / %}
{% endprovide %} {% endprovide %}
""" """
self._assert_clear_cache() _assert_clear_cache()
with pytest.raises(ValueError, match=re.escape("Oops")): with pytest.raises(ValueError, match=re.escape("Oops")):
Root.render() Root.render()
self._assert_clear_cache() _assert_clear_cache()