Skip to content

Commit 9fe5129

Browse files
Merge branch 'main' into form-passing-into-template
2 parents 306d1fd + 89d988c commit 9fe5129

File tree

18 files changed

+1215
-5
lines changed

18 files changed

+1215
-5
lines changed

docs/source/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pagination
5050
javascript
5151
queue-requests
5252
custom-morphers
53+
signals
5354
```
5455

5556
```{toctree}

docs/source/signals.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Signals
2+
3+
`Unicorn` emits [Django signals](https://docs.djangoproject.com/en/stable/topics/signals/)
4+
for each component lifecycle event. You can connect receivers to observe component
5+
activity without monkey-patching internal methods — useful for debug toolbars, logging,
6+
analytics, or custom auditing.
7+
8+
## Connecting to a signal
9+
10+
```python
11+
from django.dispatch import receiver
12+
from django_unicorn.signals import component_rendered
13+
14+
@receiver(component_rendered)
15+
def on_render(sender, component, html, **kwargs):
16+
print(f"{component.component_name} rendered {len(html)} bytes")
17+
```
18+
19+
`sender` is always the component **class** (not an instance). Every signal also
20+
passes the live `component` instance as a keyword argument, plus any event-specific
21+
kwargs documented below.
22+
23+
## Available signals
24+
25+
All signals are importable from `django_unicorn.signals`.
26+
27+
---
28+
29+
### `component_mounted`
30+
31+
Sent when a component is first created (mirrors the {meth}`mount` hook).
32+
33+
| kwarg | type | description |
34+
|-------|------|-------------|
35+
| `component` | `UnicornView` | The component instance |
36+
37+
---
38+
39+
### `component_hydrated`
40+
41+
Sent when a component's data is hydrated (mirrors the {meth}`hydrate` hook).
42+
43+
| kwarg | type | description |
44+
|-------|------|-------------|
45+
| `component` | `UnicornView` | The component instance |
46+
47+
---
48+
49+
### `component_completed`
50+
51+
Sent after all component actions have been executed (mirrors the {meth}`complete` hook).
52+
53+
| kwarg | type | description |
54+
|-------|------|-------------|
55+
| `component` | `UnicornView` | The component instance |
56+
57+
---
58+
59+
### `component_rendered`
60+
61+
Sent after a component is rendered during an AJAX request (mirrors the {meth}`rendered`
62+
hook). Not fired for the initial server-side page render via the template tag.
63+
64+
| kwarg | type | description |
65+
|-------|------|-------------|
66+
| `component` | `UnicornView` | The component instance |
67+
| `html` | `str` | The rendered HTML string |
68+
69+
---
70+
71+
### `component_parent_rendered`
72+
73+
Sent after a child component's parent is rendered (mirrors the {meth}`parent_rendered`
74+
hook).
75+
76+
| kwarg | type | description |
77+
|-------|------|-------------|
78+
| `component` | `UnicornView` | The **child** component instance |
79+
| `html` | `str` | The rendered parent HTML string |
80+
81+
---
82+
83+
### `component_property_updating`
84+
85+
Sent before a component property is updated (mirrors the {meth}`updating` hook).
86+
87+
| kwarg | type | description |
88+
|-------|------|-------------|
89+
| `component` | `UnicornView` | The component instance |
90+
| `name` | `str` | Property name being updated |
91+
| `value` | `Any` | The incoming new value |
92+
93+
---
94+
95+
### `component_property_updated`
96+
97+
Sent after a component property is updated (mirrors the {meth}`updated` hook).
98+
99+
| kwarg | type | description |
100+
|-------|------|-------------|
101+
| `component` | `UnicornView` | The component instance |
102+
| `name` | `str` | Property name that was updated |
103+
| `value` | `Any` | The new value |
104+
105+
---
106+
107+
### `component_property_resolved`
108+
109+
Sent after a component property value is resolved (mirrors the {meth}`resolved` hook).
110+
Unlike `component_property_updating` / `component_property_updated`, this signal fires
111+
**only once** per sync cycle.
112+
113+
| kwarg | type | description |
114+
|-------|------|-------------|
115+
| `component` | `UnicornView` | The component instance |
116+
| `name` | `str` | Property name that was resolved |
117+
| `value` | `Any` | The resolved value |
118+
119+
---
120+
121+
### `component_method_calling`
122+
123+
Sent before a component method is called (mirrors the {meth}`calling` hook).
124+
125+
| kwarg | type | description |
126+
|-------|------|-------------|
127+
| `component` | `UnicornView` | The component instance |
128+
| `name` | `str` | Method name about to be called |
129+
| `args` | `tuple` | Positional arguments |
130+
131+
---
132+
133+
### `component_method_called`
134+
135+
Sent after a component method is invoked — on both **success and failure**.
136+
This signal includes the return value and exception info, making it more complete
137+
than the `called()` hook.
138+
139+
| kwarg | type | description |
140+
|-------|------|-------------|
141+
| `component` | `UnicornView` | The component instance |
142+
| `method_name` | `str` | Method name that was called |
143+
| `args` | `tuple` | Positional arguments |
144+
| `kwargs` | `dict` | Keyword arguments |
145+
| `result` | `Any` | Return value of the method, or `None` on failure |
146+
| `success` | `bool` | `True` if the method completed without raising an exception |
147+
| `error` | `Exception \| None` | The exception raised, or `None` on success |
148+
149+
```python
150+
from django.dispatch import receiver
151+
from django_unicorn.signals import component_method_called
152+
153+
@receiver(component_method_called)
154+
def log_method(sender, component, method_name, result, success, error, **kwargs):
155+
if success:
156+
print(f"[unicorn] {component.component_name}.{method_name}() → {result!r}")
157+
else:
158+
print(f"[unicorn] {component.component_name}.{method_name}() raised {error!r}")
159+
```
160+
161+
---
162+
163+
### `component_pre_parsed`
164+
165+
Sent before the incoming request data is parsed and applied to the component
166+
(mirrors the {meth}`pre_parse` hook).
167+
168+
| kwarg | type | description |
169+
|-------|------|-------------|
170+
| `component` | `UnicornView` | The component instance |
171+
172+
---
173+
174+
### `component_post_parsed`
175+
176+
Sent after the incoming request data is parsed and applied to the component
177+
(mirrors the {meth}`post_parse` hook).
178+
179+
| kwarg | type | description |
180+
|-------|------|-------------|
181+
| `component` | `UnicornView` | The component instance |
182+
183+
---
184+
185+
## Overriding hooks vs. connecting signals
186+
187+
The existing lifecycle hook methods (`mount`, `hydrate`, `rendered`, etc.) and signals
188+
serve different purposes:
189+
190+
- **Hook methods** — override in your component subclass to run logic *inside* the
191+
component (e.g. `def mount(self): self.items = Items.objects.all()`).
192+
- **Signals** — connect a receiver anywhere in your project to observe *any* component
193+
without modifying component code.
194+
195+
```{note}
196+
If you override a hook method in your component class and do **not** call
197+
``super()``, the corresponding signal will **not** fire because the default
198+
implementation (which sends the signal) is bypassed.
199+
```

