Skip to content

Commit bc72121

Browse files
committed
Return a 304 Not Modified status code if the rendered content didn't change and no updates are needed.
1 parent 39cab12 commit bc72121

File tree

10 files changed

+286
-23
lines changed

10 files changed

+286
-23
lines changed

django_unicorn/components/unicorn_template_response.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ def render(self):
7272
root_element["unicorn:key"] = self.component.component_key
7373
root_element["unicorn:checksum"] = checksum
7474

75+
# Generate the hash based on the rendered content (without script tag)
76+
hash = generate_checksum(UnicornTemplateResponse._desoupify(soup))
77+
7578
if self.init_js:
7679
init = {
7780
"id": self.component.component_id,
7881
"name": self.component.component_name,
7982
"key": self.component.component_key,
8083
"data": orjson.loads(frontend_context_variables),
8184
"calls": self.component.calls,
85+
"hash": hash,
8286
}
8387
init = orjson.dumps(init).decode("utf-8")
8488
init_script = f"Unicorn.componentInit({init});"

django_unicorn/components/unicorn_view.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import inspect
33
import logging
44
import pickle
5+
import sys
56
from typing import Any, Callable, Dict, List, Optional, Sequence, Type, Union
67

78
from django.core.cache import caches
@@ -33,7 +34,7 @@
3334
# Module cache for constructed component classes
3435
# This can create a subtle race condition so a more long-term solution needs to be found
3536
constructed_views_cache = LRUCache(maxsize=100)
36-
COMPONENTS_MODULE_CACHE_ENABLED = True
37+
COMPONENTS_MODULE_CACHE_ENABLED = "pytest" not in sys.modules
3738

3839

3940
def convert_to_snake_case(s: str) -> str:

django_unicorn/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ class UnicornViewError(Exception):
88

99
class ComponentLoadError(Exception):
1010
pass
11+
12+
13+
class RenderNotModified(Exception):
14+
pass

django_unicorn/message.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def __init__(self, request, component_name):
3838
assert self.epoch, "Missing epoch"
3939

4040
self.key = self.body.get("key", "")
41+
self.hash = self.body.get("hash", "")
4142

4243
self.validate_checksum()
4344

