diff --git a/CHANGELOG.md b/CHANGELOG.md index 990336fa..b8a57335 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/concepts/advanced/hooks.md b/docs/concepts/advanced/hooks.md index 269134cb..5c129d02 100644 --- a/docs/concepts/advanced/hooks.md +++ b/docs/concepts/advanced/hooks.md @@ -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` diff --git a/src/django_components/component.py b/src/django_components/component.py index d540197a..8550b5d9 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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 + "

Hello

" ``` + +**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) diff --git a/src/django_components/perfutil/component.py b/src/django_components/perfutil/component.py index d241075d..aa86dc3c 100644 --- a/src/django_components/perfutil/component.py +++ b/src/django_components/perfutil/component.py @@ -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'') # Case: Root component - Construct the final HTML by recursively replacing placeholders @@ -205,7 +225,7 @@ def component_post_render( #

...

# # ... - # + # # # ``` # @@ -213,37 +233,38 @@ def component_post_render( # -

...

# - PLACEHOLDER djc-render-id="a1b3cf" # - ... - # - PLACEHOLDER djc-render-id="f3d3cf" + # - PLACEHOLDER djc-render-id="f3d3d0" # -
# - # And put the pairs of (content, placeholder_id) into a queue: - # - ("

...

", "a1b3cf") - # - ("...", "f3d3cf") - # - ("
", None) + # And put these into a queue: + # ```py + # [ + # TextPart("

...

"), + # ComponentPart("a1b3cf"), + # TextPart("..."), + # ComponentPart("f3d3d0"), + # TextPart("
"), + # ] + # ``` # # 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 #
# 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 + #
+ #

...

+ # + # ... + # + #
+ # ``` + # + # To: + # + # ```py + # [ + # TextPart("

...

"), + # ComponentPart("a1b3cf"), + # TextPart("..."), + # ComponentPart("f3d3d0"), + # TextPart("
"), + # ] + # ``` + 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 `` + 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 `` - 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) diff --git a/tests/test_component.py b/tests/test_component.py index 52800f96..baeaea5c 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -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"],