Skip to content

Commit ff54f5b

Browse files
add signals for lifecycle hooks
1 parent 4ec17cf commit ff54f5b

File tree

10 files changed

+550
-14
lines changed

10 files changed

+550
-14
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: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
UnicornCacheError,
2929
)
3030
from django_unicorn.settings import get_setting
31+
from django_unicorn.signals import (
32+
component_completed,
33+
component_hydrated,
34+
component_mounted,
35+
)
3136
from django_unicorn.typer import cast_attribute_value, get_type_hints
3237
from django_unicorn.utils import create_template, is_non_string_sequence
3338

@@ -149,8 +154,11 @@ def construct_component(
149154
component.calls = []
150155

151156
component._mount_result = component.mount()
157+
component_mounted.send(sender=component.__class__, component=component)
152158
component.hydrate()
159+
component_hydrated.send(sender=component.__class__, component=component)
153160
component.complete()
161+
component_completed.send(sender=component.__class__, component=component)
154162
component._validate_called = False
155163

156164
return component
@@ -342,73 +350,61 @@ def mount(self):
342350
"""
343351
Hook that gets called when the component is first created.
344352
"""
345-
pass
346353

347354
def hydrate(self):
348355
"""
349356
Hook that gets called when the component's data is hydrated.
350357
"""
351-
pass
352358

353359
def complete(self):
354360
"""
355361
Hook that gets called after all component methods are executed.
356362
"""
357-
pass
358363

359364
def rendered(self, html):
360365
"""
361366
Hook that gets called after the component has been rendered.
362367
"""
363-
pass
364368

365369
def parent_rendered(self, html):
366370
"""
367371
Hook that gets called after the component's parent has been rendered.
368372
"""
369-
pass
370373

371374
def updating(self, name, value):
372375
"""
373376
Hook that gets called when a component's data is about to get updated.
374377
"""
375-
pass
376378

377379
def updated(self, name, value):
378380
"""
379381
Hook that gets called when a component's data is updated.
380382
"""
381-
pass
382383

383384
def resolved(self, name, value):
384385
"""
385386
Hook that gets called when a component's data is resolved.
386387
"""
387-
pass
388388

389389
def calling(self, name, args):
390390
"""
391391
Hook that gets called when a component's method is about to get called.
392392
"""
393-
pass
394393

395394
def called(self, name, args):
396395
"""
397396
Hook that gets called when a component's method is called.
398397
"""
399-
pass
400398

401399
def pre_parse(self):
402400
"""
403401
Hook that gets called before the data is parsed and applied to the component.
404402
"""
405-
pass
406403

407404
def post_parse(self):
408405
"""
409406
Hook that gets called after the data is parsed and applied to the component.
410407
"""
411-
pass
412408

413409
@timed
414410
def render(self, *, init_js=False, extra_context=None, request=None, epoch=None) -> str:
@@ -456,10 +452,12 @@ def dispatch(self, request, *args, **kwargs): # noqa: ARG002
456452
"""
457453

458454
self._mount_result = self.mount()
455+
component_mounted.send(sender=self.__class__, component=self)
459456
if self._mount_result and isinstance(self._mount_result, HttpResponse):
460457
return self._mount_result
461458

462459
self.hydrate()
460+
component_hydrated.send(sender=self.__class__, component=self)
463461

464462
return self.render_to_response(
465463
context=self.get_context_data(),
@@ -948,6 +946,7 @@ def _get_component_class(module_name: str, class_name: str) -> type[Component]:
948946

949947
# Call hydrate because the component will be re-rendered
950948
cached_component.hydrate()
949+
component_hydrated.send(sender=cached_component.__class__, component=cached_component)
951950

952951
return cached_component
953952

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)