refactor: use lamba with yield in Component.on_render() (#1428)
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
Run tests / build (ubuntu-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.11) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.12) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.8) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.9) (push) Waiting to run
Run tests / build (windows-latest, 3.10) (push) Waiting to run
Run tests / build (windows-latest, 3.11) (push) Waiting to run
Run tests / build (windows-latest, 3.12) (push) Waiting to run
Run tests / build (windows-latest, 3.13) (push) Waiting to run
Run tests / build (windows-latest, 3.8) (push) Waiting to run
Run tests / build (windows-latest, 3.9) (push) Waiting to run
Run tests / test_docs (3.13) (push) Waiting to run
Run tests / test_sampleproject (3.13) (push) Waiting to run

This commit is contained in:
Juro Oravec 2025-10-03 18:19:21 +02:00 committed by GitHub
parent 3e837e20c6
commit eee3910b54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 277 additions and 116 deletions

View file

@ -4,23 +4,48 @@
#### Feat #### Feat
- Wrap the template rendering in `Component.on_render()` in a lambda function.
When you wrap the rendering call in a lambda function, and the rendering fails,
the error will be yielded back in the `(None, Exception)` tuple.
Before:
```py
class MyTable(Component):
def on_render(self, context, template):
try:
intermediate = template.render(context)
html, error = yield intermediate
except Exception as e:
html, error = None, e
```
After:
```py
class MyTable(Component):
def on_render(self, context, template):
html, error = yield lambda: template.render(context)
```
- Multiple yields in `Component.on_render()` - You can now yield multiple times within the same `on_render` method for complex rendering scenarios. - Multiple yields in `Component.on_render()` - You can now yield multiple times within the same `on_render` method for complex rendering scenarios.
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
# First yield - render with one context # First yield
with context.push({"mode": "header"}): with context.push({"mode": "header"}):
header_html, header_error = yield template.render(context) header_html, header_error = yield lambda: template.render(context)
# Second yield - render with different context # Second yield
with context.push({"mode": "body"}): with context.push({"mode": "body"}):
body_html, body_error = yield template.render(context) body_html, body_error = yield lambda: template.render(context)
# Third yield - render a string directly # Third yield
footer_html, footer_error = yield "Footer content" footer_html, footer_error = yield "Footer content"
# Process all results and return final output # Process all results
if header_error or body_error or footer_error: if header_error or body_error or footer_error:
return "Error occurred during rendering" return "Error occurred during rendering"

View file

@ -54,9 +54,6 @@ class MyTable(Component):
Do NOT modify the template in this hook. The template is reused across renders. 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` ### `on_render`
_New in version 0.140_ _New in version 0.140_
@ -86,9 +83,7 @@ with the given
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
if template is None: if template:
return None
else:
return template.render(context) return template.render(context)
``` ```
@ -98,14 +93,26 @@ The `template` argument is `None` if the component has no template.
To change what gets rendered, you can: To change what gets rendered, you can:
- Render a different template
- Render a component - Render a component
- Return a different string or SafeString - Render a template
- Return a string or SafeString
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
return "Hello" # Return a string
return "<p>Hello</p>"
# Render a component
return MyOtherTable.render(
args=self.args,
kwargs=self.kwargs,
slots=self.slots,
context=context,
)
# Render a template
return get_template("my_other_table.html").render(context)
``` ```
You can also use [`on_render()`](../../../reference/api#django_components.Component.on_render) as a router, You can also use [`on_render()`](../../../reference/api#django_components.Component.on_render) as a router,
@ -145,14 +152,19 @@ are not rendered yet.
Instead, django-components needs to take this result and process it Instead, django-components needs to take this result and process it
to actually render the child components. to actually render the child components.
To access the final output, you can `yield` the result instead of returning it. This is not a problem when you return the result directly as above. Django-components will take care of rendering the child components.
This will return a tuple of (rendered HTML, error). The error is `None` if the rendering succeeded. But if you want to access the final output, you must `yield` the result instead of returning it.
Yielding the result will return a tuple of `(rendered_html, error)`:
- On success, the error is `None` - `(string, None)`
- On failure, the rendered HTML is `None` - `(None, Exception)`
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
if error is None: if error is None:
# The rendering succeeded # The rendering succeeded
@ -162,23 +174,34 @@ class MyTable(Component):
print(f"Error: {error}") print(f"Error: {error}")
``` ```
!!! warning
Notice that we actually yield a **lambda function** instead of the result itself.
This is because calling `template.render(context)` may raise an exception.
When you wrap the result in a lambda function, and the rendering fails,
the error will be yielded back in the `(None, Exception)` tuple.
At this point you can do 3 things: At this point you can do 3 things:
1. Return a new HTML 1. Return new HTML
The new HTML will be used as the final output. The new HTML will be used as the final output.
If the original template raised an error, it will be ignored. If the original template raised an error, the original error will be ignored.
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
return "NEW HTML" # Fallback if rendering failed
# Otherwise, we keep the original HTML
if error is not None:
return "FALLBACK HTML"
``` ```
2. Raise a new exception 2. Raise new exception
The new exception is what will bubble up from the component. The new exception is what will bubble up from the component.
@ -187,48 +210,55 @@ At this point you can do 3 things:
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
raise Exception("Error message") # Override the original error
# Otherwise, we keep the original HTML
if error is not None:
raise Exception("My new error") from error
``` ```
3. Return nothing (or `None`) to handle the result as usual 3. No change - Return nothing or `None`
If you don't raise an exception, and neither return a new HTML, If you neither raise an exception, nor return a new HTML,
then original HTML / error will be used: then the original HTML / error will be used:
- If rendering succeeded, the original HTML will be used as the final output. - If rendering succeeded, the original HTML will be used as the final output.
- If rendering failed, the original error will be propagated. - If rendering failed, the original error will be propagated.
This can be useful for side effects like tracking the errors that occurred during the rendering:
```py ```py
from myapp.metrics import track_rendering_error
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
# Track how many times the rendering failed
if error is not None: if error is not None:
# The rendering failed track_rendering_error(error)
print(f"Error: {error}")
``` ```
#### Multiple yields #### Multiple yields
You can yield multiple times within the same `on_render` method. This is useful for complex rendering scenarios where you need to render different templates or handle multiple rendering operations: You can yield multiple times within the same [`on_render()`](../../../reference/api#django_components.Component.on_render) method. This is useful for complex rendering scenarios:
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
# First yield - render with one context # First yield
with context.push({"mode": "header"}): with context.push({"mode": "header"}):
header_html, header_error = yield template.render(context) header_html, header_error = yield lambda: template.render(context)
# Second yield - render with different context # Second yield
with context.push({"mode": "body"}): with context.push({"mode": "body"}):
body_html, body_error = yield template.render(context) body_html, body_error = yield lambda: template.render(context)
# Third yield - render a string directly # Third yield
footer_html, footer_error = yield "Footer content" footer_html, footer_error = yield "Footer content"
# Process all results and return final output # Process all
if header_error or body_error or footer_error: if header_error or body_error or footer_error:
return "Error occurred during rendering" return "Error occurred during rendering"
@ -246,7 +276,7 @@ That is, a component that catches errors in nested components and displays a fal
```django ```django
{% component "error_boundary" %} {% component "error_boundary" %}
{% fill "content" %} {% fill "default" %}
{% component "nested_component" %} {% component "nested_component" %}
{% endfill %} {% endfill %}
{% fill "fallback" %} {% fill "fallback" %}
@ -259,10 +289,13 @@ To implement this, we render the fallback slot in [`on_render()`](../../../refer
and return it if an error occured: and return it if an error occured:
```djc_py ```djc_py
class ErrorFallback(Component): from typing import NamedTuple, Optional
class Kwargs(NamedTuple):
fallback: Optional[str] = None
from django.template import Context, Template
from django.utils.safestring import mark_safe
from django_components import Component, OnRenderGenerator, SlotInput, types
class ErrorFallback(Component):
class Slots(NamedTuple): class Slots(NamedTuple):
default: Optional[SlotInput] = None default: Optional[SlotInput] = None
fallback: Optional[SlotInput] = None fallback: Optional[SlotInput] = None
@ -280,30 +313,22 @@ class ErrorFallback(Component):
context: Context, context: Context,
template: Template, template: Template,
) -> OnRenderGenerator: ) -> OnRenderGenerator:
fallback_kwarg = cast(ErrorFallback.Kwargs, self.kwargs).fallback fallback_slot = self.slots.default
fallback_slot = cast(ErrorFallback.Slots, self.slots).default
if fallback_kwarg is not None and fallback_slot is not None: result, error = yield lambda: template.render(context)
raise TemplateSyntaxError(
"The 'fallback' argument and slot cannot both be provided. Please provide only one.",
)
result, error = yield template.render(context) # No error, return the original result
# No error, return the result
if error is None: if error is None:
return result return None
# Error, return the fallback # Error, return the fallback
if fallback_kwarg is not None: if fallback_slot is not None:
return fallback_kwarg # Render the template second time, this time rendering
elif fallback_slot is not None: # the fallback branch
# Render the template second time, this time with the error
# So that we render the fallback slot with proper access to the outer context and whatnot.
with context.push({"error": error}): with context.push({"error": error}):
return template.render(context) return template.render(context)
else: else:
return "" return mark_safe("<pre>An error occurred</pre>")
``` ```
### `on_render_after` ### `on_render_after`
@ -333,30 +358,31 @@ as the second part of [`on_render()`](#on_render) (after the `yield`).
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render_after(self, context, template, result, error): def on_render_after(self, context, template, result, error):
if error is None: # If rendering succeeded, keep the original result
# The rendering succeeded # Otherwise, print the error
return result if error is not None:
else:
# The rendering failed
print(f"Error: {error}") print(f"Error: {error}")
``` ```
Same as [`on_render()`](#on_render), Same as [`on_render()`](#on_render),
you can return a new HTML, raise a new exception, or return nothing: you can return a new HTML, raise a new exception, or return nothing:
1. Return a new HTML 1. Return new HTML
The new HTML will be used as the final output. The new HTML will be used as the final output.
If the original template raised an error, it will be ignored. If the original template raised an error, the original error will be ignored.
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render_after(self, context, template, result, error): def on_render_after(self, context, template, result, error):
return "NEW HTML" # Fallback if rendering failed
# Otherwise, we keep the original HTML
if error is not None:
return "FALLBACK HTML"
``` ```
2. Raise a new exception 2. Raise new exception
The new exception is what will bubble up from the component. The new exception is what will bubble up from the component.
@ -365,26 +391,33 @@ you can return a new HTML, raise a new exception, or return nothing:
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render_after(self, context, template, result, error): def on_render_after(self, context, template, result, error):
raise Exception("Error message") # Override the original error
# Otherwise, we keep the original HTML
if error is not None:
raise Exception("My new error") from error
``` ```
3. Return nothing (or `None`) to handle the result as usual 3. No change - Return nothing or `None`
If you don't raise an exception, and neither return a new HTML, If you neither raise an exception, nor return a new HTML,
then original HTML / error will be used: then the original HTML / error will be used:
- If rendering succeeded, the original HTML will be used as the final output. - If rendering succeeded, the original HTML will be used as the final output.
- If rendering failed, the original error will be propagated. - If rendering failed, the original error will be propagated.
This can be useful for side effects like tracking the errors that occurred during the rendering:
```py ```py
from myapp.metrics import track_rendering_error
class MyTable(Component): class MyTable(Component):
def on_render_after(self, context, template, result, error): def on_render_after(self, context, template, result, error):
# Track how many times the rendering failed
if error is not None: if error is not None:
# The rendering failed track_rendering_error(error)
print(f"Error: {error}")
``` ```
## Example ## Example: Tabs
You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components 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. that accept a list of items via a slot.

View file

@ -127,6 +127,7 @@ ignore = [
"PLR0913", # Too many arguments in function definition (6 > 5) "PLR0913", # Too many arguments in function definition (6 > 5)
"PLR2004", # Magic value used in comparison, consider replacing `123` with a constant variable "PLR2004", # Magic value used in comparison, consider replacing `123` with a constant variable
"RET504", # Unnecessary assignment to `collected` before `return` statement "RET504", # Unnecessary assignment to `collected` before `return` statement
"RET505", # Unnecessary `elif` after `return` statement
"S308", # Use of `mark_safe` may expose cross-site scripting vulnerabilities "S308", # Use of `mark_safe` may expose cross-site scripting vulnerabilities
"S603", # `subprocess` call: check for execution of untrusted input "S603", # `subprocess` call: check for execution of untrusted input
"SIM108", # Use ternary operator `...` instead of `if`-`else`-block "SIM108", # Use ternary operator `...` instead of `if`-`else`-block

View file

@ -112,7 +112,7 @@ else:
OnRenderGenerator = Generator[ OnRenderGenerator = Generator[
Optional[SlotResult], Optional[Union[SlotResult, Callable[[], SlotResult]]],
Tuple[Optional[SlotResult], Optional[Exception]], Tuple[Optional[SlotResult], Optional[Exception]],
Optional[SlotResult], Optional[SlotResult],
] ]
@ -122,7 +122,7 @@ method if it yields (and thus returns a generator).
When `on_render()` is a generator then it: When `on_render()` is a generator then it:
- Yields a rendered template (string or `None`) - Yields a rendered template (string or `None`) or a lambda function to be called later.
- Receives back a tuple of `(final_output, error)`. - Receives back a tuple of `(final_output, error)`.
@ -151,13 +151,15 @@ class MyTable(Component):
# Same as `Component.on_render_before()` # Same as `Component.on_render_before()`
context["hello"] = "world" context["hello"] = "world"
# Yield rendered template to receive fully-rendered template or error # Yield a function that renders the template
html, error = yield template.render(context) # to receive fully-rendered template or error.
html, error = yield lambda: template.render(context)
# Do something AFTER rendering template, or post-process # Do something AFTER rendering template, or post-process
# the rendered template. # the rendered template.
# Same as `Component.on_render_after()` # Same as `Component.on_render_after()`
return html + "<p>Hello</p>" if html is not None:
return html + "<p>Hello</p>"
``` ```
**Multiple yields example:** **Multiple yields example:**
@ -165,18 +167,18 @@ class MyTable(Component):
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template) -> OnRenderGenerator: def on_render(self, context, template) -> OnRenderGenerator:
# First yield - render with one context # First yield
with context.push({"mode": "header"}): with context.push({"mode": "header"}):
header_html, header_error = yield template.render(context) header_html, header_error = yield lambda: template.render(context)
# Second yield - render with different context # Second yield
with context.push({"mode": "body"}): with context.push({"mode": "body"}):
body_html, body_error = yield template.render(context) body_html, body_error = yield lambda: template.render(context)
# Third yield - render a string directly # Third yield
footer_html, footer_error = yield "Footer content" footer_html, footer_error = yield "Footer content"
# Process all results and return final output # Process all results
if header_error or body_error or footer_error: if header_error or body_error or footer_error:
return "Error occurred during rendering" return "Error occurred during rendering"
@ -2023,7 +2025,7 @@ class Component(metaclass=ComponentMeta):
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
if error is None: if error is None:
# The rendering succeeded # The rendering succeeded
@ -2044,7 +2046,7 @@ class Component(metaclass=ComponentMeta):
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
return "NEW HTML" return "NEW HTML"
``` ```
@ -2058,7 +2060,7 @@ class Component(metaclass=ComponentMeta):
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
raise Exception("Error message") raise Exception("Error message")
``` ```
@ -2074,7 +2076,7 @@ class Component(metaclass=ComponentMeta):
```py ```py
class MyTable(Component): class MyTable(Component):
def on_render(self, context, template): def on_render(self, context, template):
html, error = yield template.render(context) html, error = yield lambda: template.render(context)
if error is not None: if error is not None:
# The rendering failed # The rendering failed
@ -2091,11 +2093,11 @@ class Component(metaclass=ComponentMeta):
def on_render(self, context, template): def on_render(self, context, template):
# First yield - render with one context # First yield - render with one context
with context.push({"mode": "header"}): with context.push({"mode": "header"}):
header_html, header_error = yield template.render(context) header_html, header_error = yield lambda: template.render(context)
# Second yield - render with different context # Second yield - render with different context
with context.push({"mode": "body"}): with context.push({"mode": "body"}):
body_html, body_error = yield template.render(context) body_html, body_error = yield lambda: template.render(context)
# Third yield - render a string directly # Third yield - render a string directly
footer_html, footer_error = yield "Footer content" footer_html, footer_error = yield "Footer content"
@ -3852,7 +3854,7 @@ class Component(metaclass=ComponentMeta):
# ``` # ```
# class MyTable(Component): # class MyTable(Component):
# def on_render(self, context, template): # def on_render(self, context, template):
# html, error = yield template.render(context) # html, error = yield lamba: template.render(context)
# return html + "<p>Hello</p>" # return html + "<p>Hello</p>"
# ``` # ```
# #

