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

@ -586,6 +586,35 @@ Summary:
)
```
- Component method `on_render_after` was updated to receive also `error` field.
For backwards compatibility, the `error` field can be omitted until v1.
Before:
```py
def on_render_after(
self,
context: Context,
template: Template,
html: str,
) -> None:
pass
```
After:
```py
def on_render_after(
self,
context: Context,
template: Template,
html: Optional[str],
error: Optional[Exception],
) -> None:
pass
```
- If you are using the Components as views, the way to access the component class is now different.
Instead of `self.component`, use `self.component_cls`. `self.component` will be removed in v1.
@ -638,7 +667,7 @@ Summary:
from django_components import ComponentExtension
class MyExtension(ComponentExtension):
class ExtensionClass(ComponentExtension.ComponentConfig):
class ExtensionClass(ComponentExtension.ExtensionClass):
pass
```
@ -938,7 +967,7 @@ Summary:
class Slots(NamedTuple):
content: SlotInput
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.kwargs.per_page == 10
content_html = self.slots.content()
@ -970,6 +999,19 @@ Summary:
Same as with the parameters in `Component.get_template_data()`, they will be instances of the `Args`, `Kwargs`, `Slots` classes
if defined, or plain lists / dictionaries otherwise.
- New component lifecycle hook `Component.on_render()`.
This hook is called when the component is being rendered.
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
See [on_render](https://django-components.github.io/django-components/0.140/concepts/advanced/hooks/#on_render) for more info.
- `get_component_url()` now optionally accepts `query` and `fragment` arguments.
```py
@ -1164,7 +1206,7 @@ Summary:
from django_components import ComponentExtension
class MyExtension(ComponentExtension):
class ExtensionClass(ComponentExtension.ComponentConfig): # Error!
class ExtensionClass(ComponentExtension.ExtensionClass): # Error!
pass
```
@ -1221,6 +1263,34 @@ Summary:
- Passing `Slot` instance to `Slot` constructor raises an error.
- Extension hook `on_component_rendered` now receives `error` field.
`on_component_rendered` now behaves similar to `Component.on_render_after`:
- Raising error in this hook overrides what error will be returned from `Component.render()`.
- Returning new string overrides what will be returned from `Component.render()`.
Before:
```py
class OnComponentRenderedContext(NamedTuple):
component: "Component"
component_cls: Type["Component"]
component_id: str
result: str
```
After:
```py
class OnComponentRenderedContext(NamedTuple):
component: "Component"
component_cls: Type["Component"]
component_id: str
result: Optional[str]
error: Optional[Exception]
```
#### Fix
- Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)).

View file

@ -1,58 +1,333 @@
_New in version 0.96_
Component hooks are functions that allow you to intercept the rendering process at specific positions.
Intercept the rendering lifecycle with Component hooks.
Unlike the [extension hooks](../../../reference/extension_hooks/), these are defined directly
on the [`Component`](../../../reference/api#django_components.Component) class.
## Available hooks
- `on_render_before`
### `on_render_before`
```py
def on_render_before(
self: Component,
context: Context,
template: Template
) -> None:
```
```py
def on_render_before(
self: Component,
context: Context,
template: Optional[Template],
) -> None:
```
Hook that runs just before the component's template is rendered.
[`Component.on_render_before`](../../../reference/api#django_components.Component.on_render_before) 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.
```py
def on_render_before(self, context, template) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
It receives the [Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
and the [Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
as arguments.
# Append text into the Template
template.nodelist.append(TextNode("FROM_ON_BEFORE"))
```
The `template` argument is `None` if the component has no template.
- `on_render_after`
**Example:**
```py
def on_render_after(
self: Component,
context: Context,
template: Template,
content: str
) -> None | str | SafeString:
```
You can use this hook to access the context or the template:
Hook that runs just after the component's template was rendered.
It receives the rendered output as the last argument.
```py
from django.template import Context, Template
from django_components import Component
You can use this hook to access the context or the template, but modifying
them won't have any effect.
class MyTable(Component):
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
To override the content that gets rendered, you can return a string or SafeString from this hook:
assert isinstance(template, Template)
```
```py
def on_render_after(self, context, template, content):
# Prepend text to the rendered content
return "Chocolate cookie recipe: " + content
```
!!! warning
## Component hooks example
If you want to pass data to the template, prefer using
[`get_template_data()`](../../../reference/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.
### `on_render`
_New in version 0.140_
```py
def on_render(
self: Component,
context: Context,
template: Optional[Template],
) -> Union[str, SafeString, OnRenderGenerator, None]:
```
[`Component.on_render`](../../../reference/api#django_components.Component.on_render) does the actual rendering.
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"
```
You can also use [`on_render()`](../../../reference/api#django_components.Component.on_render) as a router,
rendering other components based on the parent component's arguments:
```py
class MyTable(Component):
def on_render(self, context, template):
# Select different component based on `feature_new_table` kwarg
if self.kwargs.get("feature_new_table"):
comp_cls = NewTable
else:
comp_cls = OldTable
# Render the selected component
return comp_cls.render(
args=self.args,
kwargs=self.kwargs,
slots=self.slots,
context=context,
)
```
#### Post-processing rendered template
When you render the original template in [`on_render()`](../../../reference/api#django_components.Component.on_render) as:
```py
template.render(context)
```
The result is NOT the final output, but an intermediate result. Nested components
are not rendered yet.
Instead, django-components needs to take this result and process it
to actually render the child components.
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}")
```
#### Example: ErrorBoundary
[`on_render()`](../../../reference/api#django_components.Component.on_render) can be used to
implement React's [ErrorBoundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary).
That is, a component that catches errors in nested components and displays a fallback UI instead:
```django
{% component "error_boundary" %}
{% fill "content" %}
{% component "nested_component" %}
{% endfill %}
{% fill "fallback" %}
Sorry, something went wrong.
{% endfill %}
{% endcomponent %}
```
To implement this, we render the fallback slot in [`on_render()`](../../../reference/api#django_components.Component.on_render)
and return it if an error occured:
```djc_py
class ErrorFallback(Component):
template = """
{% slot "content" default / %}
"""
def on_render(self, context, template):
fallback = self.slots.fallback
if fallback is None:
raise ValueError("fallback slot is required")
html, error = yield template.render(context)
if error is not None:
return fallback()
else:
return html
```
### `on_render_after`
```py
def on_render_after(
self: Component,
context: Context,
template: Optional[Template],
result: Optional[str | SafeString],
error: Optional[Exception],
) -> Union[str, SafeString, None]:
```
[`on_render_after()`](../../../reference/api#django_components.Component.on_render_after) runs when the component was fully rendered,
including all its children.
It receives the same arguments as [`on_render_before()`](#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()`](../../../reference/api#django_components.Component.on_render_after) behaves the same way
as the second part of [`on_render()`](#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()`](#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}")
```
## Example
You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components
that accept a list of items via a slot.

View file

@ -118,6 +118,92 @@ class Button(Component):
</button>
```
### Dynamic templates
Each component has only a single template associated with it.
However, whether it's for A/B testing or for preserving public API
when sharing your components, sometimes you may need to render different templates
based on the input to your component.
You can use [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render)
to dynamically override what template gets rendered.
By default, the component's template is rendered as-is.
```py
class Table(Component):
def on_render(self, context: Context, template: Optional[Template]):
if template is not None:
return template.render(context)
```
If you want to render a different template in its place,
we recommended you to:
1. Wrap the substitute templates as new Components
2. Then render those Components inside [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render):
```py
class TableNew(Component):
template_file = "table_new.html"
class TableOld(Component):
template_file = "table_old.html"
class Table(Component):
def on_render(self, context, template):
if self.kwargs.get("feat_table_new_ui"):
return TableNew.render(
args=self.args,
kwargs=self.kwargs,
slots=self.slots,
)
else:
return TableOld.render(
args=self.args,
kwargs=self.kwargs,
slots=self.slots,
)
```
!!! warning
If you do not wrap the templates as Components,
there is a risk that some [extensions](../../advanced/extensions) will not work as expected.
```py
new_template = Template("""
{% load django_components %}
<div>
{% slot "content" %}
Other template
{% endslot %}
</div>
""")
class Table(Component):
def on_render(self, context, template):
if self.kwargs.get("feat_table_new_ui"):
return new_template.render(context)
else:
return template.render(context)
```
### Template-less components
Since you can use [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render)
to render *other* components, there is no need to define a template for the component.
So even an empty component like this is valid:
```py
class MyComponent(Component):
pass
```
These "template-less" components can be useful as base classes for other components, or as mixins.
### HTML processing
Django Components expects the rendered template to be a valid HTML. This is needed to enable features like [CSS / JS variables](../html_js_css_variables).

View file

@ -205,7 +205,7 @@ and [`self.slots`](../../../reference/api/#django_components.Component.slots) pr
```py
class ProfileCard(Component):
def on_render_before(self, *args, **kwargs):
def on_render_before(self, context: Context, template: Optional[Template]):
# Access inputs via self.args, self.kwargs, self.slots
self.args[0]
self.kwargs.get("show_details", False)

View file

@ -10,6 +10,7 @@ Render API is available inside these [`Component`](../../../reference/api#django
- [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data)
- [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
- [`on_render_before()`](../../../reference/api#django_components.Component.on_render_before)
- [`on_render()`](../../../reference/api#django_components.Component.on_render)
- [`on_render_after()`](../../../reference/api#django_components.Component.on_render_after)
Example:
@ -89,7 +90,7 @@ class Table(Component):
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
@ -104,7 +105,7 @@ Without `Args` class:
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
```
@ -131,7 +132,7 @@ class Table(Component):
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
@ -146,7 +147,7 @@ Without `Kwargs` class:
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
```
@ -173,7 +174,7 @@ class Table(Component):
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)
@ -191,7 +192,7 @@ Without `Slots` class:
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)
```

View file

@ -305,8 +305,8 @@ class Calendar(Component):
"my_slot": content,
}
# But in other methods you can still access the slots with `Component.slots`
def on_render_before(self, *args, **kwargs):
# In other methods you can still access the slots with `Component.slots`
def on_render_before(self, context, template):
if "my_slot" in self.slots:
# Do something
```

View file

@ -135,13 +135,13 @@ Thus, you can check where a slot was filled from by printing it out:
```python
class MyComponent(Component):
def on_render_before(self, *args, **kwargs):
def on_render_before(self, context: Context, template: Optional[Template]):
print(self.slots)
```
might print:
```txt
```python
{
'content': <Slot component_name='layout' slot_name='content'>,
'header': <Slot component_name='my_page' slot_name='header'>,

View file

@ -91,6 +91,10 @@
options:
show_if_no_docstring: true
::: django_components.OnRenderGenerator
options:
show_if_no_docstring: true
::: django_components.ProvideNode
options:
show_if_no_docstring: true

View file

@ -126,7 +126,8 @@ name | type | description
`component` | [`Component`](../api#django_components.Component) | The Component instance that is being rendered
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class
`component_id` | `str` | The unique identifier for this component instance
`result` | `str` | The rendered component
`error` | `Optional[Exception]` | The error that occurred during rendering, or `None` if rendering was successful
`result` | `Optional[str]` | The rendered component, or `None` if rendering failed
::: django_components.extension.ComponentExtension.on_component_unregistered
options:

View file

@ -20,7 +20,7 @@ Import as
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1022" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1010" target="_blank">See source code</a>
@ -43,7 +43,7 @@ If you insert this tag multiple times, ALL CSS links will be duplicately inserte
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1044" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1032" target="_blank">See source code</a>
@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L3350" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L3685" target="_blank">See source code</a>

View file

@ -3,8 +3,7 @@
# Template variables
Here is a list of all variables that are automatically available from inside the component's
template and in [`on_render_before` / `on_render_after`](../concepts/advanced/hooks.md#available-hooks)
hooks.
template:
::: django_components.component.ComponentVars.args

View file

@ -1,5 +1,4 @@
# Template variables
Here is a list of all variables that are automatically available from inside the component's
template and in [`on_render_before` / `on_render_after`](../concepts/advanced/hooks.md#available-hooks)
hooks.
template:

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

View file

@ -4477,7 +4477,7 @@ class Tabs(Component):
"tabs_data": {"name": name},
}
def on_render_after(self, context, template, rendered) -> str:
def on_render_after(self, context, template, rendered, error=None) -> str:
# By the time we get here, all child TabItem components should have been
# rendered, and they should've populated the tabs list.
tabs: List[TabEntry] = context["tabs"]
@ -4530,7 +4530,7 @@ class TabItem(Component):
"disabled": disabled,
}
def on_render_after(self, context, template, content) -> None:
def on_render_after(self, context, template, content, error=None) -> None:
parent_tabs: List[dict] = context["parent_tabs"]
parent_tabs.append({
"header": context["header"],

View file

@ -5,7 +5,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
import os
import re
from typing import Any, NamedTuple
from typing import Any, List, Literal, NamedTuple, Optional
import pytest
from django.conf import settings
@ -25,6 +25,7 @@ from django_components import (
all_components,
get_component_by_class_id,
register,
registry,
types,
)
from django_components.template import _get_component_template
@ -1429,260 +1430,555 @@ class TestComponentRender:
@djc_test
class TestComponentHook:
def test_on_render_before(self):
@register("nested")
class NestedComponent(Component):
def _gen_slotted_component(self, calls: List[str]):
class Slotted(Component):
template = "Hello from slotted"
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
calls.append("slotted__on_render_before")
def on_render(self, context: Context, template: Optional[Template]):
calls.append("slotted__on_render_pre")
html, error = yield template.render(context) # type: ignore[union-attr]
calls.append("slotted__on_render_post")
# Check that modifying the context or template does nothing
def on_render_after(
self,
context: Context,
template: Optional[Template],
html: Optional[str],
error: Optional[Exception],
) -> None:
calls.append("slotted__on_render_after")
return Slotted
def _gen_inner_component(self, calls: List[str]):
class Inner(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
Inner start
{% slot "content" default / %}
Inner end
"""
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
calls.append("inner__on_render_before")
def on_render(self, context: Context, template: Optional[Template]):
calls.append("inner__on_render_pre")
if template is None:
yield None
else:
html, error = yield template.render(context)
calls.append("inner__on_render_post")
# Check that modifying the context or template does nothing
def on_render_after(
self,
context: Context,
template: Optional[Template],
html: Optional[str],
error: Optional[Exception],
) -> None:
calls.append("inner__on_render_after")
return Inner
def _gen_middle_component(self, calls: List[str]):
class Middle(Component):
template: types.django_html = """
{% load component_tags %}
Middle start
{% component "inner" %}
{% component "slotted" / %}
{% endcomponent %}
Middle text
{% component "inner" / %}
Middle end
"""
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
calls.append("middle__on_render_before")
def on_render(self, context: Context, template: Optional[Template]):
calls.append("middle__on_render_pre")
html, error = yield template.render(context) # type: ignore[union-attr]
calls.append("middle__on_render_post")
# Check that modifying the context or template does nothing
def on_render_after(
self,
context: Context,
template: Optional[Template],
html: Optional[str],
error: Optional[Exception],
) -> None:
calls.append("middle__on_render_after")
return Middle
def _gen_outer_component(self, calls: List[str]):
class Outer(Component):
template: types.django_html = """
{% load component_tags %}
Outer start
{% component "middle" / %}
Outer text
{% component "middle" / %}
Outer end
"""
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
calls.append("outer__on_render_before")
def on_render(self, context: Context, template: Optional[Template]):
calls.append("outer__on_render_pre")
html, error = yield template.render(context) # type: ignore[union-attr]
calls.append("outer__on_render_post")
# Check that modifying the context or template does nothing
def on_render_after(
self,
context: Context,
template: Optional[Template],
html: Optional[str],
error: Optional[Exception],
) -> None:
calls.append("outer__on_render_after")
return Outer
def _gen_broken_component(self):
class BrokenComponent(Component):
def on_render(self, context: Context, template: Template):
raise ValueError("BROKEN")
return BrokenComponent
def test_order(self):
calls: List[str] = []
registry.register("slotted", self._gen_slotted_component(calls))
registry.register("inner", self._gen_inner_component(calls))
registry.register("middle", self._gen_middle_component(calls))
Outer = self._gen_outer_component(calls)
result = Outer.render()
assertHTMLEqual(
result,
"""
Outer start
Middle start
Inner start
Hello from slotted
Inner end
Middle text
Inner start
Inner end
Middle end
Outer text
Middle start
Inner start
Hello from slotted
Inner end
Middle text
Inner start
Inner end
Middle end
Outer end
""",
)
assert calls == [
"outer__on_render_before",
"outer__on_render_pre",
"middle__on_render_before",
"middle__on_render_pre",
"inner__on_render_before",
"inner__on_render_pre",
"slotted__on_render_before",
"slotted__on_render_pre",
"slotted__on_render_post",
"slotted__on_render_after",
"inner__on_render_post",
"inner__on_render_after",
"inner__on_render_before",
"inner__on_render_pre",
"inner__on_render_post",
"inner__on_render_after",
"middle__on_render_post",
"middle__on_render_after",
"middle__on_render_before",
"middle__on_render_pre",
"inner__on_render_before",
"inner__on_render_pre",
"slotted__on_render_before",
"slotted__on_render_pre",
"slotted__on_render_post",
"slotted__on_render_after",
"inner__on_render_post",
"inner__on_render_after",
"inner__on_render_before",
"inner__on_render_pre",
"inner__on_render_post",
"inner__on_render_after",
"middle__on_render_post",
"middle__on_render_after",
"outer__on_render_post",
"outer__on_render_after",
]
def test_context(self):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_before: {{ from_on_before }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
from_on_before__edited1: {{ from_on_before__edited1 }}
from_on_before__edited2: {{ from_on_before__edited2 }}
from_on_render_pre: {{ from_on_render_pre }}
from_on_render_post: {{ from_on_render_post }}
from_on_render_pre__edited2: {{ from_on_render_pre__edited2 }}
from_on_render_post__edited2: {{ from_on_render_post__edited2 }}
from_on_after: {{ from_on_after }}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"args": args,
"kwargs": kwargs,
}
def on_render_before(self, context: Context, template: Template) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
context["from_on_before"] = "1"
def on_render(self, context: Context, template: Template):
context["from_on_render_pre"] = "2"
# Check we can modify entries set by other methods
context["from_on_before__edited1"] = context["from_on_before"] + " (on_render)"
html, error = yield template.render(context)
context["from_on_render_post"] = "3"
# NOTE: Since this is called AFTER the render, the values set here should NOT
# make it to the rendered output.
def on_render_after(
self,
context: Context,
template: Template,
html: Optional[str],
error: Optional[Exception],
) -> None:
context["from_on_after"] = "4"
# Check we can modify entries set by other methods
# NOTE: These also check that the previous values are available
context["from_on_before__edited2"] = context["from_on_before"] + " (on_render_after)"
context["from_on_render_pre__edited2"] = context["from_on_render_pre"] + " (on_render_after)"
context["from_on_render_post__edited2"] = context["from_on_render_post"] + " (on_render_after)"
rendered = SimpleComponent.render()
assertHTMLEqual(
rendered,
"""
from_on_before: 1
from_on_before__edited1: 1 (on_render)
from_on_before__edited2:
from_on_render_pre: 2
from_on_render_post:
from_on_render_pre__edited2:
from_on_render_post__edited2:
from_on_after:
""",
)
def test_template(self):
class SimpleComponent(Component):
template: types.django_html = """
text
"""
def on_render_before(self, context: Context, template: Template) -> None:
# Insert text into the Template
#
# NOTE: Users should NOT do this, because this will insert the text every time
# the component is rendered.
template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE"))
rendered = SimpleComponent.render()
assertHTMLEqual(
rendered,
"""
args: []
kwargs: {}
---
from_on_before: :)
---
Hello from nested
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
Hello from simple
</div>
---
FROM_ON_BEFORE
""",
)
def on_render(self, context: Context, template: Template):
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_PRE"))
# Check that modifying the context or template does nothing
def test_on_render_after(self):
captured_content = None
html, error = yield template.render(context)
@register("nested")
class NestedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
"""
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_POST"))
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_after: {{ from_on_after }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"args": args,
"kwargs": kwargs,
}
# Check that modifying the context or template does nothing
def on_render_after(self, context: Context, template: Template, content: str) -> None:
# Insert value into the Context
context["from_on_after"] = ":)"
# Insert text into the Template
# NOTE: Since this is called AFTER the render, the values set here should NOT
# make it to the rendered output.
def on_render_after(
self,
context: Context,
template: Template,
html: Optional[str],
error: Optional[Exception],
) -> None:
template.nodelist.append(TextNode("\n---\nFROM_ON_AFTER"))
nonlocal captured_content
captured_content = content
rendered = SimpleComponent.render()
assertHTMLEqual(
captured_content,
"""
args: []
kwargs: {}
---
from_on_after:
---
Hello from nested
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
Hello from simple
</div>
""",
)
assertHTMLEqual(
rendered,
"""
args: []
kwargs: {}
text
---
from_on_after:
FROM_ON_BEFORE
---
Hello from nested
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
Hello from simple
</div>
FROM_ON_RENDER_PRE
""",
)
# Check that modifying the context or template does nothing
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_on_render_after_override_output(self, components_settings):
captured_content = None
@register("nested")
class NestedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
"""
def test_on_render_no_yield(self):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_before: {{ from_on_before }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
text
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"args": args,
"kwargs": kwargs,
}
def on_render_after(self, context: Context, template: Template, content: str) -> str:
nonlocal captured_content
captured_content = content
return "Chocolate cookie recipe: " + content
def on_render(self, context: Context, template: Template):
return "OVERRIDDEN"
rendered = SimpleComponent.render()
assert rendered == "OVERRIDDEN"
assertHTMLEqual(
captured_content,
"""
args: []
kwargs: {}
---
from_on_before:
---
Hello from nested
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
Hello from simple
</div>
""",
)
assertHTMLEqual(
rendered,
"""
Chocolate cookie recipe:
args: []
kwargs: {}
---
from_on_before:
---
Hello from nested
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
Hello from simple
</div>
""",
)
def test_on_render_before_after_same_context(self):
context_in_before = None
context_in_after = None
@register("nested")
class NestedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
"""
def test_on_render_reraise_error(self):
registry.register("broken", self._gen_broken_component())
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_after: {{ from_on_after }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
{% component "broken" / %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"args": args,
"kwargs": kwargs,
}
def on_render(self, context: Context, template: Template):
html, error = yield template.render(context)
def on_render_before(self, context: Context, template: Template) -> None:
context["from_on_before"] = ":)"
nonlocal context_in_before
context_in_before = context
raise error from None # Re-raise original error
# Check that modifying the context or template does nothing
def on_render_after(self, context: Context, template: Template, html: str) -> None:
context["from_on_after"] = ":)"
nonlocal context_in_after
context_in_after = context
with pytest.raises(ValueError, match=re.escape("BROKEN")):
SimpleComponent.render()
SimpleComponent.render()
@djc_test(
parametrize=(
["template", "action", "method"],
[
["simple", "return_none", "on_render"],
["broken", "return_none", "on_render"],
[None, "return_none", "on_render"],
assert context_in_before == context_in_after
assert "from_on_before" in context_in_before # type: ignore[operator]
assert "from_on_after" in context_in_after # type: ignore[operator]
["simple", "return_none", "on_render_after"],
["broken", "return_none", "on_render_after"],
[None, "return_none", "on_render_after"],
["simple", "no_return", "on_render"],
["broken", "no_return", "on_render"],
[None, "no_return", "on_render"],
["simple", "no_return", "on_render_after"],
["broken", "no_return", "on_render_after"],
[None, "no_return", "on_render_after"],
["simple", "raise_error", "on_render"],
["broken", "raise_error", "on_render"],
[None, "raise_error", "on_render"],
["simple", "raise_error", "on_render_after"],
["broken", "raise_error", "on_render_after"],
[None, "raise_error", "on_render_after"],
["simple", "return_html", "on_render"],
["broken", "return_html", "on_render"],
[None, "return_html", "on_render"],
["simple", "return_html", "on_render_after"],
["broken", "return_html", "on_render_after"],
[None, "return_html", "on_render_after"],
],
None
)
)
def test_result_interception(
self,
template: Literal["simple", "broken", None],
action: Literal["return_none", "no_return", "raise_error", "return_html"],
method: Literal["on_render", "on_render_after"],
):
calls: List[str] = []
Broken = self._gen_broken_component()
Slotted = self._gen_slotted_component(calls)
Inner = self._gen_inner_component(calls)
Middle = self._gen_middle_component(calls)
Outer = self._gen_outer_component(calls)
# Make modifications to the components based on the parameters
# Set template
if template is None:
class Inner(Inner): # type: ignore
template = None
elif template == "broken":
class Inner(Inner): # type: ignore
template = "{% component 'broken' / %}"
elif template == "simple":
pass
# Set `on_render` behavior
if method == "on_render":
if action == "return_none":
class Inner(Inner): # type: ignore
def on_render(self, context: Context, template: Optional[Template]):
if template is None:
yield None
else:
html, error = yield template.render(context)
return None
elif action == "no_return":
class Inner(Inner): # type: ignore
def on_render(self, context: Context, template: Optional[Template]):
if template is None:
yield None
else:
html, error = yield template.render(context)
elif action == "raise_error":
class Inner(Inner): # type: ignore
def on_render(self, context: Context, template: Optional[Template]):
if template is None:
yield None
else:
html, error = yield template.render(context)
raise ValueError("ERROR_FROM_ON_RENDER")
elif action == "return_html":
class Inner(Inner): # type: ignore
def on_render(self, context: Context, template: Optional[Template]):
if template is None:
yield None
else:
html, error = yield template.render(context)
return "HTML_FROM_ON_RENDER"
else:
raise pytest.fail(f"Unknown action: {action}")
# Set `on_render_after` behavior
elif method == "on_render_after":
if action == "return_none":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
return None
elif action == "no_return":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
pass
elif action == "raise_error":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
raise ValueError("ERROR_FROM_ON_RENDER")
elif action == "return_html":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
return "HTML_FROM_ON_RENDER"
else:
raise pytest.fail(f"Unknown action: {action}")
else:
raise pytest.fail(f"Unknown method: {method}")
registry.register("broken", Broken)
registry.register("slotted", Slotted)
registry.register("inner", Inner)
registry.register("middle", Middle)
registry.register("outer", Outer)
def _gen_expected_output(inner1: str, inner2: str):
return f"""
Outer start
Middle start
{inner1}
Middle text
{inner2}
Middle end
Outer text
Middle start
{inner1}
Middle text
{inner2}
Middle end
Outer end
"""
# Assert based on the behavior
if template is None:
# Overriden HTML
if action == "return_html":
expected = _gen_expected_output(inner1="HTML_FROM_ON_RENDER", inner2="HTML_FROM_ON_RENDER")
result = Outer.render()
assertHTMLEqual(result, expected)
# Overriden error
elif action == "raise_error":
with pytest.raises(ValueError, match="ERROR_FROM_ON_RENDER"):
Outer.render()
# Original output
elif action in ["return_none", "no_return"]:
expected = _gen_expected_output(inner1="", inner2="")
result = Outer.render()
assertHTMLEqual(result, expected)
else:
raise pytest.fail(f"Unknown action: {action}")
elif template == "simple":
# Overriden HTML
if action == "return_html":
expected = _gen_expected_output(inner1="HTML_FROM_ON_RENDER", inner2="HTML_FROM_ON_RENDER")
result = Outer.render()
assertHTMLEqual(result, expected)
# Overriden error
elif action == "raise_error":
with pytest.raises(ValueError, match="ERROR_FROM_ON_RENDER"):
Outer.render()
# Original output
elif action in ["return_none", "no_return"]:
expected = _gen_expected_output(
inner1="Inner start Hello from slotted Inner end",
inner2="Inner start Inner end",
)
result = Outer.render()
assertHTMLEqual(result, expected)
else:
raise pytest.fail(f"Unknown action: {action}")
elif template == "broken":
# Overriden HTML
if action == "return_html":
expected = _gen_expected_output(inner1="HTML_FROM_ON_RENDER", inner2="HTML_FROM_ON_RENDER")
result = Outer.render()
assertHTMLEqual(result, expected)
# Overriden error
elif action == "raise_error":
with pytest.raises(ValueError, match="ERROR_FROM_ON_RENDER"):
Outer.render()
# Original output
elif action in ["return_none", "no_return"]:
with pytest.raises(ValueError, match="broken"):
Outer.render()
else:
raise pytest.fail(f"Unknown action: {action}")
else:
raise pytest.fail(f"Unknown template: {template}")
@djc_test

View file

@ -1218,11 +1218,17 @@ class TestContextVarsIsFilled:
@register("is_filled_vars")
class IsFilledVarsComponent(self.IsFilledVarsComponent): # type: ignore[name-defined]
def on_render_before(self, context: Context, template: Template) -> None:
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
nonlocal captured_before
captured_before = self.is_filled.copy()
def on_render_after(self, context: Context, template: Template, content: str) -> None:
def on_render_after(
self,
context: Context,
template: Optional[Template],
content: Optional[str],
error: Optional[Exception],
) -> None:
nonlocal captured_after
captured_after = self.is_filled.copy()

View file

@ -21,6 +21,7 @@ from django_components.extension import (
OnComponentUnregisteredContext,
OnComponentInputContext,
OnComponentDataContext,
OnComponentRenderedContext,
OnSlotRenderedContext,
)
from django_components.extensions.cache import CacheExtension
@ -82,6 +83,7 @@ class DummyExtension(ComponentExtension):
"on_component_unregistered": [],
"on_component_input": [],
"on_component_data": [],
"on_component_rendered": [],
"on_slot_rendered": [],
}
@ -118,6 +120,9 @@ class DummyExtension(ComponentExtension):
def on_component_data(self, ctx: OnComponentDataContext) -> None:
self.calls["on_component_data"].append(ctx)
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None:
self.calls["on_component_rendered"].append(ctx)
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None:
self.calls["on_slot_rendered"].append(ctx)
@ -147,6 +152,20 @@ class SlotOverrideExtension(ComponentExtension):
return "OVERRIDEN BY EXTENSION"
class ErrorOnComponentRenderedExtension(ComponentExtension):
name = "error_on_component_rendered"
def on_component_rendered(self, ctx: OnComponentRenderedContext):
raise RuntimeError("Custom error from extension")
class ReturnHtmlOnComponentRenderedExtension(ComponentExtension):
name = "return_html_on_component_rendered"
def on_component_rendered(self, ctx: OnComponentRenderedContext):
return f"<div>OVERRIDDEN: {ctx.result}</div>"
def with_component_cls(on_created: Callable):
class TempComponent(Component):
template = "Hello {{ name }}!"
@ -340,6 +359,45 @@ class TestExtensionHooks:
assert data_call.js_data == {"script": "console.log('Hello!')"}
assert data_call.css_data == {"style": "body { color: blue; }"}
# Verify on_component_rendered was called with correct args
assert len(extension.calls["on_component_rendered"]) == 1
rendered_call: OnComponentRenderedContext = extension.calls["on_component_rendered"][0]
assert rendered_call.component_cls == TestComponent
assert isinstance(rendered_call.component, TestComponent)
assert isinstance(rendered_call.component_id, str)
assert rendered_call.result == "<!-- _RENDERED TestComponent_f4a4f0,ca1bc3e,, -->Hello Test!"
assert rendered_call.error is None
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_render_hooks__error(self):
@register("test_comp")
class TestComponent(Component):
template = "Hello {{ name }}!"
def on_render_after(self, context, template, result, error):
raise Exception("Oopsie woopsie")
with pytest.raises(Exception, match="Oopsie woopsie"):
# Render the component with some args and kwargs
TestComponent.render(
context=Context({"foo": "bar"}),
args=("arg1", "arg2"),
kwargs={"name": "Test"},
slots={"content": "Some content"},
)
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# Verify on_component_rendered was called with correct args
assert len(extension.calls["on_component_rendered"]) == 1
rendered_call: OnComponentRenderedContext = extension.calls["on_component_rendered"][0]
assert rendered_call.component_cls == TestComponent
assert isinstance(rendered_call.component, TestComponent)
assert isinstance(rendered_call.component_id, str)
assert rendered_call.result is None
assert isinstance(rendered_call.error, Exception)
assert str(rendered_call.error) == "An error occured while rendering components TestComponent:\nOopsie woopsie"
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_on_slot_rendered(self):
@register("test_comp")
@ -387,6 +445,30 @@ class TestExtensionHooks:
assert rendered == "Hello OVERRIDEN BY EXTENSION!"
@djc_test(components_settings={"extensions": [ErrorOnComponentRenderedExtension]})
def test_on_component_rendered__error_from_extension(self):
@register("test_comp_error_ext")
class TestComponent(Component):
template = "Hello {{ name }}!"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
with pytest.raises(RuntimeError, match="Custom error from extension"):
TestComponent.render(args=(), kwargs={"name": "Test"})
@djc_test(components_settings={"extensions": [ReturnHtmlOnComponentRenderedExtension]})
def test_on_component_rendered__return_html_from_extension(self):
@register("test_comp_html_ext")
class TestComponent(Component):
template = "Hello {{ name }}!"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
rendered = TestComponent.render(args=(), kwargs={"name": "Test"})
assert rendered == "<div>OVERRIDDEN: Hello Test!</div>"
@djc_test
class TestExtensionViews: