mirror of
https://github.com/django-components/django-components.git
synced 2025-08-07 15:58:00 +00:00
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:
parent
46e524e37d
commit
eceebb9696
24 changed files with 1793 additions and 417 deletions
|
@ -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
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue