Add a new module for dynamic page state injections.
Review Request #14548 — Created Aug. 4, 2025 and submitted
TemplateHook
is a powerful way of letting extensions add content to
templates with minimal effort, but it has two problems:
It's limited to extensions, meaning it's not possible for other code
to leverage the functionality.It has no say in the caching information, meaning the final ETag for
a page may not consider dynamic changes to the content from the
hooks.This change adds a new piece of architecture that makes the benefits of
TemplateHook
and hook points more generally-useful in an application.
djblets.pagestate
allows templates to set up page hook points that
content can be injected into via a{% page_hook_point %}
template tag.
This is much like{% template_hook_point %}
for extensions.These interface with a
PageState
instance, which is bound to an
HttpRequest
. These are created/accessed using
PageState.for_request(request)
. APageState
tracks any injected
content for the page, along with ETags for that content.Injections can be manual (through
PageState.inject()
calls) or dynamic
(through registered injector objects). The dynamic ones work more like
TemplateHook
.Injections can provide content (HTML-safe or unsafe) and ETags, but
either are optional. If an ETag is not provided, the content will be
used in its place (in either case, this is seed data for a SHA256 hash
for the resulting ETag). An injection with just an ETag and no content
is also allowed, as that can be useful for ensuring state generated
elsewhere for the page is considered in the resulting ETag.
PageStateMiddleware
manages the resulting ETag for the page. If the
response does not include an ETag, we leave it alone (so we don't cause
a page with other dynamic elements to be cached incorrectly). If it does
include an ETag, it will be mixed with thePageState
's ETag into a
final ETag.
TemplateHook
is now built on all this. The{% template_hook_point %}
tag simply wraps{% page_hook_point %}
, and an injector is registered
that bridges to the registeredTemplateHook
s.ExtensionsMiddleware
also interfaces withPageStateMiddleware
if not otherwise defined in
settings.MIDDLEWARE
. This means that no changes are needed for
projects using extensions today. Everything just works.
Unit tests pass.
Tested that existing extensions using
TemplateHook
continue to work
correctly.Built some new code using this support, and verified it worked as
expected.Built the docs and checked the new guide for any obvious errors.
Summary | ID |
---|---|
2812600f68e0fc7b8e817a34c2af55c237bd52cf |
Description | From | Last Updated |
---|---|---|
'django.utils.translation.gettext as _' imported but unused Column: 1 Error code: F401 |
![]() |
|
Leftover debug output |
|
|
Do we want to do any validation on the values passed in to this method? |
|
|
Is there really a case where there will be no etag and no content? |
|
|
Arg name should be point_name here. |
|
|
From the code it looks like the key here should be 'content' instead of 'data'? |
|
|
Same here. |
|
|
Let's capitalize ETag here. |
![]() |
|
It looks like this should be not in. Right now it's checking if we have the middleware in the list … |
|
|
typo: missing period between templatetags and djblets_pagestate |
|
|
This should be removed. |
![]() |
|
I think you can achieve the same thing by combining old_etag and injected_etag into a value that you then pass … |
![]() |
|
These docs are missing a Raises section. |
![]() |
|
Similar to my comment about using encode_etag(), maybe its worthwhile to have a function in djblets.util.http that returns an etag … |
![]() |
|
Should this be typed as HttpRequest | None instead of Any? |
|
|
typo: get_tag -> get_etag |
|
|
'hashlib' imported but unused Column: 1 Error code: F401 |
![]() |
|
'typing.Any' imported but unused Column: 5 Error code: F401 |
![]() |
|
This is constructing a new PageStateMiddleware for every request, but that class doesn't have any internal state. Can we have … |
|
- Change Summary:
-
- Removed all the logic around finalizing hook points and preventing further injections or lookups. Multiple templates may be used in a request/response cycle.
- Added
PageState.clear_injections()
to clear some or all points in-between renders.
- Commits:
-
Summary ID 6b3a6cc7bb2731cdd974c78c20e8c94d73e032f2 51e7dcb9d3b5f120e5b62e61bf5da56662c11614 - Diff:
-
Revision 2 (+2950 -132)
Checks run (1 failed, 1 succeeded)
flake8
- Change Summary:
-
Removed an unused import.
- Commits:
-
Summary ID 51e7dcb9d3b5f120e5b62e61bf5da56662c11614 8e34099389af2121af714eaa8be831a20791af7c - Diff:
-
Revision 3 (+2948 -132)
Checks run (2 succeeded)
- Change Summary:
-
- Added validation to inputs in
inject()
. - Removed leftover debug output.
- Fixed argument names.
- Added validation to inputs in
- Commits:
-
Summary ID 8e34099389af2121af714eaa8be831a20791af7c db38372e7702f59b927086e1a90a3442dbc326f6 - Diff:
-
Revision 4 (+2978 -132)
Checks run (2 succeeded)

-
-
-
-
I think you can achieve the same thing by combining
old_etag
andinjected_etag
into a value that you then pass todjblets.util.http.encode_etag()
. That way if we ever improve the way we build etags (for example when we switched from sha1 to sha256), we don't have to remember to update here too. -
-
Similar to my comment about using
encode_etag()
, maybe its worthwhile to have a function indjblets.util.http
that returns an etag SHA and a function for updating a SHA. For now these would returnhashlib.sha256()
andexisting_etag.update(new_etag.encode('utf-8'))
respectively. Makes it easier in the future if we ever want to change our standards for etags.
- Change Summary:
-
- Fixed issues with docs.
- Fixed an inversed conditional for middleware.
- Switched to
encode_etag()
when merging ETags. - Fixed a typing issue with
HttpRequest | None
.
- Commits:
-
Summary ID db38372e7702f59b927086e1a90a3442dbc326f6 fd57b0cf60142adf1ee64bbb1db55583ab4eb1f8 - Diff:
-
Revision 5 (+2988 -132)
- Change Summary:
-
Removed unused imports.
- Commits:
-
Summary ID fd57b0cf60142adf1ee64bbb1db55583ab4eb1f8 2812600f68e0fc7b8e817a34c2af55c237bd52cf - Diff:
-
Revision 6 (+2982 -132)