This is a pre-release. djust 1.1.0 has shipped since: read the djust 1.1.0 release notes.
Performance
Keyed per-item loop render cache — large-list
render_with_diffreorders re-render only changed items, flag-gated default-OFF (#1967).Node::Forin the Rust template engine previously re-rendered every loop item from the AST on every render, so a pure reorder of a 50/500-item keyed list rebuilt all N item subtrees from scratch (~9 µs/item) even though their rendered bytes are byte-identical (only positions changed). A new persistent content-hash → rendered-fragment cache (crates/djust_templates/src/loop_cache.rs, a field onRustLiveViewthat survives acrossrender_with_diffcalls) reuses each unchanged item's fragment, turning the loop-RENDER phase from O(n) toward O(changed): a pure reorder is all cache HITS (0 re-renders), a content-change of K items costs K misses, an append costs 1. Correctness is paramount and proven: the cache is restricted to loop bodies whose rendered output is fully determined by the loop item(s), enforced by TWO gates. (1) Position-dependent bodies are non-cacheable — any{% if %}(dj-if marker carries the loop index, #1832),{% cycle %}, nested{% for %},{{ forloop.* }}reference, or opaque Python/component tag (a content-hash cache there would emit stale positions). (2) Bodies that read ANY outer-context variable are non-cacheable (#1967 review) — the content hash covers only the loop item(s), but a body can also read outer context ({{ prefix }},{% with label=flag %},{% firstof flag x.name %},settings.X); outer context is constant within a render but NOT across renders, and the cache is persistent across renders, so a reorder after an outer-var change would serve stale fragments. A body is therefore cacheable ONLY if every top-level variable it reads is one of the loop's bound name(s) (x.name/x.priceresolve under loop varx→ allowed;prefix/flag→ non-cacheable; tuple-unpackingfor k, vallows bothkandv); the dep-subset test reuses the engine's existing partial-render dependency extractor (parser::body_root_var_names). Both gates are detected once per For-node and memoized. This narrows the cacheable surface to item-only bodies — the common data-list case ({{ item.field }}only) — while non-cacheable bodies fall back to normal per-item render (correct, no win). The cached fragment is the template-render output BEFORE dj-id assignment (dj-ids are assigned downstream in the html5ever parse phase), so the keyed VDOM diff (#1678/#1682) is unaffected — output is byte-identical with the cache on vs off, verified across initial render / reorder / content-change / append / remove on plain,forloop.counter,dj-if,{% cycle %}, nested, tuple-unpacking, outer-context ({{ prefix }}/{% with %}/{% firstof %}), anddj-keytemplates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable viaLIVEVIEW_CONFIG['loop_render_cache_enabled'] = True. When off, the For-node path is byte-identical to before. Render-phase reorder bench (crates/djust_templates/benches/loop_render_cache.rs, criterion, item-only body): N=50 ~83 µs → ~50 µs (~1.7×), N=500 ~819 µs → ~515 µs (~1.6×) — the win survives for cacheable bodies. NewTestOutputIdentity/TestCacheBehavior/TestLoopRenderCacheDefaults/TestOuterContextNonCacheableclasses inpython/djust/tests/test_loop_render_cache_1967.py(13 end-to-end viaRustLiveView.render_with_diff) +crates/djust_templates/tests/test_loop_render_cache_1967.rs(17 Rust correctness cases incl. three gate-offs (#1468): the position guard, the cross-render persistence, and the outer-context dep-subset gate are each proven load-bearing). NOTE: the end-to-endrender_with_diffwin is bounded by the (uncached) html5ever-parse + VDOM-diff phases (Amdahl); this lever optimizes the render half cited as the dominant cost in #1967.Parsed VNode subtree cache — reorders of unchanged loop items skip html5ever-PARSE too, not just render, flag-gated default-OFF (#1970). Extends the #1967/#1969 per-item RENDER cache to ALSO cache the PARSED VNode subtree per item, keyed by the SAME content-hash, under the SAME
LIVEVIEW_CONFIG['loop_render_cache_enabled']flag + the SAME two cacheability gates. The render cache cut the loop-render phase but the html5ever-parse + VDOM-build phases are ~60% ofrender_with_diff(#1969's render-only end-to-end win was Amdahl-bounded to ~6-11%); this reaches that bigger half. Mechanism:LoopRenderCache(crates/djust_templates/src/loop_cache.rs) gains a second map (content-hash u64 → parsedVec<VNode>) + a per-render item manifest. For a parse-cache HIT on a foster-parenting-SAFE item (the item's rendered root tag is NOT a table/select-family element —tr/td/th/tbody/thead/tfoot/caption/colgroup/col/option/optgroup), theNode::Forarm emits a tiny<dj-pc-<nonce> h=...>placeholder (a per-render random nonce in the tag name) instead of the item's HTML, so the assembled string html5ever parses is a SHORT reduced form;render_with_diff/render_binary_diffthen splice the cached parsed subtrees back into the placeholders (djust_vdom::splice_loop_placeholders) and re-assign every dj-id by a pre-order re-walk. The dj-id hazard + strategy: dj-ids are purely positional (the parser assignsnext_djust_id()pre-order), so a cached subtree's baked ids are position-WRONG when reused elsewhere — naive verbatim reuse duplicates ids ([0,1,2,3,4,1,2]for a 2-of-3 identical-content list). The fix re-walks the ASSEMBLED tree from the same id-counter base the full parse would use (0for an initialparse_html,max(old_ids)+1for a continuingparse_html_continueafter the #1550/#1552 bump), reproducing a fresh full-parse's ids byte-for-byte — so the assembled VDOM, every patch (Insert/Replace embed the new node), andlast_vdomare identical to the cache-OFF path. The foster-safe gate keeps<dj-pc>out of table/select containers (where html5ever foster-parents it out, destroying structure); foster-unsafe containers, multi-root items, and any splice anomaly (placeholder cache miss / found-count mismatch / a residualdj-pc-*sentinel) fall back to a full parse — always correct, no parse win for that render. Security (sentinel forgery, the adversarial-review 🔴): the placeholder sentinel tag carries a per-render random nonce (dj-pc-<nonce>) so a loop item that renders a literal unescaped<dj-pc ...>element via|safe/mark_safe— alongside a sibling that emitted a real placeholder — can neither be mistaken for a placeholder (which would strip it + corrupt the reconstructed HTML) nor splice a different cached item's subtree into its position via a craftedh=(content-confusion); reconstruction + splice match ONLY the current render's nonce tag, and parse-cache eligibility additionally refuses any item whose rendered HTML contains the literal sentinel prefix (belt-and-braces). Without the nonce, the bug stripped the user's<dj-pc>(cache-ON) while cache-OFF preserved it — a byte-identity violation for raw-HTML loops.VNode.attrsnow serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plainHashMapserializes in nondeterministic bucket order, which the parse-cache path (assembling a node via a different parse than the cache-OFF full parse) would otherwise surface as an ON-vs-OFF patch-JSON diff. Default OFF (rides the #1967 flag, split-foundation #1122); when off, byte-identical to before. Per-phase reorder bench (median over 60 distinct shuffles,render_with_diff): N=50 parse 0.145→0.113 ms (-21.9%) / total 0.430→0.339 ms (-21.3%); N=500 parse 1.394→1.159 ms (-16.8%) / total 4.037→3.399 ms (-15.8%) — beating #1969's render-only win by also cutting the parse phase. Correctness proven: byte-identity (html + patches + version) cache ON == OFF across plain/keyed/dj-if/cycle/nested/tuple/div/table/select/multi-root templates × initial/reorder/change/append/remove for BOTHrender_with_diffandrender_binary_diff(the dj-key reorder round-trip — post-diff dj-ids/dj-keys match cache-off exactly — is the load-bearing case); a parse-count probe (loop_parse_cache_hits()/loop_parse_cache_misses()) asserts a reorder of N unchanged keyed items is N parse hits / 0 re-parses and an append re-parses only the new item; gate-off (#1468) confirms neutering the dj-id re-walk fails 6 byte-identity cases AND neutering the nonce (bare-prefix sentinel) fails the 3 sentinel-collision security cases. New cases inTestParseCacheByteIdentity1970/TestParseCountProbe1970/TestParseCacheSentinelCollision1970(python/djust/tests/test_loop_render_cache_1967.py), theparse_cache_1970module incrates/djust_templates/tests/test_loop_render_cache_1967.rs, andcrates/djust_vdom/tests/test_loop_parse_cache_1970.rs(literal_unnonced_dj_pc_is_not_spliced+ the bare-prefix gate-off).
Changed
- Perf (cold-start): warm the Django→Rust custom-filter bridge at startup instead of on the first mount. Request-path profiling showed the first mount after server boot paid a one-time ~20 ms cost:
rust_bridge._ensure_custom_filters_bridged()lazily triggers Django to import every templatetag library (viaengine.template_libraries) on first access. It's memoized after, so steady-state is unaffected — but the first request ate the latency.DjustConfig.ready()now eagerly runs the bridge (new_warm_filter_bridge()helper) so that one-time cost lands at startup, not in the first user's request. Idempotent + non-fatal; skipped under pytest (mirrors the hot-reload gate); opt out viaLIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases intest_auto_hot_reload.py(TestFilterBridgeWarm-class behaviors, gate-off via the opt-out test). No steady-state behavior change.