Skip to content

Commit e407339

Browse files
Merge branch 'main' of https://github.com/django-commons/django-unicorn into checksum-errors
2 parents 64c0a9b + cfe11ac commit e407339

28 files changed

+950
-25
lines changed

docs/source/index.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ templates
2222
actions
2323
child-components
2424
django-models
25+
2526
```
2627

2728
```{toctree}
@@ -60,6 +61,14 @@ cli
6061
settings
6162
```
6263

64+
```{toctree}
65+
:caption: Troubleshooting
66+
:maxdepth: 3
67+
:hidden:
68+
69+
table-limitations
70+
```
71+
6372
```{toctree}
6473
:caption: Info
6574
:maxdepth: 3
@@ -93,6 +102,79 @@ Want to add some component-based magic to your front-end, but don't need the ove
93102
</a>
94103
</p>
95104

105+
106+
---
107+
108+
## Incremental Adoption — No Full Migration Required
109+
110+
```{important}
111+
You do **NOT** need to migrate your entire page or application to use Unicorn.
112+
```
113+
114+
```{note}
115+
Unicorn is designed for **progressive enhancement**.
116+
You can introduce it into an existing Django project one small component at a time.
117+
```
118+
119+
```{warning}
120+
Many developers assume reactive component frameworks require:
121+
122+
- Rewriting entire pages
123+
- Moving to an API-driven architecture
124+
- Converting everything into components
125+
- Adopting a SPA-style workflow
126+
127+
**Unicorn requires none of that.**
128+
```
129+
130+
Unicorn works alongside traditional Django views and templates. You can start
131+
with a single interactive element — such as:
132+
133+
- A live-search input
134+
- A dropdown
135+
- A modal
136+
- A counter
137+
- A form with validation
138+
- A small dashboard widget
139+
140+
while keeping the rest of your page completely unchanged.
141+
142+
---
143+
144+
### Example: Enhancing Just One Part of a Template
145+
146+
Your existing Django template:
147+
148+
```html
149+
<h1>Products</h1>
150+
151+
{% for product in products %}
152+
<p>{{ product.name }}</p>
153+
{% endfor %}
154+
```
155+
156+
Now enhance just one piece with Unicorn:
157+
158+
```html
159+
<h1>Products</h1>
160+
161+
{% unicorn 'product-search' %}
162+
163+
{% for product in products %}
164+
<p>{{ product.name }}</p>
165+
{% endfor %}
166+
```
167+
168+
That’s it.
169+
170+
- No SPA
171+
- No routing changes
172+
- No large refactor
173+
174+
Unicorn allows gradual adoption. Start small and expand only where it makes sense.
175+
176+
---
177+
96178
Here are a few reasons to consider `Unicorn`.
97179

98180
1. **Reactive Components**: With `Unicorn`, you can create reactive components that dynamically update the HTML DOM without the need for complex JavaScript. This makes it easier to build interactive web pages and enhances the user experience.

docs/source/javascript.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ class CallJavascriptView(UnicornView):
3030

3131
def hello(self):
3232
self.call("hello", self.name)
33+
```
34+
35+
## Remove a Component
36+
37+
Call `self.remove()` from any view method to remove the component's root element from the DOM. This is useful for list-item components (e.g. table rows or list items) that need to delete themselves without requiring a parent component to manage re-rendering.
38+
39+
```python
40+
# todo_item.py
41+
from django_unicorn.components import UnicornView
42+
43+
class TodoItemView(UnicornView):
44+
item_id: int = 0
45+
46+
def delete(self):
47+
# perform any server-side cleanup here
48+
TodoItem.objects.filter(pk=self.item_id).delete()
49+
self.remove()
50+
```
51+
52+
```html
53+
<!-- todo-item.html -->
54+
<li>
55+
{{ item_id }}
56+
<button unicorn:click="delete">Delete</button>
57+
</li>
58+
```
59+
60+
Calling `self.remove()` is shorthand for `self.call("Unicorn.deleteComponent", self.component_id)`. After the server responds, the component's root element is removed from the DOM and cleaned up from the internal component store.
3361

3462
## Call Method on Other Component
3563

docs/source/table-limitations.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Table Rendering Limitations
2+
3+
When using components inside HTML `<table>` elements, you must follow strict HTML structure rules. Failing to do so can cause reactivity issues that may look like state updates are not working.
4+
5+
This is not a bug in **Django Unicorn**, but a consequence of how browsers handle table DOM structure.
6+
7+
## Why This Happens
8+
9+
HTML tables are **structurally strict**. Browsers automatically correct invalid markup inside table elements.
10+
11+
Valid structure rules:
12+
* `<table>` may contain `<thead>`, `<tbody>`, `<tfoot>`
13+
* `<tbody>` may contain **only** `<tr>`
14+
* `<tr>` may contain **only** `<td>` or `<th>`
15+
* `<td>` may contain flow content (`<div>`, `<span>`, etc.)
16+
17+
If a component rendered inside a `<tbody>` outputs something like:
18+
19+
```html
20+
<tr>
21+
<td>Row content</td>
22+
</tr>
23+
24+
<div class="modal">...</div>
25+
```
26+
27+
The `<div>` is **invalid inside `<tbody>`**, so the browser will automatically move it elsewhere in the DOM.
28+
29+
When this happens:
30+
1. The browser modifies the DOM structure.
31+
2. Unicorn's DOM diffing no longer matches the expected structure.
32+
3. Reactive updates may silently fail or appear inconsistent.
33+
34+
This commonly appears as:
35+
* A modal not showing reactively
36+
* Conditional blocks not updating
37+
* Child components not re-rendering properly
38+
39+
## Example of Problematic Pattern
40+
41+
Parent template:
42+
43+
```html
44+
<tbody>
45+
{% for item in items %}
46+
{% unicorn 'row-component' item=item key=item.id %}
47+
{% endfor %}
48+
</tbody>
49+
```
50+
51+
Child component template:
52+
53+
```html
54+
<tr>
55+
<td>{{ item.name }}</td>
56+
<td>
57+
<button unicorn:click="show_modal">Delete</button>
58+
</td>
59+
</tr>
60+
61+
{% if modal_visible %}
62+
<div class="modal">Are you sure?</div>
63+
{% endif %}
64+
```
65+
66+
The `<div>` rendered after `<tr>` is invalid inside `<tbody>` and will be relocated by the browser.
67+
68+
## Why It Works With `<div>`
69+
70+
Replacing table tags with `<div>` works because `<div>` elements have no structural constraints. The browser does not auto-correct their placement, so Unicorn's DOM diffing remains stable.
71+
72+
## Recommended Solutions
73+
74+
### 1. Move Modals Outside the Table (Recommended)
75+
76+
Control modal state from the parent component and render the modal outside the `<table>`.
77+
78+
Child component:
79+
80+
```python
81+
def request_delete(self):
82+
self.parent.confirm_delete(self.item.id)
83+
```
84+
85+
Parent component:
86+
87+
```python
88+
selected_id = None
89+
show_modal = False
90+
91+
def confirm_delete(self, item_id):
92+
self.selected_id = item_id
93+
self.show_modal = True
94+
```
95+
96+
Render the modal below the table:
97+
98+
```html
99+
</table>
100+
101+
{% if show_modal %}
102+
<div class="modal">...</div>
103+
{% endif %}
104+
```
105+
106+
This keeps table markup valid and avoids DOM restructuring.
107+
108+
### 2. Render Modal Inside a Valid `<td>`
109+
110+
If you must render it within the table, wrap it inside a `<td colspan="...">`:
111+
112+
```html
113+
{% if modal_visible %}
114+
<tr>
115+
<td colspan="5">
116+
<div class="modal">...</div>
117+
</td>
118+
</tr>
119+
{% endif %}
120+
```
121+
122+
This preserves valid table structure.
123+
124+
### 3. Ensure Single Valid Root Per Component
125+
126+
When rendering a component inside `<tbody>`, its root element must be a `<tr>`.
127+
When rendering inside `<tr>`, its root must be `<td>` or `<th>`.
128+
129+
Avoid rendering sibling elements that break table hierarchy.
130+
131+
## Key Takeaway
132+
133+
If you experience reactivity issues inside tables:
134+
* Verify your component outputs valid HTML table structure.
135+
* Ensure no `<div>` or non-table elements are placed directly inside `<tbody>` or `<tr>`.
136+
* Prefer handling overlays and modals outside the table.
137+
138+
Table elements are one of the strictest parts of HTML. Following valid structure rules ensures Unicorn's DOM diffing remains reliable and reactive updates function correctly.

docs/source/templates.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ Templates are normal Django HTML templates, so anything you could normally do in
55
```{warning}
66
`Unicorn` requires there to be one root element that contains the component HTML. Valid HTML and a wrapper element is required for the DOM diffing algorithm to work correctly, so `Unicorn` will try to log a warning message if they seem invalid.
77
8+
One common issue is when a component is a table row (`tr`). Since `tr` elements can only contain `td` or `th` elements, any other element (like a `div`) will be "foster parented" out of the table structure by the browser. This will cause the DOM diffing to fail since the element structure is not what `Unicorn` expects.
9+
810
For example, this is an **invalid** template:
911
:::{code} html
1012
:force: true

docs/source/troubleshooting.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,36 @@ MIDDLEWARE = [
8181
</body>
8282
</html>
8383
```
84+
85+
## Tables and invalid HTML
86+
87+
Browsers are very strict about table structure (e.g. `<tr>` can only be a direct child of `<tbody>`, `<thead>`, `<tfoot>`, or `<table>`, and `<div>` is not allowed as a direct child of `<tr>`). If you have a component that renders a table row, unexpected behavior can occur because the browser will "foster parent" invalid elements out of the table structure before `Unicorn` can seemingly react to them.
88+
89+
For example, this valid-looking component template will cause issues:
90+
91+
```html
92+
<!-- invalid-table-row.html -->
93+
<tr>
94+
<td>{{ name }}</td>
95+
{% if show_modal %}
96+
<div class="modal">...</div>
97+
{% endif %}
98+
</tr>
99+
```
100+
101+
The browser will move the `div` out of the `tr` (and likely out of the `table` entirely), so when `Unicorn` tries to update the component, it will be confused by the missing element.
102+
103+
To fix this, ensure that all content is inside a valid table element, like a `td`:
104+
105+
```html
106+
<!-- valid-table-row.html -->
107+
<tr>
108+
<td>{{ name }}</td>
109+
<td>
110+
{% if show_modal %}
111+
<div class="modal">...</div>
112+
{% endif %}
113+
</td>
114+
</tr>
115+
```
116+

src/django_unicorn/components/unicorn_view.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ def call(self, function_name, *args):
330330
"""
331331
self.calls.append({"fn": function_name, "args": args})
332332

333+
def remove(self):
334+
"""
335+
Remove this component's root element from the DOM and delete it from the
336+
internal component store. Equivalent to calling
337+
``self.call("Unicorn.deleteComponent", self.component_id)``.
338+
"""
339+
self.call("Unicorn.deleteComponent", self.component_id)
340+
333341
def mount(self):
334342
"""
335343
Hook that gets called when the component is first created.
@@ -824,6 +832,7 @@ def _is_public(self, name: str) -> bool:
824832
"parent",
825833
"children",
826834
"call",
835+
"remove",
827836
"calls",
828837
"component_cache_key",
829838
"component_kwargs",

src/django_unicorn/serializer.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,17 @@ def _fix_floats(current: dict, data: dict | None = None, paths: list | None = No
275275
paths.append(key)
276276
_fix_floats(val, data, paths=paths)
277277
paths.pop()
278-
elif isinstance(current, list):
278+
elif isinstance(current, (list, tuple)):
279+
if isinstance(current, tuple) and paths:
280+
# Tuples are immutable; convert to list in the parent container so
281+
# we can mutate float values inside it.
282+
_piece = data
283+
for idx, path in enumerate(paths):
284+
if idx == len(paths) - 1:
285+
_piece[path] = list(current)
286+
else:
287+
_piece = _piece[path]
288+
current = _piece[paths[-1]]
279289
for idx, item in enumerate(current):
280290
paths.append(idx)
281291
_fix_floats(item, data, paths=paths)

0 commit comments

Comments
 (0)