feat: on_render (#1231)

* feat: on_render

* docs: fix typos

* refactor: fix linter errors

* refactor: make `error` in on_render_after optional to fix benchmarks

* refactor: benchmark attempt 2

* refactor: fix linter errors

* refactor: fix formatting
This commit is contained in:
Juro Oravec 2025-06-04 19:30:03 +02:00 committed by GitHub
parent 46e524e37d
commit eceebb9696
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1793 additions and 417 deletions

View file

@ -20,6 +20,7 @@ from django_components.component import (
ComponentInput,
ComponentNode,
ComponentVars,
OnRenderGenerator,
all_components,
get_component_by_class_id,
)
@ -137,6 +138,7 @@ __all__ = [
"OnComponentUnregisteredContext",
"OnRegistryCreatedContext",
"OnRegistryDeletedContext",
"OnRenderGenerator",
"ProvideNode",
"register",
"registry",

View file

@ -1,11 +1,13 @@
import sys
from dataclasses import dataclass
from inspect import signature
from types import MethodType
from typing import (
Any,
Callable,
ClassVar,
Dict,
Generator,
List,
Mapping,
NamedTuple,
@ -56,7 +58,12 @@ from django_components.extensions.debug_highlight import ComponentDebugHighlight
from django_components.extensions.defaults import ComponentDefaults
from django_components.extensions.view import ComponentView, ViewFn
from django_components.node import BaseNode
from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
from django_components.perfutil.component import (
ComponentRenderer,
OnComponentRenderedResult,
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 (
@ -98,6 +105,55 @@ else:
CompHashMapping = WeakValueDictionary
OnRenderGenerator = Generator[
Optional[SlotResult],
Tuple[Optional[SlotResult], Optional[Exception]],
Optional[SlotResult],
]
"""
This is the signature of the [`Component.on_render()`](../api/#django_components.Component.on_render)
method if it yields (and thus returns a generator).
When `on_render()` is a generator then it:
- Yields a rendered template (string or `None`)
- Receives back a tuple of `(final_output, error)`.
The final output is the rendered template that now has all its children rendered too.
May be `None` if you yielded `None` earlier.
The error is `None` if the rendering was successful. Otherwise the error is set
and the output is `None`.
- At the end it may return a new string to override the final rendered output.
**Example:**
```py
from django_components import Component, OnRenderGenerator
class MyTable(Component):
def on_render(
self,
context: Context,
template: Optional[Template],
) -> OnRenderGenerator:
# Do something BEFORE rendering template
# Same as `Component.on_render_before()`
context["hello"] = "world"
# Yield rendered template to receive fully-rendered template or error
html, error = yield template.render(context)
# Do something AFTER rendering template, or post-process
# the rendered template.
# Same as `Component.on_render_after()`
return html + "<p>Hello</p>"
```
"""
# Keep track of all the Component classes created, so we can clean up after tests
ALL_COMPONENTS: AllComponents = []
@ -414,7 +470,7 @@ class ComponentMeta(ComponentMediaMeta):
attrs["template_file"] = attrs.pop("template_name")
attrs["template_name"] = ComponentTemplateNameDescriptor()
cls = super().__new__(mcs, name, bases, attrs)
cls = cast(Type["Component"], super().__new__(mcs, name, bases, attrs))
# If the component defined `template_file`, then associate this Component class
# with that template file path.
@ -423,6 +479,23 @@ class ComponentMeta(ComponentMediaMeta):
if "template_file" in attrs and attrs["template_file"]:
cache_component_template_file(cls)
# TODO_V1 - Remove. This is only for backwards compatibility with v0.139 and earlier,
# where `on_render_after` had 4 parameters.
on_render_after_sig = signature(cls.on_render_after)
if len(on_render_after_sig.parameters) == 4:
orig_on_render_after = cls.on_render_after
def on_render_after_wrapper(
self: Component,
context: Context,
template: Template,
result: str,
error: Optional[Exception],
) -> Optional[SlotResult]:
return orig_on_render_after(self, context, template, result) # type: ignore[call-arg]
cls.on_render_after = on_render_after_wrapper # type: ignore[assignment]
return cls
# This runs when a Component class is being deleted
@ -446,7 +519,7 @@ class ComponentContext:
# When we render a component, the root component, together with all the nested Components,
# shares this dictionary for storing callbacks that are called from within `component_post_render`.
# This is so that we can pass them all in when the root component is passed to `component_post_render`.
post_render_callbacks: Dict[str, Callable[[str], str]]
post_render_callbacks: Dict[str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]]
class Component(metaclass=ComponentMeta):
@ -1791,22 +1864,237 @@ class Component(metaclass=ComponentMeta):
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
"""
Hook that runs just before the component's template is rendered.
Runs just before the component's template is rendered.
You can use this hook to access or modify the context or the template.
It is called for every component, including nested ones, as part of
the component render lifecycle.
Args:
context (Context): The Django
[Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
that will be used to render the component's template.
template (Optional[Template]): The Django
[Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
instance that will be rendered, or `None` if no template.
Returns:
None. This hook is for side effects only.
**Example:**
You can use this hook to access the context or the template:
```py
from django.template import Context, Template
from django_components import Component
class MyTable(Component):
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
assert isinstance(template, Template)
```
!!! warning
If you want to pass data to the template, prefer using
[`get_template_data()`](../api#django_components.Component.get_template_data)
instead of this hook.
!!! warning
Do NOT modify the template in this hook. The template is reused across renders.
Since this hook is called for every component, this means that the template would be modified
every time a component is rendered.
"""
pass
def on_render_after(self, context: Context, template: Optional[Template], content: str) -> Optional[SlotResult]:
def on_render(self, context: Context, template: Optional[Template]) -> Union[SlotResult, OnRenderGenerator, None]:
"""
Hook that runs just after the component's template was rendered.
It receives the rendered output as the last argument.
This method does the actual rendering.
You can use this hook to access the context or the template, but modifying
them won't have any effect.
Read more about this hook in [Component hooks](../../concepts/advanced/hooks/#on_render).
To override the content that gets rendered, you can return a string or SafeString
from this hook.
You can override this method to:
- Change what template gets rendered
- Modify the context
- Modify the rendered output after it has been rendered
- Handle errors
The default implementation renders the component's
[Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
with the given
[Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context).
```py
class MyTable(Component):
def on_render(self, context, template):
if template is None:
return None
else:
return template.render(context)
```
The `template` argument is `None` if the component has no template.
**Modifying rendered template**
To change what gets rendered, you can:
- Render a different template
- Render a component
- Return a different string or SafeString
```py
class MyTable(Component):
def on_render(self, context, template):
return "Hello"
```
**Post-processing rendered template**
To access the final output, you can `yield` the result instead of returning it.
This will return a tuple of (rendered HTML, error). The error is `None` if the rendering succeeded.
```py
class MyTable(Component):
def on_render(self, context, template):
html, error = yield template.render(context)
if error is None:
# The rendering succeeded
return html
else:
# The rendering failed
print(f"Error: {error}")
```
At this point you can do 3 things:
1. Return a new HTML
The new HTML will be used as the final output.
If the original template raised an error, it will be ignored.
```py
class MyTable(Component):
def on_render(self, context, template):
html, error = yield template.render(context)
return "NEW HTML"
```
2. Raise a new exception
The new exception is what will bubble up from the component.
The original HTML and original error will be ignored.
```py
class MyTable(Component):
def on_render(self, context, template):
html, error = yield template.render(context)
raise Exception("Error message")
```
3. Return nothing (or `None`) to handle the result as usual
If you don't raise an exception, and neither return a new HTML,
then original HTML / error will be used:
- If rendering succeeded, the original HTML will be used as the final output.
- If rendering failed, the original error will be propagated.
```py
class MyTable(Component):
def on_render(self, context, template):
html, error = yield template.render(context)
if error is not None:
# The rendering failed
print(f"Error: {error}")
```
"""
if template is None:
return None
else:
return template.render(context)
def on_render_after(
self, context: Context, template: Optional[Template], result: Optional[str], error: Optional[Exception]
) -> Optional[SlotResult]:
"""
Hook that runs when the component was fully rendered,
including all its children.
It receives the same arguments as [`on_render_before()`](../api#django_components.Component.on_render_before),
plus the outcome of the rendering:
- `result`: The rendered output of the component. `None` if the rendering failed.
- `error`: The error that occurred during the rendering, or `None` if the rendering succeeded.
[`on_render_after()`](../api#django_components.Component.on_render_after) behaves the same way
as the second part of [`on_render()`](../api#django_components.Component.on_render) (after the `yield`).
```py
class MyTable(Component):
def on_render_after(self, context, template, result, error):
if error is None:
# The rendering succeeded
return result
else:
# The rendering failed
print(f"Error: {error}")
```
Same as [`on_render()`](../api#django_components.Component.on_render),
you can return a new HTML, raise a new exception, or return nothing:
1. Return a new HTML
The new HTML will be used as the final output.
If the original template raised an error, it will be ignored.
```py
class MyTable(Component):
def on_render_after(self, context, template, result, error):
return "NEW HTML"
```
2. Raise a new exception
The new exception is what will bubble up from the component.
The original HTML and original error will be ignored.
```py
class MyTable(Component):
def on_render_after(self, context, template, result, error):
raise Exception("Error message")
```
3. Return nothing (or `None`) to handle the result as usual
If you don't raise an exception, and neither return a new HTML,
then original HTML / error will be used:
- If rendering succeeded, the original HTML will be used as the final output.
- If rendering failed, the original error will be propagated.
```py
class MyTable(Component):
def on_render_after(self, context, template, result, error):
if error is not None:
# The rendering failed
print(f"Error: {error}")
```
"""
pass
@ -2183,7 +2471,7 @@ class Component(metaclass=ComponentMeta):
page: int
per_page: int
def on_render_before(self, context: Context, template: Template) -> None:
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
assert self.args.page == 123
assert self.args.per_page == 10
@ -2198,7 +2486,7 @@ class Component(metaclass=ComponentMeta):
from django_components import Component
class Table(Component):
def on_render_before(self, context: Context, template: Template) -> None:
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
assert self.args[0] == 123
assert self.args[1] == 10
```
@ -2228,7 +2516,7 @@ class Component(metaclass=ComponentMeta):
page: int
per_page: int
def on_render_before(self, context: Context, template: Template) -> None:
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
assert self.kwargs.page == 123
assert self.kwargs.per_page == 10
@ -2246,7 +2534,7 @@ class Component(metaclass=ComponentMeta):
from django_components import Component
class Table(Component):
def on_render_before(self, context: Context, template: Template) -> None:
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
assert self.kwargs["page"] == 123
assert self.kwargs["per_page"] == 10
```
@ -2276,7 +2564,7 @@ class Component(metaclass=ComponentMeta):
header: SlotInput
footer: SlotInput
def on_render_before(self, context: Context, template: Template) -> None:
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
assert isinstance(self.slots.header, Slot)
assert isinstance(self.slots.footer, Slot)
@ -2294,7 +2582,7 @@ class Component(metaclass=ComponentMeta):
from django_components import Component, Slot, SlotInput
class Table(Component):
def on_render_before(self, context: Context, template: Template) -> None:
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
assert isinstance(self.slots["header"], Slot)
assert isinstance(self.slots["footer"], Slot)
```
@ -3183,30 +3471,49 @@ class Component(metaclass=ComponentMeta):
component_path=component_path,
css_input_hash=css_input_hash,
js_input_hash=js_input_hash,
css_scope_id=None, # TODO - Implement CSS scoping
)
# This is triggered when a component is rendered, but the component's parents
# may not have been rendered yet.
def on_component_rendered(html: str) -> str:
# Allow to optionally override/modify the rendered content
new_output = component.on_render_after(context_snapshot, template, html)
html = default(new_output, html)
def on_component_rendered(
html: Optional[str],
error: Optional[Exception],
) -> OnComponentRenderedResult:
# Allow the user to either:
# - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error
# - Or don't return anything (or return `None`) to use the original HTML / error
try:
maybe_output = component.on_render_after(context_snapshot, template, html, error)
if maybe_output is not None:
html = maybe_output
error = None
except Exception as new_error:
error = new_error
html = None
# Remove component from caches
del component_context_cache[render_id] # type: ignore[arg-type]
unregister_provide_reference(render_id) # type: ignore[arg-type]
html = extensions.on_component_rendered(
# Allow extensions to either:
# - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error
# - Or don't return anything (or return `None`) to use the original HTML / error
result = extensions.on_component_rendered(
OnComponentRenderedContext(
component=component,
component_cls=comp_cls,
component_id=render_id,
result=html,
error=error,
)
)
return html
if result is not None:
html, error = result
return html, error
post_render_callbacks[render_id] = on_component_rendered
@ -3259,14 +3566,15 @@ class Component(metaclass=ComponentMeta):
component_path: List[str],
css_input_hash: Optional[str],
js_input_hash: Optional[str],
css_scope_id: Optional[str],
) -> ComponentRenderer:
component = self
render_id = component.id
component_name = component.name
component_cls = component.__class__
def renderer(root_attributes: Optional[List[str]] = None) -> Tuple[str, Dict[str, List[str]]]:
def renderer(
root_attributes: Optional[List[str]] = None,
) -> Tuple[str, Dict[str, List[str]], Optional[OnRenderGenerator]]:
trace_component_msg(
"COMP_RENDER_START",
component_name=component_name,
@ -3280,16 +3588,31 @@ class Component(metaclass=ComponentMeta):
# Emit signal that the template is about to be rendered
template_rendered.send(sender=template, template=template, context=context)
if template is not None:
# Get the component's HTML
html_content = template.render(context)
# Get the component's HTML
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
# AFTER the yield will be called once, when the component's HTML is fully rendered.
#
# Hence we have to distinguish between the two, and pass the generator with the HTML content
html_content_or_generator = component.on_render(context, template)
if html_content_or_generator is None:
html_content: Optional[str] = None
on_render_generator: Optional[OnRenderGenerator] = None
elif isinstance(html_content_or_generator, str):
html_content = html_content_or_generator
on_render_generator = None
else:
# Move generator to the first yield
html_content = next(html_content_or_generator)
on_render_generator = html_content_or_generator
if html_content is not None:
# Add necessary HTML attributes to work with JS and CSS variables
updated_html, child_components = set_component_attrs_for_js_and_css(
html_content=html_content,
component_id=render_id,
css_input_hash=css_input_hash,
css_scope_id=css_scope_id,
root_attributes=root_attributes,
)
@ -3313,7 +3636,7 @@ class Component(metaclass=ComponentMeta):
component_path=component_path,
)
return updated_html, child_components
return updated_html, child_components, on_render_generator
return renderer

View file

@ -3,7 +3,7 @@ from typing import Any, Optional, Type, Union, cast
from django.template import Context, Template
from django_components import Component, ComponentRegistry, NotRegistered, types
from django_components import Component, ComponentRegistry, NotRegistered
from django_components.component_registry import ALL_REGISTRIES
@ -99,23 +99,25 @@ class DynamicComponent(Component):
_is_dynamic_component = True
# TODO: Replace combination of `on_render_before()` + `template` with single `on_render()`
#
# NOTE: The inner component is rendered in `on_render_before`, so that the `Context` object
# NOTE: The inner component is rendered in `on_render`, so that the `Context` object
# is already configured as if the inner component was rendered inside the template.
# E.g. the `_COMPONENT_CONTEXT_KEY` is set, which means that the child component
# will know that it's a child of this component.
def on_render_before(self, context: Context, template: Template) -> Context:
def on_render(
self,
context: Context,
template: Optional[Template],
) -> str:
# Make a copy of kwargs so we pass to the child only the kwargs that are
# actually used by the child component.
cleared_kwargs = self.input.kwargs.copy()
# Resolve the component class
registry: Optional[ComponentRegistry] = cleared_kwargs.pop("registry", None)
comp_name_or_class: Union[str, Type[Component]] = cleared_kwargs.pop("is", None)
if not comp_name_or_class:
raise TypeError(f"Component '{self.name}' is missing a required argument 'is'")
# Resolve the component class
comp_class = self._resolve_component(comp_name_or_class, registry)
output = comp_class.render(
@ -128,12 +130,7 @@ class DynamicComponent(Component):
outer_context=self.outer_context,
registry=self.registry,
)
# Set the output to the context so it can be accessed from within the template.
context["output"] = output
return context
template: types.django_html = """{{ output|safe }}"""
return output
def _resolve_component(
self,

View file

@ -229,7 +229,6 @@ def set_component_attrs_for_js_and_css(
html_content: Union[str, SafeString],
component_id: Optional[str],
css_input_hash: Optional[str],
css_scope_id: Optional[str],
root_attributes: Optional[List[str]] = None,
) -> Tuple[Union[str, SafeString], Dict[str, List[str]]]:
# These are the attributes that we want to set on the root element.
@ -249,22 +248,11 @@ def set_component_attrs_for_js_and_css(
if css_input_hash:
all_root_attributes.append(f"data-djc-css-{css_input_hash}")
# These attributes are set on all tags
all_attributes = []
# We apply the CSS scoping attribute to both root and non-root tags.
#
# This is the HTML part of Vue-like CSS scoping.
# That is, for each HTML element that the component renders, we add a `data-djc-scope-a1b2c3` attribute.
# And we stop when we come across a nested components.
if css_scope_id:
all_attributes.append(f"data-djc-scope-{css_scope_id}")
is_safestring = isinstance(html_content, SafeString)
updated_html, child_components = set_html_attributes(
html_content,
root_attributes=all_root_attributes,
all_attributes=all_attributes,
all_attributes=[],
# Setting this means that set_html_attributes will check for HTML elemetnts with this
# attribute, and return a dictionary of {attribute_value: [attributes_set_on_this_tag]}.
#

View file

@ -28,6 +28,7 @@ from django_components.util.routing import URLRoute
if TYPE_CHECKING:
from django_components import Component
from django_components.component_registry import ComponentRegistry
from django_components.perfutil.component import OnComponentRenderedResult
from django_components.slots import Slot, SlotNode, SlotResult
@ -139,8 +140,10 @@ class OnComponentRenderedContext(NamedTuple):
"""The Component class"""
component_id: str
"""The unique identifier for this component instance"""
result: str
"""The rendered component"""
result: Optional[str]
"""The rendered component, or `None` if rendering failed"""
error: Optional[Exception]
"""The error that occurred during rendering, or `None` if rendering was successful"""
@mark_extension_hook_api
@ -709,9 +712,19 @@ class ComponentExtension(metaclass=ExtensionMeta):
Use this hook to access or post-process the component's rendered output.
To modify the output, return a new string from this hook.
This hook works similarly to
[`Component.on_render_after()`](../api#django_components.Component.on_render_after):
**Example:**
1. To modify the output, return a new string from this hook. The original output or error will be ignored.
2. To cause this component to return a new error, raise that error. The original output and error
will be ignored.
3. If you neither raise nor return string, the original output or error will be used.
**Examples:**
Change the final output of a component:
```python
from django_components import ComponentExtension, OnComponentRenderedContext
@ -721,6 +734,32 @@ class ComponentExtension(metaclass=ExtensionMeta):
# Append a comment to the component's rendered output
return ctx.result + "<!-- MyExtension comment -->"
```
Cause the component to raise a new exception:
```python
from django_components import ComponentExtension, OnComponentRenderedContext
class MyExtension(ComponentExtension):
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
# Raise a new exception
raise Exception("Error message")
```
Return nothing (or `None`) to handle the result as usual:
```python
from django_components import ComponentExtension, OnComponentRenderedContext
class MyExtension(ComponentExtension):
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
if ctx.error is not None:
# The component raised an exception
print(f"Error: {ctx.error}")
else:
# The component rendered successfully
print(f"Result: {ctx.result}")
```
"""
pass
@ -1113,12 +1152,21 @@ class ExtensionManager:
for extension in self.extensions:
extension.on_component_data(ctx)
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> str:
def on_component_rendered(
self,
ctx: OnComponentRenderedContext,
) -> Optional["OnComponentRenderedResult"]:
for extension in self.extensions:
result = extension.on_component_rendered(ctx)
if result is not None:
ctx = ctx._replace(result=result)
return ctx.result
try:
result = extension.on_component_rendered(ctx)
except Exception as error:
# Error from `on_component_rendered()` - clear HTML and set error
ctx = ctx._replace(result=None, error=error)
else:
# No error from `on_component_rendered()` - set HTML and clear error
if result is not None:
ctx = ctx._replace(result=result, error=None)
return ctx.result, ctx.error
##########################
# Tags lifecycle hooks

View file

@ -198,5 +198,8 @@ class CacheExtension(ComponentExtension):
if not cache_instance.enabled:
return None
if ctx.error is not None:
return
cache_key = self.render_id_to_cache_key[ctx.component_id]
cache_instance.set_entry(cache_key, ctx.result)

View file

@ -134,7 +134,7 @@ class DebugHighlightExtension(ComponentExtension):
# Apply highlight to the rendered component
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
debug_cls: Optional[ComponentDebugHighlight] = getattr(ctx.component_cls, "DebugHighlight", None)
if not debug_cls or not debug_cls.highlight_components:
if not debug_cls or not debug_cls.highlight_components or ctx.result is None:
return None
return apply_component_highlight("component", ctx.result, f"{ctx.component.name} ({ctx.component_id})")

View file

@ -1,6 +1,6 @@
import re
from collections import deque
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Tuple
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Set, Tuple, Union
from django.utils.safestring import mark_safe
@ -8,7 +8,9 @@ from django_components.constants import COMP_ID_LENGTH
from django_components.util.exception import component_error_message
if TYPE_CHECKING:
from django_components.component import ComponentContext
from django_components.component import ComponentContext, OnRenderGenerator
OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
# 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
@ -29,28 +31,45 @@ if TYPE_CHECKING:
component_context_cache: Dict[str, "ComponentContext"] = {}
class PostRenderQueueItem(NamedTuple):
content_before_component: str
child_id: Optional[str]
class ComponentPart(NamedTuple):
"""Queue item where a component is nested in another component."""
child_id: str
parent_id: Optional[str]
grandparent_id: Optional[str]
component_name_path: List[str]
def __repr__(self) -> str:
return (
f"PostRenderQueueItem(child_id={self.child_id!r}, parent_id={self.parent_id!r}, "
f"grandparent_id={self.grandparent_id!r}, component_name_path={self.component_name_path!r}, "
f"content_before_component={self.content_before_component[:10]!r})"
f"ComponentPart(child_id={self.child_id!r}, parent_id={self.parent_id!r}, "
f"component_name_path={self.component_name_path!r})"
)
class TextPart(NamedTuple):
"""Queue item where a text is between two components."""
text: str
is_last: bool
parent_id: str
class ErrorPart(NamedTuple):
"""Queue item where a component has thrown an error."""
child_id: str
error: Exception
# 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 their root elements' HTML attributes.
#
# In other words, we use this to "delay" the actual rendering of the component's HTML content,
# until we know what HTML attributes to apply to the root elements.
ComponentRenderer = Callable[[Optional[List[str]]], Tuple[str, Dict[str, List[str]]]]
ComponentRenderer = Callable[
[Optional[List[str]]],
Tuple[str, Dict[str, List[str]], Optional["OnRenderGenerator"]],
]
# Render-time cache for component rendering
# See component_post_render()
@ -115,7 +134,9 @@ def component_post_render(
render_id: str,
component_name: str,
parent_id: Optional[str],
on_component_rendered_callbacks: Dict[str, Callable[[str], str]],
on_component_rendered_callbacks: Dict[
str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]
],
on_html_rendered: Callable[[str], str],
) -> str:
# Instead of rendering the component's HTML content immediately, we store it,
@ -123,9 +144,34 @@ def component_post_render(
# to be applied to the resulting HTML.
component_renderer_cache[render_id] = (renderer, component_name)
# Case: Nested component
# If component is nested, return a placeholder
#
# How this works is that we have nested components:
# ```
# ComponentA
# ComponentB
# ComponentC
# ```
#
# And these components are embedded one in another using the `{% component %}` tag.
# ```django
# <!-- ComponentA -->
# <div>
# {% component "ComponentB" / %}
# </div>
# ```
#
# Then the order in which components call `component_post_render()` is:
# 1. ComponentB - Triggered by `{% component "ComponentB" / %}` while A's template is being rendered,
# returns only a placeholder.
# 2. ComponentA - Triggered by the end of A's template. A isn't nested, so it starts full component
# tree render. This replaces B's placeholder with actual HTML and introduces C's placeholder.
# And so on...
# 3. ComponentC - Triggered by `{% component "ComponentC" / %}` while B's template is being rendered
# as part of full component tree render. Returns only a placeholder, to be replaced in next
# step.
if parent_id is not None:
# Case: Nested component
# If component is nested, return a placeholder
return mark_safe(f'<template djc-render-id="{render_id}"></template>')
# Case: Root component - Construct the final HTML by recursively replacing placeholders
@ -133,13 +179,25 @@ def component_post_render(
# We first generate the component's HTML content, by calling the renderer.
#
# Then we process the component's HTML from root-downwards, going depth-first.
# So if we have a structure:
# So if we have a template:
# ```django
# <div>
# <h2>...</h2>
# {% component "ComponentB" / %}
# <span>...</span>
# {% component "ComponentD" / %}
# </div>
# ```
#
# Then component's template is rendered, replacing nested components with placeholders:
# ```html
# <div>
# <h2>...</h2>
# <template djc-render-id="a1b3cf"></template>
# <span>...</span>
# <template djc-render-id="f3d3cf"></template>
# </div>
# ```
#
# Then we first split up the current HTML into parts, splitting at placeholders:
# - <div><h2>...</h2>
@ -161,14 +219,12 @@ def component_post_render(
# repeating this whole process until we've processed all nested components.
# 5. If the placeholder ID is None, then we've reached the end of the component's HTML content,
# and we can go one level up to continue the process with component's parent.
process_queue: Deque[PostRenderQueueItem] = deque()
process_queue: Deque[Union[ErrorPart, TextPart, ComponentPart]] = deque()
process_queue.append(
PostRenderQueueItem(
content_before_component="",
ComponentPart(
child_id=render_id,
parent_id=None,
grandparent_id=None,
component_name_path=[],
)
)
@ -187,61 +243,135 @@ def component_post_render(
#
# Then we end up with 3 bits - 1. text before, 2. component, and 3. text after
#
# We know when we've arrived at component's end, because `child_id` will be set to `None`.
# So we can collect the HTML parts by the component ID, and when we hit the end, we join
# all the bits that belong to the same component.
# We know when we've arrived at component's end. We then collect the HTML parts by the component ID,
# and when we hit the end, we join all the bits that belong to the same component.
#
# Once the component's HTML is joined, we can call the callback for the component, and
# then add the joined HTML to the cache for the parent component to continue the cycle.
html_parts_by_component_id: Dict[str, List[str]] = {}
content_parts: List[str] = []
# Remember which component ID had which parent ID, so we can bubble up errors
# to the parent component.
child_id_to_parent_id: Dict[str, Optional[str]] = {}
def get_html_parts(component_id: str) -> List[str]:
if component_id not in html_parts_by_component_id:
html_parts_by_component_id[component_id] = []
return html_parts_by_component_id[component_id]
def handle_error(component_id: str, error: Exception) -> None:
# Cleanup
# Remove any HTML parts that were already rendered for this component
html_parts_by_component_id.pop(component_id, None)
# Mark any remaining parts of this component (that may be still in the queue) as errored
ignored_ids.add(component_id)
# Also mark as ignored any remaining parts of the PARENT component.
# The reason is because due to the error, parent's rendering flow was disrupted.
# Even if parent recovers from the error by returning a new HTML, this new HTML
# may have nothing in common with the original HTML.
parent_id = child_id_to_parent_id[component_id]
if parent_id is not None:
ignored_ids.add(parent_id)
# Add error item to the queue so we handle it in next iteration
process_queue.appendleft(
ErrorPart(
child_id=component_id,
error=error,
)
)
def finalize_component(component_id: str, error: Optional[Exception]) -> None:
parent_id = child_id_to_parent_id[component_id]
component_parts = html_parts_by_component_id.pop(component_id, [])
if error is None:
component_html = "".join(component_parts)
else:
component_html = None
# Allow to optionally override/modify the rendered content from `Component.on_render()`
# and by extensions' `on_component_rendered` hooks.
on_component_rendered = on_component_rendered_callbacks[component_id]
component_html, error = on_component_rendered(component_html, error)
# If this component had an error, then we ignore this component's HTML, and instead
# bubble the error up to the parent component.
if error is not None:
handle_error(component_id=component_id, error=error)
return
if component_html is None:
raise RuntimeError("Unexpected `None` from `Component.on_render()`")
# At this point we have a component, and we've resolved all its children into strings.
# So the component's full HTML is now only strings.
#
# Hence we can transfer the child component's HTML to parent, treating it as if
# the parent component had the rendered HTML in child's place.
if parent_id is not None:
target_list = get_html_parts(parent_id)
target_list.append(component_html)
# If there is no parent, then we're at the root component, and we can add the
# component's HTML to the final output.
else:
content_parts.append(component_html)
# To avoid having to iterate over the queue multiple times to remove from it those
# entries that belong to components that have thrown error, we instead keep track of which
# components have thrown error, and skip any remaining parts of the component.
ignored_ids: Set[str] = set()
while len(process_queue):
curr_item = process_queue.popleft()
# In this case we've reached the end of the component's HTML content, and there's
# no more subcomponents to process.
if curr_item.child_id is None:
# Parent ID must NOT be None in this branch
if curr_item.parent_id is None:
raise RuntimeError("Parent ID is None")
# NOTE: When an error is bubbling up, then the flow goes between `handle_error()`, `finalize_component()`,
# and this branch, until we reach the root component, where the error is finally raised.
#
# Any ancestor component of the one that raised can intercept the error and instead return a new string
# (or a new error).
if isinstance(curr_item, ErrorPart):
parent_id = child_id_to_parent_id[curr_item.child_id]
parent_parts = html_parts_by_component_id.pop(curr_item.parent_id, [])
# If there is no parent, then we're at the root component, so we simply propagate the error.
# This ends the error bubbling.
if parent_id is None:
raise curr_item.error from None # Re-raise
# Add the left-over content
parent_parts.append(curr_item.content_before_component)
# This will make the parent component either handle the error and return a new string instead,
# or propagate the error to its parent.
finalize_component(component_id=parent_id, error=curr_item.error)
continue
# Allow to optionally override/modify the rendered content from outside
component_html = "".join(parent_parts)
on_component_rendered = on_component_rendered_callbacks[curr_item.parent_id]
component_html = on_component_rendered(component_html) # type: ignore[arg-type]
# Skip parts of errored components
elif curr_item.parent_id in ignored_ids:
continue
# Add the component's HTML to parent's parent's HTML parts
if curr_item.grandparent_id is not None:
target_list = get_html_parts(curr_item.grandparent_id)
target_list.append(component_html)
else:
content_parts.append(component_html)
# Process text parts
elif isinstance(curr_item, TextPart):
parent_html_parts = get_html_parts(curr_item.parent_id)
parent_html_parts.append(curr_item.text)
# In this case we've reached the end of the component's HTML content, and there's
# no more subcomponents to process. We can call `finalize_component()` to process
# the component's HTML and eventually trigger `on_component_rendered` hook.
if curr_item.is_last:
finalize_component(component_id=curr_item.parent_id, error=None)
continue
# Process content before the component
if curr_item.content_before_component:
if curr_item.parent_id is None:
raise RuntimeError("Parent ID is None")
parent_html_parts = get_html_parts(curr_item.parent_id)
parent_html_parts.append(curr_item.content_before_component)
# The rest of this branch assumes `curr_item` is a `ComponentPart`
component_id = curr_item.child_id
# Remember which component ID had which parent ID, so we can bubble up errors
# to the parent component.
child_id_to_parent_id[component_id] = curr_item.parent_id
# Generate component's content, applying the extra HTML attributes set by the parent component
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(curr_item.child_id)
# NOTE: This may be undefined, because this is set only for components that
# are also root elements in their parent's HTML
curr_comp_attrs = child_component_attrs.pop(curr_item.child_id, None)
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(component_id)
# NOTE: Attributes passed from parent to current component are `None` for the root component.
curr_comp_attrs = child_component_attrs.pop(component_id, None)
full_path = [*curr_item.component_name_path, curr_comp_name]
@ -249,23 +379,44 @@ def component_post_render(
#
# NOTE: [1:] because the root component will be yet again added to the error's
# `components` list in `_render_with_error_trace` so we remove the first element from the path.
with component_error_message(full_path[1:]):
curr_comp_content, grandchild_component_attrs = curr_comp_renderer(curr_comp_attrs)
try:
with component_error_message(full_path[1:]):
comp_content, grandchild_component_attrs, on_render_generator = curr_comp_renderer(curr_comp_attrs)
# This error may be triggered when any of following raises:
# - `Component.on_render()` (first part - before yielding)
# - `Component.on_render_before()`
# - Rendering of component's template
#
# In all cases, we want to mark the component as errored, and let the parent handle it.
except Exception as err:
handle_error(component_id=component_id, error=err)
continue
# Exclude the `data-djc-scope-...` attribute from being applied to the child component's HTML
for key in list(grandchild_component_attrs.keys()):
if key.startswith("data-djc-scope-"):
grandchild_component_attrs.pop(key, None)
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
# AFTER the yield will be called once, when the component's HTML is fully rendered.
#
# We want to make sure we call the second part of `Component.on_render()` BEFORE
# we call `Component.on_render_after()`. The latter will be triggered by calling
# corresponding `on_component_rendered`.
#
# So we want to wrap the `on_component_rendered` callback, so we get to call the generator first.
if on_render_generator is not None:
unwrapped_on_component_rendered = on_component_rendered_callbacks[component_id]
on_component_rendered_callbacks[component_id] = _call_generator_before_callback(
on_render_generator,
unwrapped_on_component_rendered,
)
child_component_attrs.update(grandchild_component_attrs)
# Process the component's content
# Split component's content by placeholders, and put the pairs of
# `(text_between_components, placeholder_id)`
# into the queue.
last_index = 0
parts_to_process: List[PostRenderQueueItem] = []
# 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):
part_before_component = curr_comp_content[last_index : match.start()] # noqa: E203
parts_to_process: List[Union[TextPart, ComponentPart]] = []
for match in nested_comp_pattern.finditer(comp_content):
part_before_component = comp_content[last_index : match.start()] # noqa: E203
last_index = match.end()
comp_part = match[0]
@ -274,27 +425,31 @@ def component_post_render(
if grandchild_id_match is None:
raise ValueError(f"No placeholder ID found in {comp_part}")
grandchild_id = grandchild_id_match.group("render_id")
parts_to_process.append(
PostRenderQueueItem(
content_before_component=part_before_component,
child_id=grandchild_id,
parent_id=curr_item.child_id,
grandparent_id=curr_item.parent_id,
component_name_path=full_path,
)
parts_to_process.extend(
[
TextPart(
text=part_before_component,
is_last=False,
parent_id=component_id,
),
ComponentPart(
child_id=grandchild_id,
parent_id=component_id,
component_name_path=full_path,
),
]
)
# Append any remaining text
parts_to_process.append(
PostRenderQueueItem(
content_before_component=curr_comp_content[last_index:],
# Setting `child_id` to None means that this is the last part of the component's HTML
# and we're done with this component
child_id=None,
parent_id=curr_item.child_id,
grandparent_id=curr_item.parent_id,
component_name_path=full_path,
)
parts_to_process.extend(
[
TextPart(
text=comp_content[last_index:],
is_last=True,
parent_id=component_id,
),
]
)
process_queue.extendleft(reversed(parts_to_process))
@ -305,3 +460,44 @@ def component_post_render(
output = on_html_rendered(output)
return mark_safe(output)
def _call_generator_before_callback(
on_render_generator: Optional["OnRenderGenerator"],
inner_fn: Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
) -> Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]:
if on_render_generator is None:
return inner_fn
def on_component_rendered_wrapper(
html: Optional[str],
error: Optional[Exception],
) -> OnComponentRenderedResult:
try:
on_render_generator.send((html, error))
# `Component.on_render()` should contain only one `yield` statement, so calling `.send()`
# should reach `return` statement in `Component.on_render()`, which triggers `StopIteration`.
# In that case, the value returned from `Component.on_render()` with the `return` keyword
# is the new output (if not `None`).
except StopIteration as generator_err:
# To override what HTML / error gets returned, user may either:
# - Return a new HTML at the end of `Component.on_render()` (after yielding),
# - Raise a new error
new_output = generator_err.value
if new_output is not None:
html = new_output
error = None
# Catch if `Component.on_render()` raises an exception, in which case this becomes
# the new error.
except Exception as new_error:
error = new_error
html = None
# This raises if `StopIteration` was not raised, which may be if `Component.on_render()`
# contains more than one `yield` statement.
else:
raise RuntimeError("`Component.on_render()` must include only one `yield` statement")
return inner_fn(html, error)
return on_component_rendered_wrapper