From eee3910b546d6d7484fe5463a16bc8cbf3f29445 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 3 Oct 2025 18:19:21 +0200 Subject: [PATCH] refactor: use lamba with yield in Component.on_render() (#1428) --- CHANGELOG.md | 37 ++++- docs/concepts/advanced/hooks.md | 169 ++++++++++++-------- pyproject.toml | 1 + src/django_components/component.py | 38 ++--- src/django_components/perfutil/component.py | 65 ++++++-- tests/test_component.py | 83 ++++++++-- 6 files changed, 277 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9f2496..3d680440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,48 @@ #### 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. ```py class MyTable(Component): def on_render(self, context, template): - # First yield - render with one context + # First yield 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"}): - 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" - # Process all results and return final output + # Process all results if header_error or body_error or footer_error: return "Error occurred during rendering" diff --git a/docs/concepts/advanced/hooks.md b/docs/concepts/advanced/hooks.md index 5c129d02..ad9711f5 100644 --- a/docs/concepts/advanced/hooks.md +++ b/docs/concepts/advanced/hooks.md @@ -54,9 +54,6 @@ class MyTable(Component): 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_ @@ -86,9 +83,7 @@ with the given ```py class MyTable(Component): def on_render(self, context, template): - if template is None: - return None - else: + if template: 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: -- Render a different template - Render a component -- Return a different string or SafeString +- Render a template +- Return a string or SafeString ```py class MyTable(Component): def on_render(self, context, template): - return "Hello" + # Return a string + return "

Hello

" + + # 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, @@ -145,14 +152,19 @@ 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 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 class MyTable(Component): def on_render(self, context, template): - html, error = yield template.render(context) + html, error = yield lambda: template.render(context) if error is None: # The rendering succeeded @@ -162,23 +174,34 @@ class MyTable(Component): 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: -1. Return a new HTML +1. Return new HTML 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 class MyTable(Component): 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. @@ -187,48 +210,55 @@ At this point you can do 3 things: ```py class MyTable(Component): 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, - then original HTML / error will be used: + If you neither raise an exception, nor return a new HTML, + then the 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. + This can be useful for side effects like tracking the errors that occurred during the rendering: + ```py + from myapp.metrics import track_rendering_error + class MyTable(Component): 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: - # The rendering failed - print(f"Error: {error}") + track_rendering_error(error) ``` #### 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 class MyTable(Component): def on_render(self, context, template): - # First yield - render with one context + # First yield 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"}): - 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" - # Process all results and return final output + # Process all if header_error or body_error or footer_error: return "Error occurred during rendering" @@ -246,7 +276,7 @@ That is, a component that catches errors in nested components and displays a fal ```django {% component "error_boundary" %} - {% fill "content" %} + {% fill "default" %} {% component "nested_component" %} {% endfill %} {% 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: ```djc_py -class ErrorFallback(Component): - class Kwargs(NamedTuple): - fallback: Optional[str] = None +from typing import NamedTuple, Optional +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): default: Optional[SlotInput] = None fallback: Optional[SlotInput] = None @@ -280,30 +313,22 @@ class ErrorFallback(Component): context: Context, template: Template, ) -> OnRenderGenerator: - fallback_kwarg = cast(ErrorFallback.Kwargs, self.kwargs).fallback - fallback_slot = cast(ErrorFallback.Slots, self.slots).default + fallback_slot = self.slots.default - if fallback_kwarg is not None and fallback_slot is not None: - raise TemplateSyntaxError( - "The 'fallback' argument and slot cannot both be provided. Please provide only one.", - ) + result, error = yield lambda: template.render(context) - result, error = yield template.render(context) - - # No error, return the result + # No error, return the original result if error is None: - return result + return None # Error, return the fallback - if fallback_kwarg is not None: - return fallback_kwarg - elif fallback_slot is not None: - # 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. + if fallback_slot is not None: + # Render the template second time, this time rendering + # the fallback branch with context.push({"error": error}): return template.render(context) else: - return "" + return mark_safe("
An error occurred
") ``` ### `on_render_after` @@ -333,30 +358,31 @@ 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 + # If rendering succeeded, keep the original result + # Otherwise, print the error + if error is not None: 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 +1. Return new HTML 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 class MyTable(Component): 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. @@ -365,26 +391,33 @@ you can return a new HTML, raise a new exception, or return nothing: ```py class MyTable(Component): 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, - then original HTML / error will be used: + If you neither raise an exception, nor return a new HTML, + then the 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. + This can be useful for side effects like tracking the errors that occurred during the rendering: + ```py + from myapp.metrics import track_rendering_error + class MyTable(Component): def on_render_after(self, context, template, result, error): + # Track how many times the rendering failed if error is not None: - # The rendering failed - print(f"Error: {error}") + track_rendering_error(error) ``` -## Example +## Example: Tabs 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. diff --git a/pyproject.toml b/pyproject.toml index 5439d8ce..bc7b98e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ ignore = [ "PLR0913", # Too many arguments in function definition (6 > 5) "PLR2004", # Magic value used in comparison, consider replacing `123` with a constant variable "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 "S603", # `subprocess` call: check for execution of untrusted input "SIM108", # Use ternary operator `...` instead of `if`-`else`-block diff --git a/src/django_components/component.py b/src/django_components/component.py index b3641b4f..06377fa0 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -112,7 +112,7 @@ else: OnRenderGenerator = Generator[ - Optional[SlotResult], + Optional[Union[SlotResult, Callable[[], SlotResult]]], Tuple[Optional[SlotResult], Optional[Exception]], Optional[SlotResult], ] @@ -122,7 +122,7 @@ method if it yields (and thus returns a generator). 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)`. @@ -151,13 +151,15 @@ class MyTable(Component): # 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) + # Yield a function that renders the template + # to receive fully-rendered template or error. + html, error = yield lambda: template.render(context) # Do something AFTER rendering template, or post-process # the rendered template. # Same as `Component.on_render_after()` - return html + "

Hello

" + if html is not None: + return html + "

Hello

" ``` **Multiple yields example:** @@ -165,18 +167,18 @@ class MyTable(Component): ```py class MyTable(Component): def on_render(self, context, template) -> OnRenderGenerator: - # First yield - render with one context + # First yield 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"}): - 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" - # Process all results and return final output + # Process all results if header_error or body_error or footer_error: return "Error occurred during rendering" @@ -2023,7 +2025,7 @@ class Component(metaclass=ComponentMeta): ```py class MyTable(Component): def on_render(self, context, template): - html, error = yield template.render(context) + html, error = yield lambda: template.render(context) if error is None: # The rendering succeeded @@ -2044,7 +2046,7 @@ class Component(metaclass=ComponentMeta): ```py class MyTable(Component): def on_render(self, context, template): - html, error = yield template.render(context) + html, error = yield lambda: template.render(context) return "NEW HTML" ``` @@ -2058,7 +2060,7 @@ class Component(metaclass=ComponentMeta): ```py class MyTable(Component): def on_render(self, context, template): - html, error = yield template.render(context) + html, error = yield lambda: template.render(context) raise Exception("Error message") ``` @@ -2074,7 +2076,7 @@ class Component(metaclass=ComponentMeta): ```py class MyTable(Component): def on_render(self, context, template): - html, error = yield template.render(context) + html, error = yield lambda: template.render(context) if error is not None: # The rendering failed @@ -2091,11 +2093,11 @@ class Component(metaclass=ComponentMeta): def on_render(self, context, template): # First yield - render with one context 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 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 footer_html, footer_error = yield "Footer content" @@ -3852,7 +3854,7 @@ class Component(metaclass=ComponentMeta): # ``` # class MyTable(Component): # def on_render(self, context, template): - # html, error = yield template.render(context) + # html, error = yield lamba: template.render(context) # return html + "

Hello

