refactor: fix component recursion (#936)

This commit is contained in:
Juro Oravec 2025-02-01 17:19:21 +01:00 committed by GitHub
parent e105500350
commit 588053803d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1549 additions and 464 deletions

View file

@ -0,0 +1,31 @@
from typing import Any, Dict
from django_components import Component, register, types
@register("recursive")
class Recursive(Component):
def get(self, request):
import time
time_before = time.time()
output = self.render_to_response(
kwargs={
"depth": 0,
},
)
time_after = time.time()
print("TIME: ", time_after - time_before)
return output
def get_context_data(self, depth: int = 0) -> Dict[str, Any]:
return {"depth": depth + 1}
template: types.django_html = """
<div id="recursive">
depth: {{ depth }}
<hr/>
{% if depth <= 100 %}
{% component "recursive" depth=depth / %}
{% endif %}
</div>
"""

View file

@ -2,6 +2,7 @@ from components.calendar.calendar import Calendar, CalendarRelative
from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs
from components.greeting import Greeting from components.greeting import Greeting
from components.nested.calendar.calendar import CalendarNested from components.nested.calendar.calendar import CalendarNested
from components.recursive import Recursive
from django.urls import path from django.urls import path
urlpatterns = [ urlpatterns = [
@ -9,6 +10,7 @@ urlpatterns = [
path("calendar/", Calendar.as_view(), name="calendar"), path("calendar/", Calendar.as_view(), name="calendar"),
path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"), path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"),
path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"), path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"),
path("recursive/", Recursive.as_view(), name="recursive"),
path("fragment/base/alpine", FragmentBaseAlpine.as_view()), path("fragment/base/alpine", FragmentBaseAlpine.as_view()),
path("fragment/base/htmx", FragmentBaseHtmx.as_view()), path("fragment/base/htmx", FragmentBaseHtmx.as_view()),
path("fragment/base/js", FragmentBaseJs.as_view()), path("fragment/base/js", FragmentBaseJs.as_view()),

View file

@ -26,10 +26,10 @@ from typing import (
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
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
from django.template.base import NodeList, Parser, Template, TextNode, Token from django.template.base import NodeList, Origin, Parser, Template, TextNode, Token
from django.template.context import Context, RequestContext from django.template.context import Context, RequestContext
from django.template.loader import get_template from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
from django.test.signals import template_rendered from django.test.signals import template_rendered
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.views import View from django.views import View
@ -38,13 +38,7 @@ from django_components.app_settings import ContextBehavior
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_ from django_components.component_registry import registry as registry_
from django_components.context import ( from django_components.context import _COMPONENT_CONTEXT_KEY, make_isolated_context_copy
_COMPONENT_SLOT_CTX_CONTEXT_KEY,
_REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY,
get_injected_context_var,
make_isolated_context_copy,
)
from django_components.dependencies import ( from django_components.dependencies import (
RenderType, RenderType,
cache_component_css, cache_component_css,
@ -52,13 +46,17 @@ from django_components.dependencies import (
cache_component_js, cache_component_js,
cache_component_js_vars, cache_component_js_vars,
comp_hash_mapping, comp_hash_mapping,
postprocess_component_html, insert_component_dependencies_comment,
)
from django_components.dependencies import render_dependencies as _render_dependencies
from django_components.dependencies import (
set_component_attrs_for_js_and_css, set_component_attrs_for_js_and_css,
) )
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.perfutil.component import component_post_render from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference
from django_components.provide import get_injected_context_var
from django_components.slots import ( from django_components.slots import (
ComponentSlotContext,
Slot, Slot,
SlotContent, SlotContent,
SlotFunc, SlotFunc,
@ -71,8 +69,11 @@ from django_components.slots import (
resolve_fills, resolve_fills,
) )
from django_components.template import cached_template from django_components.template import cached_template
from django_components.util.context import snapshot_context
from django_components.util.django_monkeypatch import is_template_cls_patched from django_components.util.django_monkeypatch import is_template_cls_patched
from django_components.util.misc import gen_id, hash_comp_cls from django_components.util.exception import component_error_message
from django_components.util.logger import trace_component_msg
from django_components.util.misc import gen_id, get_import_path, hash_comp_cls
from django_components.util.template_tag import TagAttr from django_components.util.template_tag import TagAttr
from django_components.util.validation import validate_typed_dict, validate_typed_tuple from django_components.util.validation import validate_typed_dict, validate_typed_tuple
@ -99,7 +100,6 @@ CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
@dataclass(frozen=True) @dataclass(frozen=True)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]): class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
id: str
context: Context context: Context
args: ArgsType args: ArgsType
kwargs: KwargsType kwargs: KwargsType
@ -109,7 +109,8 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
@dataclass() @dataclass()
class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]): class MetadataItem(Generic[ArgsType, KwargsType, SlotsType]):
render_id: str
input: RenderInput[ArgsType, KwargsType, SlotsType] input: RenderInput[ArgsType, KwargsType, SlotsType]
is_filled: Optional[SlotIsFilled] is_filled: Optional[SlotIsFilled]
@ -218,6 +219,21 @@ class ComponentView(View, metaclass=ComponentViewMeta):
self.component = component self.component = component
# Internal data that are made available within the component's template
@dataclass
class ComponentContext:
component_name: str
component_id: str
component_class: Type["Component"]
component_path: List[str]
template_name: Optional[str]
is_dynamic_component: bool
default_slot: Optional[str]
fills: Dict[SlotName, Slot]
outer_context: Optional[Context]
registry: ComponentRegistry
class Component( class Component(
Generic[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType], Generic[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType],
metaclass=ComponentMeta, metaclass=ComponentMeta,
@ -580,9 +596,9 @@ class Component(
self.as_view = types.MethodType(self.__class__.as_view.__func__, self) # type: ignore self.as_view = types.MethodType(self.__class__.as_view.__func__, self) # type: ignore
self.registered_name: Optional[str] = registered_name self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context() self.outer_context: Optional[Context] = outer_context
self.registry = registry or registry_ self.registry = registry or registry_
self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque() self._metadata_stack: Deque[MetadataItem[ArgsType, KwargsType, SlotsType]] = deque()
# None == uninitialized, False == No types, Tuple == types # None == uninitialized, False == No types, Tuple == types
self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None
@ -590,6 +606,12 @@ class Component(
cls._class_hash = hash_comp_cls(cls) cls._class_hash = hash_comp_cls(cls)
comp_hash_mapping[cls._class_hash] = cls comp_hash_mapping[cls._class_hash] = cls
@contextmanager
def _with_metadata(self, item: MetadataItem) -> Generator[None, None, None]:
self._metadata_stack.append(item)
yield
self._metadata_stack.pop()
@property @property
def name(self) -> str: def name(self) -> str:
return self.registered_name or self.__class__.__name__ return self.registered_name or self.__class__.__name__
@ -623,7 +645,13 @@ class Component(
# Rendering 'ab3c4d' # Rendering 'ab3c4d'
``` ```
""" """
return self.input.id if not len(self._metadata_stack):
raise RuntimeError(
f"{self.name}: Tried to access Component's `id` attribute " "while outside of rendering execution"
)
ctx = self._metadata_stack[-1]
return ctx.render_id
@property @property
def input(self) -> RenderInput[ArgsType, KwargsType, SlotsType]: def input(self) -> RenderInput[ArgsType, KwargsType, SlotsType]:
@ -631,12 +659,12 @@ class Component(
Input holds the data (like arg, kwargs, slots) that were passed to Input holds the data (like arg, kwargs, slots) that were passed to
the current execution of the `render` method. the current execution of the `render` method.
""" """
if not len(self._render_stack): if not len(self._metadata_stack):
raise RuntimeError(f"{self.name}: Tried to access Component input while outside of rendering execution") raise RuntimeError(f"{self.name}: Tried to access Component input while outside of rendering execution")
# NOTE: Input is managed as a stack, so if `render` is called within another `render`, # NOTE: Input is managed as a stack, so if `render` is called within another `render`,
# the propertes below will return only the inner-most state. # the propertes below will return only the inner-most state.
return self._render_stack[-1].input return self._metadata_stack[-1].input
@property @property
def is_filled(self) -> SlotIsFilled: def is_filled(self) -> SlotIsFilled:
@ -646,13 +674,13 @@ class Component(
This attribute is available for use only within the template as `{{ component_vars.is_filled.slot_name }}`, This attribute is available for use only within the template as `{{ component_vars.is_filled.slot_name }}`,
and within `on_render_before` and `on_render_after` hooks. and within `on_render_before` and `on_render_after` hooks.
""" """
if not len(self._render_stack): if not len(self._metadata_stack):
raise RuntimeError( raise RuntimeError(
f"{self.name}: Tried to access Component's `is_filled` attribute " f"{self.name}: Tried to access Component's `is_filled` attribute "
"while outside of rendering execution" "while outside of rendering execution"
) )
ctx = self._render_stack[-1] ctx = self._metadata_stack[-1]
if ctx.is_filled is None: if ctx.is_filled is None:
raise RuntimeError( raise RuntimeError(
f"{self.name}: Tried to access Component's `is_filled` attribute " "before slots were resolved" f"{self.name}: Tried to access Component's `is_filled` attribute " "before slots were resolved"
@ -666,7 +694,7 @@ class Component(
# #
# This is important to keep in mind, because the implication is that we should # This is important to keep in mind, because the implication is that we should
# treat Templates AND their nodelists as IMMUTABLE. # treat Templates AND their nodelists as IMMUTABLE.
def _get_template(self, context: Context) -> Template: def _get_template(self, context: Context, component_id: str) -> Template:
template_name = self.get_template_name(context) template_name = self.get_template_name(context)
# TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1 # TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1
template_getter = getattr(self, "get_template_string", self.get_template) template_getter = getattr(self, "get_template_string", self.get_template)
@ -700,9 +728,11 @@ class Component(
if template_body is not None: if template_body is not None:
# We got template string, so we convert it to Template # We got template string, so we convert it to Template
if isinstance(template_body, str): if isinstance(template_body, str):
trace_component_msg("COMP_LOAD", component_name=self.name, component_id=component_id, slot_name=None)
template: Template = cached_template( template: Template = cached_template(
template_string=template_body, template_string=template_body,
name=self.template_file, name=self.template_file or self.name,
origin=Origin(name=self.template_file or get_import_path(self.__class__)),
) )
else: else:
template = template_body template = template_body
@ -929,36 +959,14 @@ class Component(
render_dependencies: bool = True, render_dependencies: bool = True,
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
) -> str: ) -> str:
try: # Modify the error to display full component path (incl. slots)
return self._render_impl( with component_error_message([self.name]):
context, args, kwargs, slots, escape_slots_content, type, render_dependencies, request try:
) return self._render_impl(
except Exception as err: context, args, kwargs, slots, escape_slots_content, type, render_dependencies, request
# Nicely format the error message to include the component path. )
# E.g. except Exception as err:
# ``` raise err from None
# KeyError: "An error occured while rendering components ProjectPage > ProjectLayoutTabbed >
# Layout > RenderContextProvider > Base > TabItem:
# Component 'TabItem' tried to inject a variable '_tab' before it was provided.
# ```
if not hasattr(err, "_components"):
err._components = [] # type: ignore[attr-defined]
components = getattr(err, "_components", [])
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
if not components:
orig_msg = err.args[0]
else:
orig_msg = err.args[0].split("\n", 1)[1]
components.insert(0, self.name)
comp_path = " > ".join(components)
prefix = f"An error occured while rendering components {comp_path}:\n"
err.args = (prefix + orig_msg,) # tuple of one
raise err
def _render_impl( def _render_impl(
self, self,
@ -990,28 +998,84 @@ class Component(
# Required for compatibility with Django's {% extends %} tag # Required for compatibility with Django's {% extends %} tag
# See https://github.com/django-components/django-components/pull/859 # See https://github.com/django-components/django-components/pull/859
context.render_context.push({BLOCK_CONTEXT_KEY: context.render_context.get(BLOCK_CONTEXT_KEY, {})}) context.render_context.push({BLOCK_CONTEXT_KEY: context.render_context.get(BLOCK_CONTEXT_KEY, BlockContext())})
# By adding the current input to the stack, we temporarily allow users # By adding the current input to the stack, we temporarily allow users
# to access the provided context, slots, etc. Also required so users can # to access the provided context, slots, etc. Also required so users can
# call `self.inject()` from within `get_context_data()`. # call `self.inject()` from within `get_context_data()`.
#
# This is handled as a stack, as users can potentially call `component.render()`
# from within component hooks. Thus, then they do so, `component.id` will be the ID
# of the deepest-most call to `component.render()`.
render_id = gen_id() render_id = gen_id()
self._render_stack.append( metadata = MetadataItem(
RenderStackItem( render_id=render_id,
input=RenderInput( input=RenderInput(
id=render_id, context=context,
context=context, slots=slots,
slots=slots, args=args,
args=args, kwargs=kwargs,
kwargs=kwargs, type=type,
type=type, render_dependencies=render_dependencies,
render_dependencies=render_dependencies,
),
is_filled=None,
), ),
is_filled=None,
) )
context_data = self.get_context_data(*args, **kwargs) # We pass down the components the info about the component's parent.
# This is used for correctly resolving slot fills, correct rendering order,
# or CSS scoping.
if context.get(_COMPONENT_CONTEXT_KEY, None):
parent_id = cast(str, context[_COMPONENT_CONTEXT_KEY])
parent_comp_ctx = component_context_cache[parent_id]
component_path = [*parent_comp_ctx.component_path, self.name]
else:
parent_id = None
component_path = [self.name]
trace_component_msg(
"COMP_PREP_START",
component_name=self.name,
component_id=render_id,
slot_name=None,
component_path=component_path,
extra=f"Received {len(args)} args, {len(kwargs)} kwargs, {len(slots)} slots",
)
# Register the component to provide
register_provide_reference(context, render_id)
# This is data that will be accessible (internally) from within the component's template
component_ctx = ComponentContext(
component_class=self.__class__,
component_name=self.name,
component_id=render_id,
component_path=component_path,
# Template name is set only once we've resolved the component's Template instance.
template_name=None,
fills=slots_untyped,
is_dynamic_component=getattr(self, "_is_dynamic_component", False),
# This field will be modified from within `SlotNodes.render()`:
# - The `default_slot` will be set to the first slot that has the `default` attribute set.
# If multiple slots have the `default` attribute set, yet have different name, then
# we will raise an error.
default_slot=None,
outer_context=snapshot_context(self.outer_context) if self.outer_context is not None else None,
registry=self.registry,
)
# Instead of passing the ComponentContext directly through the Context, the entry on the Context
# contains only a key to retrieve the ComponentContext from `component_context_cache`.
#
# This way, the flow is easier to debug. Because otherwise, if you try to print out
# or inspect the Context object, your screen is filled with the deeply nested ComponentContext objects.
component_context_cache[render_id] = component_ctx
# Allow to access component input and metadata like component ID from within these hook
with self._with_metadata(metadata):
context_data = self.get_context_data(*args, **kwargs)
# TODO - enable JS and CSS vars
# js_data = self.get_js_data(*args, **kwargs)
# css_data = self.get_css_data(*args, **kwargs)
self._validate_outputs(data=context_data) self._validate_outputs(data=context_data)
# Process Component's JS and CSS # Process Component's JS and CSS
@ -1023,40 +1087,19 @@ class Component(
cache_component_css(self.__class__) cache_component_css(self.__class__)
css_input_hash = cache_component_css_vars(self.__class__, css_data) if css_data else None css_input_hash = cache_component_css_vars(self.__class__, css_data) if css_data else None
with _prepare_template(self, context, context_data) as template: with _prepare_template(self, context, context_data, metadata) as template:
component_ctx.template_name = template.name
# For users, we expose boolean variables that they may check # For users, we expose boolean variables that they may check
# to see if given slot was filled, e.g.: # to see if given slot was filled, e.g.:
# `{% if variable > 8 and component_vars.is_filled.header %}` # `{% if variable > 8 and component_vars.is_filled.header %}`
is_filled = SlotIsFilled(slots_untyped) is_filled = SlotIsFilled(slots_untyped)
self._render_stack[-1].is_filled = is_filled metadata.is_filled = is_filled
# If any slot fills were defined within the template, we want to scope them
# to the CSS of the parent component. Thus we keep track of the parent component.
if context.get(_COMPONENT_SLOT_CTX_CONTEXT_KEY, None):
parent_comp_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
parent_id = parent_comp_ctx.component_id
else:
parent_id = None
component_slot_ctx = ComponentSlotContext(
component_name=self.name,
component_id=render_id,
template_name=template.name,
fills=slots_untyped,
is_dynamic_component=getattr(self, "_is_dynamic_component", False),
# This field will be modified from within `SlotNodes.render()`:
# - The `default_slot` will be set to the first slot that has the `default` attribute set.
# If multiple slots have the `default` attribute set, yet have different name, then
# we will raise an error.
default_slot=None,
)
with context.update( with context.update(
{ {
# Private context fields # Private context fields
_ROOT_CTX_CONTEXT_KEY: self.outer_context, _COMPONENT_CONTEXT_KEY: render_id,
_COMPONENT_SLOT_CTX_CONTEXT_KEY: component_slot_ctx,
_REGISTRY_CONTEXT_KEY: self.registry,
# NOTE: Public API for variables accessible from within a component's template # NOTE: Public API for variables accessible from within a component's template
# See https://github.com/django-components/django-components/issues/280#issuecomment-2081180940 # See https://github.com/django-components/django-components/issues/280#issuecomment-2081180940
"component_vars": ComponentVars( "component_vars": ComponentVars(
@ -1064,60 +1107,149 @@ class Component(
), ),
} }
): ):
self.on_render_before(context, template) # Make a "snapshot" of the context as it was at the time of the render call.
#
# Previously, we recursively called `Template.render()` as this point, but due to recursion
# this was limiting the number of nested components to only about 60 levels deep.
#
# Now, we make a flat copy, so that the context copy is static and doesn't change even if
# we leave the `with context.update` blocks.
#
# This makes it possible to render nested components with a queue, avoiding recursion limits.
context_snapshot = snapshot_context(context)
# Cleanup
context.render_context.pop()
# Instead of rendering component at the time we come across the `{% component %}` tag
# in the template, we defer rendering in order to scalably handle deeply nested components.
#
# See `_gen_component_renderer()` for more details.
deferred_render = self._gen_component_renderer(
render_id=render_id,
template=template,
context=context_snapshot,
metadata=metadata,
component_path=component_path,
css_input_hash=css_input_hash,
js_input_hash=js_input_hash,
css_scope_id=None, # TODO - Implement CSS scoping
)
# Remove component from caches
def on_component_rendered(component_id: str) -> None:
del component_context_cache[component_id] # type: ignore[arg-type]
unregister_provide_reference(component_id) # type: ignore[arg-type]
# After the component and all its children are rendered, we resolve
# all inserted HTML comments into <script> and <link> tags (if render_dependencies=True)
def on_html_rendered(html: str) -> str:
if render_dependencies:
html = _render_dependencies(html, type)
return html
trace_component_msg(
"COMP_PREP_END",
component_name=self.name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
return component_post_render(
renderer=deferred_render,
render_id=render_id,
component_name=self.name,
parent_id=parent_id,
on_component_rendered=on_component_rendered,
on_html_rendered=on_html_rendered,
)
# Creates a renderer function that will be called only once, when the component is to be rendered.
#
# By encapsulating components' output as render function, we can render components top-down,
# starting from root component, and moving down.
#
# This way, when it comes to rendering a particular component, we have already rendered its parent,
# and we KNOW if there were any HTML attributes that were passed from parent to children.
#
# Thus, the returned renderer function accepts the extra HTML attributes that were passed from parent,
# and returns the updated HTML content.
#
# Because the HTML attributes are all boolean (e.g. `data-djc-id-a1b3c4`), they are passed as a list.
#
# This whole setup makes it possible for multiple components to resolve to the same HTML element.
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
# IDs of both CompA and CompB.
# ```html
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
# ```
def _gen_component_renderer(
self,
render_id: str,
template: Template,
context: Context,
metadata: MetadataItem,
component_path: List[str],
css_input_hash: Optional[str],
js_input_hash: Optional[str],
css_scope_id: Optional[str],
) -> ComponentRenderer:
component = self
component_name = self.name
component_cls = self.__class__
def renderer(root_attributes: Optional[List[str]] = None) -> Tuple[str, Dict[str, List[str]]]:
trace_component_msg(
"COMP_RENDER_START",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
# Allow to access component input and metadata like component ID from within `on_render` hook
with component._with_metadata(metadata):
component.on_render_before(context, template)
# Emit signal that the template is about to be rendered # Emit signal that the template is about to be rendered
template_rendered.send(sender=self, template=self, context=context) template_rendered.send(sender=template, template=template, context=context)
# Get the component's HTML # Get the component's HTML
html_content = template.render(context) html_content = template.render(context)
# Allow to optionally override/modify the rendered content # Allow to optionally override/modify the rendered content
new_output = self.on_render_after(context, template, html_content) new_output = component.on_render_after(context, template, html_content)
html_content = new_output if new_output is not None else html_content html_content = new_output if new_output is not None else html_content
# After rendering is done, remove the current state from the stack, which means # Add necessary HTML attributes to work with JS and CSS variables
# properties like `self.context` will no longer return the current state.
self._render_stack.pop()
context.render_context.pop()
# Internal component HTML post-processing:
# - Add the HTML attributes to work with JS and CSS variables
# - Resolve component's JS / CSS into <script> and <link> (if render_dependencies=True)
#
# However, to ensure that we run an HTML parser only once over the HTML content,
# we have to wrap it in this callback. This callback runs only once we know whether
# there are any extra HTML attributes that should be applied to this component's root elements.
#
# This makes it possible for multiple components to resolve to the same HTML element.
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
# IDs of both CompA and CompB.
# ```html
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
# ```
def post_processor(root_attributes: Optional[List[str]] = None) -> Tuple[str, Dict[str, List[str]]]:
nonlocal html_content
updated_html, child_components = set_component_attrs_for_js_and_css( updated_html, child_components = set_component_attrs_for_js_and_css(
html_content=html_content, html_content=html_content,
component_id=render_id, component_id=render_id,
css_input_hash=css_input_hash, css_input_hash=css_input_hash,
css_scope_id=None, # TODO - Implement css_scope_id=css_scope_id,
root_attributes=root_attributes, root_attributes=root_attributes,
) )
updated_html = postprocess_component_html( # Prepend an HTML comment to instructs how and what JS and CSS scripts are associated with it.
component_cls=self.__class__, updated_html = insert_component_dependencies_comment(
updated_html,
component_cls=component_cls,
component_id=render_id, component_id=render_id,
html_content=updated_html,
css_input_hash=css_input_hash,
js_input_hash=js_input_hash, js_input_hash=js_input_hash,
type=type, css_input_hash=css_input_hash,
render_dependencies=render_dependencies, )
trace_component_msg(
"COMP_RENDER_END",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
) )
return updated_html, child_components return updated_html, child_components
return component_post_render(post_processor, render_id, parent_id) return renderer
def _normalize_slot_fills( def _normalize_slot_fills(
self, self,
@ -1129,12 +1261,41 @@ class Component(
# NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within # NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within
# the forloop, because the value the forloop variable points to changes with each loop iteration. # the forloop, because the value the forloop variable points to changes with each loop iteration.
def gen_escaped_content_func(content: SlotFunc) -> Slot: def gen_escaped_content_func(content: SlotFunc, slot_name: str) -> Slot:
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult: # Case: Already Slot, already escaped, and names tracing names assigned, so nothing to do.
rendered = content(ctx, slot_data, slot_ref) if isinstance(content, Slot) and content.escaped and content.slot_name and content.component_name:
return conditional_escape(rendered) if escape_content else rendered return content
# Otherwise, we create a new instance of Slot, whether `content` was already Slot or not.
# This is so that user can potentially define a single `Slot` and use it in multiple components.
if not isinstance(content, Slot) or not content.escaped:
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
rendered = content(ctx, slot_data, slot_ref)
return conditional_escape(rendered) if escape_content else rendered
content_func = cast(SlotFunc, content_fn)
else:
content_func = content.content_func
# Populate potentially missing fields so we can trace the component and slot
if isinstance(content, Slot):
used_component_name = content.component_name or self.name
used_slot_name = content.slot_name or slot_name
used_nodelist = content.nodelist
else:
used_component_name = self.name
used_slot_name = slot_name
used_nodelist = None
slot = Slot(
content_func=content_func,
component_name=used_component_name,
slot_name=used_slot_name,
nodelist=used_nodelist,
escaped=True,
)
slot = Slot(content_func=cast(SlotFunc, content_fn))
return slot return slot
for slot_name, content in fills.items(): for slot_name, content in fills.items():
@ -1142,13 +1303,14 @@ class Component(
continue continue
elif not callable(content): elif not callable(content):
slot = _nodelist_to_slot_render_func( slot = _nodelist_to_slot_render_func(
slot_name, component_name=self.name,
NodeList([TextNode(conditional_escape(content) if escape_content else content)]), slot_name=slot_name,
nodelist=NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
data_var=None, data_var=None,
default_var=None, default_var=None,
) )
else: else:
slot = gen_escaped_content_func(content) slot = gen_escaped_content_func(content, slot_name)
norm_fills[slot_name] = slot norm_fills[slot_name] = slot
@ -1460,13 +1622,15 @@ def _prepare_template(
component: Component, component: Component,
context: Context, context: Context,
context_data: Any, context_data: Any,
metadata: MetadataItem,
) -> Generator[Template, Any, None]: ) -> Generator[Template, Any, None]:
with context.update(context_data): with context.update(context_data):
# Associate the newly-created Context with a Template, otherwise we get # Associate the newly-created Context with a Template, otherwise we get
# an error when we try to use `{% include %}` tag inside the template? # an error when we try to use `{% include %}` tag inside the template?
# See https://github.com/django-components/django-components/issues/580 # See https://github.com/django-components/django-components/issues/580
# And https://github.com/django-components/django-components/issues/634 # And https://github.com/django-components/django-components/issues/634
template = component._get_template(context) with component._with_metadata(metadata):
template = component._get_template(context, component_id=metadata.render_id)
if not is_template_cls_patched(template): if not is_template_cls_patched(template):
raise RuntimeError( raise RuntimeError(

View file

@ -120,7 +120,7 @@ class DynamicComponent(Component):
# NOTE: The inner component is rendered in `on_render_before`, so that the `Context` object # NOTE: The inner component is rendered in `on_render_before`, so that the `Context` object
# is already configured as if the inner component was rendered inside the template. # is already configured as if the inner component was rendered inside the template.
# E.g. the `_COMPONENT_SLOT_CTX_CONTEXT_KEY` is set, which means that the child component # E.g. the `_COMPONENT_CONTEXT_KEY` is set, which means that the child component
# will know that it's a child of this component. # will know that it's a child of this component.
def on_render_before(self, context: Context, template: Template) -> Context: def on_render_before(self, context: Context, template: Template) -> Context:
comp_class = context["comp_class"] comp_class = context["comp_class"]

View file

@ -2,34 +2,29 @@
This file centralizes various ways we use Django's Context class This file centralizes various ways we use Django's Context class
pass data across components, nodes, slots, and contexts. pass data across components, nodes, slots, and contexts.
You can think of the Context as our storage system. Compared to `django_components/util/context.py`, this file contains the "business" logic
and the list of all internal keys that we define on the `Context` object.
""" """
from collections import namedtuple from django.template import Context
from typing import Any, Dict, Optional
from django.template import Context, TemplateSyntaxError from django_components.util.misc import get_last_index
from django_components.util.misc import find_last_index _COMPONENT_CONTEXT_KEY = "_DJC_COMPONENT_CTX"
_INJECT_CONTEXT_KEY_PREFIX = "_DJC_INJECT__"
_COMPONENT_SLOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_COMPONENT_SLOT_CTX"
_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
_REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY"
_INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__"
def make_isolated_context_copy(context: Context) -> Context: def make_isolated_context_copy(context: Context) -> Context:
context_copy = context.new() context_copy = context.new()
copy_forloop_context(context, context_copy) _copy_forloop_context(context, context_copy)
# Required for compatibility with Django's {% extends %} tag # Required for compatibility with Django's {% extends %} tag
# See https://github.com/django-components/django-components/pull/859 # See https://github.com/django-components/django-components/pull/859
context_copy.render_context = context.render_context context_copy.render_context = context.render_context
# Pass through our internal keys # Pass through our internal keys
context_copy[_REGISTRY_CONTEXT_KEY] = context.get(_REGISTRY_CONTEXT_KEY, None) if _COMPONENT_CONTEXT_KEY in context:
if _ROOT_CTX_CONTEXT_KEY in context: context_copy[_COMPONENT_CONTEXT_KEY] = context[_COMPONENT_CONTEXT_KEY]
context_copy[_ROOT_CTX_CONTEXT_KEY] = context[_ROOT_CTX_CONTEXT_KEY]
# Make inject/provide to work in isolated mode # Make inject/provide to work in isolated mode
context_keys = context.flatten().keys() context_keys = context.flatten().keys()
@ -40,76 +35,14 @@ def make_isolated_context_copy(context: Context) -> Context:
return context_copy return context_copy
def copy_forloop_context(from_context: Context, to_context: Context) -> None: def _copy_forloop_context(from_context: Context, to_context: Context) -> None:
"""Forward the info about the current loop""" """Forward the info about the current loop"""
# Note that the ForNode (which implements for loop behavior) does not # Note that the ForNode (which implements `{% for %}`) does not
# only add the `forloop` key, but also keys corresponding to the loop elements # only add the `forloop` key, but also keys corresponding to the loop elements
# So if the loop syntax is `{% for my_val in my_lists %}`, then ForNode also # So if the loop syntax is `{% for my_val in my_lists %}`, then ForNode also
# sets a `my_val` key. # sets a `my_val` key.
# For this reason, instead of copying individual keys, we copy the whole stack layer # For this reason, instead of copying individual keys, we copy the whole stack layer
# set by ForNode. # set by ForNode.
if "forloop" in from_context: if "forloop" in from_context:
forloop_dict_index = find_last_index(from_context.dicts, lambda d: "forloop" in d) forloop_dict_index = get_last_index(from_context.dicts, lambda d: "forloop" in d) or -1
to_context.update(from_context.dicts[forloop_dict_index]) to_context.update(from_context.dicts[forloop_dict_index])
def get_injected_context_var(
component_name: str,
context: Context,
key: str,
default: Optional[Any] = None,
) -> Any:
"""
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
# Return provided value if found
if internal_key in context:
return context[internal_key]
# If a default was given, return that
if default is not None:
return default
# Otherwise raise error
raise KeyError(
f"Component '{component_name}' tried to inject a variable '{key}' before it was provided."
f" To fix this, make sure that at least one ancestor of component '{component_name}' has"
f" the variable '{key}' in their 'provide' attribute."
)
def set_provided_context_var(
context: Context,
key: str,
provided_kwargs: Dict[str, Any],
) -> None:
"""
'Provide' given data under given key. In other words, this data can be retrieved
using `self.inject(key)` inside of `get_context_data()` method of components that
are nested inside the `{% provide %}` tag.
"""
# NOTE: We raise TemplateSyntaxError since this func should be called only from
# within template.
if not key:
raise TemplateSyntaxError(
"Provide tag received an empty string. Key must be non-empty and a valid identifier."
)
if not key.isidentifier():
raise TemplateSyntaxError(
"Provide tag received a non-identifier string. Key must be non-empty and a valid identifier."
)
# We turn the kwargs into a NamedTuple so that the object that's "provided"
# is immutable. This ensures that the data returned from `inject` will always
# have all the keys that were passed to the `provide` tag.
tpl_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
payload = tpl_cls(**provided_kwargs)
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
context[internal_key] = payload

View file

@ -321,7 +321,24 @@ def set_component_attrs_for_js_and_css(
return updated_html, child_components return updated_html, child_components
def _insert_component_comment( # NOTE: To better understand the next section, consider this:
#
# We define and cache the component's JS and CSS at the same time as
# when we render the HTML. However, the resulting HTML MAY OR MAY NOT
# be used in another component.
#
# IF the component's HTML IS used in another component, and the other
# component want to render the JS or CSS dependencies (e.g. inside <head>),
# then it's only at that point when we want to access the data about
# which JS and CSS scripts is the component's HTML associated with.
#
# This happens AFTER the rendering context, so there's no Context to rely on.
#
# Hence, we store the info about associated JS and CSS right in the HTML itself.
# As an HTML comment `<!-- -->`. Thus, the inner component can be used as many times
# and in different components, and they will all know to fetch also JS and CSS of the
# inner components.
def insert_component_dependencies_comment(
content: str, content: str,
# NOTE: We pass around the component CLASS, so the dependencies logic is not # NOTE: We pass around the component CLASS, so the dependencies logic is not
# dependent on ComponentRegistries # dependent on ComponentRegistries
@ -329,7 +346,7 @@ def _insert_component_comment(
component_id: str, component_id: str,
js_input_hash: Optional[str], js_input_hash: Optional[str],
css_input_hash: Optional[str], css_input_hash: Optional[str],
) -> str: ) -> SafeString:
""" """
Given some textual content, prepend it with a short string that Given some textual content, prepend it with a short string that
will be used by the ComponentDependencyMiddleware to collect all will be used by the ComponentDependencyMiddleware to collect all
@ -343,54 +360,6 @@ def _insert_component_comment(
return output return output
# Anything and everything that needs to be done with a Component's HTML
# script in order to support running JS and CSS per-instance.
def postprocess_component_html(
component_cls: Type["Component"],
component_id: str,
html_content: Union[str, SafeString],
css_input_hash: Optional[str],
js_input_hash: Optional[str],
type: RenderType,
render_dependencies: bool,
) -> Union[str, SafeString]:
is_safestring = isinstance(html_content, SafeString)
# NOTE: To better understand the next section, consider this:
#
# We define and cache the component's JS and CSS at the same time as
# when we render the HTML. However, the resulting HTML MAY OR MAY NOT
# be used in another component.
#
# IF the component's HTML IS used in another component, and the other
# component want to render the JS or CSS dependencies (e.g. inside <head>),
# then it's only at that point when we want to access the data about
# which JS and CSS scripts is the component's HTML associated with.
#
# This happens AFTER the rendering context, so there's no Context to rely on.
#
# Hence, we store the info about associated JS and CSS right in the HTML itself.
# As an HTML comment `<!-- -->`. Thus, the inner component can be used as many times
# and in different components, and they will all know to fetch also JS and CSS of the
# inner components.
# Mark the generated HTML so that we will know which JS and CSS
# scripts are associated with it.
output = _insert_component_comment(
html_content,
component_cls=component_cls,
component_id=component_id,
js_input_hash=js_input_hash,
css_input_hash=css_input_hash,
)
if render_dependencies:
output = _render_dependencies(output, type)
output = mark_safe(output) if is_safestring else output
return output
######################################################### #########################################################
# 3. Given a FINAL HTML composed of MANY components, # 3. Given a FINAL HTML composed of MANY components,
# process all the HTML dependency comments (created in # process all the HTML dependency comments (created in

View file

@ -6,7 +6,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast
from django.template import Context, Library from django.template import Context, Library
from django.template.base import Node, NodeList, Parser, Token from django.template.base import Node, NodeList, Parser, Token
from django_components.util.logger import trace_msg from django_components.util.logger import trace_node_msg
from django_components.util.misc import gen_id from django_components.util.misc import gen_id
from django_components.util.template_tag import ( from django_components.util.template_tag import (
TagAttr, TagAttr,
@ -89,7 +89,7 @@ class NodeMeta(type):
@functools.wraps(orig_render) @functools.wraps(orig_render)
def wrapper_render(self: "BaseNode", context: Context) -> str: def wrapper_render(self: "BaseNode", context: Context) -> str:
trace_msg("RENDR", self.tag, self.node_id) trace_node_msg("RENDER", self.tag, self.node_id)
resolved_params = resolve_params(self.tag, self.params, context) resolved_params = resolve_params(self.tag, self.params, context)
@ -196,7 +196,7 @@ class NodeMeta(type):
] + resolved_params_without_invalid_kwargs ] + resolved_params_without_invalid_kwargs
output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs) output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs)
trace_msg("RENDR", self.tag, self.node_id, msg="...Done!") trace_node_msg("RENDER", self.tag, self.node_id, msg="...Done!")
return output return output
# Wrap cls.render() so we resolve the args and kwargs and pass them to the # Wrap cls.render() so we resolve the args and kwargs and pass them to the
@ -357,7 +357,7 @@ class BaseNode(Node, metaclass=NodeMeta):
tag_id = gen_id() tag_id = gen_id()
tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token) tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
trace_msg("PARSE", cls.tag, tag_id) trace_node_msg("PARSE", cls.tag, tag_id)
body = tag.parse_body() body = tag.parse_body()
node = cls( node = cls(
@ -368,7 +368,7 @@ class BaseNode(Node, metaclass=NodeMeta):
**kwargs, **kwargs,
) )
trace_msg("PARSE", cls.tag, tag_id, "...Done!") trace_node_msg("PARSE", cls.tag, tag_id, "...Done!")
return node return node
@classmethod @classmethod

View file

@ -1,9 +1,40 @@
import re import re
from collections import deque from collections import deque
from typing import Callable, Deque, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Tuple
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_components.util.exception import component_error_message
if TYPE_CHECKING:
from django_components.component import ComponentContext
# When we're inside a component's template, we need to acccess some component data,
# as defined by `ComponentContext`. If we have nested components, then
# each nested component will point to the Context of its parent component
# via `outer_context`. This make is possible to access the correct data
# inside `{% fill %}` tags.
#
# Previously, `ComponentContext` was stored directly on the `Context` object, but
# this was problematic:
# - The need for creating a Context snapshot meant potentially a lot of copying
# - It was hard to trace and debug. Because if you printed the Context, it included the
# `ComponentContext` data, including the `outer_context` which contained another
# `ComponentContext` object, and so on.
#
# Thus, similarly to the data stored by `{% provide %}`, we store the actual
# `ComponentContext` data on a separate dictionary, and what's passed through the Context
# is only a key to this dictionary.
component_context_cache: Dict[str, "ComponentContext"] = {}
class PostRenderQueueItem(NamedTuple):
content_before_component: str
component_id: Optional[str]
parent_id: Optional[str]
component_name_path: List[str]
# Function that accepts a list of extra HTML attributes to be set on the component's root elements # Function that accepts a list of extra HTML attributes to be set on the component's root elements
# and returns the component's HTML content and a dictionary of child components' IDs # and returns the component's HTML content and a dictionary of child components' IDs
# and their root elements' HTML attributes. # and their root elements' HTML attributes.
@ -13,8 +44,8 @@ from django.utils.safestring import mark_safe
ComponentRenderer = Callable[[Optional[List[str]]], Tuple[str, Dict[str, List[str]]]] ComponentRenderer = Callable[[Optional[List[str]]], Tuple[str, Dict[str, List[str]]]]
# Render-time cache for component rendering # Render-time cache for component rendering
# See Component._post_render() # See component_post_render()
component_renderer_cache: Dict[str, ComponentRenderer] = {} component_renderer_cache: Dict[str, Tuple[ComponentRenderer, str]] = {}
child_component_attrs: Dict[str, List[str]] = {} child_component_attrs: Dict[str, List[str]] = {}
nested_comp_pattern = re.compile(r'<template [^>]*?djc-render-id="\w{6}"[^>]*?></template>') nested_comp_pattern = re.compile(r'<template [^>]*?djc-render-id="\w{6}"[^>]*?></template>')
@ -69,12 +100,15 @@ render_id_pattern = re.compile(r'djc-render-id="(?P<render_id>\w{6})"')
def component_post_render( def component_post_render(
renderer: ComponentRenderer, renderer: ComponentRenderer,
render_id: str, render_id: str,
component_name: str,
parent_id: Optional[str], parent_id: Optional[str],
on_component_rendered: Callable[[str], None],
on_html_rendered: Callable[[str], str],
) -> str: ) -> str:
# Instead of rendering the component's HTML content immediately, we store it, # Instead of rendering the component's HTML content immediately, we store it,
# so we can render the component only once we know if there are any HTML attributes # so we can render the component only once we know if there are any HTML attributes
# to be applied to the resulting HTML. # to be applied to the resulting HTML.
component_renderer_cache[render_id] = renderer component_renderer_cache[render_id] = (renderer, component_name)
if parent_id is not None: if parent_id is not None:
# Case: Nested component # Case: Nested component
@ -113,27 +147,44 @@ def component_post_render(
# 4. We split the content by placeholders, and put the pairs of (content, placeholder_id) into the queue, # 4. We split the content by placeholders, and put the pairs of (content, placeholder_id) into the queue,
# repeating this whole process until we've processed all nested components. # repeating this whole process until we've processed all nested components.
content_parts: List[str] = [] content_parts: List[str] = []
process_queue: Deque[Tuple[str, Optional[str]]] = deque() process_queue: Deque[PostRenderQueueItem] = deque()
process_queue.append(("", render_id)) process_queue.append(
PostRenderQueueItem(
content_before_component="",
component_id=render_id,
parent_id=None,
component_name_path=[],
)
)
while len(process_queue): while len(process_queue):
curr_content_before_component, curr_comp_id = process_queue.popleft() curr_item = process_queue.popleft()
# Process content before the component # Process content before the component
if curr_content_before_component: if curr_item.content_before_component:
content_parts.append(curr_content_before_component) content_parts.append(curr_item.content_before_component)
# The entry was only a remaining text, no more components to process, we're done # In this case we've reached the end of the component's HTML content, and there's
if curr_comp_id is None: # no more subcomponents to process.
if curr_item.component_id is None:
on_component_rendered(curr_item.parent_id) # type: ignore[arg-type]
continue continue
# Generate component's content, applying the extra HTML attributes set by the parent component # Generate component's content, applying the extra HTML attributes set by the parent component
curr_comp_renderer = component_renderer_cache.pop(curr_comp_id) curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(curr_item.component_id)
# NOTE: This may be undefined, because this is set only for components that # NOTE: This may be undefined, because this is set only for components that
# are also root elements in their parent's HTML # are also root elements in their parent's HTML
curr_comp_attrs = child_component_attrs.pop(curr_comp_id, None) curr_comp_attrs = child_component_attrs.pop(curr_item.component_id, None)
curr_comp_content, curr_child_component_attrs = curr_comp_renderer(curr_comp_attrs)
full_path = [*curr_item.component_name_path, curr_comp_name]
# This is where we actually render the component
#
# NOTE: [1:] because the root component will be yet again added to the error's
# `components` list in `_render` so we remove the first element from the path.
with component_error_message(full_path[1:]):
curr_comp_content, curr_child_component_attrs = curr_comp_renderer(curr_comp_attrs)
# Exclude the `data-djc-scope-...` attribute from being applied to the child component's HTML # Exclude the `data-djc-scope-...` attribute from being applied to the child component's HTML
for key in list(curr_child_component_attrs.keys()): for key in list(curr_child_component_attrs.keys()):
@ -144,7 +195,7 @@ def component_post_render(
# Process the component's content # Process the component's content
last_index = 0 last_index = 0
parts_to_process: List[Tuple[str, Optional[str]]] = [] parts_to_process: List[PostRenderQueueItem] = []
# Split component's content by placeholders, and put the pairs of (content, placeholder_id) into the queue # Split component's content by placeholders, and put the pairs of (content, placeholder_id) into the queue
for match in nested_comp_pattern.finditer(curr_comp_content): for match in nested_comp_pattern.finditer(curr_comp_content):
@ -157,13 +208,33 @@ def component_post_render(
if curr_child_id_match is None: if curr_child_id_match is None:
raise ValueError(f"No placeholder ID found in {comp_part}") raise ValueError(f"No placeholder ID found in {comp_part}")
curr_child_id = curr_child_id_match.group("render_id") curr_child_id = curr_child_id_match.group("render_id")
parts_to_process.append((part_before_component, curr_child_id)) parts_to_process.append(
PostRenderQueueItem(
content_before_component=part_before_component,
component_id=curr_child_id,
parent_id=curr_item.component_id,
component_name_path=full_path,
)
)
# Append any remaining text # Append any remaining text
if last_index < len(curr_comp_content): if last_index < len(curr_comp_content):
parts_to_process.append((curr_comp_content[last_index:], None)) parts_to_process.append(
PostRenderQueueItem(
content_before_component=curr_comp_content[last_index:],
# Setting component_id to None means that this is the last part of the component's HTML
# and we're done with this component
component_id=None,
parent_id=curr_item.component_id,
component_name_path=full_path,
)
)
process_queue.extendleft(reversed(parts_to_process)) process_queue.extendleft(reversed(parts_to_process))
# Lastly, join up all pieces of the component's HTML content
output = "".join(content_parts) output = "".join(content_parts)
output = on_html_rendered(output)
return mark_safe(output) return mark_safe(output)

View file

@ -0,0 +1,155 @@
"""
This module contains optimizations for the `{% provide %}` feature.
"""
from contextlib import contextmanager
from typing import Dict, Generator, NamedTuple, Set
from django.template import Context
from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
# Originally, when `{% provide %}` was used, the provided data was passed down
# through the Context object.
#
# However, this was hard to debug if the provided data was large (e.g. a long
# list of items).
#
# Instead, similarly to how the component internal data is passed through
# the Context object, there's now a level of indirection - the Context now stores
# only a key that points to the provided data.
#
# So when we inspect a Context layers, we may see something like this:
#
# ```py
# [
# {"False": False, "None": None, "True": True}, # All Contexts contain this
# {"custom_key": "custom_value"}, # Data passed to Context()
# {"_DJC_INJECT__my_provide": "a1b3c3"}, # Data provided by {% provide %}
# # containing only the key to "my_provide"
# ]
# ```
#
# Since the provided data is represented only as a key, we have to store the ACTUAL
# data somewhere. Thus, we store it in a separate dictionary.
#
# So when one calls `Component.inject(key)`, we use the key to look up the actual data
# in the dictionary and return that.
#
# This approach has several benefits:
# - Debugging: One needs to only follow the IDs to trace the flow of data.
# - Debugging: All provided data is stored in a separate dictionary, so it's easy to
# see what data is provided.
# - Perf: The Context object is copied each time we call `Component.render()`, to have a "snapshot"
# of the context, in order to defer the rendering. Passing around only the key instead
# of actual value avoids potentially copying the provided data. This also keeps the source of truth
# unambiguous.
# - Tests: It's easier to test the provided data, as we can just modify the dictionary directly.
#
# However, there is a price to pay for this:
# - Manual memory management - Because the data is stored in a separate dictionary, we now need to
# keep track of when to delete the entries.
#
# The challenge with this manual memory management is that:
# 1. Component rendering is deferred, so components are rendered AFTER we finish `Template.render()`.
# 2. For the deferred rendering, we copy the Context object.
#
# This means that:
# 1. We can't rely on simply reaching the end of `Template.render()` to delete the provided data.
# 2. We can't rely on the Context object being deleted to delete the provided data.
#
# So we need to manually delete the provided data when we know it's no longer needed.
#
# Thus, the strategy is to count references to the provided data:
# 1. When `{% provide %}` is used, it adds a key to the context.
# 2. When we come across `{% component %}` that is within the `{% provide %}` tags,
# the component will see the provide's key and the component will register itself as a "child" of
# the `{% provide %}` tag at `Component.render()`.
# 3. Once the component's deferred rendering takes place and finishes, the component makes a call
# to unregister itself from any "subscribed" provided data.
# 4. While unsubscribing, if we see that there are no more children subscribed to the provided data,
# we can finally delete the provided data from the cache.
#
# However, this leaves open the edge case of when `{% provide %}` contains NO components.
# In such case, we check if there are any subscribed components after rendering the contents
# of `{% provide %}`. If there are NONE, we delete the provided data.
# Similarly to ComponentContext instances, we store the actual Provided data
# 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]] = {}
# Keep track of all the listeners that are referencing any provided data.
all_reference_ids: Set[str] = set()
@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()
# Forward the error
raise e from None
# Cleanup
cache_cleanup()
def register_provide_reference(context: Context, reference_id: str) -> 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():
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)
def unregister_provide_reference(reference_id: str) -> None:
# No registered references, nothing to unregister
if reference_id not in all_reference_ids:
return
all_reference_ids.remove(reference_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)
# 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)

View file

@ -1,10 +1,13 @@
from typing import Any from collections import namedtuple
from typing import Any, Dict, Optional
from django.template import Context from django.template import Context, TemplateSyntaxError
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django_components.context import set_provided_context_var 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.util.misc import gen_id
class ProvideNode(BaseNode): class ProvideNode(BaseNode):
@ -87,8 +90,79 @@ class ProvideNode(BaseNode):
# add the provided kwargs into the Context. # add the provided kwargs into the Context.
with context.update({}): with context.update({}):
# "Provide" the data to child nodes # "Provide" the data to child nodes
set_provided_context_var(context, name, kwargs) provide_id = set_provided_context_var(context, name, kwargs)
output = self.nodelist.render(context) # `managed_provide_cache` will remove the cache entry at the end if no components reference it.
with managed_provide_cache(provide_id):
output = self.nodelist.render(context)
return output return output
def get_injected_context_var(
component_name: str,
context: Context,
key: str,
default: Optional[Any] = None,
) -> Any:
"""
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
# Return provided value if found
if internal_key in context:
cache_key = context[internal_key]
return provide_cache[cache_key]
# If a default was given, return that
if default is not None:
return default
# Otherwise raise error
raise KeyError(
f"Component '{component_name}' tried to inject a variable '{key}' before it was provided."
f" To fix this, make sure that at least one ancestor of component '{component_name}' has"
f" the variable '{key}' in their 'provide' attribute."
)
def set_provided_context_var(
context: Context,
key: str,
provided_kwargs: Dict[str, Any],
) -> str:
"""
'Provide' given data under given key. In other words, this data can be retrieved
using `self.inject(key)` inside of `get_context_data()` method of components that
are nested inside the `{% provide %}` tag.
"""
# NOTE: We raise TemplateSyntaxError since this func should be called only from
# within template.
if not key:
raise TemplateSyntaxError(
"Provide tag received an empty string. Key must be non-empty and a valid identifier."
)
if not key.isidentifier():
raise TemplateSyntaxError(
"Provide tag received a non-identifier string. Key must be non-empty and a valid identifier."
)
# We turn the kwargs into a NamedTuple so that the object that's "provided"
# is immutable. This ensures that the data returned from `inject` will always
# have all the keys that were passed to the `provide` tag.
tpl_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
payload = tpl_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.
context_key = _INJECT_CONTEXT_KEY_PREFIX + key
provide_id = gen_id()
context[context_key] = provide_id
provide_cache[provide_id] = payload
return provide_id

View file

@ -19,23 +19,21 @@ from typing import (
runtime_checkable, runtime_checkable,
) )
from django.template import Context from django.template import Context, Template
from django.template.base import NodeList, TextNode from django.template.base import NodeList, TextNode
from django.template.exceptions import TemplateSyntaxError from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import ContextBehavior from django_components.app_settings import ContextBehavior
from django_components.context import ( from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX
_COMPONENT_SLOT_CTX_CONTEXT_KEY,
_INJECT_CONTEXT_KEY_PREFIX,
_REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY,
)
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.util.misc import get_last_index, is_identifier from django_components.perfutil.component import component_context_cache
from django_components.util.exception import add_slot_to_error_message
from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_index, get_last_index, is_identifier
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry from django_components.component import ComponentContext
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True) TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
@ -65,6 +63,16 @@ class Slot(Generic[TSlotData]):
"""This class holds the slot content function along with related metadata.""" """This class holds the slot content function along with related metadata."""
content_func: SlotFunc[TSlotData] content_func: SlotFunc[TSlotData]
escaped: bool = False
"""Whether the slot content has been escaped."""
# Following fields are only for debugging
component_name: Optional[str] = None
"""Name of the component that originally defined or accepted this slot fill."""
slot_name: Optional[str] = None
"""Name of the slot that originally defined or accepted this slot fill."""
nodelist: Optional[NodeList] = None
"""Nodelist of the slot content."""
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not callable(self.content_func): if not callable(self.content_func):
@ -85,6 +93,11 @@ class Slot(Generic[TSlotData]):
def do_not_call_in_templates(self) -> bool: def do_not_call_in_templates(self) -> bool:
return True return True
def __repr__(self) -> str:
comp_name = f"'{self.component_name}'" if self.component_name else None
slot_name = f"'{self.slot_name}'" if self.slot_name else None
return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>"
# Internal type aliases # Internal type aliases
SlotName = str SlotName = str
@ -137,16 +150,6 @@ class SlotIsFilled(dict):
return False return False
@dataclass
class ComponentSlotContext:
component_name: str
component_id: str
template_name: str
is_dynamic_component: bool
default_slot: Optional[str]
fills: Dict[SlotName, Slot]
class SlotNode(BaseNode): class SlotNode(BaseNode):
""" """
Slot tag marks a place inside a component where content can be inserted Slot tag marks a place inside a component where content can be inserted
@ -303,18 +306,31 @@ class SlotNode(BaseNode):
if _is_extracting_fill(context): if _is_extracting_fill(context):
return "" return ""
if _COMPONENT_SLOT_CTX_CONTEXT_KEY not in context or not context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]: if _COMPONENT_CONTEXT_KEY not in context or not context[_COMPONENT_CONTEXT_KEY]:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Encountered a SlotNode outside of a ComponentNode context. " "Encountered a SlotNode outside of a Component context. "
"Make sure that all {% slot %} tags are nested within {% component %} tags.\n" "Make sure that all {% slot %} tags are nested within {% component %} tags.\n"
f"SlotNode: {self.__repr__()}" f"SlotNode: {self.__repr__()}"
) )
component_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY] component_id: str = context[_COMPONENT_CONTEXT_KEY]
component_ctx = component_context_cache[component_id]
component_name = component_ctx.component_name
component_path = component_ctx.component_path
slot_fills = component_ctx.fills
slot_name = name slot_name = name
is_default = self.flags[SLOT_DEFAULT_KEYWORD] is_default = self.flags[SLOT_DEFAULT_KEYWORD]
is_required = self.flags[SLOT_REQUIRED_KEYWORD] is_required = self.flags[SLOT_REQUIRED_KEYWORD]
trace_component_msg(
"RENDER_SLOT_START",
component_name=component_name,
component_id=component_id,
slot_name=slot_name,
component_path=component_path,
slot_fills=slot_fills,
)
# Check for errors # Check for errors
if is_default and not component_ctx.is_dynamic_component: if is_default and not component_ctx.is_dynamic_component:
# Allow one slot to be marked as 'default', or multiple slots but with # Allow one slot to be marked as 'default', or multiple slots but with
@ -325,7 +341,7 @@ class SlotNode(BaseNode):
"Only one component slot may be marked as 'default', " "Only one component slot may be marked as 'default', "
f"found '{default_slot_name}' and '{slot_name}'. " f"found '{default_slot_name}' and '{slot_name}'. "
f"To fix, check template '{component_ctx.template_name}' " f"To fix, check template '{component_ctx.template_name}' "
f"of component '{component_ctx.component_name}'." f"of component '{component_name}'."
) )
if default_slot_name is None: if default_slot_name is None:
@ -335,23 +351,88 @@ class SlotNode(BaseNode):
# by specifying the fill both by explicit slot name and implicitly as 'default'. # by specifying the fill both by explicit slot name and implicitly as 'default'.
if ( if (
slot_name != DEFAULT_SLOT_KEY slot_name != DEFAULT_SLOT_KEY
and component_ctx.fills.get(slot_name, False) and slot_fills.get(slot_name, False)
and component_ctx.fills.get(DEFAULT_SLOT_KEY, False) and slot_fills.get(DEFAULT_SLOT_KEY, False)
): ):
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot '{slot_name}' of component '{component_ctx.component_name}' was filled twice: " f"Slot '{slot_name}' of component '{component_name}' was filled twice: "
"once explicitly and once implicitly as 'default'." "once explicitly and once implicitly as 'default'."
) )
# If slot is marked as 'default', we use the name 'default' for the fill, # If slot is marked as 'default', we use the name 'default' for the fill,
# IF SUCH FILL EXISTS. Otherwise, we use the slot's name. # IF SUCH FILL EXISTS. Otherwise, we use the slot's name.
if is_default and DEFAULT_SLOT_KEY in component_ctx.fills: if is_default and DEFAULT_SLOT_KEY in slot_fills:
fill_name = DEFAULT_SLOT_KEY fill_name = DEFAULT_SLOT_KEY
else: else:
fill_name = slot_name fill_name = slot_name
if fill_name in component_ctx.fills: # NOTE: TBH not sure why this happens. But there's an edge case when:
slot_fill_fn = component_ctx.fills[fill_name] # 1. Using the "django" context behavior
# 2. AND the slot fill is defined in the root template
#
# Then `ctx_with_fills.fills` does NOT contain any fills (`{% fill %}`). So in this case,
# we need to use a different strategy to find the fills Context layer that contains the fills.
#
# ------------------------------------------------------------------------------------------
#
# Context:
# When we render slot fills, we want to use the context as was OUTSIDE of the component.
# E.g. In this example, we want to render `{{ item.name }}` inside the `{% fill %}` tag:
#
# ```django
# {% for item in items %}
# {% component "my_component" %}
# {% fill "my_slot" %}
# {{ item.name }}
# {% endfill %}
# {% endcomponent %}
# {% endfor %}
# ```
#
# In this case, we need to find the context that was used to render the component,
# and use the fills from that context.
if (
component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO
and component_ctx.outer_context is None
):
# When we have nested components with fills, the context layers are added in
# the following order:
# Page -> SubComponent -> NestedComponent -> ChildComponent
#
# Then, if ChildComponent defines a `{% slot %}` tag, its `{% fill %}` will be defined
# within the context of its parent, NestedComponent. The context is updated as follows:
# Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent
#
# And if, WITHIN THAT `{% fill %}`, there is another `{% slot %}` tag, its `{% fill %}`
# will be defined within the context of its parent, SubComponent. The context becomes:
# Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent -> SubComponent
#
# If that top-level `{% fill %}` defines a `{% component %}`, and the component accepts a `{% fill %}`,
# we'd go one down inside the component, and then one up outside of it inside the `{% fill %}`.
# Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent -> SubComponent ->
# -> CompA -> SubComponent
#
# So, given a context of nested components like this, we need to find which component was parent
# of the current component, and use the fills from that component.
#
# In the Context, the components are identified by their ID, NOT by their name, as in the example above.
# So the path is more like this:
# ax3c89 -> hui3q2 -> kok92a -> a1b2c3 -> kok92a -> hui3q2 -> d4e5f6 -> hui3q2
#
# We're at the right-most `hui3q2`, and we want to find `ax3c89`.
# To achieve that, we first find the left-most `hui3q2`, and then find the `ax3c89`
# in the list of dicts before it.
curr_index = get_index(
context.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d and d[_COMPONENT_CONTEXT_KEY] == component_id
)
parent_index = get_last_index(context.dicts[:curr_index], lambda d: _COMPONENT_CONTEXT_KEY in d)
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.fills
if fill_name in slot_fills:
slot_fill_fn = slot_fills[fill_name]
slot_fill = SlotFill( slot_fill = SlotFill(
name=slot_name, name=slot_name,
is_filled=True, is_filled=True,
@ -363,6 +444,7 @@ class SlotNode(BaseNode):
name=slot_name, name=slot_name,
is_filled=False, is_filled=False,
slot=_nodelist_to_slot_render_func( slot=_nodelist_to_slot_render_func(
component_name=component_name,
slot_name=slot_name, slot_name=slot_name,
nodelist=self.nodelist, nodelist=self.nodelist,
data_var=None, data_var=None,
@ -385,7 +467,7 @@ class SlotNode(BaseNode):
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), " f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template.'" f"yet no fill is provided. Check template.'"
) )
fill_names = list(component_ctx.fills.keys()) fill_names = list(slot_fills.keys())
if fill_names: if fill_names:
fuzzy_fill_name_matches = difflib.get_close_matches(fill_name, fill_names, n=1, cutoff=0.7) fuzzy_fill_name_matches = difflib.get_close_matches(fill_name, fill_names, n=1, cutoff=0.7)
if fuzzy_fill_name_matches: if fuzzy_fill_name_matches:
@ -404,8 +486,8 @@ class SlotNode(BaseNode):
# then we will enter an endless loop. E.g.: # then we will enter an endless loop. E.g.:
# ```django # ```django
# {% component "mycomponent" %} # {% component "mycomponent" %}
# {% slot "content" %} # {% slot "content" %} <--,
# {% fill "content" %} # {% fill "content" %} ---'
# ... # ...
# {% endfill %} # {% endfill %}
# {% endslot %} # {% endslot %}
@ -413,14 +495,18 @@ class SlotNode(BaseNode):
# ``` # ```
# #
# Hence, even in the "django" mode, we MUST use slots of the context of the parent component. # Hence, even in the "django" mode, we MUST use slots of the context of the parent component.
all_ctxs = [d for d in context.dicts if _COMPONENT_SLOT_CTX_CONTEXT_KEY in d] if (
if len(all_ctxs) > 1: component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO
second_to_last_ctx = all_ctxs[-2] and component_ctx.outer_context is not None
extra_context[_COMPONENT_SLOT_CTX_CONTEXT_KEY] = second_to_last_ctx[_COMPONENT_SLOT_CTX_CONTEXT_KEY] and _COMPONENT_CONTEXT_KEY in component_ctx.outer_context
):
extra_context[_COMPONENT_CONTEXT_KEY] = component_ctx.outer_context[_COMPONENT_CONTEXT_KEY]
# This ensures that `component_vars.is_filled`is accessible in the fill
extra_context["component_vars"] = component_ctx.outer_context["component_vars"]
# Irrespective of which context we use ("root" context or the one passed to this # Irrespective of which context we use ("root" context or the one passed to this
# render function), pass down the keys used by inject/provide feature. This makes it # render function), pass down the keys used by inject/provide feature. This makes it
# possible to pass the provided values down the slots, e.g.: # possible to pass the provided values down through slots, e.g.:
# {% provide "abc" val=123 %} # {% provide "abc" val=123 %}
# {% slot "content" %}{% endslot %} # {% slot "content" %}{% endslot %}
# {% endprovide %} # {% endprovide %}
@ -432,22 +518,42 @@ class SlotNode(BaseNode):
# For the user-provided slot fill, we want to use the context of where the slot # For the user-provided slot fill, we want to use the context of where the slot
# came from (or current context if configured so) # came from (or current context if configured so)
used_ctx = self._resolve_slot_context(context, slot_fill) used_ctx = self._resolve_slot_context(context, slot_fill, component_ctx)
with used_ctx.update(extra_context): with used_ctx.update(extra_context):
# Required for compatibility with Django's {% extends %} tag # Required for compatibility with Django's {% extends %} tag
# This makes sure that the render context used outside of a component # This makes sure that the render context used outside of a component
# is the same as the one used inside the slot. # is the same as the one used inside the slot.
# See https://github.com/django-components/django-components/pull/859 # See https://github.com/django-components/django-components/pull/859
render_ctx_layer = used_ctx.render_context.dicts[-2] if len(used_ctx.render_context.dicts) > 1 else {} if len(used_ctx.render_context.dicts) > 1 and "block_context" in used_ctx.render_context.dicts[-2]:
render_ctx_layer = used_ctx.render_context.dicts[-2]
else:
# Otherwise we simply re-use the last layer, so that following logic uses `with` in either case
render_ctx_layer = used_ctx.render_context.dicts[-1]
with used_ctx.render_context.push(render_ctx_layer): with used_ctx.render_context.push(render_ctx_layer):
# Render slot as a function with add_slot_to_error_message(component_name, slot_name):
# NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables, # Render slot as a function
# the render function ALWAYS receives them. # NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
output = slot_fill.slot(used_ctx, kwargs, slot_ref) # the render function ALWAYS receives them.
output = slot_fill.slot(used_ctx, kwargs, slot_ref)
trace_component_msg(
"RENDER_SLOT_END",
component_name=component_name,
component_id=component_id,
slot_name=slot_name,
component_path=component_path,
slot_fills=slot_fills,
)
return output return output
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context: def _resolve_slot_context(
self,
context: Context,
slot_fill: "SlotFill",
component_ctx: "ComponentContext",
) -> Context:
"""Prepare the context used in a slot fill based on the settings.""" """Prepare the context used in a slot fill based on the settings."""
# If slot is NOT filled, we use the slot's default AKA content between # If slot is NOT filled, we use the slot's default AKA content between
# the `{% slot %}` tags. These should be evaluated as if the `{% slot %}` # the `{% slot %}` tags. These should be evaluated as if the `{% slot %}`
@ -455,13 +561,14 @@ class SlotNode(BaseNode):
if not slot_fill.is_filled: if not slot_fill.is_filled:
return context return context
registry: "ComponentRegistry" = context[_REGISTRY_CONTEXT_KEY] registry_settings = component_ctx.registry.settings
if registry.settings.context_behavior == ContextBehavior.DJANGO: if registry_settings.context_behavior == ContextBehavior.DJANGO:
return context return context
elif registry.settings.context_behavior == ContextBehavior.ISOLATED: elif registry_settings.context_behavior == ContextBehavior.ISOLATED:
return context[_ROOT_CTX_CONTEXT_KEY] outer_context = component_ctx.outer_context
return outer_context if outer_context is not None else Context()
else: else:
raise ValueError(f"Unknown value for context_behavior: '{registry.settings.context_behavior}'") raise ValueError(f"Unknown value for context_behavior: '{registry_settings.context_behavior}'")
class FillNode(BaseNode): class FillNode(BaseNode):
@ -760,8 +867,9 @@ def resolve_fills(
if not nodelist_is_empty: if not nodelist_is_empty:
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot_render_func( slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot_render_func(
DEFAULT_SLOT_KEY, component_name=component_name,
nodelist, slot_name=None, # Will be populated later
nodelist=nodelist,
data_var=None, data_var=None,
default_var=None, default_var=None,
) )
@ -772,6 +880,7 @@ def resolve_fills(
# This is different from the default slot, where we ignore empty content. # This is different from the default slot, where we ignore empty content.
for fill in maybe_fills: for fill in maybe_fills:
slots[fill.name] = _nodelist_to_slot_render_func( slots[fill.name] = _nodelist_to_slot_render_func(
component_name=component_name,
slot_name=fill.name, slot_name=fill.name,
nodelist=fill.fill.nodelist, nodelist=fill.fill.nodelist,
data_var=fill.data_var, data_var=fill.data_var,
@ -843,7 +952,8 @@ def _escape_slot_name(name: str) -> str:
def _nodelist_to_slot_render_func( def _nodelist_to_slot_render_func(
slot_name: str, component_name: str,
slot_name: Optional[str],
nodelist: NodeList, nodelist: NodeList,
data_var: Optional[str] = None, data_var: Optional[str] = None,
default_var: Optional[str] = None, default_var: Optional[str] = None,
@ -861,6 +971,13 @@ def _nodelist_to_slot_render_func(
f"Slot default alias in fill '{slot_name}' must be a valid identifier. Got '{default_var}'" f"Slot default alias in fill '{slot_name}' must be a valid identifier. Got '{default_var}'"
) )
# We use Template.render() to render the nodelist, so that Django correctly sets up
# and binds the context.
template = Template("")
template.nodelist = nodelist
# This allows the template to access current RenderContext layer.
template._djc_is_component_nested = True
def render_func(ctx: Context, slot_data: Dict[str, Any], slot_ref: SlotRef) -> SlotResult: def render_func(ctx: Context, slot_data: Dict[str, Any], slot_ref: SlotRef) -> SlotResult:
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs # Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs
# are made available through a variable name that was set on the `{% fill %}` # are made available through a variable name that was set on the `{% fill %}`
@ -887,34 +1004,43 @@ def _nodelist_to_slot_render_func(
# #
# Thus, when we get here and `extra_context` is not None, it means that the component # Thus, when we get here and `extra_context` is not None, it means that the component
# is being rendered from within the template. And so we know that we're inside `Component._render()`. # is being rendered from within the template. And so we know that we're inside `Component._render()`.
# And that means that the context MUST contain our internal context keys like `_ROOT_CTX_CONTEXT_KEY`. # And that means that the context MUST contain our internal context keys like `_COMPONENT_CONTEXT_KEY`.
# #
# And so we want to put the `extra_context` into the same layer that contains `_ROOT_CTX_CONTEXT_KEY`. # And so we want to put the `extra_context` into the same layer that contains `_COMPONENT_CONTEXT_KEY`.
# #
# HOWEVER, the layer with `_ROOT_CTX_CONTEXT_KEY` also contains user-defined data from `get_context_data()`. # HOWEVER, the layer with `_COMPONENT_CONTEXT_KEY` also contains user-defined data from `get_context_data()`.
# Data from `get_context_data()` should take precedence over `extra_context`. So we have to insert # Data from `get_context_data()` should take precedence over `extra_context`. So we have to insert
# the forloop variables BEFORE that. # the forloop variables BEFORE that.
index_of_last_component_layer = get_last_index(ctx.dicts, lambda d: _ROOT_CTX_CONTEXT_KEY in d) index_of_last_component_layer = get_last_index(ctx.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d)
if index_of_last_component_layer is None: if index_of_last_component_layer is None:
index_of_last_component_layer = 0 index_of_last_component_layer = 0
# TODO: Currently there's one more layer before the `_ROOT_CTX_CONTEXT_KEY` layer, which is # TODO: Currently there's one more layer before the `_COMPONENT_CONTEXT_KEY` layer, which is
# pushed in `_prepare_template()` in `component.py`. # pushed in `_prepare_template()` in `component.py`.
# That layer should be removed when `Component.get_template()` is removed, after which # That layer should be removed when `Component.get_template()` is removed, after which
# the following line can be removed. # the following line can be removed.
index_of_last_component_layer -= 1 index_of_last_component_layer -= 1
# Insert the `extra_context` into the correct layer of the context stack # Insert the `extra_context` layer BEFORE the layer that defines the variables from get_context_data.
# Thus, get_context_data will overshadow these on conflict.
ctx.dicts.insert(index_of_last_component_layer, extra_context or {}) ctx.dicts.insert(index_of_last_component_layer, extra_context or {})
rendered = nodelist.render(ctx) trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name)
rendered = template.render(ctx)
# After the rendering is done, remove the `extra_context` from the context stack # After the rendering is done, remove the `extra_context` from the context stack
ctx.dicts.pop(index_of_last_component_layer) ctx.dicts.pop(index_of_last_component_layer)
return rendered return rendered
return Slot(content_func=cast(SlotFunc, render_func)) return Slot(
content_func=cast(SlotFunc, render_func),
component_name=component_name,
slot_name=slot_name,
escaped=False,
nodelist=nodelist,
)
def _is_extracting_fill(context: Context) -> bool: def _is_extracting_fill(context: Context) -> bool:

View file

@ -0,0 +1,114 @@
import copy
from typing import List
from django.template import Context
from django.template.loader_tags import BlockContext
class CopiedDict(dict):
"""Dict subclass to identify dictionaries that have been copied with `snapshot_context`"""
pass
def snapshot_context(context: Context) -> Context:
"""
Make a copy of the Context object, so that it can be used as a snapshot.
Snapshot here means that the copied Context can leave any scopes that the original
Context is part of, and the copy will NOT be modified:
```py
ctx = Context({ ... })
with ctx.render_context.push({}):
with ctx.update({"a": 1}):
snapshot = snapshot_context(ctx)
assert snapshot["a"] == 1 # OK
assert ctx["a"] == 1 # ERROR
```
This function aims to make a shallow copy, but needs to make deeper copies
for certain features like forloops or support for `{% block %}` / `{% extends %}`.
"""
# Using `copy()` should also copy flags like `autoescape`, `use_l10n`, etc.
context_copy = copy.copy(context)
# Context is a list of dicts, where the dicts can be thought of as "layers" - when a new
# layer is added, the keys defined on the latest layer overshadow the previous layers.
#
# For some special cases, like when we're inside forloops, we need to make deep copies
# of the objects created by the forloop, so all forloop metadata (index, first, last, etc.)
# is preserved for all (potentially nested) forloops.
#
# For non-forloop layers, we just make shallow copies.
dicts_with_copied_forloops: List[CopiedDict] = []
# NOTE: For better performance, we iterate over the dicts in reverse order.
# This is because:
# 1. Layers are added to the end of the list.
# 2. We assume that user won't be replacing the dicts in the older layers.
# 3. Thus, when we come across a layer that has already been copied,
# we know that all layers before it have also been copied.
for ctx_dict_index in reversed(range(len(context.dicts))):
ctx_dict = context.dicts[ctx_dict_index]
# This layer is already copied, reuse this and all before it
if isinstance(ctx_dict, CopiedDict):
# NOTE: +1 because we want to include the current layer
dicts_with_copied_forloops = context.dicts[: ctx_dict_index + 1] + dicts_with_copied_forloops
break
# Copy the dict
ctx_dict_copy = CopiedDict(ctx_dict)
if "forloop" in ctx_dict:
ctx_dict_copy["forloop"] = ctx_dict["forloop"].copy()
# Recursively copy the state of potentially nested forloops
curr_forloop = ctx_dict_copy["forloop"]
while curr_forloop is not None:
curr_forloop["parentloop"] = curr_forloop["parentloop"].copy()
if "parentloop" in curr_forloop["parentloop"]:
curr_forloop = curr_forloop["parentloop"]
else:
break
dicts_with_copied_forloops.insert(0, ctx_dict_copy)
context_copy.dicts = dicts_with_copied_forloops
# Make a copy of RenderContext
render_ctx_copies: List[CopiedDict] = []
for render_ctx_dict_index in reversed(range(len(context.render_context.dicts))):
render_ctx_dict = context.render_context.dicts[render_ctx_dict_index]
# This layer is already copied, reuse this and all before it
if isinstance(render_ctx_dict, CopiedDict):
# NOTE: +1 because we want to include the current layer
render_ctx_copies = context.render_context.dicts[: render_ctx_dict_index + 1] + render_ctx_copies
break
# This holds info on what `{% block %}` blocks are defined
render_ctx_dict_copy = CopiedDict(render_ctx_dict)
if "block_context" in render_ctx_dict:
render_ctx_dict_copy["block_context"] = _copy_block_context(render_ctx_dict["block_context"])
# "extends_context" is a list of Origin objects
if "extends_context" in render_ctx_dict:
render_ctx_dict_copy["extends_context"] = render_ctx_dict["extends_context"].copy()
render_ctx_dict_copy["_djc_snapshot"] = True
render_ctx_copies.insert(0, render_ctx_dict_copy)
context_copy.render_context.dicts = render_ctx_copies
return context_copy
def _copy_block_context(block_context: BlockContext) -> BlockContext:
"""Make a shallow copy of BlockContext"""
block_context_copy = block_context.__class__()
for key, val in block_context.blocks.items():
# Individual dict values should be lists of Nodes. We don't
# need to modify the Nodes, but we need to make a copy of the lists.
block_context_copy.blocks[key] = val.copy()
return block_context_copy

View file

@ -0,0 +1,61 @@
from contextlib import contextmanager
from typing import Generator, List
@contextmanager
def component_error_message(component_path: List[str]) -> Generator[None, None, None]:
"""
If an error occurs within the context, format the error message to include
the component path. E.g.
```
KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content)
```
"""
try:
yield
except Exception as err:
if not hasattr(err, "_components"):
err._components = [] # type: ignore[attr-defined]
components = getattr(err, "_components", [])
components = err._components = [*component_path, *components] # type: ignore[attr-defined]
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
if len(err.args) and err.args[0] is not None:
if not components:
orig_msg = str(err.args[0])
else:
orig_msg = err.args[0].split("\n", 1)[-1]
else:
orig_msg = str(err)
# Format component path as
# "MyPage > MyComponent > MyComponent(slot:content) > Base(slot:tab)"
comp_path = " > ".join(components)
prefix = f"An error occured while rendering components {comp_path}:\n"
err.args = (prefix + orig_msg,) # tuple of one
# `from None` should still raise the original error, but without showing this
# line in the traceback.
raise err from None
@contextmanager
def add_slot_to_error_message(component_name: str, slot_name: str) -> Generator[None, None, None]:
"""
This compliments `component_error_message` and is used inside SlotNode to add
the slots to the component path in the error message, e.g.:
```
KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content)
```
"""
try:
yield
except Exception as err:
if not hasattr(err, "_components"):
err._components = [] # type: ignore[attr-defined]
err._components.insert(0, f"{component_name}(slot:{slot_name})") # type: ignore[attr-defined]
raise err from None

View file

@ -1,6 +1,6 @@
import logging import logging
import sys import sys
from typing import Any, Dict, Literal from typing import Any, Dict, List, Literal, Optional
DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10 DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10
@ -29,7 +29,7 @@ def _get_log_levels() -> Dict[str, int]:
return logging._nameToLevel.copy() return logging._nameToLevel.copy()
def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None: def trace(message: str, *args: Any, **kwargs: Any) -> None:
""" """
TRACE level logger. TRACE level logger.
@ -61,20 +61,64 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
logger.log(actual_trace_level_num, message, *args, **kwargs) logger.log(actual_trace_level_num, message, *args, **kwargs)
def trace_msg( def trace_node_msg(
action: Literal["PARSE", "RENDR"], action: Literal["PARSE", "RENDER"],
node_type: str, node_type: str,
node_id: str, node_id: str,
msg: str = "", msg: str = "",
) -> None: ) -> None:
""" """
TRACE level logger with opinionated format for tracing interaction of components, TRACE level logger with opinionated format for tracing interaction of nodes.
nodes, and slots. Formats messages like so: Formats messages like so:
`"PARSE slot ID 0088 ...Done!"` `"PARSE slot ID 0088 ...Done!"`
""" """
full_msg = f"{action} {node_type} ID {node_id} {msg}" action_normalized = action.ljust(6, " ")
full_msg = f"{action_normalized} NODE {node_type} ID {node_id} {msg}"
# NOTE: When debugging tests during development, it may be easier to change # NOTE: When debugging tests during development, it may be easier to change
# this to `print()` # this to `print()`
trace(logger, full_msg) trace(full_msg)
def trace_component_msg(
action: str,
component_name: str,
component_id: Optional[str],
slot_name: Optional[str],
component_path: Optional[List[str]] = None,
slot_fills: Optional[Dict[str, Any]] = None,
extra: str = "",
) -> None:
"""
TRACE level logger with opinionated format for tracing interaction of components
and slots. Formats messages like so:
`"RENDER_SLOT COMPONENT 'component_name' SLOT: 'slot_name' FILLS: 'fill_name' PATH: Root > Child > Grandchild "`
"""
if component_id:
component_id_str = f"ID {component_id}"
else:
component_id_str = ""
if slot_name:
slot_name_str = f"SLOT: '{slot_name}'"
else:
slot_name_str = ""
if component_path:
component_path_str = "PATH: " + " > ".join(component_path)
else:
component_path_str = ""
if slot_fills:
slot_fills_str = "FILLS: " + ", ".join(slot_fills.keys())
else:
slot_fills_str = ""
full_msg = f"{action} COMPONENT: '{component_name}' {component_id_str} {slot_name_str} {slot_fills_str} {component_path_str} {extra}" # noqa: E501
# NOTE: When debugging tests during development, it may be easier to change
# this to `print()`
trace(full_msg)

View file

@ -24,13 +24,6 @@ def gen_id() -> str:
) )
def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
for r_idx, elem in enumerate(reversed(lst)):
if predicate(elem):
return len(lst) - 1 - r_idx
return -1
def is_str_wrapped_in_quotes(s: str) -> bool: def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2 return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
@ -66,7 +59,16 @@ def default(val: Optional[T], default: T) -> T:
return val if val is not None else default return val if val is not None else default
def get_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
"""Get the index of the first item in the list that satisfies the key"""
for i in range(len(lst)):
if key(lst[i]):
return i
return None
def get_last_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]: def get_last_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
"""Get the index of the last item in the list that satisfies the key"""
for index, item in enumerate(reversed(lst)): for index, item in enumerate(reversed(lst)):
if key(item): if key(item):
return len(lst) - 1 - index return len(lst) - 1 - index

View file

@ -153,7 +153,7 @@ class ComponentTest(BaseTestCase):
pass pass
with self.assertRaises(ImproperlyConfigured): with self.assertRaises(ImproperlyConfigured):
EmptyComponent("empty_component")._get_template(Context({})) EmptyComponent("empty_component")._get_template(Context({}), "123")
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_template_string_static_inlined(self): def test_template_string_static_inlined(self):
@ -425,7 +425,7 @@ class ComponentTest(BaseTestCase):
with self.assertRaisesMessage( with self.assertRaisesMessage(
TypeError, TypeError,
"An error occured while rendering components Root > parent > provider > broken:\n" "An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n"
"tuple indices must be integers or slices, not str", "tuple indices must be integers or slices, not str",
): ):
Root.render() Root.render()

View file

@ -513,7 +513,7 @@ class OuterContextPropertyTests(BaseTestCase):
""" """
def get_context_data(self): def get_context_data(self):
return self.outer_context.flatten() return self.outer_context.flatten() # type: ignore[union-attr]
def setUp(self): def setUp(self):
super().setUp() super().setUp()

View file

@ -508,7 +508,7 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered1) assert_dependencies(rendered1)
self.assertEqual( self.assertEqual(
rendered1.count('Variable: <strong data-djc-id-a1bc41="" data-djc-id-a1bc42="">value</strong>'), rendered1.count('Variable: <strong data-djc-id-a1bc42="" data-djc-id-a1bc41="">value</strong>'),
1, 1,
) )
@ -519,7 +519,7 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered2) assert_dependencies(rendered2)
self.assertEqual( self.assertEqual(
rendered2.count('Variable: <strong data-djc-id-a1bc43="" data-djc-id-a1bc44="">value</strong>'), rendered2.count('Variable: <strong data-djc-id-a1bc44="" data-djc-id-a1bc43="">value</strong>'),
1, 1,
) )
@ -530,6 +530,6 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered3) assert_dependencies(rendered3)
self.assertEqual( self.assertEqual(
rendered3.count('Variable: <strong data-djc-id-a1bc45="" data-djc-id-a1bc46="">value</strong>'), rendered3.count('Variable: <strong data-djc-id-a1bc46="" data-djc-id-a1bc45="">value</strong>'),
1, 1,
) )

View file

@ -658,9 +658,9 @@ class SpreadOperatorTests(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40>{'@click': '() =&gt; {}', 'style': 'height: 20px'}</div> <div data-djc-id-a1bc41>{'@click': '() =&gt; {}', 'style': 'height: 20px'}</div>
<div data-djc-id-a1bc40>[1, 2, 3]</div> <div data-djc-id-a1bc41>[1, 2, 3]</div>
<div data-djc-id-a1bc40>1</div> <div data-djc-id-a1bc41>1</div>
""", """,
) )

View file

@ -38,8 +38,8 @@ class TemplateCacheTest(BaseTestCase):
} }
comp = SimpleComponent() comp = SimpleComponent()
template_1 = comp._get_template(Context({})) template_1 = comp._get_template(Context({}), component_id="123")
template_1._test_id = "123" template_1._test_id = "123"
template_2 = comp._get_template(Context({})) template_2 = comp._get_template(Context({}), component_id="123")
self.assertEqual(template_2._test_id, "123") self.assertEqual(template_2._test_id, "123")

View file

@ -752,6 +752,32 @@ class AggregateInputTests(BaseTestCase):
) )
class RecursiveComponentTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_recursive_component(self):
DEPTH = 100
@register("recursive")
class Recursive(Component):
def get_context_data(self, depth: int = 0):
print("depth:", depth)
return {"depth": depth + 1, "DEPTH": DEPTH}
template: types.django_html = """
<div>
<span> depth: {{ depth }} </span>
{% if depth <= DEPTH %}
{% component "recursive" depth=depth / %}
{% endif %}
</div>
"""
result = Recursive.render()
for i in range(DEPTH):
self.assertIn(f"<span> depth: {i + 1} </span>", result)
class ComponentTemplateSyntaxErrorTests(BaseTestCase): class ComponentTemplateSyntaxErrorTests(BaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()

View file

@ -557,7 +557,6 @@ class ExtendsCompatTests(BaseTestCase):
template: types.django_html = """ template: types.django_html = """
{% extends "block_in_component.html" %} {% extends "block_in_component.html" %}
{% load component_tags %}
{% block body %} {% block body %}
<div> <div>
58 giraffes and 2 pantaloons 58 giraffes and 2 pantaloons
@ -826,10 +825,10 @@ class ExtendsCompatTests(BaseTestCase):
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<body> <body>
<custom-template data-djc-id-a1bc43> <custom-template data-djc-id-a1bc44>
<header></header> <header></header>
<main> <main>
<div data-djc-id-a1bc47> injected: DepInject(hello='from_block') </div> <div data-djc-id-a1bc48> injected: DepInject(hello='from_block') </div>
</main> </main>
<footer>Default footer</footer> <footer>Default footer</footer>
</custom-template> </custom-template>

View file

@ -3,6 +3,7 @@ from typing import Any
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
from django_components import Component, register, types from django_components import Component, register, types
from django_components.perfutil.provide import provide_cache, provide_references, all_reference_ids
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
@ -11,6 +12,11 @@ setup_test_config({"autodiscover": False})
class ProvideTemplateTagTest(BaseTestCase): class ProvideTemplateTagTest(BaseTestCase):
def _assert_clear_cache(self):
self.assertEqual(provide_cache, {})
self.assertEqual(provide_references, {})
self.assertEqual(all_reference_ids, set())
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_basic(self): def test_provide_basic(self):
@register("injectee") @register("injectee")
@ -25,7 +31,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=1 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -36,16 +42,17 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40> injected: DepInject(key='hi', another=123) </div> <div data-djc-id-a1bc41> injected: DepInject(key='hi', another=1) </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_basic_self_closing(self): def test_provide_basic_self_closing(self):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<div> <div>
{% provide "my_provide" key="hi" another=123 / %} {% provide "my_provide" key="hi" another=2 / %}
</div> </div>
""" """
template = Template(template_str) template = Template(template_str)
@ -57,6 +64,7 @@ class ProvideTemplateTagTest(BaseTestCase):
<div></div> <div></div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_access_keys_in_python(self): def test_provide_access_keys_in_python(self):
@ -76,7 +84,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=3 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -87,10 +95,11 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40> key: hi </div> <div data-djc-id-a1bc41> key: hi </div>
<div data-djc-id-a1bc40> another: 123 </div> <div data-djc-id-a1bc41> another: 3 </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_access_keys_in_django(self): def test_provide_access_keys_in_django(self):
@ -109,7 +118,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=4 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -120,10 +129,11 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40> key: hi </div> <div data-djc-id-a1bc41> key: hi </div>
<div data-djc-id-a1bc40> another: 123 </div> <div data-djc-id-a1bc41> another: 4 </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_does_not_leak(self): def test_provide_does_not_leak(self):
@ -139,7 +149,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=5 %}
{% endprovide %} {% endprovide %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
@ -150,9 +160,10 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40> injected: default </div> <div data-djc-id-a1bc41> injected: default </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_empty(self): def test_provide_empty(self):
@ -183,12 +194,13 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc41> injected: DepInject() </div> <div data-djc-id-a1bc42> injected: DepInject() </div>
<div data-djc-id-a1bc42> injected: default </div> <div data-djc-id-a1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_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"""
@ -203,7 +215,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=6 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -216,10 +228,11 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc41></div>
<div data-djc-id-a1bc42></div> <div data-djc-id-a1bc42></div>
<div data-djc-id-a1bc43></div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_name_single_quotes(self): def test_provide_name_single_quotes(self):
@ -235,7 +248,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide 'my_provide' key="hi" another=123 %} {% provide 'my_provide' key="hi" another=7 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -248,10 +261,11 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=123) </div> <div data-djc-id-a1bc42> injected: DepInject(key='hi', another=7) </div>
<div data-djc-id-a1bc42> injected: default </div> <div data-djc-id-a1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_name_as_var(self): def test_provide_name_as_var(self):
@ -267,7 +281,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide var_a key="hi" another=123 %} {% provide var_a key="hi" another=8 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -286,10 +300,11 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=123) </div> <div data-djc-id-a1bc42> injected: DepInject(key='hi', another=8) </div>
<div data-djc-id-a1bc42> injected: default </div> <div data-djc-id-a1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_name_as_spread(self): def test_provide_name_as_spread(self):
@ -319,7 +334,7 @@ class ProvideTemplateTagTest(BaseTestCase):
"provide_props": { "provide_props": {
"name": "my_provide", "name": "my_provide",
"key": "hi", "key": "hi",
"another": 123, "another": 9,
}, },
} }
) )
@ -328,10 +343,11 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=123) </div> <div data-djc-id-a1bc42> injected: DepInject(key='hi', another=9) </div>
<div data-djc-id-a1bc42> injected: default </div> <div data-djc-id-a1bc43> injected: default </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_no_name_raises(self): def test_provide_no_name_raises(self):
@ -347,7 +363,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide key="hi" another=123 %} {% provide key="hi" another=10 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -360,6 +376,8 @@ class ProvideTemplateTagTest(BaseTestCase):
): ):
Template(template_str).render(Context({})) Template(template_str).render(Context({}))
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_name_must_be_string_literal(self): def test_provide_name_must_be_string_literal(self):
@register("injectee") @register("injectee")
@ -374,7 +392,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide my_var key="hi" another=123 %} {% provide my_var key="hi" another=11 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -387,6 +405,8 @@ class ProvideTemplateTagTest(BaseTestCase):
): ):
Template(template_str).render(Context({})) Template(template_str).render(Context({}))
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_name_must_be_identifier(self): def test_provide_name_must_be_identifier(self):
@register("injectee") @register("injectee")
@ -401,7 +421,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "%heya%" key="hi" another=123 %} {% provide "%heya%" key="hi" another=12 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -409,8 +429,10 @@ class ProvideTemplateTagTest(BaseTestCase):
{% endcomponent %} {% endcomponent %}
""" """
template = Template(template_str) template = Template(template_str)
with self.assertRaises(TemplateSyntaxError): with self.assertRaises(TemplateSyntaxError):
template.render(Context({})) template.render(Context({}))
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_aggregate_dics(self): def test_provide_aggregate_dics(self):
@ -426,7 +448,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" var1:key="hi" var1:another=123 var2:x="y" %} {% provide "my_provide" var1:key="hi" var1:another=13 var2:x="y" %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -437,9 +459,10 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40> injected: DepInject(var1={'key': 'hi', 'another': 123}, var2={'x': 'y'}) </div> <div data-djc-id-a1bc41> injected: DepInject(var1={'key': 'hi', 'another': 13}, var2={'x': 'y'}) </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_does_not_expose_kwargs_to_context(self): def test_provide_does_not_expose_kwargs_to_context(self):
@ -459,7 +482,7 @@ class ProvideTemplateTagTest(BaseTestCase):
{% load component_tags %} {% load component_tags %}
var_out: {{ var }} var_out: {{ var }}
key_out: {{ key }} key_out: {{ key }}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=14 %}
var_in: {{ var }} var_in: {{ var }}
key_in: {{ key }} key_in: {{ key }}
{% endprovide %} {% endprovide %}
@ -476,6 +499,7 @@ class ProvideTemplateTagTest(BaseTestCase):
key_in: key_in:
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_nested_in_provide_same_key(self): def test_provide_nested_in_provide_same_key(self):
@ -493,8 +517,8 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 lost=0 %} {% provide "my_provide" key="hi" another=15 lost=0 %}
{% provide "my_provide" key="hi1" another=1231 new=3 %} {% provide "my_provide" key="hi1" another=16 new=3 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -511,12 +535,14 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc43> injected: DepInject(key='hi1', another=1231, new=3) </div> <div data-djc-id-a1bc45> injected: DepInject(key='hi1', another=16, new=3) </div>
<div data-djc-id-a1bc44> injected: DepInject(key='hi', another=123, lost=0) </div> <div data-djc-id-a1bc46> injected: DepInject(key='hi', another=15, lost=0) </div>
<div data-djc-id-a1bc45> injected: default </div> <div data-djc-id-a1bc47> injected: default </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_nested_in_provide_different_key(self): def test_provide_nested_in_provide_different_key(self):
"""Check that `provide` tag with different keys don't affect each other""" """Check that `provide` tag with different keys don't affect each other"""
@ -538,8 +564,8 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "first_provide" key="hi" another=123 lost=0 %} {% provide "first_provide" key="hi" another=17 lost=0 %}
{% provide "second_provide" key="hi1" another=1231 new=3 %} {% provide "second_provide" key="hi1" another=18 new=3 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -551,10 +577,11 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc41> first_provide: DepInject(key='hi', another=123, lost=0) </div> <div data-djc-id-a1bc43> first_provide: DepInject(key='hi', another=17, lost=0) </div>
<div data-djc-id-a1bc41> second_provide: DepInject(key='hi1', another=1231, new=3) </div> <div data-djc-id-a1bc43> second_provide: DepInject(key='hi1', another=18, new=3) </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_in_include(self): def test_provide_in_include(self):
@ -570,7 +597,7 @@ class ProvideTemplateTagTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=19 %}
{% include "inject.html" %} {% include "inject.html" %}
{% endprovide %} {% endprovide %}
""" """
@ -581,10 +608,11 @@ class ProvideTemplateTagTest(BaseTestCase):
rendered, rendered,
""" """
<div> <div>
<div data-djc-id-a1bc40> injected: DepInject(key='hi', another=123) </div> <div data-djc-id-a1bc41> injected: DepInject(key='hi', another=19) </div>
</div> </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_slot_in_provide(self): def test_slot_in_provide(self):
@ -602,7 +630,7 @@ class ProvideTemplateTagTest(BaseTestCase):
class ParentComponent(Component): class ParentComponent(Component):
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=20 %}
{% slot "content" default %}{% endslot %} {% slot "content" default %}{% endslot %}
{% endprovide %} {% endprovide %}
""" """
@ -619,14 +647,20 @@ class ProvideTemplateTagTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40 data-djc-id-a1bc43> <div data-djc-id-a1bc40 data-djc-id-a1bc44>
injected: DepInject(key='hi', another=123) injected: DepInject(key='hi', another=20)
</div> </div>
""", """,
) )
self._assert_clear_cache()
class InjectTest(BaseTestCase): class InjectTest(BaseTestCase):
def _assert_clear_cache(self):
self.assertEqual(provide_cache, {})
self.assertEqual(provide_references, {})
self.assertEqual(all_reference_ids, set())
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_inject_basic(self): def test_inject_basic(self):
@register("injectee") @register("injectee")
@ -641,7 +675,7 @@ class InjectTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=21 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -652,9 +686,10 @@ class InjectTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc40> injected: DepInject(key='hi', another=123) </div> <div data-djc-id-a1bc41> injected: DepInject(key='hi', another=21) </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_inject_missing_key_raises_without_default(self): def test_inject_missing_key_raises_without_default(self):
@ -678,6 +713,8 @@ class InjectTest(BaseTestCase):
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
template.render(Context({})) template.render(Context({}))
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_inject_missing_key_ok_with_default(self): def test_inject_missing_key_ok_with_default(self):
@register("injectee") @register("injectee")
@ -703,6 +740,7 @@ class InjectTest(BaseTestCase):
<div data-djc-id-a1bc3f> injected: default </div> <div data-djc-id-a1bc3f> injected: default </div>
""", """,
) )
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_inject_empty_string(self): def test_inject_empty_string(self):
@ -718,7 +756,7 @@ class InjectTest(BaseTestCase):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% provide "my_provide" key="hi" another=123 %} {% provide "my_provide" key="hi" another=22 %}
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
{% endprovide %} {% endprovide %}
@ -730,6 +768,8 @@ class InjectTest(BaseTestCase):
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
template.render(Context({})) template.render(Context({}))
self._assert_clear_cache()
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_inject_raises_on_called_outside_get_context_data(self): def test_inject_raises_on_called_outside_get_context_data(self):
@register("injectee") @register("injectee")
@ -746,6 +786,8 @@ class InjectTest(BaseTestCase):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
comp.inject("abc", "def") comp.inject("abc", "def")
self._assert_clear_cache()
# See https://github.com/django-components/django-components/pull/778 # See https://github.com/django-components/django-components/pull/778
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_inject_in_fill(self): def test_inject_in_fill(self):
@ -805,14 +847,15 @@ class InjectTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc48> <div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc49>
injected: DepInject(key='hi', data=123) injected: DepInject(key='hi', data=123)
</div> </div>
<main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc48> <main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc49>
456 456
</main> </main>
""", """,
) )
self._assert_clear_cache()
# See https://github.com/django-components/django-components/pull/786 # See https://github.com/django-components/django-components/pull/786
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
@ -869,10 +912,173 @@ class InjectTest(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
<div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc47> <div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc48>
injected: DepInject(key='hi', data=123) injected: DepInject(key='hi', data=123)
</div> </div>
<main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc47> <main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc48>
</main> </main>
""", """,
) )
self._assert_clear_cache()
# When there is `{% component %}` that's a descendant of `{% provide %}`,
# then the cache entry is NOT removed as soon as we have rendered the children (nodelist)
# of `{% provide %}`.
#
# Instead, we manage the state ourselves, and remove the cache entry
# when the component rendered is done.
class ProvideCacheTest(BaseTestCase):
def _assert_clear_cache(self):
self.assertEqual(provide_cache, {})
self.assertEqual(provide_references, {})
self.assertEqual(all_reference_ids, set())
def test_provide_outside_component(self):
tester = self
@register("injectee")
class Injectee(Component):
template: types.django_html = """
{% load component_tags %}
<div> injected: {{ data|safe }} </div>
<div> Ran: {{ ran }} </div>
"""
def get_context_data(self):
tester.assertEqual(len(provide_cache), 1)
data = self.inject("my_provide")
return {"data": data, "ran": True}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=23 %}
{% component "injectee" / %}
{% endprovide %}
"""
self._assert_clear_cache()
template = Template(template_str)
self._assert_clear_cache()
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div data-djc-id-a1bc41>
injected: DepInject(key='hi', another=23)
</div>
<div data-djc-id-a1bc41>
Ran: True
</div>
""",
)
self._assert_clear_cache()
# Cache should be cleared even if there is an error.
def test_provide_outside_component_with_error(self):
tester = self
@register("injectee")
class Injectee(Component):
template = ""
def get_context_data(self):
tester.assertEqual(len(provide_cache), 1)
data = self.inject("my_provide")
raise ValueError("Oops")
return {"data": data, "ran": True}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=24 %}
{% component "injectee" / %}
{% endprovide %}
"""
self._assert_clear_cache()
template = Template(template_str)
self._assert_clear_cache()
with self.assertRaisesMessage(ValueError, "Oops"):
template.render(Context({}))
self._assert_clear_cache()
def test_provide_inside_component(self):
tester = self
@register("injectee")
class Injectee(Component):
template: types.django_html = """
{% load component_tags %}
<div> injected: {{ data|safe }} </div>
<div> Ran: {{ ran }} </div>
"""
def get_context_data(self):
tester.assertEqual(len(provide_cache), 1)
data = self.inject("my_provide")
return {"data": data, "ran": True}
@register("root")
class Root(Component):
template: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=25 %}
{% component "injectee" / %}
{% endprovide %}
"""
self._assert_clear_cache()
rendered = Root.render()
self.assertHTMLEqual(
rendered,
"""
<div data-djc-id-a1bc3e data-djc-id-a1bc42>
injected: DepInject(key='hi', another=25)
</div>
<div data-djc-id-a1bc3e data-djc-id-a1bc42>
Ran: True
</div>
""",
)
self._assert_clear_cache()
def test_provide_inside_component_with_error(self):
tester = self
@register("injectee")
class Injectee(Component):
template = ""
def get_context_data(self):
tester.assertEqual(len(provide_cache), 1)
data = self.inject("my_provide")
raise ValueError("Oops")
return {"data": data, "ran": True}
@register("root")
class Root(Component):
template: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=26 %}
{% component "injectee" / %}
{% endprovide %}
"""
self._assert_clear_cache()
with self.assertRaisesMessage(ValueError, "Oops"):
Root.render()
self._assert_clear_cache()

View file

@ -531,7 +531,7 @@ class ComponentSlotTests(BaseTestCase):
with self.assertRaisesMessage( with self.assertRaisesMessage(
TemplateSyntaxError, TemplateSyntaxError,
"Encountered a SlotNode outside of a ComponentNode context.", "Encountered a SlotNode outside of a Component context.",
): ):
Template(template_str).render(Context({})) Template(template_str).render(Context({}))

View file

@ -4,7 +4,7 @@ from typing import Any, Dict, Optional
from django.template import Context, Template from django.template import Context, Template
from django_components import Component, registry, types from django_components import Component, register, registry, types
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
@ -604,38 +604,10 @@ class ComponentNestingTests(BaseTestCase):
</div> </div>
""" """
class ComplexChildComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "content" default %}
No slot!
{% endslot %}
</div>
"""
class ComplexParentComponent(Component):
template: types.django_html = """
{% load component_tags %}
ITEMS: {{ items|safe }}
{% for item in items %}
<li>
{% component "complex_child" %}
{{ item.value }}
{% endcomponent %}
</li>
{% endfor %}
"""
def get_context_data(self, items, *args, **kwargs) -> Dict[str, Any]:
return {"items": items}
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
registry.register("dashboard", self.DashboardComponent) registry.register("dashboard", self.DashboardComponent)
registry.register("calendar", self.CalendarComponent) registry.register("calendar", self.CalendarComponent)
registry.register("complex_child", self.ComplexChildComponent)
registry.register("complex_parent", self.ComplexParentComponent)
# NOTE: Second arg in tuple are expected names in nested fills. In "django" mode, # NOTE: Second arg in tuple are expected names in nested fills. In "django" mode,
# the value should be overridden by the component, while in "isolated" it should # the value should be overridden by the component, while in "isolated" it should
@ -770,6 +742,34 @@ class ComponentNestingTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_component_nesting_deep_slot_inside_component_fill(self): def test_component_nesting_deep_slot_inside_component_fill(self):
@register("complex_child")
class ComplexChildComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "content" default %}
No slot!
{% endslot %}
</div>
"""
@register("complex_parent")
class ComplexParentComponent(Component):
template: types.django_html = """
{% load component_tags %}
ITEMS: {{ items|safe }}
{% for item in items %}
<li>
{% component "complex_child" %}
{{ item.value }}
{% endcomponent %}
</li>
{% endfor %}
"""
def get_context_data(self, items, *args, **kwargs) -> Dict[str, Any]:
return {"items": items}
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "complex_parent" items=items %}{% endcomponent %} {% component "complex_parent" items=items %}{% endcomponent %}
@ -792,6 +792,114 @@ class ComponentNestingTests(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) self.assertHTMLEqual(rendered, expected)
# This test is based on real-life example.
# It ensures that deeply nested slots in fills with same names are resolved correctly.
# It also ensures that the component_vars.is_filled context is correctly populated.
@parametrize_context_behavior(["django", "isolated"])
def test_component_nesting_deep_slot_inside_component_fill_2(self):
@register("TestPage")
class TestPage(Component):
template: types.django_html = """
{% component "TestLayout" %}
{% fill "content" %}
<div class="test-page">
------PROJ_LAYOUT_TABBED META ------<br/>
component_vars.is_filled: {{ component_vars.is_filled|safe }}<br/>
------------------------<br/>
{% if component_vars.is_filled.left_panel %}
<div style="background-color: red;">
{% slot "left_panel" / %}
</div>
{% endif %}
{% slot "content" default %}
DEFAULT LAYOUT TABBED CONTENT
{% endslot %}
</div>
{% endfill %}
{% endcomponent %}
"""
@register("TestLayout")
class TestLayout(Component):
template: types.django_html = """
{% component "TestBase" %}
{% fill "content" %}
------LAYOUT META ------<br/>
component_vars.is_filled: {{ component_vars.is_filled|safe }}<br/>
------------------------<br/>
<div class="test-layout">
{% slot "content" default / %}
</div>
{% endfill %}
{% endcomponent %}
"""
@register("TestBase")
class TestBase(Component):
template: types.django_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test app</title>
</head>
<body class="test-base">
------BASE META ------<br/>
component_vars.is_filled: {{ component_vars.is_filled|safe }}<br/>
------------------------<br/>
{% slot "content" default / %}
</body>
</html>
"""
rendered = TestPage.render(
slots={
"left_panel": "LEFT PANEL SLOT FROM FILL",
"content": "CONTENT SLOT FROM FILL",
}
)
expected = """
<!DOCTYPE html>
<html lang="en" data-djc-id-a1bc3e data-djc-id-a1bc43 data-djc-id-a1bc47><head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test app</title>
</head>
<body class="test-base">
------BASE META ------ <br>
component_vars.is_filled: {'content': True}<br>
------------------------ <br>
------LAYOUT META ------<br>
component_vars.is_filled: {'content': True}<br>
------------------------<br>
<div class="test-layout">
<div class="test-page">
------PROJ_LAYOUT_TABBED META ------<br>
component_vars.is_filled: {'left_panel': True, 'content': True}<br>
------------------------<br>
<div style="background-color: red;">
LEFT PANEL SLOT FROM FILL
</div>
CONTENT SLOT FROM FILL
</div>
</div>
<script src="django_components/django_components.min.js"></script>
</body>
</html>
"""
self.assertHTMLEqual(rendered, expected)
# NOTE: Second arg in tuple is expected list content. In isolated mode, loops should NOT leak. # NOTE: Second arg in tuple is expected list content. In isolated mode, loops should NOT leak.
@parametrize_context_behavior( @parametrize_context_behavior(
[ [