Skip to content

Commit 9ef9a2b

Browse files
feat: allow Django Form/ModelForm instances to be passed as template kwargs without serialization or pickling errors
1 parent 705f9ab commit 9ef9a2b

File tree

5 files changed

+177
-2
lines changed

5 files changed

+177
-2
lines changed

docs/source/views.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,45 @@ class HelloKwargView(UnicornView):
195195
assert self.component_kwargs["hello"] == "World"
196196
```
197197

198+
### Passing a Django Form
199+
200+
A Django `Form` (or `ModelForm`) instance can be passed directly from a template into a unicorn component as a keyword argument. The form will be available in the component's template context for rendering, but it is automatically excluded from the JSON state sent to the browser (since forms cannot be serialized to JSON).
201+
202+
```html
203+
<!-- index.html -->
204+
{% unicorn 'my-form-component' form=my_django_form %}
205+
```
206+
207+
```python
208+
# my_form_component.py
209+
from django_unicorn.components import UnicornView
210+
211+
class MyFormComponentView(UnicornView):
212+
form = None # will hold the passed-in form instance
213+
214+
def mount(self):
215+
# self.form is available here on the initial render
216+
pass
217+
```
218+
219+
```html
220+
<!-- unicorn/my-form-component.html -->
221+
<div>
222+
<form method="POST">
223+
{% csrf_token %}
224+
{{ form.as_p }}
225+
<button unicorn:click="submit">Submit</button>
226+
</form>
227+
</div>
228+
```
229+
230+
```{note}
231+
Because forms cannot be pickled, `self.form` will be `None` on subsequent AJAX
232+
interactions (after the initial page load). If you need to process submitted form
233+
data reactively, declare a `form_class` on the component and use
234+
[component validation](validation.md) instead.
235+
```
236+
198237
### request
199238

200239
The current `request`.
@@ -392,7 +431,12 @@ class HelloStateView(UnicornView):
392431

393432
### javascript_exclude
394433

395-
To allow an attribute to be included in the the context to be used by a Django template, but not exposed to JavaScript, add it to the `Meta` class's `javascript_exclude` tuple.
434+
To allow an attribute to be included in the context to be used by a Django template, but not exposed to JavaScript, add it to the `Meta` class's `javascript_exclude` tuple.
435+
436+
```{note}
437+
Django `Form` and `ModelForm` instances are **automatically** excluded from the
438+
JavaScript context — you do not need to add them to `javascript_exclude`.
439+
```
396440

397441
```html
398442
<!-- hello-state.html -->
@@ -465,4 +509,12 @@ Do not store unpickleable objects (e.g. generators) on the component instance.
465509

466510
If you need to use an unpickleable object, either convert it to a pickleable type (e.g. convert a generator to a list) or re-initialize it within the method that needs it without storing it on `self`.
467511

512+
```{note}
513+
Django `Form` and `ModelForm` instances are handled automatically — they are stripped
514+
from the component before pickling and restored afterwards, so passing a form as a
515+
template kwarg (see [Passing a Django Form](#passing-a-django-form)) will not cause
516+
pickling errors. The form will be `None` after a cache restore (i.e. on subsequent
517+
AJAX requests).
518+
```
519+
468520

src/django_unicorn/cacher.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django_unicorn.components.unicorn_view import UnicornView
77

88
from django.core.cache import caches
9+
from django.forms import BaseForm
910
from django.http import HttpRequest
1011

1112
from django_unicorn.errors import UnicornCacheError
@@ -66,13 +67,22 @@ def __enter__(self):
6667
if not isinstance(component.template_name, str):
6768
component.template_name = None
6869

70+
# Strip Django Form instances before pickling — forms contain unpicklable
71+
# references (e.g. lazily-bound validators) and are only needed for rendering.
72+
form_attributes: dict = {}
73+
for attr_name, attr_val in list(vars(component).items()):
74+
if isinstance(attr_val, BaseForm):
75+
form_attributes[attr_name] = attr_val
76+
setattr(component, attr_name, None)
77+
6978
self._state[component.component_id] = (
7079
component,
7180
request,
7281
extra_context,
7382
component.parent,
7483
component.children.copy(),
7584
template_name,
85+
form_attributes,
7686
)
7787

7888
if component.parent:
@@ -104,12 +114,16 @@ def __enter__(self):
104114
return self
105115

106116
def __exit__(self, *args):
107-
for component, request, extra_context, parent, children, template_name in self._state.values():
117+
for component, request, extra_context, parent, children, template_name, form_attributes in self._state.values():
108118
component.request = request
109119
component.parent = parent
110120
component.children = children
111121
component.template_name = template_name
112122

123+
# Restore any form instances that were stripped before pickling
124+
for attr_name, attr_val in form_attributes.items():
125+
setattr(component, attr_name, attr_val)
126+
113127
# Re-create the template_name `Template` object if it is `None`
114128
if component.template_name is None and hasattr(component, "template_html"):
115129
component.template_name = create_template(component.template_html)

src/django_unicorn/components/unicorn_view.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.apps import apps as django_apps_module
1212
from django.core.exceptions import NON_FIELD_ERRORS
1313
from django.db.models import Model
14+
from django.forms import BaseForm
1415
from django.forms.widgets import CheckboxInput, Select
1516
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
1617
from django.utils.decorators import classonlymethod
@@ -520,6 +521,12 @@ def get_frontend_context_variables(self) -> str:
520521

521522
del frontend_context_variables[field_name]
522523

524+
# Auto-exclude Django Form instances (they can't be serialized to JSON).
525+
# This lets forms be passed as template kwargs for rendering without causing errors.
526+
for field_name in list(frontend_context_variables.keys()):
527+
if isinstance(frontend_context_variables[field_name], BaseForm):
528+
del frontend_context_variables[field_name]
529+
523530
# Add cleaned values to `frontend_content_variables` based on the widget in form's fields
524531
form = self._get_form(attributes)
525532

tests/components/test_component.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import orjson
44
import pytest
5+
from django import forms
56
from tests.views.fake_components import (
67
FakeAuthenticationComponent,
78
FakeValidationComponent,
@@ -345,3 +346,58 @@ def test_get_frontend_context_variables_authentication_form(component):
345346
)
346347

347348
component.get_frontend_context_variables()
349+
350+
351+
class SimpleForm(forms.Form):
352+
name = forms.CharField()
353+
354+
355+
def test_get_frontend_context_variables_excludes_form_instance():
356+
"""
357+
A Django Form instance passed as a component attribute should be automatically
358+
excluded from the serialized frontend context variables (issue #165).
359+
"""
360+
361+
class ComponentWithForm(UnicornView):
362+
name = "test"
363+
form = None
364+
365+
def __init__(self, *args, **kwargs):
366+
super().__init__(*args, **kwargs)
367+
368+
component = ComponentWithForm(
369+
component_id="test_excludes_form_instance",
370+
component_name="example",
371+
)
372+
component.form = SimpleForm()
373+
374+
frontend_context_variables = component.get_frontend_context_variables()
375+
frontend_context_variables_dict = orjson.loads(frontend_context_variables)
376+
377+
# The form instance must not be included in the JSON (not serialisable)
378+
assert "form" not in frontend_context_variables_dict
379+
# Other public attributes are still present
380+
assert "name" in frontend_context_variables_dict
381+
382+
383+
def test_get_frontend_context_variables_form_available_in_context():
384+
"""
385+
Even though the form is excluded from the JSON frontend context, it should still
386+
be accessible in the template context via get_context_data().
387+
"""
388+
389+
class ComponentWithForm(UnicornView):
390+
form = None
391+
392+
def __init__(self, *args, **kwargs):
393+
super().__init__(*args, **kwargs)
394+
395+
component = ComponentWithForm(
396+
component_id="test_form_in_context_data",
397+
component_name="example",
398+
)
399+
form_instance = SimpleForm()
400+
component.form = form_instance
401+
402+
context = component.get_context_data()
403+
assert context["form"] is form_instance

tests/test_cacher.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest.mock import MagicMock, patch
22

33
import pytest
4+
from django import forms
45

56
from django_unicorn.cacher import (
67
CacheableComponent,
@@ -292,3 +293,48 @@ def test_cacheable_component_restores_nested_state_on_pickle_failure():
292293
assert not isinstance(parent.parent, PointerUnicornView)
293294
assert child in parent.children
294295
assert parent in grandparent.children
296+
297+
298+
class SimpleForm(forms.Form):
299+
name = forms.CharField()
300+
301+
302+
class ComponentWithForm(UnicornView):
303+
"""Component whose instance attributes include a Django Form."""
304+
305+
def __init__(self, *args, **kwargs):
306+
self.form = kwargs.pop("form", None)
307+
super().__init__(*args, **kwargs)
308+
309+
310+
def test_cacheable_component_strips_form_before_pickling():
311+
"""Form instances are stripped before pickling so the component can be cached."""
312+
form_instance = SimpleForm()
313+
component = ComponentWithForm(
314+
component_id="test_form_strip",
315+
component_name="with-form",
316+
form=form_instance,
317+
)
318+
assert component.form is form_instance
319+
320+
with CacheableComponent(component):
321+
# Inside context: form should be stripped (set to None) so pickle can succeed
322+
assert component.form is None
323+
324+
# After context: form should be restored
325+
assert component.form is form_instance
326+
327+
328+
def test_cacheable_component_restores_form_after_context():
329+
"""Form instances are fully restored on CacheableComponent.__exit__."""
330+
form_instance = SimpleForm(data={"name": "Alice"})
331+
component = ComponentWithForm(
332+
component_id="test_form_restore",
333+
component_name="with-form",
334+
form=form_instance,
335+
)
336+
337+
with CacheableComponent(component):
338+
pass
339+
340+
assert component.form is form_instance

0 commit comments

Comments
 (0)