" # ``` # diff --git a/src/django_components/perfutil/component.py b/src/django_components/perfutil/component.py index 171253f7..d72935ad 100644 --- a/src/django_components/perfutil/component.py +++ b/src/django_components/perfutil/component.py @@ -1,6 +1,6 @@ import re 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 @@ -90,7 +90,7 @@ class ErrorPart(NamedTuple): class GeneratorResult(NamedTuple): html: Optional[str] error: Optional[Exception] - needs_processing: bool + action: Literal["needs_processing", "rerender", "stop"] spent: bool """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 # it's a new component's HTML. - if result.needs_processing: + if result.action == "needs_processing": # Ignore the old version of the component 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) process_queue.extendleft(reversed(parts_to_process)) return - # If we don't need to re-do the processing, then we can just use the result. - component_html, error = new_html, result.error + elif result.action == "rerender": + # 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()` # and by extensions' `on_component_rendered` hooks. @@ -590,22 +602,55 @@ def _call_generator( # The return value is on `StopIteration.value` new_output = generator_err.value 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 - 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 # the new error. except Exception as new_error: # noqa: BLE001 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, # that we need to process. 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 + "

Hello

" + # ``` + # 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: 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 - return GeneratorResult(html=html, error=error, needs_processing=False, spent=False) + return GeneratorResult(html=html, error=error, action="stop", spent=False) diff --git a/tests/test_component.py b/tests/test_component.py index c8a7b1f6..bafb2ee6 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1533,7 +1533,7 @@ class TestComponentHook: 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] + _html, _error = yield lambda: template.render(context) # type: ignore[union-attr] calls.append("slotted__on_render_post") @@ -1566,7 +1566,7 @@ class TestComponentHook: if template is None: yield None else: - _html, _error = yield template.render(context) + _html, _error = yield lambda: template.render(context) calls.append("inner__on_render_post") @@ -1600,7 +1600,7 @@ class TestComponentHook: 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] + _html, _error = yield lambda: template.render(context) # type: ignore[union-attr] calls.append("middle__on_render_post") @@ -1632,7 +1632,7 @@ class TestComponentHook: 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] + _html, _error = yield lambda: template.render(context) # type: ignore[union-attr] calls.append("outer__on_render_post") @@ -1752,7 +1752,7 @@ class TestComponentHook: # 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) + _html, _error = yield lambda: template.render(context) context["from_on_render_post"] = "3" @@ -1804,7 +1804,7 @@ class TestComponentHook: def on_render(self, context: Context, template: Template): 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")) @@ -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 + "

Hello

" + + rendered = SimpleComponent.render() + assertHTMLEqual( + rendered, + "text

Hello

", + ) + + # Works without lambda + class SimpleComponent2(SimpleComponent): + def on_render(self, context: Context, template: Template): + html, _error = yield template.render(context) + return html + "

Hello

" + + rendered2 = SimpleComponent2.render() + assertHTMLEqual( + rendered2, + "text

Hello

", + ) + + 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): class SimpleComponent(Component): template: types.django_html = """ @@ -1852,7 +1907,7 @@ class TestComponentHook: """ 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 @@ -1879,15 +1934,15 @@ class TestComponentHook: assert template is not None with context.push({"case": 1}): - html1, error1 = yield template.render(context) + html1, error1 = yield lambda: template.render(context) results.append((html1, error1)) with context.push({"case": 2}): - html2, error2 = yield template.render(context) + html2, error2 = yield lambda: template.render(context) results.append((html2.strip(), error2)) with context.push({"case": 3}): - html3, error3 = yield template.render(context) + html3, error3 = yield lambda: template.render(context) results.append((html3.strip(), error3)) html4, error4 = yield "
Other result
" @@ -1990,7 +2045,7 @@ class TestComponentHook: if template is None: yield None else: - _html, _error = yield template.render(context) + _html, _error = yield lambda: template.render(context) return None # noqa: PLR1711 elif action == "no_return": @@ -2000,7 +2055,7 @@ class TestComponentHook: if template is None: yield None else: - _html, _error = yield template.render(context) + _html, _error = yield lambda: template.render(context) elif action == "raise_error": @@ -2009,7 +2064,7 @@ class TestComponentHook: if template is None: yield None else: - _html, _error = yield template.render(context) + _html, _error = yield lambda: template.render(context) raise ValueError("ERROR_FROM_ON_RENDER") elif action == "return_html": @@ -2019,7 +2074,7 @@ class TestComponentHook: if template is None: yield None else: - _html, _error = yield template.render(context) + _html, _error = yield lambda: template.render(context) return "HTML_FROM_ON_RENDER" else: