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

@ -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: