mirror of
https://github.com/django-components/django-components.git
synced 2025-10-03 18:54:33 +00:00
feat: allow to yield multiple times in Component.on_render() (#1421)
This commit is contained in:
parent
400008f4da
commit
e9d1b6c4b2
5 changed files with 511 additions and 207 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
@ -1,5 +1,34 @@
|
|||
# Release notes
|
||||
|
||||
## v0.142.0
|
||||
|
||||
#### Feat
|
||||
|
||||
- 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
|
||||
with context.push({"mode": "header"}):
|
||||
header_html, header_error = yield template.render(context)
|
||||
|
||||
# Second yield - render with different context
|
||||
with context.push({"mode": "body"}):
|
||||
body_html, body_error = yield template.render(context)
|
||||
|
||||
# Third yield - render a string directly
|
||||
footer_html, footer_error = yield "Footer content"
|
||||
|
||||
# Process all results and return final output
|
||||
if header_error or body_error or footer_error:
|
||||
return "Error occurred during rendering"
|
||||
|
||||
return f"{header_html}\n{body_html}\n{footer_html}"
|
||||
```
|
||||
|
||||
Each yield operation is independent and returns its own `(html, error)` tuple, allowing you to handle each rendering result separately.
|
||||
|
||||
## v0.141.6
|
||||
|
||||
#### Fix
|
||||
|
|
|
@ -134,7 +134,9 @@ class MyTable(Component):
|
|||
When you render the original template in [`on_render()`](../../../reference/api#django_components.Component.on_render) as:
|
||||
|
||||
```py
|
||||
template.render(context)
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
result = template.render(context)
|
||||
```
|
||||
|
||||
The result is NOT the final output, but an intermediate result. Nested components
|
||||
|
@ -208,6 +210,33 @@ At this point you can do 3 things:
|
|||
print(f"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:
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
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)
|
||||
|
||||
# Second yield - render with different context
|
||||
with context.push({"mode": "body"}):
|
||||
body_html, body_error = yield template.render(context)
|
||||
|
||||
# Third yield - render a string directly
|
||||
footer_html, footer_error = yield "Footer content"
|
||||
|
||||
# Process all results and return final output
|
||||
if header_error or body_error or footer_error:
|
||||
return "Error occurred during rendering"
|
||||
|
||||
return f"{header_html}\n{body_html}\n{footer_html}"
|
||||
```
|
||||
|
||||
Each yield operation is independent and returns its own `(html, error)` tuple, allowing you to handle each rendering result separately.
|
||||
|
||||
#### Example: ErrorBoundary
|
||||
|
||||
[`on_render()`](../../../reference/api#django_components.Component.on_render) can be used to
|
||||
|
@ -231,22 +260,50 @@ and return it if an error occured:
|
|||
|
||||
```djc_py
|
||||
class ErrorFallback(Component):
|
||||
template = """
|
||||
{% slot "content" default / %}
|
||||
class Kwargs(NamedTuple):
|
||||
fallback: Optional[str] = None
|
||||
|
||||
class Slots(NamedTuple):
|
||||
default: Optional[SlotInput] = None
|
||||
fallback: Optional[SlotInput] = None
|
||||
|
||||
template: types.django_html = """
|
||||
{% if not error %}
|
||||
{% slot "default" default / %}
|
||||
{% else %}
|
||||
{% slot "fallback" error=error / %}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
def on_render(self, context, template):
|
||||
fallback = self.slots.fallback
|
||||
def on_render(
|
||||
self,
|
||||
context: Context,
|
||||
template: Template,
|
||||
) -> OnRenderGenerator:
|
||||
fallback_kwarg = cast(ErrorFallback.Kwargs, self.kwargs).fallback
|
||||
fallback_slot = cast(ErrorFallback.Slots, self.slots).default
|
||||
|
||||
if fallback is None:
|
||||
raise ValueError("fallback slot is required")
|
||||
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.",
|
||||
)
|
||||
|
||||
html, error = yield template.render(context)
|
||||
result, error = yield template.render(context)
|
||||
|
||||
if error is not None:
|
||||
return fallback()
|
||||
# No error, return the result
|
||||
if error is None:
|
||||
return result
|
||||
|
||||
# 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.
|
||||
with context.push({"error": error}):
|
||||
return template.render(context)
|
||||
else:
|
||||
return html
|
||||
return ""
|
||||
```
|
||||
|
||||
### `on_render_after`
|
||||
|
|
|
@ -131,6 +131,8 @@ When `on_render()` is a generator then it:
|
|||
The error is `None` if the rendering was successful. Otherwise the error is set
|
||||
and the output is `None`.
|
||||
|
||||
- Can yield multiple times within the same method for complex rendering scenarios
|
||||
|
||||
- At the end it may return a new string to override the final rendered output.
|
||||
|
||||
**Example:**
|
||||
|
@ -156,6 +158,29 @@ class MyTable(Component):
|
|||
# Same as `Component.on_render_after()`
|
||||
return html + "<p>Hello</p>"
|
||||
```
|
||||
|
||||
**Multiple yields example:**
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template) -> OnRenderGenerator:
|
||||
# First yield - render with one context
|
||||
with context.push({"mode": "header"}):
|
||||
header_html, header_error = yield template.render(context)
|
||||
|
||||
# Second yield - render with different context
|
||||
with context.push({"mode": "body"}):
|
||||
body_html, body_error = yield template.render(context)
|
||||
|
||||
# Third yield - render a string directly
|
||||
footer_html, footer_error = yield "Footer content"
|
||||
|
||||
# Process all results and return final output
|
||||
if header_error or body_error or footer_error:
|
||||
return "Error occurred during rendering"
|
||||
|
||||
return f"{header_html}\n{body_html}\n{footer_html}"
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
|
@ -2036,6 +2061,35 @@ class Component(metaclass=ComponentMeta):
|
|||
# The rendering failed
|
||||
print(f"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:
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
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)
|
||||
|
||||
# Second yield - render with different context
|
||||
with context.push({"mode": "body"}):
|
||||
body_html, body_error = yield template.render(context)
|
||||
|
||||
# Third yield - render a string directly
|
||||
footer_html, footer_error = yield "Footer content"
|
||||
|
||||
# Process all results and return final output
|
||||
if header_error or body_error or footer_error:
|
||||
return "Error occurred during rendering"
|
||||
|
||||
return f"{header_html}{body_html}{footer_html}"
|
||||
```
|
||||
|
||||
Each yield operation is independent and returns its own `(html, error)` tuple,
|
||||
allowing you to handle each rendering result separately.
|
||||
"""
|
||||
if template is None:
|
||||
return None
|
||||
|
@ -3682,7 +3736,7 @@ class Component(metaclass=ComponentMeta):
|
|||
renderer=deferred_render,
|
||||
render_id=render_id,
|
||||
component_name=component_name,
|
||||
parent_id=parent_id,
|
||||
parent_render_id=parent_id,
|
||||
on_component_rendered_callbacks=post_render_callbacks,
|
||||
on_html_rendered=on_html_rendered,
|
||||
)
|
||||
|
@ -3739,7 +3793,7 @@ class Component(metaclass=ComponentMeta):
|
|||
# Get the component's HTML
|
||||
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
|
||||
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
|
||||
# AFTER the yield will be called once, when the component's HTML is fully rendered.
|
||||
# AFTER the yield will be called once when the component's HTML is fully rendered.
|
||||
#
|
||||
# Hence we have to distinguish between the two, and pass the generator with the HTML content
|
||||
html_content_or_generator = component.on_render(context, template)
|
||||
|
|
|
@ -40,33 +40,53 @@ component_context_cache: Dict[str, "ComponentContext"] = {}
|
|||
component_instance_cache: Dict[str, "Component"] = {}
|
||||
|
||||
|
||||
class QueueItemId(NamedTuple):
|
||||
"""
|
||||
Identifies which queue items we should ignore when we come across them
|
||||
(due to a component having raised an error).
|
||||
"""
|
||||
|
||||
component_id: str
|
||||
# NOTE: Versions are used so we can `yield` multiple times from `Component.on_render()`.
|
||||
# Each time a value is yielded (or returned by `return`), we discard the previous HTML
|
||||
# by incrementing the version and tagging the old version to be ignored.
|
||||
version: int
|
||||
|
||||
|
||||
class ComponentPart(NamedTuple):
|
||||
"""Queue item where a component is nested in another component."""
|
||||
|
||||
child_id: str
|
||||
parent_id: Optional[str]
|
||||
component_name_path: List[str]
|
||||
item_id: QueueItemId
|
||||
parent_id: Optional[QueueItemId]
|
||||
full_path: List[str]
|
||||
"""Path of component names from the root component to the current component."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"ComponentPart(child_id={self.child_id!r}, parent_id={self.parent_id!r}, "
|
||||
f"component_name_path={self.component_name_path!r})"
|
||||
)
|
||||
return f"ComponentPart(item_id={self.item_id!r}, parent_id={self.parent_id!r}, full_path={self.full_path!r})"
|
||||
|
||||
|
||||
class TextPart(NamedTuple):
|
||||
"""Queue item where a text is between two components."""
|
||||
|
||||
item_id: QueueItemId
|
||||
text: str
|
||||
is_last: bool
|
||||
parent_id: str
|
||||
|
||||
|
||||
class ErrorPart(NamedTuple):
|
||||
"""Queue item where a component has thrown an error."""
|
||||
|
||||
child_id: str
|
||||
item_id: QueueItemId
|
||||
error: Exception
|
||||
full_path: List[str]
|
||||
|
||||
|
||||
class GeneratorResult(NamedTuple):
|
||||
html: Optional[str]
|
||||
error: Optional[Exception]
|
||||
needs_processing: bool
|
||||
spent: bool
|
||||
"""Whether the generator has been "spent" - e.g. reached its end with `StopIteration`."""
|
||||
|
||||
|
||||
# Function that accepts a list of extra HTML attributes to be set on the component's root elements
|
||||
|
@ -142,7 +162,7 @@ def component_post_render(
|
|||
renderer: ComponentRenderer,
|
||||
render_id: str,
|
||||
component_name: str,
|
||||
parent_id: Optional[str],
|
||||
parent_render_id: Optional[str],
|
||||
on_component_rendered_callbacks: Dict[
|
||||
str,
|
||||
Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
|
||||
|
@ -181,7 +201,7 @@ def component_post_render(
|
|||
# 3. ComponentC - Triggered by `{% component "ComponentC" / %}` while B's template is being rendered
|
||||
# as part of full component tree render. Returns only a placeholder, to be replaced in next
|
||||
# step.
|
||||
if parent_id is not None:
|
||||
if parent_render_id is not None:
|
||||
return mark_safe(f'<template djc-render-id="{render_id}"></template>')
|
||||
|
||||
# Case: Root component - Construct the final HTML by recursively replacing placeholders
|
||||
|
@ -205,7 +225,7 @@ def component_post_render(
|
|||
# <h2>...</h2>
|
||||
# <template djc-render-id="a1b3cf"></template>
|
||||
# <span>...</span>
|
||||
# <template djc-render-id="f3d3cf"></template>
|
||||
# <template djc-render-id="f3d3d0"></template>
|
||||
# </div>
|
||||
# ```
|
||||
#
|
||||
|
@ -213,37 +233,38 @@ def component_post_render(
|
|||
# - <div><h2>...</h2>
|
||||
# - PLACEHOLDER djc-render-id="a1b3cf"
|
||||
# - <span>...</span>
|
||||
# - PLACEHOLDER djc-render-id="f3d3cf"
|
||||
# - PLACEHOLDER djc-render-id="f3d3d0"
|
||||
# - </div>
|
||||
#
|
||||
# And put the pairs of (content, placeholder_id) into a queue:
|
||||
# - ("<div><h2>...</h2>", "a1b3cf")
|
||||
# - ("<span>...</span>", "f3d3cf")
|
||||
# - ("</div>", None)
|
||||
# And put these into a queue:
|
||||
# ```py
|
||||
# [
|
||||
# TextPart("<div><h2>...</h2>"),
|
||||
# ComponentPart("a1b3cf"),
|
||||
# TextPart("<span>...</span>"),
|
||||
# ComponentPart("f3d3d0"),
|
||||
# TextPart("</div>"),
|
||||
# ]
|
||||
# ```
|
||||
#
|
||||
# Then we process each part:
|
||||
# 1. Append the content to the output
|
||||
# 2. If the placeholder ID is not None, then we fetch the renderer by its placeholder ID (e.g. "a1b3cf")
|
||||
# 1. If TextPart, we append the content to the output
|
||||
# 2. If ComponentPart, then we fetch the renderer by its placeholder ID (e.g. "a1b3cf")
|
||||
# 3. If there were any extra attributes set by the parent component, we apply these to the renderer.
|
||||
# 4. We split the content by placeholders, and put the pairs of (content, placeholder_id) into the queue,
|
||||
# repeating this whole process until we've processed all nested components.
|
||||
# 5. If the placeholder ID is None, then we've reached the end of the component's HTML content,
|
||||
# 4. We get back the rendered HTML for given component instance, with any extra attributes applied.
|
||||
# 5. We split/parse this content by placeholders, resulting in more `TextPart` and `ComponentPart` items.
|
||||
# 6. We insert these parts back into the queue, repeating this process until we've processed all nested components.
|
||||
# 7. When we reach TextPart with `is_last=True`, then we've reached the end of the component's HTML content,
|
||||
# and we can go one level up to continue the process with component's parent.
|
||||
process_queue: Deque[Union[ErrorPart, TextPart, ComponentPart]] = deque()
|
||||
|
||||
process_queue.append(
|
||||
ComponentPart(
|
||||
child_id=render_id,
|
||||
parent_id=None,
|
||||
component_name_path=[],
|
||||
)
|
||||
)
|
||||
|
||||
# By looping over the queue below, we obtain bits of rendered HTML, which we then
|
||||
# must all join together into a single final HTML.
|
||||
# `html_parts_by_component_id` holds component-specific bits of rendered HTML
|
||||
# so that we can call `on_component_rendered` hook with the correct component instance.
|
||||
#
|
||||
# We then use `content_parts` to collect the final HTML for the component.
|
||||
#
|
||||
# Example - if component has a template like this:
|
||||
#
|
||||
# But instead of joining it all up once at the end, we join the bits on component basis.
|
||||
# So if component has a template like this:
|
||||
# ```django
|
||||
# <div>
|
||||
# Hello
|
||||
|
@ -254,62 +275,190 @@ def component_post_render(
|
|||
# Then we end up with 3 bits - 1. text before, 2. component, and 3. text after
|
||||
#
|
||||
# We know when we've arrived at component's end. We then collect the HTML parts by the component ID,
|
||||
# and when we hit the end, we join all the bits that belong to the same component.
|
||||
# and we join all the bits that belong to the same component.
|
||||
#
|
||||
# Once the component's HTML is joined, we can call the callback for the component, and
|
||||
# then add the joined HTML to the cache for the parent component to continue the cycle.
|
||||
# Once the component's HTML is joined, we then pass that to the callback for
|
||||
# the corresponding component ID.
|
||||
#
|
||||
# Lastly we assign the child's final HTML to parent's parts, continuing the cycle.
|
||||
html_parts_by_component_id: Dict[str, List[str]] = {}
|
||||
content_parts: List[str] = []
|
||||
|
||||
# Remember which component ID had which parent ID, so we can bubble up errors
|
||||
# Remember which component instance + version had which parent, so we can bubble up errors
|
||||
# to the parent component.
|
||||
child_id_to_parent_id: Dict[str, Optional[str]] = {}
|
||||
child_to_parent: Dict[QueueItemId, Optional[QueueItemId]] = {}
|
||||
|
||||
def get_html_parts(component_id: str) -> List[str]:
|
||||
# We want to avoid having to iterate over the queue every time an error raises an error or
|
||||
# when `on_render()` returns a new HTML, making the old HTML stale.
|
||||
#
|
||||
# So instead we keep track of which combinations of component ID + versions we should skip.
|
||||
#
|
||||
# When we then come across these instances in the main loop, we skip them.
|
||||
ignored_components: Set[QueueItemId] = set()
|
||||
|
||||
# When `Component.on_render()` contains a `yield` statement, it becomes a generator.
|
||||
#
|
||||
# The generator may `yield` multiple times. So we keep track of which generator belongs to
|
||||
# which component ID.
|
||||
generators_by_component_id: Dict[str, Optional[OnRenderGenerator]] = {}
|
||||
|
||||
def get_html_parts(item_id: QueueItemId) -> List[str]:
|
||||
component_id = item_id.component_id
|
||||
if component_id not in html_parts_by_component_id:
|
||||
html_parts_by_component_id[component_id] = []
|
||||
return html_parts_by_component_id[component_id]
|
||||
|
||||
def handle_error(component_id: str, error: Exception) -> None:
|
||||
def pop_html_parts(item_id: QueueItemId) -> Optional[List[str]]:
|
||||
component_id = item_id.component_id
|
||||
return html_parts_by_component_id.pop(component_id, None)
|
||||
|
||||
# Split component's rendered HTML by placeholders, from:
|
||||
#
|
||||
# ```html
|
||||
# <div>
|
||||
# <h2>...</h2>
|
||||
# <template djc-render-id="a1b3cf"></template>
|
||||
# <span>...</span>
|
||||
# <template djc-render-id="f3d3d0"></template>
|
||||
# </div>
|
||||
# ```
|
||||
#
|
||||
# To:
|
||||
#
|
||||
# ```py
|
||||
# [
|
||||
# TextPart("<div><h2>...</h2>"),
|
||||
# ComponentPart("a1b3cf"),
|
||||
# TextPart("<span>...</span>"),
|
||||
# ComponentPart("f3d3d0"),
|
||||
# TextPart("</div>"),
|
||||
# ]
|
||||
# ```
|
||||
def parse_component_result(
|
||||
content: str,
|
||||
item_id: QueueItemId,
|
||||
full_path: List[str],
|
||||
) -> List[Union[TextPart, ComponentPart]]:
|
||||
last_index = 0
|
||||
parts_to_process: List[Union[TextPart, ComponentPart]] = []
|
||||
for match in nested_comp_pattern.finditer(content):
|
||||
part_before_component = content[last_index : match.start()]
|
||||
last_index = match.end()
|
||||
comp_part = match[0]
|
||||
|
||||
# Extract the placeholder ID from `<template djc-render-id="a1b3cf"></template>`
|
||||
child_id_match = render_id_pattern.search(comp_part)
|
||||
if child_id_match is None:
|
||||
raise ValueError(f"No placeholder ID found in {comp_part}")
|
||||
child_id = child_id_match.group("render_id")
|
||||
|
||||
parts_to_process.extend(
|
||||
[
|
||||
TextPart(
|
||||
item_id=item_id,
|
||||
text=part_before_component,
|
||||
is_last=False,
|
||||
),
|
||||
ComponentPart(
|
||||
# NOTE: Since this is the first that that this component will be rendered,
|
||||
# the version is 0.
|
||||
item_id=QueueItemId(component_id=child_id, version=0),
|
||||
parent_id=item_id,
|
||||
full_path=full_path,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Append any remaining text
|
||||
parts_to_process.extend(
|
||||
[
|
||||
TextPart(
|
||||
item_id=item_id,
|
||||
text=content[last_index:],
|
||||
is_last=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return parts_to_process
|
||||
|
||||
def handle_error(item_id: QueueItemId, error: Exception, full_path: List[str]) -> None:
|
||||
# Cleanup
|
||||
# Remove any HTML parts that were already rendered for this component
|
||||
html_parts_by_component_id.pop(component_id, None)
|
||||
# Mark any remaining parts of this component (that may be still in the queue) as errored
|
||||
ignored_ids.add(component_id)
|
||||
# Also mark as ignored any remaining parts of the PARENT component.
|
||||
pop_html_parts(item_id)
|
||||
|
||||
# Mark any remaining parts of this component version (that may be still in the queue) as errored
|
||||
ignored_components.add(item_id)
|
||||
|
||||
# Also mark as ignored any remaining parts of this version of the PARENT component.
|
||||
# The reason is because due to the error, parent's rendering flow was disrupted.
|
||||
# Even if parent recovers from the error by returning a new HTML, this new HTML
|
||||
# may have nothing in common with the original HTML.
|
||||
parent_id = child_id_to_parent_id[component_id]
|
||||
# Parent may recover from the error by returning a new HTML. But in that case
|
||||
# we will be processing that *new* HTML (by setting new version), and NOT this broken version.
|
||||
parent_id = child_to_parent[item_id]
|
||||
if parent_id is not None:
|
||||
ignored_ids.add(parent_id)
|
||||
ignored_components.add(parent_id)
|
||||
|
||||
# Add error item to the queue so we handle it in next iteration
|
||||
process_queue.appendleft(
|
||||
ErrorPart(
|
||||
child_id=component_id,
|
||||
item_id=item_id,
|
||||
error=error,
|
||||
)
|
||||
full_path=full_path,
|
||||
),
|
||||
)
|
||||
|
||||
def finalize_component(component_id: str, error: Optional[Exception]) -> None:
|
||||
parent_id = child_id_to_parent_id[component_id]
|
||||
def finalize_component(item_id: QueueItemId, error: Optional[Exception], full_path: List[str]) -> None:
|
||||
parent_id = child_to_parent[item_id]
|
||||
|
||||
component_parts = html_parts_by_component_id.pop(component_id, [])
|
||||
component_parts = pop_html_parts(item_id)
|
||||
if error is None:
|
||||
component_html = "".join(component_parts)
|
||||
component_html = "".join(component_parts) if component_parts else ""
|
||||
else:
|
||||
component_html = None
|
||||
|
||||
# Allow to optionally override/modify the rendered content from `Component.on_render()`
|
||||
# If we've got error, and the component has defined `on_render()` as a generator
|
||||
# (with `yield`), then pass the result to the generator, and process the result.
|
||||
#
|
||||
# NOTE: We want to call the generator (`Component.on_render()`) BEFORE
|
||||
# we call `Component.on_render_after()`. The latter will be called only once
|
||||
# `Component.on_render()` has no more `yield` statements, so that `on_render_after()`
|
||||
# (and `on_component_rendered` extension hook) are called at the very end of component rendering.
|
||||
on_render_generator = generators_by_component_id.pop(item_id.component_id, None)
|
||||
if on_render_generator is not None:
|
||||
result = _call_generator(on_render_generator, component_html, error)
|
||||
|
||||
# Component's `on_render()` contains multiple `yield` keywords, so keep the generator.
|
||||
if not result.spent:
|
||||
generators_by_component_id[item_id.component_id] = on_render_generator
|
||||
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
# Split the new HTML by placeholders, and put the parts into the queue.
|
||||
parts_to_process = parse_component_result(result.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 = result.html, result.error
|
||||
|
||||
# Allow to optionally override/modify the rendered content from `Component.on_render_after()`
|
||||
# and by extensions' `on_component_rendered` hooks.
|
||||
on_component_rendered = on_component_rendered_callbacks[component_id]
|
||||
on_component_rendered = on_component_rendered_callbacks[item_id.component_id]
|
||||
component_html, error = on_component_rendered(component_html, error)
|
||||
|
||||
# If this component had an error, then we ignore this component's HTML, and instead
|
||||
# bubble the error up to the parent component.
|
||||
if error is not None:
|
||||
handle_error(component_id=component_id, error=error)
|
||||
handle_error(item_id=item_id, error=error, full_path=full_path)
|
||||
return
|
||||
|
||||
if component_html is None:
|
||||
|
@ -328,21 +477,15 @@ def component_post_render(
|
|||
else:
|
||||
content_parts.append(component_html)
|
||||
|
||||
# To avoid having to iterate over the queue multiple times to remove from it those
|
||||
# entries that belong to components that have thrown error, we instead keep track of which
|
||||
# components have thrown error, and skip any remaining parts of the component.
|
||||
ignored_ids: Set[str] = set()
|
||||
|
||||
while len(process_queue):
|
||||
curr_item = process_queue.popleft()
|
||||
|
||||
# NOTE: When an error is bubbling up, then the flow goes between `handle_error()`, `finalize_component()`,
|
||||
# Body of the iteration, scoped in a function to avoid spilling the state out of the loop.
|
||||
def on_item(curr_item: Union[ErrorPart, TextPart, ComponentPart]) -> None:
|
||||
# NOTE: When an error is bubbling up, when the flow goes between `handle_error()`, `finalize_component()`,
|
||||
# and this branch, until we reach the root component, where the error is finally raised.
|
||||
#
|
||||
# Any ancestor component of the one that raised can intercept the error and instead return a new string
|
||||
# (or a new error).
|
||||
if isinstance(curr_item, ErrorPart):
|
||||
parent_id = child_id_to_parent_id[curr_item.child_id]
|
||||
parent_id = child_to_parent[curr_item.item_id]
|
||||
|
||||
# If there is no parent, then we're at the root component, so we simply propagate the error.
|
||||
# This ends the error bubbling.
|
||||
|
@ -351,163 +494,134 @@ def component_post_render(
|
|||
|
||||
# This will make the parent component either handle the error and return a new string instead,
|
||||
# or propagate the error to its parent.
|
||||
finalize_component(component_id=parent_id, error=curr_item.error)
|
||||
continue
|
||||
finalize_component(item_id=parent_id, error=curr_item.error, full_path=curr_item.full_path)
|
||||
return
|
||||
|
||||
# Skip parts of errored components
|
||||
if curr_item.parent_id in ignored_ids:
|
||||
continue
|
||||
# Skip parts that belong to component versions that error'd
|
||||
if curr_item.item_id in ignored_components:
|
||||
return
|
||||
|
||||
# Process text parts
|
||||
if isinstance(curr_item, TextPart):
|
||||
parent_html_parts = get_html_parts(curr_item.parent_id)
|
||||
parent_html_parts.append(curr_item.text)
|
||||
curr_html_parts = get_html_parts(curr_item.item_id)
|
||||
curr_html_parts.append(curr_item.text)
|
||||
|
||||
# In this case we've reached the end of the component's HTML content, and there's
|
||||
# no more subcomponents to process. We can call `finalize_component()` to process
|
||||
# the component's HTML and eventually trigger `on_component_rendered` hook.
|
||||
if curr_item.is_last:
|
||||
finalize_component(component_id=curr_item.parent_id, error=None)
|
||||
finalize_component(item_id=curr_item.item_id, error=None, full_path=[])
|
||||
|
||||
continue
|
||||
return
|
||||
|
||||
# The rest of this branch assumes `curr_item` is a `ComponentPart`
|
||||
component_id = curr_item.child_id
|
||||
if isinstance(curr_item, ComponentPart):
|
||||
component_id = curr_item.item_id.component_id
|
||||
|
||||
# Remember which component ID had which parent ID, so we can bubble up errors
|
||||
# to the parent component.
|
||||
child_id_to_parent_id[component_id] = curr_item.parent_id
|
||||
# Remember which component ID had which parent ID, so we can bubble up errors
|
||||
# to the parent component.
|
||||
child_to_parent[curr_item.item_id] = curr_item.parent_id
|
||||
|
||||
# Generate component's content, applying the extra HTML attributes set by the parent component
|
||||
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(component_id)
|
||||
# NOTE: Attributes passed from parent to current component are `None` for the root component.
|
||||
curr_comp_attrs = child_component_attrs.pop(component_id, None)
|
||||
# Generate component's content, applying the extra HTML attributes set by the parent component
|
||||
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(component_id)
|
||||
# NOTE: Attributes passed from parent to current component are `None` for the root component.
|
||||
curr_comp_attrs = child_component_attrs.pop(component_id, None)
|
||||
|
||||
full_path = [*curr_item.component_name_path, curr_comp_name]
|
||||
full_path = [*curr_item.full_path, curr_comp_name]
|
||||
|
||||
# This is where we actually render the component
|
||||
#
|
||||
# NOTE: [1:] because the root component will be yet again added to the error's
|
||||
# `components` list in `_render_with_error_trace` so we remove the first element from the path.
|
||||
try:
|
||||
with component_error_message(full_path[1:]):
|
||||
comp_content, grandchild_component_attrs, on_render_generator = curr_comp_renderer(curr_comp_attrs)
|
||||
# This error may be triggered when any of following raises:
|
||||
# - `Component.on_render()` (first part - before yielding)
|
||||
# - `Component.on_render_before()`
|
||||
# - Rendering of component's template
|
||||
#
|
||||
# In all cases, we want to mark the component as errored, and let the parent handle it.
|
||||
except Exception as err: # noqa: BLE001
|
||||
handle_error(component_id=component_id, error=err)
|
||||
continue
|
||||
# This is where we actually render the component
|
||||
#
|
||||
# NOTE: [1:] because the root component will be yet again added to the error's
|
||||
# `components` list in `_render_with_error_trace` so we remove the first element from the path.
|
||||
try:
|
||||
with component_error_message(full_path[1:]):
|
||||
comp_content, extra_child_component_attrs, on_render_generator = curr_comp_renderer(
|
||||
curr_comp_attrs,
|
||||
)
|
||||
# This error may be triggered when any of following raises:
|
||||
# - `Component.on_render()` (first part - before yielding)
|
||||
# - `Component.on_render_before()`
|
||||
# - Rendering of component's template
|
||||
#
|
||||
# In all cases, we want to mark the component as errored, and let the parent handle it.
|
||||
except Exception as err: # noqa: BLE001
|
||||
handle_error(item_id=curr_item.item_id, error=err, full_path=full_path)
|
||||
return
|
||||
|
||||
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
|
||||
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
|
||||
# AFTER the yield will be called once, when the component's HTML is fully rendered.
|
||||
#
|
||||
# We want to make sure we call the second part of `Component.on_render()` BEFORE
|
||||
# we call `Component.on_render_after()`. The latter will be triggered by calling
|
||||
# corresponding `on_component_rendered`.
|
||||
#
|
||||
# So we want to wrap the `on_component_rendered` callback, so we get to call the generator first.
|
||||
if on_render_generator is not None:
|
||||
unwrapped_on_component_rendered = on_component_rendered_callbacks[component_id]
|
||||
on_component_rendered_callbacks[component_id] = _call_generator_before_callback(
|
||||
on_render_generator,
|
||||
unwrapped_on_component_rendered,
|
||||
)
|
||||
if on_render_generator is not None:
|
||||
generators_by_component_id[component_id] = on_render_generator
|
||||
|
||||
child_component_attrs.update(grandchild_component_attrs)
|
||||
child_component_attrs.update(extra_child_component_attrs)
|
||||
|
||||
# Split component's content by placeholders, and put the pairs of
|
||||
# `(text_between_components, placeholder_id)`
|
||||
# into the queue.
|
||||
last_index = 0
|
||||
parts_to_process: List[Union[TextPart, ComponentPart]] = []
|
||||
for match in nested_comp_pattern.finditer(comp_content):
|
||||
part_before_component = comp_content[last_index : match.start()]
|
||||
last_index = match.end()
|
||||
comp_part = match[0]
|
||||
# Split the component's rendered HTML by placeholders, and put the parts into the queue.
|
||||
parts_to_process = parse_component_result(comp_content, curr_item.item_id, full_path)
|
||||
process_queue.extendleft(reversed(parts_to_process))
|
||||
|
||||
# Extract the placeholder ID from `<template djc-render-id="a1b3cf"></template>`
|
||||
grandchild_id_match = render_id_pattern.search(comp_part)
|
||||
if grandchild_id_match is None:
|
||||
raise ValueError(f"No placeholder ID found in {comp_part}")
|
||||
grandchild_id = grandchild_id_match.group("render_id")
|
||||
else:
|
||||
raise TypeError("Unknown item type")
|
||||
|
||||
parts_to_process.extend(
|
||||
[
|
||||
TextPart(
|
||||
text=part_before_component,
|
||||
is_last=False,
|
||||
parent_id=component_id,
|
||||
),
|
||||
ComponentPart(
|
||||
child_id=grandchild_id,
|
||||
parent_id=component_id,
|
||||
component_name_path=full_path,
|
||||
),
|
||||
]
|
||||
)
|
||||
# Kick off the process by adding the root component to the queue
|
||||
process_queue.append(
|
||||
ComponentPart(
|
||||
item_id=QueueItemId(component_id=render_id, version=0),
|
||||
parent_id=None,
|
||||
full_path=[],
|
||||
),
|
||||
)
|
||||
|
||||
# Append any remaining text
|
||||
parts_to_process.extend(
|
||||
[
|
||||
TextPart(
|
||||
text=comp_content[last_index:],
|
||||
is_last=True,
|
||||
parent_id=component_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
process_queue.extendleft(reversed(parts_to_process))
|
||||
while len(process_queue):
|
||||
curr_item = process_queue.popleft()
|
||||
on_item(curr_item)
|
||||
|
||||
# Lastly, join up all pieces of the component's HTML content
|
||||
output = "".join(content_parts)
|
||||
|
||||
# Allow to optionally modify the final output
|
||||
output = on_html_rendered(output)
|
||||
|
||||
return mark_safe(output)
|
||||
|
||||
|
||||
def _call_generator_before_callback(
|
||||
on_render_generator: Optional["OnRenderGenerator"],
|
||||
inner_fn: Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
|
||||
) -> Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]:
|
||||
if on_render_generator is None:
|
||||
return inner_fn
|
||||
def _call_generator(
|
||||
on_render_generator: "OnRenderGenerator",
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> GeneratorResult:
|
||||
generator_spent = False
|
||||
needs_processing = False
|
||||
|
||||
def on_component_rendered_wrapper(
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> OnComponentRenderedResult:
|
||||
try:
|
||||
on_render_generator.send((html, error))
|
||||
# `Component.on_render()` should contain only one `yield` statement, so calling `.send()`
|
||||
# should reach `return` statement in `Component.on_render()`, which triggers `StopIteration`.
|
||||
# In that case, the value returned from `Component.on_render()` with the `return` keyword
|
||||
# is the new output (if not `None`).
|
||||
except StopIteration as generator_err:
|
||||
# To override what HTML / error gets returned, user may either:
|
||||
# - Return a new HTML at the end of `Component.on_render()` (after yielding),
|
||||
# - Raise a new error
|
||||
new_output = generator_err.value
|
||||
if new_output is not None:
|
||||
html = new_output
|
||||
error = None
|
||||
try:
|
||||
# `Component.on_render()` may have any number of `yield` statements, so we need to
|
||||
# call `.send()` any number of times.
|
||||
#
|
||||
# To override what HTML / error gets returned, user may either:
|
||||
# - Return a new HTML with `return` - We handle error / result ourselves
|
||||
# - Yield a new HTML with `yield` - We return back to the user the processed HTML / error
|
||||
# for them to process further
|
||||
# - Raise a new error
|
||||
new_result = on_render_generator.send((html, error))
|
||||
|
||||
# Catch if `Component.on_render()` raises an exception, in which case this becomes
|
||||
# the new error.
|
||||
except Exception as new_error: # noqa: BLE001
|
||||
error = new_error
|
||||
html = None
|
||||
# This raises if `StopIteration` was not raised, which may be if `Component.on_render()`
|
||||
# contains more than one `yield` statement.
|
||||
else:
|
||||
raise RuntimeError("`Component.on_render()` must include only one `yield` statement")
|
||||
# If we've reached the end of `Component.on_render()` (or `return` statement), then we get `StopIteration`.
|
||||
# In that case, we want to check if user returned new HTML from the `return` statement.
|
||||
except StopIteration as generator_err:
|
||||
generator_spent = True
|
||||
|
||||
return inner_fn(html, error)
|
||||
# The return value is on `StopIteration.value`
|
||||
new_output = generator_err.value
|
||||
if new_output is not None:
|
||||
html = new_output
|
||||
error = None
|
||||
needs_processing = True
|
||||
|
||||
return on_component_rendered_wrapper
|
||||
# Catch if `Component.on_render()` raises an exception, in which case this becomes
|
||||
# the new error.
|
||||
except Exception as new_error: # noqa: BLE001
|
||||
error = new_error
|
||||
html = None
|
||||
|
||||
# If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result,
|
||||
# that we need to process.
|
||||
else:
|
||||
needs_processing = True
|
||||
return GeneratorResult(html=new_result, error=None, needs_processing=needs_processing, spent=generator_spent)
|
||||
|
||||
return GeneratorResult(html=html, error=error, needs_processing=needs_processing, spent=generator_spent)
|
||||
|
|
|
@ -1809,6 +1809,56 @@ class TestComponentHook:
|
|||
with pytest.raises(ValueError, match=re.escape("BROKEN")):
|
||||
SimpleComponent.render()
|
||||
|
||||
def test_on_render_multiple_yields(self):
|
||||
registry.register("broken", self._gen_broken_component())
|
||||
|
||||
results = []
|
||||
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% if case == 1 %}
|
||||
{% component "broken" / %}
|
||||
{% elif case == 2 %}
|
||||
Hello
|
||||
{% elif case == 3 %}
|
||||
There
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
assert template is not None
|
||||
|
||||
with context.push({"case": 1}):
|
||||
html1, error1 = yield template.render(context)
|
||||
results.append((html1, error1))
|
||||
|
||||
with context.push({"case": 2}):
|
||||
html2, error2 = yield template.render(context)
|
||||
results.append((html2.strip(), error2))
|
||||
|
||||
with context.push({"case": 3}):
|
||||
html3, error3 = yield template.render(context)
|
||||
results.append((html3.strip(), error3))
|
||||
|
||||
html4, error4 = yield "Other result"
|
||||
results.append((html4, error4))
|
||||
|
||||
return "Final result"
|
||||
|
||||
result = SimpleComponent.render()
|
||||
assert result == "Final result"
|
||||
|
||||
# NOTE: Exceptions are stubborn, comparison evaluates to False even with the same message.
|
||||
assert results[0][0] is None
|
||||
assert isinstance(results[0][1], ValueError)
|
||||
assert results[0][1].args[0] == "An error occured while rendering components broken:\nBROKEN"
|
||||
|
||||
assert results[1:] == [
|
||||
("Hello", None),
|
||||
("There", None),
|
||||
("Other result", None),
|
||||
]
|
||||
|
||||
@djc_test(
|
||||
parametrize=(
|
||||
["template", "action", "method"],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue