Skip to content

Commit 77585e0

Browse files
authored
Merge branch 'adamghill:main' into main
2 parents 5f98d0f + 811ba12 commit 77585e0

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

+1504
-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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ Thanks to the following wonderful people ([emoji key](https://allcontributors.or
220220
<tr>
221221
<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>
222222
<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>
223-
<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>
223+
<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>
224224
<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>
225225
</tr>
226226
</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
@@ -84,6 +84,7 @@ export class Component {
8484
// Skip the component root element
8585
return;
8686
}
87+
8788
const componentId = el.getAttribute("unicorn:id");
8889

8990
if (componentId) {
@@ -493,8 +494,18 @@ export class Component {
493494
}
494495
});
495496

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

django_unicorn/static/unicorn/js/eventListeners.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function handleLoading(component, targetElement) {
6262
}
6363
} else {
6464
loadingElement.handleLoading();
65+
6566
if (loadingElement.loading.hide) {
6667
loadingElement.hide();
6768
} else if (loadingElement.loading.show) {

0 commit comments

Comments
 (0)