src/django_unicorn/components/unicorn_template_response.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
NoRootComponentElementError,
1717
)
1818
from django_unicorn.settings import get_minify_html_enabled
19+
from django_unicorn.signals import component_rendered
1920
from django_unicorn.utils import generate_checksum, html_element_to_string
2021

2122
logger = logging.getLogger(__name__)
@@ -236,6 +237,7 @@ def render(self):
236237
rendered_template = html_element_to_string(root_element)
237238

238239
self.component.rendered(rendered_template)
240+
component_rendered.send(sender=self.component.__class__, component=self.component, html=rendered_template)
239241
response.content = rendered_template
240242

241243
if get_minify_html_enabled():

src/django_unicorn/components/unicorn_view.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
UnicornCacheError,
3030
)
3131
from django_unicorn.settings import get_setting
32+
from django_unicorn.signals import (
33+
component_completed,
34+
component_hydrated,
35+
component_mounted,
36+
)
3237
from django_unicorn.typer import cast_attribute_value, get_type_hints
3338
from django_unicorn.utils import create_template, is_non_string_sequence
3439

@@ -150,8 +155,11 @@ def construct_component(
150155
component.calls = []
151156

152157
component._mount_result = component.mount()
158+
component_mounted.send(sender=component.__class__, component=component)
153159
component.hydrate()
160+
component_hydrated.send(sender=component.__class__, component=component)
154161
component.complete()
162+
component_completed.send(sender=component.__class__, component=component)
155163
component._validate_called = False
156164

157165
return component
@@ -331,6 +339,7 @@ def call(self, function_name, *args):
331339
"""
332340
self.calls.append({"fn": function_name, "args": args})
333341

342+
334343
def remove(self):
335344
"""
336345
Remove this component's root element from the DOM and delete it from the
@@ -457,10 +466,12 @@ def dispatch(self, request, *args, **kwargs): # noqa: ARG002
457466
"""
458467

459468
self._mount_result = self.mount()
469+
component_mounted.send(sender=self.__class__, component=self)
460470
if self._mount_result and isinstance(self._mount_result, HttpResponse):
461471
return self._mount_result
462472

463473
self.hydrate()
474+
component_hydrated.send(sender=self.__class__, component=self)
464475

465476
return self.render_to_response(
466477
context=self.get_context_data(),
@@ -955,6 +966,7 @@ def _get_component_class(module_name: str, class_name: str) -> type[Component]:
955966

956967
# Call hydrate because the component will be re-rendered
957968
cached_component.hydrate()
969+
component_hydrated.send(sender=cached_component.__class__, component=cached_component)
958970

959971
return cached_component
960972

@@ -1024,8 +1036,15 @@ def _get_component_class(module_name: str, class_name: str) -> type[Component]:
10241036

10251037
return component
10261038
except ModuleNotFoundError as e:
1027-
logger.debug(e)
1028-
pass
1039+
# Only silently skip when the module we're looking for simply doesn't
1040+
# exist. If a *different* module is missing it means the component file
1041+
# was found and started executing but contains a broken import - that
1042+
# error should be surfaced rather than swallowed.
1043+
if e.name is None or module_name == e.name or module_name.startswith(e.name + "."):
1044+
logger.debug(e)
1045+
else:
1046+
message = f"The component module '{module_name}' could not be loaded: {e}"
1047+
raise ComponentModuleLoadError(message, locations=locations) from e
10291048
except AttributeError as e:
10301049
logger.debug(e)
10311050
attribute_exception = e

src/django_unicorn/signals.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Django signals for django-unicorn component lifecycle events.
3+
4+
All signals are sent with ``sender=component.__class__`` and at minimum a
5+
``component`` kwarg containing the live ``UnicornView`` instance. Connect a
6+
receiver to observe events without monkey-patching internal methods:
7+
8+
from django.dispatch import receiver
9+
from django_unicorn.signals import component_rendered
10+
11+
@receiver(component_rendered)
12+
def on_render(sender, component, html, **kwargs):
13+
print(f"{component.component_name} rendered {len(html)} bytes")
14+
"""
15+
16+
from django.dispatch import Signal
17+
18+
component_mounted = Signal()
19+
20+
component_hydrated = Signal()
21+
22+
component_completed = Signal()
23+
24+
component_rendered = Signal()
25+
26+
component_parent_rendered = Signal()
27+
28+
component_property_updating = Signal()
29+
30+
component_property_updated = Signal()
31+
32+
component_property_resolved = Signal()
33+
34+
component_method_calling = Signal()
35+
36+
component_method_called = Signal()
37+
38+
component_pre_parsed = Signal()
39+
40+
component_post_parsed = Signal()

0 commit comments

Comments
 (0)