mirror of
				https://github.com/django-components/django-components.git
				synced 2025-11-04 08:48:22 +00:00 
			
		
		
		
	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
				
			
		
		
	
	
				
					
				
			
		
			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:
		
							parent
							
								
									3e837e20c6
								
							
						
					
					
						commit
						eee3910b54
					
				
					 6 changed files with 277 additions and 116 deletions
				
			
		
							
								
								
									
										37
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						
									
										37
									
								
								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"
 | 
			
		||||
            
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 "<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,
 | 
			
		||||
| 
						 | 
				
			
			@ -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("<pre>An error occurred</pre>")
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### `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.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 + "<p>Hello</p>"
 | 
			
		||||
        if html is not None:
 | 
			
		||||
            return html + "<p>Hello</p>"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**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 + "<p>Hello</p>"
 | 
			
		||||
        # ```
 | 
			
		||||
        #
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 + "<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:
 | 
			
		||||
            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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 + "<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):
 | 
			
		||||
        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 "<div>Other result</div>"
 | 
			
		||||
| 
						 | 
				
			
			@ -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:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue