Skip to content

Commit 3ea0332

Browse files
committed
Merge branch 'loosen' into feature/optional_checksum_check
# Conflicts: # django_unicorn/views/objects.py
2 parents 3b1f191 + 9c31164 commit 3ea0332

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1508
-1028
lines changed

.all-contributorsrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,8 @@
228228
"avatar_url": "https://avatars.githubusercontent.com/u/75075?v=4",
229229
"profile": "https://roman.pt",
230230
"contributions": [
231-
"test"
231+
"test",
232+
"code"
232233
]
233234
},
234235
{

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ Available additional settings that can be set to `UNICORN` dict in settings.py w
2424

2525
## Customization changelog
2626

27+
### 0.58.1.1 - (2024-01-10)
28+
29+
- No customizations, just sync with main package.
30+
2731
### 0.57.1.1 - (2023-11-10)
2832

2933
- No customizations, just sync with main package.
@@ -280,7 +284,7 @@ Thanks to the following wonderful people ([emoji key](https://allcontributors.or
280284
<tr>
281285
<td align="center" valign="top" width="14.28%"><a href="https://marteydodoo.com"><img src="https://avatars.githubusercontent.com/u/49076?v=4?s=100" width="100px;" alt="Martey Dodoo"/><br /><sub><b>Martey Dodoo</b></sub></a><br /><a href="https://github.com/adamghill/django-unicorn/commits?author=martey" title="Documentation">📖</a></td>
282286
<td align="center" valign="top" width="14.28%"><a href="https://digitalpinup.art"><img src="https://avatars.githubusercontent.com/u/1392097?v=4?s=100" width="100px;" alt="Pierre"/><br /><sub><b>Pierre</b></sub></a><br /><a href="https://github.com/adamghill/django-unicorn/commits?author=bloodywing" title="Code">💻</a></td>
283-
<td align="center" valign="top" width="14.28%"><a href="https://roman.pt"><img src="https://avatars.githubusercontent.com/u/75075?v=4?s=100" width="100px;" alt="Roman Imankulov"/><br /><sub><b>Roman Imankulov</b></sub></a><br /><a href="https://github.com/adamghill/django-unicorn/commits?author=imankulov" title="Tests">⚠️</a></td>
287+
<td align="center" valign="top" width="14.28%"><a href="https://roman.pt"><img src="https://avatars.githubusercontent.com/u/75075?v=4?s=100" width="100px;" alt="Roman Imankulov"/><br /><sub><b>Roman Imankulov</b></sub></a><br /><a href="https://github.com/adamghill/django-unicorn/commits?author=imankulov" title="Tests">⚠️</a> <a href="https://github.com/adamghill/django-unicorn/commits?author=imankulov" title="Code">💻</a></td>
284288
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rhymiz"><img src="https://avatars.githubusercontent.com/u/7029352?v=4?s=100" width="100px;" alt="Lemi Boyce"/><br /><sub><b>Lemi Boyce</b></sub></a><br /><a href="https://github.com/adamghill/django-unicorn/commits?author=rhymiz" title="Code">💻</a></td>
285289
</tr>
286290
</tbody>

django_unicorn/cacher.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import logging
2+
import pickle
3+
from typing import List
4+
5+
from django.core.cache import caches
6+
from django.http import HttpRequest
7+
8+
import django_unicorn
9+
from django_unicorn.errors import UnicornCacheError
10+
from django_unicorn.settings import get_cache_alias
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class PointerUnicornView:
16+
def __init__(self, component_cache_key):
17+
self.component_cache_key = component_cache_key
18+
self.parent = None
19+
self.children = []
20+
21+
22+
class CacheableComponent:
23+
"""
24+
Updates a component into something that is cacheable/pickleable. Also set pointers to parents/children.
25+
Use in a `with` statement or explicitly call `__enter__` `__exit__` to use. It will restore the original component
26+
on exit.
27+
"""
28+
29+
def __init__(self, component: "django_unicorn.views.UnicornView"):
30+
self._state = {}
31+
self.cacheable_component = component
32+
33+
def __enter__(self):
34+
components = []
35+
components.append(self.cacheable_component)
36+
37+
while components:
38+
component = components.pop()
39+
40+
if component.component_id in self._state:
41+
continue
42+
43+
if hasattr(component, "extra_context"):
44+
extra_context = component.extra_context
45+
component.extra_context = None
46+
else:
47+
extra_context = None
48+
49+
request = component.request
50+
component.request = None
51+
52+
self._state[component.component_id] = (
53+
component,
54+
request,
55+
extra_context,
56+
component.parent,
57+
component.children.copy(),
58+
)
59+
60+
if component.parent:
61+
components.append(component.parent)
62+
component.parent = PointerUnicornView(component.parent.component_cache_key)
63+
64+
for index, child in enumerate(component.children):
65+
components.append(child)
66+
component.children[index] = PointerUnicornView(child.component_cache_key)
67+
68+
for component, *_ in self._state.values():
69+
try:
70+
pickle.dumps(component)
71+
except (
72+
TypeError,
73+
AttributeError,
74+
NotImplementedError,
75+
pickle.PicklingError,
76+
) as e:
77+
raise UnicornCacheError(
78+
f"Cannot cache component '{type(component)}' because it is not picklable: {type(e)}: {e}"
79+
) from e
80+
81+
return self
82+
83+
def __exit__(self, *args):
84+
for component, request, extra_context, parent, children in self._state.values():
85+
component.request = request
86+
component.parent = parent
87+
component.children = children
88+
89+
if extra_context:
90+
component.extra_context = extra_context
91+
92+
def components(self) -> List["django_unicorn.views.UnicornView"]:
93+
return [component for component, *_ in self._state.values()]
94+
95+
96+
def cache_full_tree(component: "django_unicorn.views.UnicornView"):
97+
root = component
98+
99+
while root.parent:
100+
root = root.parent
101+
102+
cache = caches[get_cache_alias()]
103+
104+
with CacheableComponent(root) as caching:
105+
for component in caching.components():
106+
cache.set(component.component_cache_key, component)
107+
108+
109+
def restore_from_cache(component_cache_key: str, request: HttpRequest = None) -> "django_unicorn.views.UnicornView":
110+
"""
111+
Gets a cached unicorn view by key, restoring and getting cached parents and children
112+
and setting the request.
113+
"""
114+
115+
cache = caches[get_cache_alias()]
116+
cached_component = cache.get(component_cache_key)
117+
118+
if cached_component:
119+
roots = {}
120+
root: "django_unicorn.views.UnicornView" = cached_component
121+
roots[root.component_cache_key] = root
122+
123+
while root.parent:
124+
root = cache.get(root.parent.component_cache_key)
125+
roots[root.component_cache_key] = root
126+
127+
to_traverse: List["django_unicorn.views.UnicornView"] = []
128+
to_traverse.append(root)
129+
130+
while to_traverse:
131+
current = to_traverse.pop()
132+
current.setup(request)
133+
current._validate_called = False
134+
current.calls = []
135+
136+
for index, child in enumerate(current.children):
137+
key = child.component_cache_key
138+
cached_child = roots.pop(key, None) or cache.get(key)
139+
140+
cached_child.parent = current
141+
current.children[index] = cached_child
142+
to_traverse.append(cached_child)
143+
144+
return cached_component

django_unicorn/call_method_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from types import MappingProxyType
55
from typing import Any, Dict, List, Mapping, Tuple
66

7-
from django_unicorn.utils import CASTERS
7+
from django_unicorn.typer import CASTERS
88

99
logger = logging.getLogger(__name__)
1010

django_unicorn/components/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django_unicorn.components.mixins import ModelValueMixin
2-
from django_unicorn.components.typing import QuerySetType
32
from django_unicorn.components.unicorn_view import UnicornField, UnicornView
43
from django_unicorn.components.updaters import HashUpdate, LocationUpdate, PollUpdate
4+
from django_unicorn.typing import QuerySetType
55

66
__all__ = [
77
"QuerySetType",

django_unicorn/components/unicorn_template_response.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def render(self):
137137

138138
frontend_context_variables = self.component.get_frontend_context_variables()
139139
frontend_context_variables_dict = orjson.loads(frontend_context_variables)
140-
checksum = generate_checksum(str(frontend_context_variables_dict))
140+
checksum = generate_checksum(frontend_context_variables_dict)
141141

142142
# Use `html.parser` and not `lxml` because in testing it was no faster even with `cchardet`
143143
# despite https://thehftguy.com/2020/07/28/making-beautifulsoup-parsing-10-times-faster/
@@ -153,9 +153,11 @@ def render(self):
153153
root_element["unicorn:name"] = self.component.component_name
154154
root_element["unicorn:key"] = self.component.component_key
155155
root_element["unicorn:checksum"] = checksum
156+
root_element["unicorn:data"] = frontend_context_variables
157+
root_element["unicorn:calls"] = orjson.dumps(self.component.calls).decode("utf-8")
156158

157159
# Generate the checksum based on the rendered content (without script tag)
158-
checksum = generate_checksum(UnicornTemplateResponse._desoupify(soup))
160+
content_hash = generate_checksum(UnicornTemplateResponse._desoupify(soup))
159161

160162
if self.init_js:
161163
init = {
@@ -164,7 +166,7 @@ def render(self):
164166
"key": self.component.component_key,
165167
"data": orjson.loads(frontend_context_variables),
166168
"calls": self.component.calls,
167-
"hash": checksum,
169+
"hash": content_hash,
168170
}
169171
init = orjson.dumps(init).decode("utf-8")
170172
json_element_id = f"unicorn:data:{self.component.component_id}"

django_unicorn/components/unicorn_view.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from django.views.generic.base import TemplateView
1818

1919
from django_unicorn import serializer
20+
from django_unicorn.cacher import cache_full_tree, restore_from_cache
2021
from django_unicorn.components.fields import UnicornField
2122
from django_unicorn.components.unicorn_template_response import UnicornTemplateResponse
2223
from django_unicorn.decorators import timed
@@ -26,11 +27,8 @@
2627
UnicornCacheError,
2728
)
2829
from django_unicorn.settings import get_setting
29-
from django_unicorn.utils import (
30-
cache_full_tree,
31-
is_non_string_sequence,
32-
restore_from_cache,
33-
)
30+
from django_unicorn.typer import cast_attribute_value, get_type_hints
31+
from django_unicorn.utils import is_non_string_sequence
3432

3533
try:
3634
from cachetools.lru import LRUCache
@@ -203,6 +201,9 @@ def __init__(self, component_args: Optional[List] = None, **kwargs):
203201
# JavaScript method calls
204202
self.calls = []
205203

204+
# Default force render to False
205+
self.force_render = False
206+
206207
super().__init__(**kwargs)
207208

208209
if not self.component_name:
@@ -587,6 +588,13 @@ def _attribute_names(self) -> List[str]:
587588
non_callables = [member[0] for member in inspect.getmembers(self, lambda x: not callable(x))]
588589
attribute_names = [name for name in non_callables if self._is_public(name)]
589590

591+
# Add type hints for the component to the attribute names since
592+
# they won't be returned from `getmembers`
593+
for type_hint_attribute_name in get_type_hints(self).keys():
594+
if self._is_public(type_hint_attribute_name):
595+
if type_hint_attribute_name not in attribute_names:
596+
attribute_names.append(type_hint_attribute_name)
597+
590598
return attribute_names
591599

592600
@timed
@@ -599,7 +607,7 @@ def _attributes(self) -> Dict[str, Any]:
599607
attributes = {}
600608

601609
for attribute_name in attribute_names:
602-
attributes[attribute_name] = getattr(self, attribute_name)
610+
attributes[attribute_name] = getattr(self, attribute_name, None)
603611

604612
return attributes
605613

@@ -614,7 +622,10 @@ def _set_property(
614622
) -> None:
615623
# Get the correct value type by using the form if it is available
616624
data = self._attributes()
625+
626+
value = cast_attribute_value(self, name, value)
617627
data[name] = value
628+
618629
form = self._get_form(data)
619630

620631
if form and name in form.fields and name in form.cleaned_data:
@@ -752,6 +763,7 @@ def _is_public(self, name: str) -> bool:
752763
"component_cache_key",
753764
"component_kwargs",
754765
"component_args",
766+
"force_render",
755767
)
756768
excludes = []
757769

@@ -833,6 +845,20 @@ def _get_component_class(module_name: str, class_name: str) -> Type[UnicornView]
833845
if use_cache and cached_component:
834846
logger.debug(f"Retrieve {component_id} from constructed views cache")
835847

848+
cached_component.component_args = component_args
849+
cached_component.component_kwargs = kwargs
850+
851+
# TODO: How should args be handled?
852+
# Set kwargs onto the cached component
853+
for key, value in kwargs.items():
854+
if hasattr(cached_component, key):
855+
setattr(cached_component, key, value)
856+
857+
cached_component._cache_component(parent=parent, component_args=component_args, **kwargs)
858+
859+
# Call hydrate because the component will be re-rendered
860+
cached_component.hydrate()
861+
836862
return cached_component
837863

838864
if component_id in views_cache:

django_unicorn/static/unicorn/js/component.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export class Component {
8585
// Skip the component root element
8686
return;
8787
}
88+
8889
const componentId = el.getAttribute("unicorn:id");
8990

9091
if (componentId) {
@@ -494,8 +495,18 @@ export class Component {
494495
}
495496
});
496497

498+
// Re-set model values for all children
499+
this.getChildrenComponents().forEach((childComponent) => {
500+
childComponent.setModelValues(
501+
triggeringElements,
502+
forceModelUpdates,
503+
false
504+
);
505+
});
506+
497507
if (updateParents) {
498508
const parent = this.getParentComponent();
509+
499510
if (parent) {
500511
parent.setModelValues(
501512
triggeringElements,
@@ -541,4 +552,51 @@ export class Component {
541552
}
542553
});
543554
}
555+
556+
/**
557+
* Replace the target DOM with the rerendered component.
558+
*
559+
* The function updates the DOM, and updates the Unicorn component store by deleting
560+
* components that were removed, and adding new components.
561+
*/
562+
morph(targetDom, rerenderedComponent) {
563+
if (!rerenderedComponent) {
564+
return;
565+
}
566+
567+
// Helper function that returns an array of nodes with an attribute unicorn:id
568+
const findUnicorns = () => [
569+
...targetDom.querySelectorAll("[unicorn\\:id]"),
570+
];
571+
572+
// Find component IDs before morphing
573+
const componentIdsBeforeMorph = new Set(
574+
findUnicorns().map((el) => el.getAttribute("unicorn:id"))
575+
);
576+
577+
// Morph
578+
this.morpher.morph(targetDom, rerenderedComponent);
579+
580+
// Find all component IDs after morphing
581+
const componentIdsAfterMorph = new Set(
582+
findUnicorns().map((el) => el.getAttribute("unicorn:id"))
583+
);
584+
585+
// Delete components that were removed
586+
const removedComponentIds = [...componentIdsBeforeMorph].filter(
587+
(id) => !componentIdsAfterMorph.has(id)
588+
);
589+
removedComponentIds.forEach((id) => {
590+
Unicorn.deleteComponent(id);
591+
});
592+
593+
// Populate Unicorn with new components
594+
findUnicorns().forEach((el) => {
595+
Unicorn.insertComponentFromDom(el);
596+
});
597+
}
598+
599+
morphRoot(rerenderedComponent) {
600+
this.morph(this.root, rerenderedComponent);
601+
}
544602
}

0 commit comments

Comments
 (0)