Skip to content

Commit a033f2f

Browse files
committed
fix: initialize latestActionEpoch to 0 to prevent race condition
1 parent b4df50f commit a033f2f

File tree

9 files changed

+742
-241
lines changed

9 files changed

+742
-241
lines changed

src/django_unicorn/static/unicorn/js/component.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class Component {
5959
this.initPolling();
6060

6161
// epoch is the time in milliseconds when the packet was created
62-
this.latestActionEpoch = new Date().getTime();
62+
this.latestActionEpoch = 0;
6363

6464
this.callCalls(args.calls);
6565
}

src/django_unicorn/views/action.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
1-
from typing import Any
1+
from dataclasses import dataclass
2+
from typing import TYPE_CHECKING, Any
23

34
from django_unicorn.call_method_parser import parse_call_method_name
5+
from django_unicorn.components import UnicornView
6+
7+
if TYPE_CHECKING:
8+
from django_unicorn.views.request import ComponentRequest
9+
10+
11+
@dataclass
12+
class HandleResult:
13+
component: UnicornView
14+
is_refresh_called: bool = False
15+
is_reset_called: bool = False
16+
validate_all_fields: bool = False
17+
return_data: Any = None
418

519

620
class Action:
@@ -21,6 +35,32 @@ def __init__(self, data: dict[str, Any]):
2135
def __repr__(self):
2236
return f"Action(type='{self.action_type}', payload={self.payload})"
2337

38+
def __eq__(self, other):
39+
if not isinstance(other, Action):
40+
return False
41+
return (
42+
self.action_type == other.action_type and self.payload == other.payload and self.partials == other.partials
43+
)
44+
45+
__hash__ = None
46+
47+
def to_json(self) -> dict:
48+
return {
49+
"type": self.action_type,
50+
"payload": self.payload,
51+
"partials": self.partials,
52+
}
53+
54+
def handle(self, component_request: "ComponentRequest", component: UnicornView) -> HandleResult:
55+
from django_unicorn.views.action_parsers import sync_input # noqa: PLC0415
56+
57+
# Generic handler fallback
58+
if self.action_type == "syncInput":
59+
sync_input.handle(component_request, component, self.payload)
60+
return HandleResult(component=component)
61+
62+
return HandleResult(component=component)
63+
2464

2565
class SyncInput(Action):
2666
__slots__ = ("name", "value")
@@ -33,6 +73,19 @@ def __init__(self, data: dict[str, Any]):
3373
def __repr__(self):
3474
return f"SyncInput(name='{self.name}', value='{self.value}')"
3575

76+
def __eq__(self, other):
77+
if not isinstance(other, SyncInput):
78+
return False
79+
return super().__eq__(other) and self.name == other.name and self.value == other.value
80+
81+
__hash__ = None
82+
83+
def handle(self, component_request: "ComponentRequest", component: UnicornView) -> HandleResult:
84+
from django_unicorn.views.action_parsers import sync_input # noqa: PLC0415
85+
86+
sync_input.handle(component_request, component, self.payload)
87+
return HandleResult(component=component)
88+
3689

3790
class CallMethod(Action):
3891
__slots__ = ("args", "kwargs", "method_name")
@@ -46,20 +99,67 @@ def __init__(self, data: dict[str, Any]):
4699
def __repr__(self):
47100
return f"CallMethod(method_name='{self.method_name}', args={self.args}, kwargs={self.kwargs})"
48101

102+
def __eq__(self, other):
103+
if not isinstance(other, CallMethod):
104+
return False
105+
return (
106+
super().__eq__(other)
107+
and self.method_name == other.method_name
108+
and self.args == other.args
109+
and self.kwargs == other.kwargs
110+
)
111+
112+
__hash__ = None
113+
114+
def handle(self, component_request: "ComponentRequest", component: UnicornView) -> HandleResult:
115+
from django_unicorn.views.action_parsers import call_method # noqa: PLC0415
116+
117+
(
118+
component,
119+
is_refresh_called,
120+
is_reset_called,
121+
validate_all_fields,
122+
return_data,
123+
) = call_method.handle(component_request, component, self.payload)
124+
125+
return HandleResult(
126+
component=component,
127+
is_refresh_called=is_refresh_called,
128+
is_reset_called=is_reset_called,
129+
validate_all_fields=validate_all_fields,
130+
return_data=return_data,
131+
)
132+
49133

50134
class Reset(Action):
51135
__slots__ = ()
52136

53137
def __repr__(self):
54138
return "Reset()"
55139

140+
def __eq__(self, other):
141+
return isinstance(other, Reset) and super().__eq__(other)
142+
143+
__hash__ = None
144+
145+
def handle(self, component_request: "ComponentRequest", component: UnicornView) -> HandleResult:
146+
return CallMethod(self.to_json()).handle(component_request, component)
147+
56148

57149
class Refresh(Action):
58150
__slots__ = ()
59151

60152
def __repr__(self):
61153
return "Refresh()"
62154

155+
def __eq__(self, other):
156+
return isinstance(other, Refresh) and super().__eq__(other)
157+
158+
__hash__ = None
159+
160+
def handle(self, component_request: "ComponentRequest", component: UnicornView) -> HandleResult:
161+
return CallMethod(self.to_json()).handle(component_request, component)
162+
63163

64164
class Toggle(Action):
65165
__slots__ = ("args", "kwargs", "method_name")
@@ -73,3 +173,18 @@ def __init__(self, data: dict[str, Any]):
73173

74174
def __repr__(self):
75175
return f"Toggle(args={self.args})"
176+
177+
def __eq__(self, other):
178+
if not isinstance(other, Toggle):
179+
return False
180+
return (
181+
super().__eq__(other)
182+
and self.method_name == other.method_name
183+
and self.args == other.args
184+
and self.kwargs == other.kwargs
185+
)
186+
187+
__hash__ = None
188+
189+
def handle(self, component_request: "ComponentRequest", component: UnicornView) -> HandleResult:
190+
return CallMethod(self.to_json()).handle(component_request, component)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import copy
2+
import logging
3+
4+
import orjson
5+
from django.forms import ValidationError
6+
from django.http import HttpRequest
7+
8+
from django_unicorn.components import UnicornView
9+
from django_unicorn.components.unicorn_template_response import get_root_element
10+
from django_unicorn.utils import html_element_to_string
11+
from django_unicorn.views.request import ComponentRequest
12+
from django_unicorn.views.response import ComponentResponse
13+
from django_unicorn.views.utils import set_property_from_data
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class ActionDispatcher:
19+
def __init__(self, request: HttpRequest, component_request: ComponentRequest):
20+
self.request = request
21+
self.component_request = component_request
22+
self.component = None
23+
self.partials = []
24+
self.return_data = None
25+
self.validate_all_fields = False
26+
self.is_refresh_called = False
27+
self.is_reset_called = False
28+
29+
def dispatch(self) -> dict:
30+
self._create_component()
31+
self._hydrate()
32+
self._execute_actions()
33+
self._process_safe_fields()
34+
self._validate()
35+
rendered_component = self._render()
36+
return self._create_response(rendered_component)
37+
38+
def _create_component(self):
39+
self.component = UnicornView.create(
40+
component_id=self.component_request.id,
41+
component_name=self.component_request.name,
42+
request=self.request,
43+
)
44+
45+
# Ensure request is attached to the component and its parent
46+
if self.component.request is None:
47+
self.component.request = self.request
48+
if self.component.parent is not None and self.component.parent.request is None:
49+
self.component.parent.request = self.request
50+
51+
def _hydrate(self):
52+
if self.component_request.data is None:
53+
raise AssertionError("Component request data is required")
54+
55+
self.original_data = copy.deepcopy(self.component_request.data)
56+
57+
self.component.pre_parse()
58+
59+
for property_name, property_value in self.component_request.data.items():
60+
set_property_from_data(self.component, property_name, property_value, ignore_m2m=True)
61+
62+
self.component.post_parse()
63+
self.component.hydrate()
64+
65+
def _execute_actions(self):
66+
for action in self.component_request.action_queue:
67+
if action.partials:
68+
self.partials.extend(action.partials)
69+
70+
try:
71+
result = action.handle(self.component_request, self.component)
72+
73+
# Update state from result
74+
if result.component:
75+
self.component = result.component
76+
77+
self.is_refresh_called = self.is_refresh_called | result.is_refresh_called
78+
self.is_reset_called = self.is_reset_called | result.is_reset_called
79+
self.validate_all_fields = self.validate_all_fields | result.validate_all_fields
80+
if result.return_data:
81+
self.return_data = result.return_data
82+
except ValidationError as e:
83+
self.component._handle_validation_error(e)
84+
85+
self.component.complete()
86+
87+
def _process_safe_fields(self):
88+
# Reload frontend context variables to capture changes
89+
self.component_request.data = orjson.loads(self.component.get_frontend_context_variables())
90+
self.component._handle_safe_fields()
91+
92+
def _validate(self):
93+
# Calculate updated data to support partial validation
94+
updated_data = self.component_request.data
95+
if not self.is_reset_called:
96+
if not self.is_refresh_called:
97+
updated_data = {}
98+
for key, value in self.original_data.items():
99+
if value != self.component_request.data.get(key):
100+
updated_data[key] = self.component_request.data.get(key)
101+
102+
if self.validate_all_fields:
103+
self.component.validate()
104+
else:
105+
self.component.validate(model_names=list(updated_data.keys()))
106+
107+
def _render(self) -> str:
108+
if self.return_data and self.return_data.redirect:
109+
return ""
110+
111+
rendered_component = self.component.render(request=self.request)
112+
self.component.rendered(rendered_component)
113+
return rendered_component
114+
115+
def _create_response(self, rendered_component: str) -> dict:
116+
partial_doms = self._get_partial_doms(rendered_component) if rendered_component else []
117+
118+
self.component.last_rendered_dom = rendered_component
119+
120+
response = ComponentResponse(
121+
self.component, self.component_request, return_data=self.return_data, partials=partial_doms
122+
)
123+
return response.get_data()
124+
125+
def _get_partial_doms(self, rendered_component: str) -> list[dict]:
126+
partial_doms = []
127+
if self.partials:
128+
soup = get_root_element(rendered_component)
129+
130+
for partial in self.partials:
131+
target = partial.get("target") or partial.get("key") or partial.get("id")
132+
if not target:
133+
raise AssertionError("Partial target is required")
134+
135+
found = False
136+
if soup.get("unicorn:key") == target:
137+
partial_doms.append({"key": target, "dom": html_element_to_string(soup, with_tail=False)})
138+
found = True
139+
continue
140+
141+
if soup.get("id") == target:
142+
partial_doms.append({"id": target, "dom": html_element_to_string(soup, with_tail=False)})
143+
found = True
144+
continue
145+
146+
for element in soup.iter():
147+
if element.get("unicorn:key") == target:
148+
partial_doms.append({"key": target, "dom": html_element_to_string(element, with_tail=False)})
149+
found = True
150+
break
151+
152+
if not found:
153+
for element in soup.iter():
154+
if element.get("id") == target:
155+
partial_doms.append({"id": target, "dom": html_element_to_string(element, with_tail=False)})
156+
found = True
157+
break
158+
return partial_doms

0 commit comments

Comments
 (0)