refactor: fix multiple yield in on_render + prepare for scoped CSS (#1425)
Some checks failed
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
Docs - build & deploy / docs (push) Has been cancelled

This commit is contained in:
Juro Oravec 2025-10-02 13:46:39 +02:00 committed by GitHub
parent cd7a9c9703
commit 91012829ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 330 additions and 268 deletions

View file

@ -98,6 +98,22 @@ pytest
tox -e py38 tox -e py38
``` ```
## Snapshot tests
Some tests rely on snapshot testing with [syrupy](https://github.com/syrupy-project/syrupy) to test the HTML output of the components.
If you need to update the snapshot tests, add `--snapshot-update` to the pytest command:
```sh
pytest --snapshot-update
```
Or with tox:
```sh
tox -e py39 -- --snapshot-update
```
## Dev server ## Dev server
How do you check that your changes to django-components project will work in an actual Django project? How do you check that your changes to django-components project will work in an actual Django project?

View file

@ -19,7 +19,7 @@ from typing import (
Union, Union,
cast, cast,
) )
from weakref import ReferenceType, WeakValueDictionary, finalize, ref from weakref import ReferenceType, WeakKeyDictionary, WeakValueDictionary, finalize, ref
from django.forms.widgets import Media as MediaCls from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
@ -58,7 +58,6 @@ from django_components.extensions.defaults import ComponentDefaults
from django_components.extensions.view import ComponentView, ViewFn from django_components.extensions.view import ComponentView, ViewFn
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.perfutil.component import ( from django_components.perfutil.component import (
ComponentRenderer,
OnComponentRenderedResult, OnComponentRenderedResult,
component_context_cache, component_context_cache,
component_instance_cache, component_instance_cache,
@ -79,7 +78,7 @@ from django_components.template import cache_component_template_file, prepare_co
from django_components.util.context import gen_context_processors_data, snapshot_context from django_components.util.context import gen_context_processors_data, snapshot_context
from django_components.util.exception import component_error_message from django_components.util.exception import component_error_message
from django_components.util.logger import trace_component_msg from django_components.util.logger import trace_component_msg
from django_components.util.misc import default, gen_id, hash_comp_cls, to_dict from django_components.util.misc import default, gen_id, hash_comp_cls, is_generator, to_dict
from django_components.util.template_tag import TagAttr from django_components.util.template_tag import TagAttr
from django_components.util.weakref import cached_ref from django_components.util.weakref import cached_ref
@ -104,10 +103,12 @@ if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType[Type["Component"]]] AllComponents = List[ReferenceType[Type["Component"]]]
CompHashMapping = WeakValueDictionary[str, Type["Component"]] CompHashMapping = WeakValueDictionary[str, Type["Component"]]
ComponentRef = ReferenceType["Component"] ComponentRef = ReferenceType["Component"]
StartedGenerators = WeakKeyDictionary["OnRenderGenerator", bool]
else: else:
AllComponents = List[ReferenceType] AllComponents = List[ReferenceType]
CompHashMapping = WeakValueDictionary CompHashMapping = WeakValueDictionary
ComponentRef = ReferenceType ComponentRef = ReferenceType
StartedGenerators = WeakKeyDictionary
OnRenderGenerator = Generator[ OnRenderGenerator = Generator[
@ -541,6 +542,27 @@ class ComponentMeta(ComponentMediaMeta):
extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls)) extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls))
# Internal data that's shared across the entire component tree
@dataclass
class ComponentTreeContext:
# HTML attributes that are passed from parent to child components
component_attrs: Dict[str, List[str]]
# When we render a component, the root component, together with all the nested Components,
# shares these dictionaries for storing callbacks.
# These callbacks are called from within `component_post_render`
on_component_intermediate_callbacks: Dict[str, Callable[[Optional[str]], Optional[str]]]
on_component_rendered_callbacks: Dict[
str,
Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
]
# Track which generators have been started. We need this info because the input to
# `Generator.send()` changes when calling it the first time vs subsequent times.
# Moreover, we can't simply store this directly on the generator object themselves
# (e.g. `generator.started = True`), because generator object does not allow setting
# extra attributes.
started_generators: StartedGenerators
# Internal data that are made available within the component's template # Internal data that are made available within the component's template
@dataclass @dataclass
class ComponentContext: class ComponentContext:
@ -549,10 +571,7 @@ class ComponentContext:
template_name: Optional[str] template_name: Optional[str]
default_slot: Optional[str] default_slot: Optional[str]
outer_context: Optional[Context] outer_context: Optional[Context]
# When we render a component, the root component, together with all the nested Components, tree: ComponentTreeContext
# shares this dictionary for storing callbacks that are called from within `component_post_render`.
# This is so that we can pass them all in when the root component is passed to `component_post_render`.
post_render_callbacks: Dict[str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]]
def on_component_garbage_collected(component_id: str) -> None: def on_component_garbage_collected(component_id: str) -> None:
@ -3501,10 +3520,15 @@ class Component(metaclass=ComponentMeta):
parent_id, parent_comp_ctx = _get_parent_component_context(context) parent_id, parent_comp_ctx = _get_parent_component_context(context)
if parent_comp_ctx is not None: if parent_comp_ctx is not None:
component_path = [*parent_comp_ctx.component_path, component_name] component_path = [*parent_comp_ctx.component_path, component_name]
post_render_callbacks = parent_comp_ctx.post_render_callbacks component_tree_context = parent_comp_ctx.tree
else: else:
component_path = [component_name] component_path = [component_name]
post_render_callbacks = {} component_tree_context = ComponentTreeContext(
component_attrs={},
on_component_intermediate_callbacks={},
on_component_rendered_callbacks={},
started_generators=WeakKeyDictionary(),
)
trace_component_msg( trace_component_msg(
"COMP_PREP_START", "COMP_PREP_START",
@ -3537,7 +3561,7 @@ class Component(metaclass=ComponentMeta):
default_slot=None, default_slot=None,
# NOTE: This is only a SNAPSHOT of the outer context. # NOTE: This is only a SNAPSHOT of the outer context.
outer_context=snapshot_context(outer_context) if outer_context is not None else None, outer_context=snapshot_context(outer_context) if outer_context is not None else None,
post_render_callbacks=post_render_callbacks, tree=component_tree_context,
) )
# Instead of passing the ComponentContext directly through the Context, the entry on the Context # Instead of passing the ComponentContext directly through the Context, the entry on the Context
@ -3645,26 +3669,70 @@ class Component(metaclass=ComponentMeta):
# Cleanup # Cleanup
context.render_context.pop() # type: ignore[union-attr] context.render_context.pop() # type: ignore[union-attr]
trace_component_msg(
"COMP_PREP_END",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
###################################### ######################################
# 5. Render component # 5. Render component
# #
# NOTE: To support infinite recursion, we don't directly call `Template.render()`. # NOTE: To support infinite recursion, we don't directly call `Template.render()`.
# Instead, we defer rendering of the component - we prepare a callback that will # Instead, we defer rendering of the component - we prepare a generator function
# be called when the rendering process reaches this component. # that will be called when the rendering process reaches this component.
###################################### ######################################
trace_component_msg(
"COMP_RENDER_START",
component_name=component.name,
component_id=component.id,
slot_name=None,
component_path=component_path,
)
component.on_render_before(context_snapshot, template)
# Emit signal that the template is about to be rendered
if template is not None:
template_rendered.send(sender=template, template=template, context=context_snapshot)
# Instead of rendering component at the time we come across the `{% component %}` tag # Instead of rendering component at the time we come across the `{% component %}` tag
# in the template, we defer rendering in order to scalably handle deeply nested components. # in the template, we defer rendering in order to scalably handle deeply nested components.
# #
# See `_gen_component_renderer()` for more details. # See `_make_renderer_generator()` for more details.
deferred_render = component._gen_component_renderer( renderer_generator = component._make_renderer_generator(
template=template, template=template,
context=context_snapshot, context=context_snapshot,
component_path=component_path, component_path=component_path,
css_input_hash=css_input_hash,
js_input_hash=js_input_hash,
) )
# This callback is called with the value that was yielded from `Component.on_render()`.
# It may be called multiple times for the same component, e.g. if `Component.on_render()`
# contains multiple `yield` keywords.
def on_component_intermediate(html_content: Optional[str]) -> Optional[str]:
# HTML attributes passed from parent to current component.
# NOTE: Is `None` for the root component.
curr_comp_attrs = component_tree_context.component_attrs.get(render_id, None)
if html_content:
# Add necessary HTML attributes to work with JS and CSS variables
html_content, child_components_attrs = set_component_attrs_for_js_and_css(
html_content=html_content,
component_id=render_id,
css_input_hash=css_input_hash,
root_attributes=curr_comp_attrs,
)
# Store the HTML attributes that will be passed from this component to its children's components
component_tree_context.component_attrs.update(child_components_attrs)
return html_content
component_tree_context.on_component_intermediate_callbacks[render_id] = on_component_intermediate
# `on_component_rendered` is triggered when a component is rendered. # `on_component_rendered` is triggered when a component is rendered.
# The component's parent(s) may not be fully rendered yet. # The component's parent(s) may not be fully rendered yet.
# #
@ -3673,6 +3741,7 @@ class Component(metaclass=ComponentMeta):
# so that the component instance can be garbage collected. # so that the component instance can be garbage collected.
component_instance_cache[render_id] = component component_instance_cache[render_id] = component
# NOTE: This is called only once for a single component instance.
def on_component_rendered( def on_component_rendered(
html: Optional[str], html: Optional[str],
error: Optional[Exception], error: Optional[Exception],
@ -3697,6 +3766,17 @@ class Component(metaclass=ComponentMeta):
error = new_error error = new_error
html = None html = None
# Prepend an HTML comment to instruct how and what JS and CSS scripts are associated with it.
# E.g. `<!-- _RENDERED table,123,a92ef298,bd002c3 -->`
if html is not None:
html = insert_component_dependencies_comment(
html,
component_cls=comp_cls,
component_id=render_id,
js_input_hash=js_input_hash,
css_input_hash=css_input_hash,
)
# Allow extensions to either: # Allow extensions to either:
# - Override/modify the rendered HTML by returning new value # - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error # - Raise an exception to discard the HTML and bubble up error
@ -3714,122 +3794,6 @@ class Component(metaclass=ComponentMeta):
if result is not None: if result is not None:
html, error = result html, error = result
return html, error
post_render_callbacks[render_id] = on_component_rendered
# This is triggered after a full component tree was rendered, we resolve
# all inserted HTML comments into <script> and <link> tags.
def on_html_rendered(html: str) -> str:
html = _render_dependencies(html, deps_strategy)
return html
trace_component_msg(
"COMP_PREP_END",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
return component_post_render(
renderer=deferred_render,
render_id=render_id,
component_name=component_name,
parent_render_id=parent_id,
on_component_rendered_callbacks=post_render_callbacks,
on_html_rendered=on_html_rendered,
)
# Creates a renderer function that will be called only once, when the component is to be rendered.
#
# By encapsulating components' output as render function, we can render components top-down,
# starting from root component, and moving down.
#
# This way, when it comes to rendering a particular component, we have already rendered its parent,
# and we KNOW if there were any HTML attributes that were passed from parent to children.
#
# Thus, the returned renderer function accepts the extra HTML attributes that were passed from parent,
# and returns the updated HTML content.
#
# Because the HTML attributes are all boolean (e.g. `data-djc-id-ca1b3c4`), they are passed as a list.
#
# This whole setup makes it possible for multiple components to resolve to the same HTML element.
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
# IDs of both CompA and CompB.
# ```html
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
# ```
def _gen_component_renderer(
self,
template: Optional[Template],
context: Context,
component_path: List[str],
css_input_hash: Optional[str],
js_input_hash: Optional[str],
) -> ComponentRenderer:
component = self
render_id = component.id
component_name = component.name
component_cls = component.__class__
def renderer(
root_attributes: Optional[List[str]] = None,
) -> Tuple[str, Dict[str, List[str]], Optional[OnRenderGenerator]]:
trace_component_msg(
"COMP_RENDER_START",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
component.on_render_before(context, template)
# Emit signal that the template is about to be rendered
if template is not None:
template_rendered.send(sender=template, template=template, context=context)
# 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.
#
# 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)
if html_content_or_generator is None:
html_content: Optional[str] = None
on_render_generator: Optional[OnRenderGenerator] = None
elif isinstance(html_content_or_generator, str):
html_content = html_content_or_generator
on_render_generator = None
else:
# Move generator to the first yield
html_content = next(html_content_or_generator)
on_render_generator = html_content_or_generator
if html_content is not None:
# Add necessary HTML attributes to work with JS and CSS variables
updated_html, child_components = set_component_attrs_for_js_and_css(
html_content=html_content,
component_id=render_id,
css_input_hash=css_input_hash,
root_attributes=root_attributes,
)
# Prepend an HTML comment to instructs how and what JS and CSS scripts are associated with it.
updated_html = insert_component_dependencies_comment(
updated_html,
component_cls=component_cls,
component_id=render_id,
js_input_hash=js_input_hash,
css_input_hash=css_input_hash,
)
else:
updated_html = ""
child_components = {}
trace_component_msg( trace_component_msg(
"COMP_RENDER_END", "COMP_RENDER_END",
component_name=component_name, component_name=component_name,
@ -3838,9 +3802,90 @@ class Component(metaclass=ComponentMeta):
component_path=component_path, component_path=component_path,
) )
return updated_html, child_components, on_render_generator return html, error
return renderer component_tree_context.on_component_rendered_callbacks[render_id] = on_component_rendered
# This is triggered after a full component tree was rendered, we resolve
# all inserted HTML comments into <script> and <link> tags.
def on_component_tree_rendered(html: str) -> str:
html = _render_dependencies(html, deps_strategy)
return html
return component_post_render(
renderer=renderer_generator,
render_id=render_id,
component_name=component_name,
parent_render_id=parent_id,
component_tree_context=component_tree_context,
on_component_tree_rendered=on_component_tree_rendered,
)
# Convert `Component.on_render()` to a generator function.
#
# By encapsulating components' output as a generator, we can render components top-down,
# starting from root component, and moving down.
#
# This allows us to pass HTML attributes from parent to children.
# Because by the time we get to a child component, its parent was already rendered.
#
# This whole setup makes it possible for multiple components to resolve to the same HTML element.
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
# IDs of both CompA and CompB.
# ```html
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
# ```
def _make_renderer_generator(
self,
template: Optional[Template],
context: Context,
component_path: List[str],
) -> Optional[OnRenderGenerator]:
component = self
# Convert the component's HTML to a generator function.
#
# 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.
#
# ```
# class MyTable(Component):
# def on_render(self, context, template):
# html, error = yield template.render(context)
# return html + "<p>Hello</p>"
# ```
#
# However, the way Python works is that when you call a function that contains `yield` keyword,
# the function is NOT executed immediately. Instead it returns a generator object.
#
# On the other hand, if it's a regular function, the function is executed immediately.
#
# We must be careful not to execute the function immediately, because that will cause the
# entire component tree to be rendered recursively. Instead we want to defer the execution
# and render nested components via a flat stack, as done in `perfutils/component.py`.
# That allows us to create component trees of any depth, without hitting recursion limits.
#
# So we create a wrapper generator function that we KNOW is a generator when called.
def inner_generator() -> OnRenderGenerator:
# NOTE: May raise
html_content_or_generator = component.on_render(context, template)
# If we DIDN'T raise an exception
if html_content_or_generator is None:
return None
# Generator function (with `yield`) - yield multiple times with the result
elif is_generator(html_content_or_generator):
generator = cast("OnRenderGenerator", html_content_or_generator)
result = yield from generator
# If the generator had a return statement, `result` will contain that value.
# So we pass the return value through.
return result
# String (or other unknown type) - yield once with the result
else:
yield html_content_or_generator
return None
return inner_generator()
def _call_data_methods( def _call_data_methods(
self, self,

View file

@ -5,10 +5,16 @@ from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optio
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_components.constants import COMP_ID_LENGTH from django_components.constants import COMP_ID_LENGTH
from django_components.util.exception import component_error_message from django_components.util.exception import component_error_message, set_component_error_message
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import Component, ComponentContext, OnRenderGenerator from django_components.component import (
Component,
ComponentContext,
ComponentTreeContext,
OnRenderGenerator,
StartedGenerators,
)
OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]] OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
@ -89,21 +95,9 @@ class GeneratorResult(NamedTuple):
"""Whether the generator has been "spent" - e.g. reached its end with `StopIteration`.""" """Whether the generator has been "spent" - e.g. reached its end with `StopIteration`."""
# Function that accepts a list of extra HTML attributes to be set on the component's root elements
# and returns the component's HTML content and a dictionary of child components' IDs
# and their root elements' HTML attributes.
#
# In other words, we use this to "delay" the actual rendering of the component's HTML content,
# until we know what HTML attributes to apply to the root elements.
ComponentRenderer = Callable[
[Optional[List[str]]],
Tuple[str, Dict[str, List[str]], Optional["OnRenderGenerator"]],
]
# Render-time cache for component rendering # Render-time cache for component rendering
# See component_post_render() # See component_post_render()
component_renderer_cache: Dict[str, Tuple[ComponentRenderer, str]] = {} component_renderer_cache: "Dict[str, Tuple[Optional[OnRenderGenerator], str]]" = {}
child_component_attrs: Dict[str, List[str]] = {}
nested_comp_pattern = re.compile( nested_comp_pattern = re.compile(
r'<template [^>]*?djc-render-id="\w{{{COMP_ID_LENGTH}}}"[^>]*?></template>'.format(COMP_ID_LENGTH=COMP_ID_LENGTH), # noqa: UP032 r'<template [^>]*?djc-render-id="\w{{{COMP_ID_LENGTH}}}"[^>]*?></template>'.format(COMP_ID_LENGTH=COMP_ID_LENGTH), # noqa: UP032
@ -159,15 +153,12 @@ render_id_pattern = re.compile(
# to the root elements. # to the root elements.
# 8. Lastly, we merge all the parts together, and return the final HTML. # 8. Lastly, we merge all the parts together, and return the final HTML.
def component_post_render( def component_post_render(
renderer: ComponentRenderer, renderer: "Optional[OnRenderGenerator]",
render_id: str, render_id: str,
component_name: str, component_name: str,
parent_render_id: Optional[str], parent_render_id: Optional[str],
on_component_rendered_callbacks: Dict[ component_tree_context: "ComponentTreeContext",
str, on_component_tree_rendered: Callable[[str], str],
Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
],
on_html_rendered: Callable[[str], str],
) -> str: ) -> str:
# Instead of rendering the component's HTML content immediately, we store it, # Instead of rendering the component's HTML content immediately, we store it,
# so we can render the component only once we know if there are any HTML attributes # so we can render the component only once we know if there are any HTML attributes
@ -407,11 +398,11 @@ def component_post_render(
), ),
) )
def finalize_component(item_id: QueueItemId, error: Optional[Exception], full_path: List[str]) -> None: def next_renderer_result(item_id: QueueItemId, error: Optional[Exception], full_path: List[str]) -> None:
parent_id = child_to_parent[item_id] parent_id = child_to_parent[item_id]
component_parts = pop_html_parts(item_id) component_parts = pop_html_parts(item_id)
if error is None: if error is None and component_parts:
component_html = "".join(component_parts) if component_parts else "" component_html = "".join(component_parts) if component_parts else ""
else: else:
component_html = None component_html = None
@ -425,7 +416,14 @@ def component_post_render(
# (and `on_component_rendered` extension hook) are called at the very end of component rendering. # (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) on_render_generator = generators_by_component_id.pop(item_id.component_id, None)
if on_render_generator is not None: if on_render_generator is not None:
result = _call_generator(on_render_generator, component_html, error) result = _call_generator(
on_render_generator=on_render_generator,
html=component_html,
error=error,
started_generators_cache=component_tree_context.started_generators,
full_path=full_path,
)
new_html = result.html
# Component's `on_render()` contains multiple `yield` keywords, so keep the generator. # Component's `on_render()` contains multiple `yield` keywords, so keep the generator.
if not result.spent: if not result.spent:
@ -443,16 +441,27 @@ def component_post_render(
# Set the current parent as the parent of the new version # Set the current parent as the parent of the new version
child_to_parent[new_item_id] = parent_id child_to_parent[new_item_id] = parent_id
# Allow to optionally override/modify the intermediate result returned from `Component.on_render()`
# and by extensions' `on_component_intermediate` hooks.
on_component_intermediate = component_tree_context.on_component_intermediate_callbacks[
item_id.component_id
]
# 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.
with component_error_message(full_path[1:]):
new_html = on_component_intermediate(new_html)
# Split the new HTML by placeholders, and put the parts into the queue. # 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) parts_to_process = parse_component_result(new_html or "", new_item_id, full_path)
process_queue.extendleft(reversed(parts_to_process)) process_queue.extendleft(reversed(parts_to_process))
return return
# If we don't need to re-do the processing, then we can just use the result. # If we don't need to re-do the processing, then we can just use the result.
component_html, error = result.html, result.error component_html, error = new_html, result.error
# Allow to optionally override/modify the rendered content from `Component.on_render_after()` # Allow to optionally override/modify the rendered content from `Component.on_render_after()`
# and by extensions' `on_component_rendered` hooks. # and by extensions' `on_component_rendered` hooks.
on_component_rendered = on_component_rendered_callbacks[item_id.component_id] on_component_rendered = component_tree_context.on_component_rendered_callbacks[item_id.component_id]
with component_error_message(full_path[1:]):
component_html, error = on_component_rendered(component_html, error) component_html, error = on_component_rendered(component_html, error)
# If this component had an error, then we ignore this component's HTML, and instead # If this component had an error, then we ignore this component's HTML, and instead
@ -462,7 +471,7 @@ def component_post_render(
return return
if component_html is None: if component_html is None:
raise RuntimeError("Unexpected `None` from `Component.on_render()`") return
# At this point we have a component, and we've resolved all its children into strings. # At this point we have a component, and we've resolved all its children into strings.
# So the component's full HTML is now only strings. # So the component's full HTML is now only strings.
@ -479,7 +488,7 @@ def component_post_render(
# Body of the iteration, scoped in a function to avoid spilling the state out of the loop. # 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: 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()`, # NOTE: When an error is bubbling up, when the flow goes between `handle_error()`, `next_renderer_result()`,
# and this branch, until we reach the root component, where the error is finally raised. # 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 # Any ancestor component of the one that raised can intercept the error and instead return a new string
@ -494,7 +503,7 @@ def component_post_render(
# This will make the parent component either handle the error and return a new string instead, # This will make the parent component either handle the error and return a new string instead,
# or propagate the error to its parent. # or propagate the error to its parent.
finalize_component(item_id=parent_id, error=curr_item.error, full_path=curr_item.full_path) next_renderer_result(item_id=parent_id, error=curr_item.error, full_path=curr_item.full_path)
return return
# Skip parts that belong to component versions that error'd # Skip parts that belong to component versions that error'd
@ -507,10 +516,10 @@ def component_post_render(
curr_html_parts.append(curr_item.text) curr_html_parts.append(curr_item.text)
# In this case we've reached the end of the component's HTML content, and there's # 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 # no more subcomponents to process. We can call `next_renderer_result()` to process
# the component's HTML and eventually trigger `on_component_rendered` hook. # the component's HTML and eventually trigger `on_component_rendered` hook.
if curr_item.is_last: if curr_item.is_last:
finalize_component(item_id=curr_item.item_id, error=None, full_path=[]) next_renderer_result(item_id=curr_item.item_id, error=None, full_path=[])
return return
@ -521,40 +530,12 @@ def component_post_render(
# to the parent component. # to the parent component.
child_to_parent[curr_item.item_id] = curr_item.parent_id 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 on_render_generator, curr_comp_name = component_renderer_cache.pop(component_id)
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.full_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, 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
if on_render_generator is not None:
generators_by_component_id[component_id] = on_render_generator generators_by_component_id[component_id] = on_render_generator
child_component_attrs.update(extra_child_component_attrs) # This is where we actually render the component
next_renderer_result(item_id=curr_item.item_id, error=None, full_path=full_path)
# 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))
else: else:
raise TypeError("Unknown item type") raise TypeError("Unknown item type")
@ -576,7 +557,7 @@ def component_post_render(
output = "".join(content_parts) output = "".join(content_parts)
# Allow to optionally modify the final output # Allow to optionally modify the final output
output = on_html_rendered(output) output = on_component_tree_rendered(output)
return mark_safe(output) return mark_safe(output)
@ -585,10 +566,10 @@ def _call_generator(
on_render_generator: "OnRenderGenerator", on_render_generator: "OnRenderGenerator",
html: Optional[str], html: Optional[str],
error: Optional[Exception], error: Optional[Exception],
started_generators_cache: "StartedGenerators",
full_path: List[str],
) -> GeneratorResult: ) -> GeneratorResult:
generator_spent = False is_first_send = not started_generators_cache.get(on_render_generator, False)
needs_processing = False
try: try:
# `Component.on_render()` may have any number of `yield` statements, so we need to # `Component.on_render()` may have any number of `yield` statements, so we need to
# call `.send()` any number of times. # call `.send()` any number of times.
@ -598,30 +579,33 @@ def _call_generator(
# - Yield a new HTML with `yield` - We return back to the user the processed HTML / error # - Yield a new HTML with `yield` - We return back to the user the processed HTML / error
# for them to process further # for them to process further
# - Raise a new error # - Raise a new error
if is_first_send:
new_result = on_render_generator.send(None) # type: ignore[arg-type]
else:
new_result = on_render_generator.send((html, error)) new_result = on_render_generator.send((html, error))
# If we've reached the end of `Component.on_render()` (or `return` statement), then we get `StopIteration`. # 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. # In that case, we want to check if user returned new HTML from the `return` statement.
except StopIteration as generator_err: except StopIteration as generator_err:
generator_spent = True
# The return value is on `StopIteration.value` # The return value is on `StopIteration.value`
new_output = generator_err.value new_output = generator_err.value
if new_output is not None: if new_output is not None:
html = new_output return GeneratorResult(html=new_output, error=None, needs_processing=True, spent=True)
error = None # Nothing returned at the end of the generator, keep the original HTML and error
needs_processing = True return GeneratorResult(html=html, error=error, needs_processing=False, spent=True)
# Catch if `Component.on_render()` raises an exception, in which case this becomes # Catch if `Component.on_render()` raises an exception, in which case this becomes
# the new error. # the new error.
except Exception as new_error: # noqa: BLE001 except Exception as new_error: # noqa: BLE001
error = new_error set_component_error_message(new_error, full_path[1:])
html = None return GeneratorResult(html=None, error=new_error, needs_processing=False, spent=True)
# If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result, # If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result,
# that we need to process. # that we need to process.
else: else:
needs_processing = True if is_first_send or new_result is not None:
return GeneratorResult(html=new_result, error=None, needs_processing=needs_processing, spent=generator_spent) started_generators_cache[on_render_generator] = True
return GeneratorResult(html=new_result, error=None, needs_processing=True, spent=False)
return GeneratorResult(html=html, error=error, needs_processing=needs_processing, spent=generator_spent) # Generator yielded `None`, keep the previous HTML and error
return GeneratorResult(html=html, error=error, needs_processing=False, spent=False)

View file

@ -2,18 +2,13 @@ from contextlib import contextmanager
from typing import Generator, List from typing import Generator, List
@contextmanager def set_component_error_message(err: Exception, component_path: List[str]) -> None:
def component_error_message(component_path: List[str]) -> Generator[None, None, None]:
""" """
If an error occurs within the context, format the error message to include Format the error message to include the component path. E.g.
the component path. E.g.
``` ```
KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content) KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content)
``` ```
""" """
try:
yield
except Exception as err:
if not hasattr(err, "_components"): if not hasattr(err, "_components"):
err._components = [] # type: ignore[attr-defined] err._components = [] # type: ignore[attr-defined]
@ -43,6 +38,21 @@ def component_error_message(component_path: List[str]) -> Generator[None, None,
err.args = (prefix + orig_msg,) # tuple of one err.args = (prefix + orig_msg,) # tuple of one
@contextmanager
def component_error_message(component_path: List[str]) -> Generator[None, None, None]:
"""
If an error occurs within the context, format the error message to include
the component path. E.g.
```
KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content)
```
"""
try:
yield
except Exception as err:
set_component_error_message(err, component_path)
# `from None` should still raise the original error, but without showing this # `from None` should still raise the original error, but without showing this
# line in the traceback. # line in the traceback.
raise err from None raise err from None

View file

@ -5,7 +5,21 @@ from hashlib import md5
from importlib import import_module from importlib import import_module
from itertools import chain from itertools import chain
from types import ModuleType from types import ModuleType
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, TypeVar, Union, cast from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
List,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from urllib import parse from urllib import parse
from django_components.constants import UID_LENGTH from django_components.constants import UID_LENGTH
@ -247,3 +261,9 @@ def format_as_ascii_table(
# Combine all parts into the final table # Combine all parts into the final table
table = "\n".join([header_row, separator, *data_rows]) if include_headers else "\n".join(data_rows) table = "\n".join([header_row, separator, *data_rows]) if include_headers else "\n".join(data_rows)
return table return table
# TODO - Convert to TypeGuard once Python 3.9 is dropped
def is_generator(obj: Any) -> bool:
"""Check if an object is a generator with send method."""
return hasattr(obj, "send")

View file

@ -909,9 +909,6 @@
class="flex flex-col" class="flex flex-col"
> >
<div class="prose flex flex-col gap-8" data-djc-id-ca1bc9b="" data-djc-id-ca1bccd="" data-djc-id-ca1bcd5=""> <div class="prose flex flex-col gap-8" data-djc-id-ca1bc9b="" data-djc-id-ca1bccd="" data-djc-id-ca1bcd5="">
<div class="border-b border-neutral-300"> <div class="border-b border-neutral-300">
@ -1237,9 +1234,6 @@
class="flex flex-col" class="flex flex-col"
> >
<div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd0="" data-djc-id-ca1bcf2=""> <div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd0="" data-djc-id-ca1bcf2="">
<h3>Notes</h3> <h3>Notes</h3>
@ -1410,9 +1404,6 @@
class="flex flex-col" class="flex flex-col"
> >
<div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd1="" data-djc-id-ca1bcff=""> <div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd1="" data-djc-id-ca1bcff="">
<h3>Notes</h3> <h3>Notes</h3>
@ -1516,9 +1507,6 @@
class="flex flex-col" class="flex flex-col"
> >
<div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd2="" data-djc-id-ca1bd05=""> <div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd2="" data-djc-id-ca1bd05="">
<h3>Notes</h3> <h3>Notes</h3>
@ -1622,9 +1610,6 @@
class="flex flex-col" class="flex flex-col"
> >
<div class="flex flex-col gap-y-3" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd3="" data-djc-id-ca1bd0b=""> <div class="flex flex-col gap-y-3" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd3="" data-djc-id-ca1bd0b="">

View file

@ -1694,8 +1694,10 @@ class TestComponentHook:
"outer__on_render_before", "outer__on_render_before",
"outer__on_render_pre", "outer__on_render_pre",
"middle__on_render_before", "middle__on_render_before",
"middle__on_render_before",
"middle__on_render_pre", "middle__on_render_pre",
"inner__on_render_before", "inner__on_render_before",
"inner__on_render_before",
"inner__on_render_pre", "inner__on_render_pre",
"slotted__on_render_before", "slotted__on_render_before",
"slotted__on_render_pre", "slotted__on_render_pre",
@ -1703,15 +1705,14 @@ class TestComponentHook:
"slotted__on_render_after", "slotted__on_render_after",
"inner__on_render_post", "inner__on_render_post",
"inner__on_render_after", "inner__on_render_after",
"inner__on_render_before",
"inner__on_render_pre", "inner__on_render_pre",
"inner__on_render_post", "inner__on_render_post",
"inner__on_render_after", "inner__on_render_after",
"middle__on_render_post", "middle__on_render_post",
"middle__on_render_after", "middle__on_render_after",
"middle__on_render_before",
"middle__on_render_pre", "middle__on_render_pre",
"inner__on_render_before", "inner__on_render_before",
"inner__on_render_before",
"inner__on_render_pre", "inner__on_render_pre",
"slotted__on_render_before", "slotted__on_render_before",
"slotted__on_render_pre", "slotted__on_render_pre",
@ -1719,7 +1720,6 @@ class TestComponentHook:
"slotted__on_render_after", "slotted__on_render_after",
"inner__on_render_post", "inner__on_render_post",
"inner__on_render_after", "inner__on_render_after",
"inner__on_render_before",
"inner__on_render_pre", "inner__on_render_pre",
"inner__on_render_post", "inner__on_render_post",
"inner__on_render_after", "inner__on_render_after",
@ -1869,9 +1869,9 @@ class TestComponentHook:
{% if case == 1 %} {% if case == 1 %}
{% component "broken" / %} {% component "broken" / %}
{% elif case == 2 %} {% elif case == 2 %}
Hello <div>Hello</div>
{% elif case == 3 %} {% elif case == 3 %}
There <div>There</div>
{% endif %} {% endif %}
""" """
@ -1890,23 +1890,25 @@ class TestComponentHook:
html3, error3 = yield template.render(context) html3, error3 = yield template.render(context)
results.append((html3.strip(), error3)) results.append((html3.strip(), error3))
html4, error4 = yield "Other result" html4, error4 = yield "<div>Other result</div>"
results.append((html4, error4)) results.append((html4, error4))
return "Final result" return "<div>Final result</div>"
result = SimpleComponent.render() result = SimpleComponent.render()
assert result == "Final result" assert result == '<div data-djc-id-ca1bc3e="">Final result</div>'
# NOTE: Exceptions are stubborn, comparison evaluates to False even with the same message. # NOTE: Exceptions are stubborn, comparison evaluates to False even with the same message.
assert results[0][0] is None assert results[0][0] is None
assert isinstance(results[0][1], ValueError) assert isinstance(results[0][1], ValueError)
assert results[0][1].args[0] == "An error occured while rendering components broken:\nBROKEN" assert results[0][1].args[0] == "An error occured while rendering components broken:\nBROKEN"
# NOTE: It's important that all the results are wrapped in `<div>`
# so we can check if the djc-id attribute was set.
assert results[1:] == [ assert results[1:] == [
("Hello", None), ('<div data-djc-id-ca1bc3e="">Hello</div>', None),
("There", None), ('<div data-djc-id-ca1bc3e="">There</div>', None),
("Other result", None), ('<div data-djc-id-ca1bc3e="">Other result</div>', None),
] ]
@djc_test( @djc_test(