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

@ -19,7 +19,7 @@ from typing import (
Union,
cast,
)
from weakref import ReferenceType, WeakValueDictionary, finalize
from weakref import ReferenceType, WeakValueDictionary, finalize, ref
from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse
@ -61,9 +61,10 @@ from django_components.perfutil.component import (
ComponentRenderer,
OnComponentRenderedResult,
component_context_cache,
component_instance_cache,
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.slots import (
Slot,
@ -102,9 +103,11 @@ COMP_ONLY_FLAG = "only"
if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType[Type["Component"]]]
CompHashMapping = WeakValueDictionary[str, Type["Component"]]
ComponentRef = ReferenceType["Component"]
else:
AllComponents = List[ReferenceType]
CompHashMapping = WeakValueDictionary
ComponentRef = ReferenceType
OnRenderGenerator = Generator[
@ -516,7 +519,7 @@ class ComponentMeta(ComponentMediaMeta):
# Internal data that are made available within the component's template
@dataclass
class ComponentContext:
component: "Component"
component: ComponentRef
component_path: List[str]
template_name: Optional[str]
default_slot: Optional[str]
@ -527,6 +530,12 @@ class ComponentContext:
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):
# #####################################
# PUBLIC API (Configurable by users)
@ -2314,6 +2323,9 @@ class Component(metaclass=ComponentMeta):
self.registry = default(registry, registry_)
self.node = node
# Run finalizer when component is garbage collected
finalize(self, on_component_garbage_collected, self.id)
extensions._init_component_instance(self)
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.
"""
return get_injected_context_var(self.name, self.context, key, default)
return get_injected_context_var(self.id, self.name, key, default)
@classmethod
def as_view(cls, **initkwargs: Any) -> ViewFn:
@ -3302,26 +3314,34 @@ class Component(metaclass=ComponentMeta):
node: Optional["ComponentNode"] = None,
) -> str:
component_name = _get_component_name(cls, registered_name)
render_id = _gen_component_id()
# Modify the error to display full component path (incl. slots)
with component_error_message([component_name]):
return cls._render_impl(
context=context,
args=args,
kwargs=kwargs,
slots=slots,
deps_strategy=deps_strategy,
request=request,
outer_context=outer_context,
# TODO_v2 - Remove `registered_name` and `registry`
registry=registry,
registered_name=registered_name,
node=node,
)
try:
return cls._render_impl(
render_id=render_id,
context=context,
args=args,
kwargs=kwargs,
slots=slots,
deps_strategy=deps_strategy,
request=request,
outer_context=outer_context,
# TODO_v2 - Remove `registered_name` and `registry`
registry=registry,
registered_name=registered_name,
node=node,
)
except Exception as e:
# Clean up if rendering fails
component_instance_cache.pop(render_id, None)
raise e from None
@classmethod
def _render_impl(
comp_cls,
render_id: str,
context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[Any] = None,
kwargs: Optional[Any] = None,
@ -3348,7 +3368,8 @@ class Component(metaclass=ComponentMeta):
if request is None:
_, parent_comp_ctx = _get_parent_component_context(context)
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)
@ -3371,8 +3392,6 @@ class Component(metaclass=ComponentMeta):
if not isinstance(context, (Context, RequestContext)):
context = RequestContext(request, context) if request else Context(context)
render_id = _gen_component_id()
component = comp_cls(
id=render_id,
args=args_list,
@ -3446,11 +3465,14 @@ class Component(metaclass=ComponentMeta):
)
# 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=component,
component=ref(component),
component_path=component_path,
# Template name is set only once we've resolved the component's Template instance.
template_name=None,
@ -3477,7 +3499,7 @@ class Component(metaclass=ComponentMeta):
# 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(
OnComponentDataContext(
@ -3589,12 +3611,25 @@ class Component(metaclass=ComponentMeta):
js_input_hash=js_input_hash,
)
# This is triggered when a component is rendered, but the component's parents
# may not have been rendered yet.
# `on_component_rendered` is triggered when a component is rendered.
# 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(
html: Optional[str],
error: Optional[Exception],
) -> 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:
# - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error
@ -3608,10 +3643,6 @@ class Component(metaclass=ComponentMeta):
error = new_error
html = None
# Remove component from caches
del component_context_cache[render_id]
unregister_provide_reference(render_id)
# Allow extensions to either:
# - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error
@ -3759,7 +3790,6 @@ class Component(metaclass=ComponentMeta):
def _call_data_methods(
self,
context: Context,
# TODO_V2 - Remove `raw_args` and `raw_kwargs` in v2
raw_args: List,
raw_kwargs: Dict,
@ -3779,11 +3809,11 @@ class Component(metaclass=ComponentMeta):
# TODO - Enable JS and CSS vars - expose, and document
# 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, {}))
# 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, {}))
# Validate outputs
@ -4005,7 +4035,9 @@ class ComponentNode(BaseNode):
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)
if parent_id is None:
return None, None

View file

@ -14,6 +14,7 @@ from typing import (
TypeVar,
Union,
)
from weakref import ref
import django.urls
from django.template import Context, Origin, Template
@ -255,18 +256,25 @@ class ExtensionComponentConfig:
component_class: Type["Component"]
"""The [`Component`](./api.md#django_components.Component) class that this extension is defined on."""
component: "Component"
"""
When a [`Component`](./api.md#django_components.Component) is instantiated,
also the nested extension classes (such as `Component.View`) are instantiated,
receiving the component instance as an argument.
@property
def component(self) -> "Component":
"""
When a [`Component`](./api.md#django_components.Component) is instantiated,
also the nested extension classes (such as `Component.View`) are instantiated,
receiving the component instance as an argument.
This attribute holds the owner [`Component`](./api.md#django_components.Component) instance
that this extension is defined on.
"""
This attribute holds the owner [`Component`](./api.md#django_components.Component) instance
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:
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

View file

@ -73,6 +73,9 @@ def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
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 isinstance(default_field, Field):
if default_field.default is not MISSING:

View file

@ -161,6 +161,9 @@ class ComponentView(ExtensionComponentConfig, View):
ComponentExtension.ComponentConfig.__init__(self, component)
View.__init__(self, **kwargs)
# TODO_v1 - Remove. Superseded by `component_cls`. This was used for backwards compatibility.
self.component = component
@property
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
if TYPE_CHECKING:
from django_components.component import ComponentContext, OnRenderGenerator
from django_components.component import Component, ComponentContext, OnRenderGenerator
OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
@ -30,6 +30,15 @@ OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
# is only a key to this dictionary.
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):
"""Queue item where a component is nested in another component."""

View file

@ -1,12 +1,16 @@
"""This module contains optimizations for the `{% provide %}` feature."""
from collections import defaultdict
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_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
# 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.
provide_cache: Dict[str, NamedTuple] = {}
# Keep track of how many components are referencing each provided data.
provide_references: Dict[str, Set[str]] = {}
# Given a `{% provide %}` instance, keep track of which components are referencing it.
# 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.
all_reference_ids: Set[str] = set()
# The opposite - Given a component, keep track of which `{% provide %}` instances it is referencing.
# Component -> ProvideID[]
# NOTE: We manually clean up the entries when components are garbage collected.
component_provides: Dict[str, Dict[str, str]] = defaultdict(dict)
@contextmanager
def managed_provide_cache(provide_id: str) -> Generator[None, None, None]:
all_reference_ids_before = all_reference_ids.copy()
def cache_cleanup() -> None:
# Lastly, remove provided data from the cache that was generated during this run,
# IF there are no more references to it.
if provide_id in provide_references and not provide_references[provide_id]:
provide_references.pop(provide_id)
provide_cache.pop(provide_id)
# Case: `{% provide %}` contained no components in its body.
# 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:
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
cache_cleanup()
# 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
# Cleanup
cache_cleanup()
# Cleanup on success
_cache_cleanup(provide_id)
def register_provide_reference(context: Context, reference_id: str) -> None:
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]:
provide_references.pop(provide_id)
provide_cache.pop(provide_id, None)
# Case: `{% provide %}` contained no components in its body.
# 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:
provide_cache.pop(provide_id)
# TODO - Once components can access their parents:
# Do NOT pass provide keys through components in isolated mode.
# Instead get parent's provide keys by getting the parent's id, `component.parent.id`
# and then accessing `component_provides[component.parent.id]`.
# The logic below would still remain, as that defines the `{% provide %}`
# instances defined INSIDE the parent component.
# And we would combine the two sources, and set that to `component_provides[component.id]`.
def register_provide_reference(context: Context, component: "Component") -> None:
# No `{% provide %}` among the ancestors, nothing to register to
if not provide_cache:
return
all_reference_ids.add(reference_id)
for key, provide_id in context.flatten().items():
# For all instances of `{% provide %}` that the current component is within,
# make note that this component has access to them.
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):
continue
if provide_id not in provide_references:
provide_references[provide_id] = set()
provide_references[provide_id].add(reference_id)
provide_id = cast("str", value)
provide_key = key.split(_INJECT_CONTEXT_KEY_PREFIX, 1)[1]
# 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:
# No registered references, nothing to unregister
if reference_id not in all_reference_ids:
def unregister_provide_reference(component_id: str) -> None:
# List of `{% provide %}` IDs that the component had access to.
component_provides_ids = component_provides.get(component_id)
if not component_provides_ids:
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.
if not provide_references[provide_id]:
provide_cache.pop(provide_id)
provide_references.pop(provide_id)
Unlinking the component at this point ensures that one can call `Component.inject()`
even after the component was rendered, as long as one keeps the reference to the component object.
"""
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.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
@ -102,8 +102,8 @@ class ProvideNode(BaseNode):
def get_injected_context_var(
component_id: str,
component_name: str,
context: Context,
key: str,
default: Optional[Any] = None,
) -> Any:
@ -111,15 +111,13 @@ def get_injected_context_var(
Retrieve a 'provided' field. The field MUST have been previously 'provided'
by the component's ancestors using the `{% provide %}` template tag.
"""
# NOTE: For simplicity, we keep the provided values directly on the context.
# This plays nicely with Django's Context, which behaves like a stack, so "newer"
# values overshadow the "older" ones.
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
# NOTE: `component_provides` is defaultdict. Use `.get()` to avoid making an empty dictionary.
providers = component_provides.get(component_id)
# Return provided value if found
if internal_key in context:
cache_key = context[internal_key]
return provide_cache[cache_key]
if providers and key in providers:
provide_id = providers[key]
return provide_cache[provide_id]
# If a default was given, return that
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(
context: Context,
key: str,
@ -161,8 +161,12 @@ def set_provided_context_var(
tuple_cls = NamedTuple("DepInject", fields) # type: ignore[misc]
payload = tuple_cls(**provided_kwargs)
# Instead of storing the provided data on the Context object, we store it
# in a separate dictionary, and we set only the key to the data on the Context.
# To allow the components nested inside `{% provide %}` to access the provided data,
# 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
provide_id = gen_id()
context[context_key] = provide_id

View file

@ -680,7 +680,11 @@ class SlotNode(BaseNode):
# Component info
component_id: str = context[_COMPONENT_CONTEXT_KEY]
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_path = component_ctx.component_path
is_dynamic_component = getattr(component, "_is_dynamic_component", False)
@ -828,7 +832,12 @@ class SlotNode(BaseNode):
if parent_index is not None:
ctx_id_with_fills = context.dicts[parent_index][_COMPONENT_CONTEXT_KEY]
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
trace_component_msg(