View file

@ -1,6 +1,6 @@
import re import re
from collections import deque from collections import deque
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Set, Tuple, Union from typing import TYPE_CHECKING, Callable, Deque, Dict, List, Literal, NamedTuple, Optional, Set, Tuple, Union
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -90,7 +90,7 @@ class ErrorPart(NamedTuple):
class GeneratorResult(NamedTuple): class GeneratorResult(NamedTuple):
html: Optional[str] html: Optional[str]
error: Optional[Exception] error: Optional[Exception]
needs_processing: bool action: Literal["needs_processing", "rerender", "stop"]
spent: bool spent: bool
"""Whether the generator has been "spent" - e.g. reached its end with `StopIteration`.""" """Whether the generator has been "spent" - e.g. reached its end with `StopIteration`."""
@ -431,7 +431,7 @@ def component_post_render(
# The generator yielded or returned a new HTML. We want to process it as if # The generator yielded or returned a new HTML. We want to process it as if
# it's a new component's HTML. # it's a new component's HTML.
if result.needs_processing: if result.action == "needs_processing":
# Ignore the old version of the component # Ignore the old version of the component
ignored_components.add(item_id) ignored_components.add(item_id)
@ -455,8 +455,20 @@ def component_post_render(
parts_to_process = parse_component_result(new_html or "", new_item_id, full_path) parts_to_process = parse_component_result(new_html or "", new_item_id, full_path)
process_queue.extendleft(reversed(parts_to_process)) process_queue.extendleft(reversed(parts_to_process))
return return
# If we don't need to re-do the processing, then we can just use the result. elif result.action == "rerender":
component_html, error = new_html, result.error # Ignore the old version of the component
ignored_components.add(item_id)
new_version = item_id.version + 1
new_item_id = QueueItemId(component_id=item_id.component_id, version=new_version)
# Set the current parent as the parent of the new version
child_to_parent[new_item_id] = parent_id
next_renderer_result(item_id=new_item_id, error=result.error, full_path=full_path)
return
else:
# If we don't need to re-do the processing, then we can just use the result.
component_html, error = new_html, result.error
# Allow to optionally override/modify the rendered content from `Component.on_render_after()` # Allow to optionally override/modify the rendered content from `Component.on_render_after()`
# and by extensions' `on_component_rendered` hooks. # and by extensions' `on_component_rendered` hooks.
@ -590,22 +602,55 @@ def _call_generator(
# The return value is on `StopIteration.value` # The return value is on `StopIteration.value`
new_output = generator_err.value new_output = generator_err.value
if new_output is not None: if new_output is not None:
return GeneratorResult(html=new_output, error=None, needs_processing=True, spent=True) return GeneratorResult(html=new_output, error=None, action="needs_processing", spent=True)
# Nothing returned at the end of the generator, keep the original HTML and error # Nothing returned at the end of the generator, keep the original HTML and error
return GeneratorResult(html=html, error=error, needs_processing=False, spent=True) return GeneratorResult(html=html, error=error, action="stop", spent=True)
# Catch if `Component.on_render()` raises an exception, in which case this becomes # Catch if `Component.on_render()` raises an exception, in which case this becomes
# the new error. # the new error.
except Exception as new_error: # noqa: BLE001 except Exception as new_error: # noqa: BLE001
set_component_error_message(new_error, full_path[1:]) set_component_error_message(new_error, full_path[1:])
return GeneratorResult(html=None, error=new_error, needs_processing=False, spent=True) return GeneratorResult(html=None, error=new_error, action="stop", spent=True)
# If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result, # If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result,
# that we need to process. # that we need to process.
else: else:
# NOTE: Users may yield a function from `on_render()` instead of rendered template:
# ```py
# class MyTable(Component):
# def on_render(self, context, template):
# html, error = yield lambda: template.render(context)
# return html + "<p>Hello</p>"
# ```
# This is so that we can keep the API simple, handling the errors in template rendering.
# Otherwise, people would have to write out:
# ```py
# try:
# intermediate = template.render(context)
# except Exception as err:
# result = None
# error = err
# else:
# result, error = yield intermediate
# ```
if callable(new_result):
try:
new_result = new_result()
except Exception as new_err: # noqa: BLE001
started_generators_cache[on_render_generator] = True
set_component_error_message(new_err, full_path[1:])
# In other cases, when a component raises an error during rendering,
# we discard the errored component and move up to the parent component
# to decide what to do (propagate or return a new HTML).
#
# But if user yielded a function from `Component.on_render()`,
# we want to let the CURRENT component decide what to do.
# Hence why the action is "rerender" instead of "stop".
return GeneratorResult(html=None, error=new_err, action="rerender", spent=False)
if is_first_send or new_result is not None: if is_first_send or new_result is not None:
started_generators_cache[on_render_generator] = True started_generators_cache[on_render_generator] = True
return GeneratorResult(html=new_result, error=None, needs_processing=True, spent=False) return GeneratorResult(html=new_result, error=None, action="needs_processing", spent=False)
# Generator yielded `None`, keep the previous HTML and error # Generator yielded `None`, keep the previous HTML and error
return GeneratorResult(html=html, error=error, needs_processing=False, spent=False) return GeneratorResult(html=html, error=error, action="stop", spent=False)

View file

@ -1533,7 +1533,7 @@ class TestComponentHook:
def on_render(self, context: Context, template: Optional[Template]): def on_render(self, context: Context, template: Optional[Template]):
calls.append("slotted__on_render_pre") calls.append("slotted__on_render_pre")
_html, _error = yield template.render(context) # type: ignore[union-attr] _html, _error = yield lambda: template.render(context) # type: ignore[union-attr]
calls.append("slotted__on_render_post") calls.append("slotted__on_render_post")
@ -1566,7 +1566,7 @@ class TestComponentHook:
if template is None: if template is None:
yield None yield None
else: else:
_html, _error = yield template.render(context) _html, _error = yield lambda: template.render(context)
calls.append("inner__on_render_post") calls.append("inner__on_render_post")
@ -1600,7 +1600,7 @@ class TestComponentHook:
def on_render(self, context: Context, template: Optional[Template]): def on_render(self, context: Context, template: Optional[Template]):
calls.append("middle__on_render_pre") calls.append("middle__on_render_pre")
_html, _error = yield template.render(context) # type: ignore[union-attr] _html, _error = yield lambda: template.render(context) # type: ignore[union-attr]
calls.append("middle__on_render_post") calls.append("middle__on_render_post")
@ -1632,7 +1632,7 @@ class TestComponentHook:
def on_render(self, context: Context, template: Optional[Template]): def on_render(self, context: Context, template: Optional[Template]):
calls.append("outer__on_render_pre") calls.append("outer__on_render_pre")
_html, _error = yield template.render(context) # type: ignore[union-attr] _html, _error = yield lambda: template.render(context) # type: ignore[union-attr]
calls.append("outer__on_render_post") calls.append("outer__on_render_post")
@ -1752,7 +1752,7 @@ class TestComponentHook:
# Check we can modify entries set by other methods # Check we can modify entries set by other methods
context["from_on_before__edited1"] = context["from_on_before"] + " (on_render)" context["from_on_before__edited1"] = context["from_on_before"] + " (on_render)"
_html, _error = yield template.render(context) _html, _error = yield lambda: template.render(context)
context["from_on_render_post"] = "3" context["from_on_render_post"] = "3"
@ -1804,7 +1804,7 @@ class TestComponentHook:
def on_render(self, context: Context, template: Template): def on_render(self, context: Context, template: Template):
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_PRE")) template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_PRE"))
_html, _error = yield template.render(context) _html, _error = yield lambda: template.render(context)
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_POST")) template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_POST"))
@ -1831,6 +1831,61 @@ class TestComponentHook:
""", """,
) )
def test_lambda_yield(self):
class SimpleComponent(Component):
template: types.django_html = """
text
"""
def on_render(self, context: Context, template: Template):
html, _error = yield lambda: template.render(context)
return html + "<p>Hello</p>"
rendered = SimpleComponent.render()
assertHTMLEqual(
rendered,
"text<p data-djc-id-ca1bc3e>Hello</p>",
)
# Works without lambda
class SimpleComponent2(SimpleComponent):
def on_render(self, context: Context, template: Template):
html, _error = yield template.render(context)
return html + "<p>Hello</p>"
rendered2 = SimpleComponent2.render()
assertHTMLEqual(
rendered2,
"text<p data-djc-id-ca1bc3f>Hello</p>",
)
def test_lambda_yield_error(self):
def broken_template():
raise ValueError("BROKEN")
class SimpleComponent(Component):
def on_render(self, context: Context, template: Template):
_html, error = yield lambda: broken_template()
error.args = ("ERROR MODIFIED",)
with pytest.raises(
ValueError, match=re.escape("An error occured while rendering components SimpleComponent:\nERROR MODIFIED")
):
SimpleComponent.render()
# Does NOT work without lambda
class SimpleComponent2(SimpleComponent):
def on_render(self, context: Context, template: Template):
# This raises an error instead of capturing it,
# so we never get to modifying the error.
_html, error = yield broken_template()
error.args = ("ERROR MODIFIED",)
with pytest.raises(
ValueError, match=re.escape("An error occured while rendering components SimpleComponent2:\nBROKEN")
):
SimpleComponent2.render()
def test_on_render_no_yield(self): def test_on_render_no_yield(self):
class SimpleComponent(Component): class SimpleComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -1852,7 +1907,7 @@ class TestComponentHook:
""" """
def on_render(self, context: Context, template: Template): def on_render(self, context: Context, template: Template):
_html, error = yield template.render(context) _html, error = yield lambda: template.render(context)
raise error from None # Re-raise original error raise error from None # Re-raise original error
@ -1879,15 +1934,15 @@ class TestComponentHook:
assert template is not None assert template is not None
with context.push({"case": 1}): with context.push({"case": 1}):
html1, error1 = yield template.render(context) html1, error1 = yield lambda: template.render(context)
results.append((html1, error1)) results.append((html1, error1))
with context.push({"case": 2}): with context.push({"case": 2}):
html2, error2 = yield template.render(context) html2, error2 = yield lambda: template.render(context)
results.append((html2.strip(), error2)) results.append((html2.strip(), error2))
with context.push({"case": 3}): with context.push({"case": 3}):
html3, error3 = yield template.render(context) html3, error3 = yield lambda: template.render(context)
results.append((html3.strip(), error3)) results.append((html3.strip(), error3))
html4, error4 = yield "<div>Other result</div>" html4, error4 = yield "<div>Other result</div>"
@ -1990,7 +2045,7 @@ class TestComponentHook:
if template is None: if template is None:
yield None yield None
else: else:
_html, _error = yield template.render(context) _html, _error = yield lambda: template.render(context)
return None # noqa: PLR1711 return None # noqa: PLR1711
elif action == "no_return": elif action == "no_return":
@ -2000,7 +2055,7 @@ class TestComponentHook:
if template is None: if template is None:
yield None yield None
else: else:
_html, _error = yield template.render(context) _html, _error = yield lambda: template.render(context)
elif action == "raise_error": elif action == "raise_error":
@ -2009,7 +2064,7 @@ class TestComponentHook:
if template is None: if template is None:
yield None yield None
else: else:
_html, _error = yield template.render(context) _html, _error = yield lambda: template.render(context)
raise ValueError("ERROR_FROM_ON_RENDER") raise ValueError("ERROR_FROM_ON_RENDER")
elif action == "return_html": elif action == "return_html":
@ -2019,7 +2074,7 @@ class TestComponentHook:
if template is None: if template is None:
yield None yield None
else: else:
_html, _error = yield template.render(context) _html, _error = yield lambda: template.render(context)
return "HTML_FROM_ON_RENDER" return "HTML_FROM_ON_RENDER"
else: else: