djust 0.9.4

StableReleased
Install
pip install djust==0.9.4

Added

  • {% if %} blocks now emit dj-if boundary 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 inner If(B) inside the outer's false_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. The cond= attribute is intentionally OMITTED for safety (condition strings could contain -- or > that would close the comment early; Iter 3's differ keys off the id alone). {% 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>-N assigned at parse time via parser::assign_if_marker_ids walking 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 one Node::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 accepts dj-if, dj-if<space-or-tab>..., and /dj-if; it rejects lookalikes like dj-iffy, dj-if-extra, dj-ifid="...". Client- side getNodeByPath path-fallback (12-vdom-patch.js) now mirrors this predicate via the new isDjIfComment helper, keeping client and server in lock-step. Public render_template / render_template_with_dirs strip ALL dj-if-family markers via strip_dj_if_markers helper — 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 / InsertSubtree patch types.
    • Iter 3 (Capability): Rust VDOM differ recognizes dj-if boundaries; 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 under cross_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::tests for prefix-deriving functions (parse_with_source shape / source-distinctness / source-stability / token-fallback).
    • 25 cases in python/tests/test_template_if_markers.py across TestElementBearingIfMarkers / TestPureTextSkip / TestPublicRenderTemplateStrips / TestIdStability / TestIdAssignment / TestAttributeContext / TestSiblingStability / TestIdPrefixUniqueness / TestCsrfTokenElementBearing.
    • 20 cases in tests/js/dj_if_comment_predicate.test.js (predicate matrix + path-fallback integration).
  • Client VDOM patch dispatcher learns RemoveSubtree + InsertSubtree patch 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 recognizes dj-if boundaries (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 via TreeWalker-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 at parent[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 from applySinglePatch via a short-circuit ahead of the path/d resolution so subtree patches don't try to resolve a non-applicable path. 25 regression cases in tests/js/dj_if_subtree_patches.test.js covering extractDjIfMarkerId (5) + marker-pair finder + nesting (5) + RemoveSubtree positive / empty-inner / nested-outer-removes-inner / nested-inner-leaves-outer / id-not-found / root-pair / missing-id (7) + InsertSubtree parses-and-inserts-at-index / appends-on-out-of-range / inert-script-via-template / missing-html / unresolvable-parent (5) + applySinglePatch dispatch wiring (3).

    What this enables (NOT in this PR): Iter 3 (Capability): Rust VDOM differ recognizes dj-if boundaries 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 notRemoveSubtree { id: X }. Client locates the marker pair by id (NOT by position) and removes the bracketed range.
    • NEW has boundary id=Y, OLD does notInsertSubtree { id: Y, path, d, index, html }. Client parses the full marker-pair HTML (Shape A) via inert <template>.innerHTML and 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 via diff_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 + InsertChild patches 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_inner recursively runs on the body slice. Each recursion level handles only its OWN top-level pairs (via the new find_top_level_dj_if_pairs helper), 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-none workaround 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 for InsertSubtree.html), build_excluded_mask.
    • New dj_if_pre_pass runs at diff_children entry; delegates to dj_if_pre_pass_inner which 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), None otherwise (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-499 and JS-side at python/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-key attributes 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 to diff_keyed_children when 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-none workaround documentation in CLAUDE.md / downstream repos.

    Regression suite: 19 cases in crates/djust_vdom/tests/test_dj_if_keyed_diff_1358.rs covering: 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 via serde_json::Value for RemoveSubtree / InsertSubtree / d omission when None (tightened from substring contains per Stage 11 finding), backward compat with legacy bare <!--dj-if--> placeholder, end-to-end via parse_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.

Fixed

  • _sortPatches now orders RemoveSubtree / InsertSubtree BEFORE path-based child ops — the actual root cause of #1370._sortPatches assigned 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-based RemoveChild/InsertChild indices reflect the NEW tree's positions (after subtree ops applied). Running RemoveChild against the still-old DOM targeted the wrong child → silent DOM corruption that accumulated across tab switches until client and server state fully desynced. Fix: assign RemoveSubtree phase -2 and InsertSubtree phase -1, so both sort ahead of RemoveChild (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 / InsertSubtree are 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 a RemoveSubtree for a marker already removed in a prior patch (or an InsertSubtree for 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. RemoveSubtree with a missing marker is a no-op (returns true); InsertSubtree with an already-present marker is a no-op (doesn't duplicate content). Symmetric. Matches the semantics of RemoveChild on 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) with liveview_html. Single dj-root level, correct marker IDs from self._rust_view, no path index drift.

  • Handler metadata <script> no longer injected inside dj-root (#1370 final fix)._inject_handler_metadata was appending a <script> element inside the dj-root content 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_template now renders dj-root content via self._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's dj-root innerHTML is replaced with self._rust_view.render(). No marker stripping, no first-WS overhead, no mismatch possible. Removes the architectural debt of two RustLiveView instances rendering the same view.

  • Marker ID mismatch between HTTP render and WS diff resolved (#1370 re-open).render_full_template created a temporary RustLiveView(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 WS render_with_diff, the differ sees "NEW has markers, OLD doesn't" → emits InsertSubtree with the correct (VDOM-tracked) IDs. The non-inheritance path (self.render()) was already correct (same RustLiveView instance as WS path). This only affected projects using {% extends %} template inheritance.

  • RemoveSubtree / InsertSubtree patches no longer crash groupPatchesByParent (#1370 follow-up). v0.9.4rc2 fixed the hooks TDZ but exposed a second crash: TypeError: Cannot read properties of undefined (reading 'slice') at groupPatchesByParent in 12-vdom-patch.js. The Iter 3 patches (RemoveSubtree, InsertSubtree) don't carry a path field — they locate their target by marker id. groupPatchesByParent assumed all patches have path. Fix: filter out id-based patches and apply them directly via applySinglePatch BEFORE 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.js that threw Uncaught ReferenceError: Cannot access 'G' before initialization (G is 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 _activeHooks was in TDZ when _ensureHooksInit was invoked from earlier modules' djustInit (the synchronous-init branch fires when document.readyState !== 'loading'). Fix: letvar for _activeHooks and _hookIdCounter in src/19-hooks.js:54-56 (hoisted, no TDZ). Bundle rebuilt; new regression test in tests/js/bundle-init-no-tdz.test.js (2 cases) loads the bundled client.js in a fresh JSDOM context with readyState === 'complete' and asserts no ReferenceError on 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; _activeHooks is the inverse (DECLARED-LATE-USED-EARLY in the concat). The new bundle-init regression test catches the class structurally.

  • dj-transition now respects CSS transition-duration instead of a hard-coded 600ms fallback (#1348). The fallback timeout is auto-derived from the element's computed transition-duration + transition-delay (longest pair across all transitioning properties) plus a 50ms grace window. For multi-property transitions, expected transitionend events are counted from transition-property and 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 to dj-remove. Source-only commit; bundle (client.js) rebuild deferred per #1351 (392 pre-existing eslint warnings block --max-warnings 0).

  • db.notifications exits cleanly on permanent failures instead of retrying forever. Background — incident 2026-05-05: a 3.5-day-old djust deploy missing the optional psycopg[binary]>=3.2 dependency 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() raises DatabaseNotificationNotSupported (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 in python/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 cached RustLiveView (the in-memory backend returned the same Python reference on cache hits), concurrent &mut self Rust methods on the shared view would collide inside Rust's RefCell::borrow_mut and surface as RuntimeError: Already borrowed (a downstream consumer observed 17.5% 500-rate at concurrency 2). The race spanned more than the _sync_state_to_rust mutation calls — render() itself holds &mut self across template evaluation, and Context::resolve_dotted_via_getattr (crates/djust_core/src/context.rs) wraps Python::with_gil so the embedded getattr can yield the GIL inside an active mutable borrow. Any peer thread entering an &mut self method during that window panicked. Fixed by switching InMemoryStateBackend.get() to return an isolated serialize_msgpack / deserialize_msgpack clone of the cached view (option 2 of three suggested in the issue body), mirroring the RedisStateBackend contract — which already deserialized fresh on every read. With each caller holding its own RustLiveView instance, no two threads can share a Rust &mut self borrow and the race class is eliminated at the source. No Python-side lock is needed. New regression cases in TestInMemoryGetReturnsIsolatedView (4 cases — clone identity, state preservation, mutation isolation, concurrent get) and TestConcurrentRenderNoBorrowError (2 cases — concurrent render with GIL-yielding sidecar, concurrent update_state) in python/tests/test_rust_bridge_concurrent.py.

  • State backend honours top-level DJUST_STATE_BACKEND / DJUST_REDIS_URL settings (#1354). Previously BackendRegistry only consulted DJUST_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 of DJUST_CONFIG (DJUST_CONFIG still wins when both are set — backwards-compatible). URL-shaped values (redis://, rediss://, redis+sentinel://) are auto-translated to backend_type="redis" plus REDIS_URL=<url>; the prefix list lives in BackendRegistry._REDIS_URL_PREFIXES. When DEBUG=False and the resolved backend is the default (in-memory), djust.utils.BackendRegistry.get now emits a logger.warning flagging the production misconfig — multi-process deployments lose state across replicas. unix:// URLs are left as a TODO follow-up because the underlying redis-py client takes Unix sockets via a different parameter name. New regression cases in TestTopLevelStateBackendSetting (6 cases) and TestDjustConfigRegression (3 cases) in python/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 cached RustLiveView state 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 from parse_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 can djust clear --all for 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 single djust_templates::parser::template_hash_hex Rust helper, so they cannot drift. 12 regression tests in python/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 the get_template() cost). 3 new Rust unit tests in crates/djust_templates/src/parser.rs (hash consistency, distinguishability, marker-ID prefix equality). Existing test_vdom_cache_key.py updated 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 called get_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 touching get_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 by test_compute_template_hash_stable_across_rebuilds. Stage 12 rewrites it to set up real parent.html + child.html files, rewrite child.html between 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).
  • 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.js and debug-panel.js are now eslint-clean (#1351). The 393 pre-existing eslint warnings in client.js (and 32 in debug-panel.js) have been resolved across the ~70 source modules in python/djust/static/djust/src/ and src/debug/. Breakdown of the fixes:

    • Auto-fixed: 222 (prefer-const, no-var) via eslint --fix python/djust/static/djust/src/.
    • Targeted disables: 116 in client.js sources + 25 in debug-panel.js sources (security/detect-object-injection, all on internal data structures — typed for-loop indices, controlled object-literal lookups, DOM-controlled keys like field.name. djust already validates against UNSAFE_KEYS for 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). 1 security/detect-non-literal-regexp for server-controlled route patterns in 18-navigation.js.
    • Cross-module guards: 4 prefer-const reverted to let for 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 0 flag is now enforced on the eslint pre-commit hook (.pre-commit-config.yaml) — contributors no longer need SKIP=build-js,eslint to commit JS source changes. Unblocks the bundle rebuild deferred from PR #1357 (dj-transition fix #1348).

All releases · Atom feed