Added
{% if %}blocks now emitdj-ifboundary markers — Foundation 1 of #1358. Iter 1 of 3 toward the keyed VDOM diff for conditional subtrees (re-open of #256 Option A). At template-render time, every{% if %}block whose body contains element nodes is wrapped in HTML-comment boundary markers:<!--dj-if id="if-<prefix>-N"-->...rendered body...<!--/dj-if-->Browsers ignore HTML comments, so this is zero-observable-behavior — markers are framework-internal metadata for the upcoming Iter 3 (Rust VDOM differ) which uses them as keyed boundaries when conditionals flip.
Marker shape: Option B (pair per
Node::If). Nested elif chains produce nested marker pairs (the parser already nests an innerIf(B)inside the outer'sfalse_nodes). Pure-text conditionals (text-only true/false bodies) skip emission — text positions are sibling-stable already; the legacy<!--dj-if-->placeholder for false-no-else (issue #295) is preserved unchanged. HTML attribute context (issue #380) skips emission. Thecond=attribute is intentionally OMITTED for safety (condition strings could contain--or>that would close the comment early; Iter 3's differ keys off theidalone).{% csrf_token %}is treated as element-bearing (renders<input type="hidden">), so{% if request.method == "POST" %}{% csrf_token %}{% endif %}correctly emits the wrapping pair.ID generation: stable per-template counter
if-<prefix>-Nassigned at parse time viaparser::assign_if_marker_idswalking the AST in document order. The<prefix>is an 8-hex-character source-derived hash (parser::parse_with_source(tokens, source)), so independently- parsed templates ({% extends %}parents,{% include %}partials, separately-loaded macros) get distinct prefixes and don't collide when their rendered HTML is composed in a single output buffer. Same source → same prefix → IDs are stable across re-renders. The{% for %}{% if %}pattern reuses the same id across loop iterations because the parser only sees oneNode::If.VDOM parser (
crates/djust_vdom/src/parser.rs) extended to preserve the new opening/closing markers as comment vnodes alongside the legacy<!--dj-if-->placeholder. The parser predicate acceptsdj-if,dj-if<space-or-tab>..., and/dj-if; it rejects lookalikes likedj-iffy,dj-if-extra,dj-ifid="...". Client- sidegetNodeByPathpath-fallback (12-vdom-patch.js) now mirrors this predicate via the newisDjIfCommenthelper, keeping client and server in lock-step. Publicrender_template/render_template_with_dirsstrip ALLdj-if-family markers viastrip_dj_if_markershelper — preserves the existing contract that public rendering yields clean HTML.What this enables (NOT in this PR):
- Iter 2 (Foundation 2): client patch applier learns
RemoveSubtree/InsertSubtreepatch types. - Iter 3 (Capability): Rust VDOM differ recognizes
dj-ifboundaries; emits subtree-level patches when conditionals flip.
Regression suite (post Stage 11 fix), totals across 5 files:
- 30 cases in
crates/djust_templates/tests/test_if_markers.rs(15 element-bearing / elif / nested / for-if / attribute-context cases + 4 cross-template uniqueness cases undercross_template_ids+ 3 csrf_token / variable / raw-input classifier cases + 8 stability/ordering cases). - 11 cases in
djust_vdom::parser::tests(legacy placeholder + boundary markers + 7 lookalike-rejection / whitespace-tolerance / prefixed-id / close-marker boundary cases). - 4 cases in
parser::testsfor prefix-deriving functions (parse_with_sourceshape / source-distinctness / source-stability / token-fallback). - 25 cases in
python/tests/test_template_if_markers.pyacrossTestElementBearingIfMarkers/TestPureTextSkip/TestPublicRenderTemplateStrips/TestIdStability/TestIdAssignment/TestAttributeContext/TestSiblingStability/TestIdPrefixUniqueness/TestCsrfTokenElementBearing. - 20 cases in
tests/js/dj_if_comment_predicate.test.js(predicate matrix + path-fallback integration).
- Iter 2 (Foundation 2): client patch applier learns
Client VDOM patch dispatcher learns
RemoveSubtree+InsertSubtreepatch types — Foundation 2 of #1358. Iter 2 of 3 toward keyed VDOM diff for conditional subtrees. The server doesn't emit these patch types yet (Iter 3 adds that), so this is zero-observable-behavior. When the upcoming Iter 3 differ recognizesdj-ifboundaries (from Iter 1) and emits subtree-level patches on conditional flips, this dispatcher will route them correctly without a coordinated client+server release.Wire formats:
{type: "RemoveSubtree", id: "if-<prefix>-N"}— locates the<!--dj-if id="...">open marker viaTreeWalker-backed scan, walks forward depth-counting opens/closes, removes the entire bracketed range (markers + inner content) inclusive.{type: "InsertSubtree", id: "...", html: "<!--dj-if-->...<!--/dj-if-->", path: [...], index: N, d: <parent dj-id?>}— parses the server-emitted HTML fragment via a<template>element so any<script>tags inside are inert by spec, then inserts atparent[index]using the same path/d resolution other child-targeting patches use.
New helpers (all reused from Iter 1's
isDjIfComment):_extractDjIfMarkerId,_findDjIfOpenMarker,_findDjIfCloseMarker(depth-counter, handles arbitrary nesting),_removeDjIfBracketedRange,_parseSubtreeHtml,applyRemoveSubtree,applyInsertSubtree. Dispatched fromapplySinglePatchvia a short-circuit ahead of the path/d resolution so subtree patches don't try to resolve a non-applicable path. 25 regression cases intests/js/dj_if_subtree_patches.test.jscoveringextractDjIfMarkerId(5) + marker-pair finder + nesting (5) +RemoveSubtreepositive / empty-inner / nested-outer-removes-inner / nested-inner-leaves-outer / id-not-found / root-pair / missing-id (7) +InsertSubtreeparses-and-inserts-at-index / appends-on-out-of-range / inert-script-via-template / missing-html / unresolvable-parent (5) +applySinglePatchdispatch wiring (3).What this enables (NOT in this PR): Iter 3 (Capability): Rust VDOM differ recognizes
dj-ifboundaries from Iter 1; emits these patch types when conditionals flip.Keyed VDOM diff for
{% if %}conditional subtrees (#1358; closes #256 Option A; capability of v0.9.4-1). Iter 3 of 3 — the iter that actually fixes the bug. After this PR, the long-standing class of{% if %}-breaks-VDOM-patching bugs that has plagued djust for over 3 months is eliminated. The Rust VDOM differ now recognizes<!--dj-if id="if-<prefix>-N"-->...<!--/dj-if-->boundary markers (emitted by Iter 1 template renderer, PR #1363) as KEYED units in the diff algorithm.When conditionals flip, the differ emits the new patch types from Iter 2 (PR #1364):
- OLD has boundary id=X, NEW does not →
RemoveSubtree { id: X }. Client locates the marker pair by id (NOT by position) and removes the bracketed range. - NEW has boundary id=Y, OLD does not →
InsertSubtree { id: Y, path, d, index, html }. Client parses the full marker-pair HTML (Shape A) via inert<template>.innerHTMLand inserts at the parent / index resolved via the same path/d resolution other child-targeting patches use. - Both have boundary id=Z → recurse into the inner body via
dj_if_pre_pass_inner. The recursion handles arbitrary nesting cleanly, including{% if %}/{% elif %}/{% else %}cascades where the outer marker is matched in both OLD and NEW but the body introduces (or removes) an inner boundary marker. Standard intra- subtree diff fires for the inner content (SetText, SetAttr, etc.) only when the body has NO nested boundaries.
Position-based path tracking is BYPASSED within boundaries: the id normalizes positions, so adding or removing a boundary no longer cascades into mis-targeted patches in surrounding siblings. Non- boundary siblings are paired by relative position AMONG non-boundary siblings — the conditional's presence/absence doesn't shift their relative order.
The 17.5%-error-rate tab-switch regression in a downstream consumer (cited in #1358's body) no longer reproduces. The recovery-HTML / page-reload fallback path is no longer triggered by
{% if %}flips.Recursive pre-pass for
{% if %}/{% elif %}/{% else %}cascades (Stage 11 finding on PR #1365 — capability iter). The first iteration of this fix iterated matched-id body children element- by-element viadiff_nodes, treating any nested boundary markers as ordinary VNodes. That produced overlapping patches when a cascade introduced or removed nested boundaries:- Top-level step 2 emitted
InsertSubtree(B)correctly. - Top-level step 3 ALSO emitted
Replace+InsertChildpatches for the same content (because element-by-element pairing saw B's markers as ordinary comment nodes and B's content as new sibling). - Both applied = corrupt DOM with duplicated content and mismatched markers.
The recursive pre-pass closes this gap: when matched-id A's body has nested boundaries,
dj_if_pre_pass_innerrecursively runs on the body slice. Each recursion level handles only its OWN top-level pairs (via the newfind_top_level_dj_if_pairshelper), so nested pairs are discovered at the recursion level that descends into their containing boundary. No more overlap; no more duplicate patches; arbitrary nesting (3+ levels) handled coherently.Backwards-compatible: Apps using the
d-noneworkaround documented in CLAUDE.md and downstream repos continue to work identically — the workaround sidesteps{% if %}entirely. Apps using the legacy bare<!--dj-if-->placeholder for false-no-else conditionals (issue #295) take the existing diff path unchanged (those placeholders have NO id and don't trigger the new keyed pre-pass). The pre-pass only fires when at least one sibling list contains an id-bearing boundary marker.Implementation in
crates/djust_vdom/src/diff.rs:- New helpers:
dj_if_open_id,is_dj_if_close,find_top_level_dj_if_pairs(depth-counter that returns ONLY outermost pairs at the current slice level — nested pairs are discovered when the recursion descends),render_dj_if_boundary_html(serializes boundary slice forInsertSubtree.html),build_excluded_mask. - New
dj_if_pre_passruns atdiff_childrenentry; delegates todj_if_pre_pass_innerwhich carries old/new offsets so absolute parent-children indices propagate correctly across recursion levels (DOM parent stays the same — markers don't create container elements). - Returns
Some(patches)when boundaries are present (caller short-circuits its keyed/indexed diff),Noneotherwise (caller proceeds unchanged or, in the recursive case, falls back to element-by-element pairing of the body slice). - Predicates mirror parser-side at
crates/djust_vdom/src/parser.rs:494-499and JS-side atpython/djust/static/djust/src/12-vdom-patch.js:38-43.
Wire format (locked by Iter 2):
{type: "RemoveSubtree", id: "if-<prefix>-N"}{type: "InsertSubtree", id: "...", path: [...], d: "<parent dj-id?>", index: N, html: "<!--dj-if id=...-->...<!--/dj-if-->"}
Limitation noted in code: when non-boundary siblings carry
dj-keyattributes AND reorder within their relative slot, the position-based pairing of non-boundary children can produce suboptimal patches. Production templates don't typically reorder elements across{% if %}boundaries; if a regression surfaces, the pre-pass can be extended to delegate non-boundary children todiff_keyed_childrenwhen any of them have keys.Out of scope (deferred to v0.10): wholesale-replace heuristic for same-id matched boundaries (e.g., when inner content differs by >X%); LIS within boundary bodies; relaxing
d-noneworkaround documentation in CLAUDE.md / downstream repos.Regression suite: 19 cases in
crates/djust_vdom/tests/test_dj_if_keyed_diff_1358.rscovering: two separate{% if %}blocks flipping (the renamed Case 1), conditional flip-off, conditional flip-on, same-id inner text change (recurses, NOT subtree replace), same-id identical inner (0 patches), nested boundaries inside DOM elements (inner flip leaves outer alone), sibling-shift regression with DIFFERENT boundary span lengths (3 vs 1 inner children — exercises the position-cascade class explicitly), empty boundary same id (0 patches), empty boundary different ids (Remove + Insert), JSON wire-format shape comparisons viaserde_json::ValueforRemoveSubtree/InsertSubtree/domission when None (tightened from substringcontainsper Stage 11 finding), backward compat with legacy bare<!--dj-if-->placeholder, end-to-end viaparse_html(proves parser-side and differ-side predicates agree), and 5 NEW elif-cascade cases (Stage 11 finding on this PR): A → elif-B flip (cascade introduces nested marker), elif-B → A flip (cascade collapses nested marker — symmetric direction, SHOULD-FIX #4), A → else with double-nested matched ids (no subtree-flip patches when both outer+inner ids match), cascade with extra static siblings (footer SetText path must use NEW tree's absolute index, not OLD's), 3-level cascade (A → B → C nesting introduced atomically — proves recursive pre-pass handles arbitrary depth).All 19 dj-if keyed-diff tests pass. All Rust tests pass. All Python tests pass. All 1559 JS tests pass.
Closes the capability half of v0.9.4-1 milestone (Iter 3 of 3). Foundation 1: PR #1363 (template markers). Foundation 2: PR #1364 (client patch types). Stage 11 must-fix and should-fix findings from this PR's review addressed in commit on this same PR.
- OLD has boundary id=X, NEW does not →
Fixed
_sortPatchesnow ordersRemoveSubtree/InsertSubtreeBEFORE path-based child ops — the actual root cause of #1370._sortPatchesassigned id-based patches to the default phase (3), so on the short-path (≤10 patches) the batch ran as[RemoveChild, InsertChild, SetAttr, RemoveSubtree, InsertSubtree]. The server's path-basedRemoveChild/InsertChildindices reflect the NEW tree's positions (after subtree ops applied). RunningRemoveChildagainst the still-old DOM targeted the wrong child → silent DOM corruption that accumulated across tab switches until client and server state fully desynced. Fix: assignRemoveSubtreephase -2 andInsertSubtreephase -1, so both sort ahead ofRemoveChild(phase 0). The long-path (>10 patches) was already pre-separating id-based patches (rc3 fix); this unifies short and long paths on the same ordering. Diagnosed via djust-browser MCP inspecting WS frames against a production reproducer.RemoveSubtree/InsertSubtreeare now idempotent w.r.t. the desired end-state (#1370 rc8). After many tab switches the server's VDOM diff baseline could drift, occasionally emitting aRemoveSubtreefor a marker already removed in a prior patch (or anInsertSubtreefor a marker already present). 19/20 patches succeeded but the one stale patch failed → client triggered recovery-HTML → page reload. Fix: both patch handlers now treat "already in the desired state" as success.RemoveSubtreewith a missing marker is a no-op (returns true);InsertSubtreewith an already-present marker is a no-op (doesn't duplicate content). Symmetric. Matches the semantics ofRemoveChildon an already-removed node in the standard patch set.Double-nested dj-root eliminated (#1370 final).
self._rust_view.render()produces HTML that already includes its own<div dj-root>...</div>wrapper. The Step 3 replacement was inserting that as the innerHTML of the shell's dj-root → double nesting. Fix: replace the shell's ENTIRE<div dj-root>...</div>element (opening tag through closing tag) withliveview_html. Single dj-root level, correct marker IDs fromself._rust_view, no path index drift.Handler metadata
<script>no longer injected insidedj-root(#1370 final fix)._inject_handler_metadatawas appending a<script>element inside thedj-rootcontent on initial HTTP render. The server's VDOM doesn't include this script → every sibling path after the script was shifted by +1 → all path-based patches failed with "parent: SCRIPT". Fix: inject handler metadata into the full page HTML (before</body>) AFTER the dj-root replacement, so it lives outside the VDOM-tracked subtree. This was the actual root cause of the "15/17 patches failed" pattern — not marker IDs (rc4/rc5 fixed those) nor the extension (confirmed by disabling it).Architectural fix: single RustLiveView for HTTP + WS render (#1370 final).
render_full_templatenow rendersdj-rootcontent viaself._rust_view(the SAME instance the WS path uses), guaranteeing marker IDs match by construction. The page shell is still rendered by a temp instance, but the shell'sdj-rootinnerHTML is replaced withself._rust_view.render(). No marker stripping, no first-WS overhead, no mismatch possible. Removes the architectural debt of twoRustLiveViewinstances rendering the same view.Marker ID mismatch between HTTP render and WS diff resolved (#1370 re-open).
render_full_templatecreated a temporaryRustLiveView(self._full_template)whose template-source hash differed from the VDOM-tracked template's hash (self.get_template()). HTTP-rendered DOM had markers with prefix A; WS differ emitted patches with prefix B → "RemoveSubtree: open marker not found" → recovery HTML → page reload. Fix: strip<!--dj-if-->markers from the initial HTTP render so the client DOM starts marker-free. On first WSrender_with_diff, the differ sees "NEW has markers, OLD doesn't" → emitsInsertSubtreewith the correct (VDOM-tracked) IDs. The non-inheritance path (self.render()) was already correct (sameRustLiveViewinstance as WS path). This only affected projects using{% extends %}template inheritance.RemoveSubtree/InsertSubtreepatches no longer crashgroupPatchesByParent(#1370 follow-up). v0.9.4rc2 fixed the hooks TDZ but exposed a second crash:TypeError: Cannot read properties of undefined (reading 'slice')atgroupPatchesByParentin12-vdom-patch.js. The Iter 3 patches (RemoveSubtree,InsertSubtree) don't carry apathfield — they locate their target by markerid.groupPatchesByParentassumed all patches havepath. Fix: filter out id-based patches and apply them directly viaapplySinglePatchBEFORE the path-grouped batching pass. Without this, any{% if %}block flip (the exact feature v0.9.4-1 shipped) triggered the TypeError → recovery HTML → page reload.HOTFIX: v0.9.4rc1 hooks TDZ regression (#1370). v0.9.4rc1 shipped a bundled
client.jsthat threwUncaught ReferenceError: Cannot access 'G' before initialization(Gis the minified_activeHooks) on every page load and every WS patch. Module 19 (19-hooks.js) is concatenated after the bootstrap call at bundle line ~7842;let _activeHookswas in TDZ when_ensureHooksInitwas invoked from earlier modules'djustInit(the synchronous-init branch fires whendocument.readyState !== 'loading'). Fix:let→varfor_activeHooksand_hookIdCounterinsrc/19-hooks.js:54-56(hoisted, no TDZ). Bundle rebuilt; new regression test intests/js/bundle-init-no-tdz.test.js(2 cases) loads the bundledclient.jsin a fresh JSDOM context withreadyState === 'complete'and asserts noReferenceErroron init — verified to FAIL against the rc1 bundle and PASS against the fixed bundle. Why PR #1359 (eslint cleanup) missed this: the missed-revert was caught via vitest import-order tests that simulate DECLARED-EARLY-USED-LATE patterns, but those tests do not simulate bundle-concat-order execution;_activeHooksis the inverse (DECLARED-LATE-USED-EARLY in the concat). The new bundle-init regression test catches the class structurally.dj-transitionnow respects CSStransition-durationinstead of a hard-coded 600ms fallback (#1348). The fallback timeout is auto-derived from the element's computedtransition-duration+transition-delay(longest pair across all transitioning properties) plus a 50ms grace window. For multi-property transitions, expectedtransitionendevents are counted fromtransition-propertyand cleanup runs only after all have fired — otherwise the first-finishing property would cut off slower ones._FALLBACK_MS_DEFAULT(600ms) is used only when computed-style reading fails or yields zero. Same auto-derivation extended todj-remove. Source-only commit; bundle (client.js) rebuild deferred per #1351 (392 pre-existing eslint warnings block--max-warnings 0).db.notificationsexits cleanly on permanent failures instead of retrying forever. Background — incident 2026-05-05: a 3.5-day-old djust deploy missing the optionalpsycopg[binary]>=3.2dependency had_run()retrying_connect()every 1 second forever (~302,000 attempts), accumulating 15.4 GiB of anonymous heap from un-reaped asyncio Task / coroutine closure state. The kubelet hit memory pressure, transitioned to NodeNotReady for ~7 seconds, which was enough for cnpg to fail over the postgres-cluster primary → 3-minute platform outage. Fix: when_connect()raisesDatabaseNotificationNotSupported(missing psycopg or non-postgres engine), treat as PERMANENT — log once at WARNING with operator-actionable wording, set_stopping = True, fire_ready_event, return from the loop. Process restart re-enables once the cause is fixed. Transient failures (ConnectionRefusedError,OSError, timeout, etc.) retain their 1-second-backoff retry behaviour. 2 regression tests inpython/djust/tests/test_notifications_permanent_failure.py(test_run_exits_immediately_on_permanent_failure,test_run_retries_transient_connect_failures).In-memory state backend no longer panics on concurrent same-session HTTP renders (#1353). When two HTTP requests for the same
(session, view_path)pair shared a cachedRustLiveView(the in-memory backend returned the same Python reference on cache hits), concurrent&mut selfRust methods on the shared view would collide inside Rust'sRefCell::borrow_mutand surface asRuntimeError: Already borrowed(a downstream consumer observed 17.5% 500-rate at concurrency 2). The race spanned more than the_sync_state_to_rustmutation calls —render()itself holds&mut selfacross template evaluation, andContext::resolve_dotted_via_getattr(crates/djust_core/src/context.rs) wrapsPython::with_gilso the embeddedgetattrcan yield the GIL inside an active mutable borrow. Any peer thread entering an&mut selfmethod during that window panicked. Fixed by switchingInMemoryStateBackend.get()to return an isolatedserialize_msgpack/deserialize_msgpackclone of the cached view (option 2 of three suggested in the issue body), mirroring theRedisStateBackendcontract — which already deserialized fresh on every read. With each caller holding its ownRustLiveViewinstance, no two threads can share a Rust&mut selfborrow and the race class is eliminated at the source. No Python-side lock is needed. New regression cases inTestInMemoryGetReturnsIsolatedView(4 cases — clone identity, state preservation, mutation isolation, concurrent get) andTestConcurrentRenderNoBorrowError(2 cases — concurrent render with GIL-yielding sidecar, concurrent update_state) inpython/tests/test_rust_bridge_concurrent.py.State backend honours top-level
DJUST_STATE_BACKEND/DJUST_REDIS_URLsettings (#1354). PreviouslyBackendRegistryonly consultedDJUST_CONFIG["STATE_BACKEND"], so projects configuring via top-level Django settings (e.g.DJUST_STATE_BACKEND = "redis://localhost:6379/0") were silently downgraded to in-memory with no warning. Now the registry layers top-level aliases on top ofDJUST_CONFIG(DJUST_CONFIGstill wins when both are set — backwards-compatible). URL-shaped values (redis://,rediss://,redis+sentinel://) are auto-translated tobackend_type="redis"plusREDIS_URL=<url>; the prefix list lives inBackendRegistry._REDIS_URL_PREFIXES. WhenDEBUG=Falseand the resolved backend is the default (in-memory),djust.utils.BackendRegistry.getnow emits alogger.warningflagging the production misconfig — multi-process deployments lose state across replicas.unix://URLs are left as a TODO follow-up because the underlyingredis-pyclient takes Unix sockets via a different parameter name. New regression cases inTestTopLevelStateBackendSetting(6 cases) andTestDjustConfigRegression(3 cases) inpython/tests/test_state_backend_config.py.
Changed
Redis state-backend cache keys now include the template-source hash for automatic deploy-time invalidation (#1362). Previously operators had to set
REDIS_KEY_PREFIX = f"djust:{BUILD_ID}:"(or otherwise rotate the prefix on every deploy) to ensure cachedRustLiveViewstate from a prior deploy didn't act as a stale diff baseline for the new render. Easy to forget; production failure mode was patches failing on WS reconnect post-deploy → recovery HTML unavailable → forced page reload. The framework now reuses the 8-hex template-source hash fromparse_with_source(PR #1363, Foundation 1 of #1358) as part of the cache key:djust:state:<session>_liveview_<view_path>[_<query_hash>]_t<template_8hex>When ANY operator edits a template (whitespace, attribute, structural change), the per-template hash flips → cache key flips → next reconnect misses the cache → fresh state is constructed cleanly, no stale baseline. Zero operator config; no env var to set, no setting to flip. Backwards compat: existing cached entries with the old key shape become unreachable on the deploy that ships this — bounded by TTL (default 1 hour). Multi-template caveat: the cache key uses the PRIMARY template's hash; sub-template-only changes via
{% include %}/{% extends %}parents that don't alter the primary's source bytes won't invalidate by themselves (operators candjust clear --allfor immediate invalidation in that edge case). Both consumers of the template hash (parser-side<!--dj-if id="if-<prefix>-N"-->markers and the new cache-key slot) flow through the singledjust_templates::parser::template_hash_hexRust helper, so they cannot drift. 12 regression tests inpython/tests/test_template_hash_redis_cache.py(cache HIT/MISS behavior, multi-session isolation, cross-deploy reproducer, PyO3 boundary equality, multi-template caveat with real Django include resolution, plus 2 perf-regression tests verifying the cache HIT path no longer pays theget_template()cost). 3 new Rust unit tests incrates/djust_templates/src/parser.rs(hash consistency, distinguishability, marker-ID prefix equality). Existingtest_vdom_cache_key.pyupdated for the new key shape.Stage 12 (address-findings) refinements on the same PR:
- Cache HIT perf-regression fix. First implementation hoisted
self.get_template()to before the cache lookup so the per-template hash could be derived. That regressed the cache HIT path: pre-#1362 a WS reconnect with a warm cache never calledget_template(), post-#1362 every reconnect ate the Django template loader + inheritance resolution cost. Stage 12 introduces_get_cached_template_hash_slot()which memoizes the_t<8hex>slot on the view CLASS so the cost is paid ONCE per class lifetime; subsequent calls return the slot in O(1) without touchingget_template(). Cache HITs now match the pre-#1362 perf profile. - Multi-template caveat test rewritten. First version called
compute_template_hash(primary_src)twice on the same input and asserted equality — a tautology already covered bytest_compute_template_hash_stable_across_rebuilds. Stage 12 rewrites it to set up realparent.html+child.htmlfiles, rewritechild.htmlbetween two renders, verify the rendered output ACTUALLY differs (so the include is being re-resolved), then assert the primary's source bytes hash to the same_t<8hex>slot. The test would FAIL on a hypothetical Option B (composite-hash) implementation, which is the discipline-correct way to demonstrate Option A's caveat (Action #1200).
- Cache HIT perf-regression fix. First implementation hoisted
Deployment guide additions for production gaps surfaced from a downstream consumer (#1362). Added three subsections to
docs/website/guides/deployment.md:- Recovery HTML semantics: per-consumer one-shot, fresh-consumer- after-reconnect = no recovery state, multi-task amplification of the user-visible impact. Cross-references v0.9.4-1's keyed conditional VDOM diff (PR #1365 / #1358) as the architectural escape hatch.
- Quantified Daphne → Uvicorn benchmark: 6.4× rps / 8.3× p99 on health-check endpoints from a 1 vCPU / 2 GB Fargate task with the a representative downstream-consumer app. Per-app variance disclaimer included.
- Production checklist: 8-line copy-pasteable recipe linking to each relevant subsection of the guide; inserted as the first subsection of the existing Deployment Checklist.
Also updated the Redis state-backend coverage to note that the template-hash-keyed cache (PR #1367, Iter 1 of v0.9.4-2) makes the previous manual
REDIS_KEY_PREFIX = f"djust:{BUILD_ID}:"pattern obsolete. Pure docs PR — no code changes; the framework behavior is unchanged from Iter 1.Bundled
client.jsanddebug-panel.jsare now eslint-clean (#1351). The 393 pre-existing eslint warnings inclient.js(and 32 indebug-panel.js) have been resolved across the ~70 source modules inpython/djust/static/djust/src/andsrc/debug/. Breakdown of the fixes:- Auto-fixed: 222 (
prefer-const,no-var) viaeslint --fix python/djust/static/djust/src/. - Targeted disables: 116 in
client.jssources + 25 indebug-panel.jssources (security/detect-object-injection, all on internal data structures — typed for-loop indices, controlled object-literal lookups, DOM-controlled keys likefield.name. djust already validates againstUNSAFE_KEYSfor the real prototype-pollution attack surface). - Refactored: 16
no-unused-vars(mostly catch-error parameters_-prefixed; 2 functions inlined as dead code, 1 redundant parameter renamed). 1security/detect-non-literal-regexpfor server-controlled route patterns in18-navigation.js. - Cross-module guards: 4
prefer-constreverted toletfor cross-file reassigned globals (liveViewWS,clientVdomVersion,_eventRefCounter,_isBroadcastUpdate) that ESLint's per-file scope incorrectly suggests as const — auto-fix had broken the transport-switch + broadcast paths until reverted. - ESLint config: catch-error parameters now respect
caughtErrorsIgnorePattern: "^_"; concat-fragment source modules (00-namespace.js,21-guard-close.js,src/debug/*.js) are correctly identified as bundle inputs that don't parse standalone. The--max-warnings 0flag is now enforced on the eslint pre-commit hook (.pre-commit-config.yaml) — contributors no longer needSKIP=build-js,eslintto commit JS source changes. Unblocks the bundle rebuild deferred from PR #1357 (dj-transition fix #1348).
- Auto-fixed: 222 (