django_unicorn/static/js/component.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class Component {
2020
this.key = args.key;
2121
this.messageUrl = args.messageUrl;
2222
this.csrfTokenHeaderName = args.csrfTokenHeaderName;
23+
this.hash = args.hash;
2324
this.data = args.data || {};
2425
this.syncUrl = `${this.messageUrl}/${this.name}`;
2526

django_unicorn/static/js/messageSender.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function send(component, callback) {
3131
checksum: component.checksum,
3232
actionQueue: component.currentActionQueue,
3333
epoch: Date.now(),
34+
hash: component.hash,
3435
};
3536

3637
const headers = {
@@ -49,6 +50,12 @@ export function send(component, callback) {
4950
return response.json();
5051
}
5152

53+
// HTTP status code of 304 is `Not Modified`. This null gets caught in the next promise
54+
// and stops any more processing.
55+
if (response.status === 304) {
56+
return null;
57+
}
58+
5259
throw Error(
5360
`Error when getting response: ${response.statusText} (${response.status})`
5461
);
@@ -110,6 +117,7 @@ export function send(component, callback) {
110117

111118
component.errors = responseJson.errors || {};
112119
component.return = responseJson.return || {};
120+
component.hash = responseJson.hash;
113121

114122
const parent = responseJson.parent || {};
115123
const rerenderedComponent = responseJson.dom || {};

django_unicorn/views.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.core.cache import caches
88
from django.db.models import Model
99
from django.http import HttpRequest, JsonResponse
10+
from django.http.response import HttpResponseNotModified
1011
from django.views.decorators.csrf import csrf_protect
1112
from django.views.decorators.http import require_POST
1213

@@ -17,7 +18,7 @@
1718
from .call_method_parser import InvalidKwarg, parse_call_method_name, parse_kwarg
1819
from .components import UnicornField, UnicornView
1920
from .decorators import timed
20-
from .errors import UnicornCacheError, UnicornViewError
21+
from .errors import RenderNotModified, UnicornCacheError, UnicornViewError
2122
from .message import ComponentRequest, Return
2223
from .serializer import dumps, loads
2324
from .settings import get_cache_alias, get_serial_enabled, get_serial_timeout
@@ -41,6 +42,8 @@ def wrapped_view(*args, **kwargs):
4142
return view_func(*args, **kwargs)
4243
except UnicornViewError as e:
4344
return JsonResponse({"error": str(e)})
45+
except RenderNotModified:
46+
return HttpResponseNotModified()
4447
except AssertionError as e:
4548
return JsonResponse({"error": str(e)})
4649

@@ -569,7 +572,18 @@ def _process_component_request(
569572
if partial_doms:
570573
res.update({"partials": partial_doms})
571574
else:
572-
res.update({"dom": rendered_component})
575+
hash = generate_checksum(rendered_component)
576+
577+
if (
578+
component_request.hash == hash
579+
and (not return_data or not return_data.value)
580+
and not component.calls
581+
):
582+
raise RenderNotModified()
583+
584+
res.update(
585+
{"dom": rendered_component, "hash": hash,}
586+
)
573587

574588
if return_data:
575589
res.update(
@@ -714,20 +728,22 @@ def _handle_queued_component_requests(
714728
component_requests = sorted(component_requests, key=lambda r: r.epoch)
715729
first_component_request = component_requests[0]
716730

717-
# Can't store request on a `ComponentRequest` and cache it because `HttpRequest`
718-
# isn't pickleable. Does it matter that a different request gets passed in then
719-
# the original request that generated the `ComponentRequest`?
720-
first_json_result = _process_component_request(request, first_component_request)
721-
722-
# Re-check for requests after the first request is processed
723-
component_requests = cache.get(queue_cache_key)
724-
725-
# Check that the request is in the cache before popping it off
726-
if component_requests:
727-
component_requests.pop(0)
728-
cache.set(
729-
queue_cache_key, component_requests, timeout=get_serial_timeout(),
730-
)
731+
try:
732+
# Can't store request on a `ComponentRequest` and cache it because `HttpRequest` isn't pickleable
733+
first_json_result = _process_component_request(request, first_component_request)
734+
except RenderNotModified:
735+
# Catching this and re-raising, but need the finally clause to clear the cache
736+
raise
737+
finally:
738+
# Re-check for requests after the first request is processed
739+
component_requests = cache.get(queue_cache_key)
740+
741+
# Check that the request is in the cache before popping it off
742+
if component_requests:
743+
component_requests.pop(0)
744+
cache.set(
745+
queue_cache_key, component_requests, timeout=get_serial_timeout(),
746+
)
731747

732748
if component_requests:
733749
# Create one new `component_request` from all of the queued requests that can be processed

tests/templatetags/test_unicorn_render.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from django_unicorn.components import UnicornView
66
from django_unicorn.templatetags.unicorn import unicorn
7+
from django_unicorn.utils import generate_checksum
78
from example.coffee.models import Flavor
89

910

@@ -247,7 +248,7 @@ def test_unicorn_render_calls(settings):
247248

248249
assert "<script" in html
249250
assert len(re.findall("<script", html)) == 1
250-
assert '"calls":[{"fn":"testCall","args":[]}]});' in html
251+
assert '"calls":[{"fn":"testCall","args":[]}]' in html
251252

252253

253254
def test_unicorn_render_calls_with_arg(settings):
@@ -262,7 +263,7 @@ def test_unicorn_render_calls_with_arg(settings):
262263

263264
assert "<script" in html
264265
assert len(re.findall("<script", html)) == 1
265-
assert '"calls":[{"fn":"testCall2","args":["hello"]}]});' in html
266+
assert '"calls":[{"fn":"testCall2","args":["hello"]}]' in html
266267

267268

268269
def test_unicorn_render_calls_no_mount_call(settings):
@@ -277,4 +278,25 @@ def test_unicorn_render_calls_no_mount_call(settings):
277278

278279
assert "<script" in html
279280
assert len(re.findall("<script", html)) == 1
280-
assert '"calls":[]});' in html
281+
assert '"calls":[]' in html
282+
283+
284+
def test_unicorn_render_hash(settings):
285+
settings.DEBUG = True
286+
token = Token(
287+
TokenType.TEXT,
288+
"unicorn 'tests.templatetags.test_unicorn_render.FakeComponentParent'",
289+
)
290+
unicorn_node = unicorn(None, token)
291+
context = {}
292+
html = unicorn_node.render(context)
293+
294+
assert "<script" in html
295+
assert len(re.findall("<script", html)) == 1
296+
assert '"hash":"' in html
297+
298+
# Assert that the content hash is correct
299+
script_idx = html.index("<script")
300+
rendered_content = html[:script_idx]
301+
expected_hash = generate_checksum(rendered_content)
302+
assert f'"hash":"{expected_hash}"' in html

0 commit comments

Comments
 (0)