djust 1.1.0rc4

Pre-releaseReleased

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_diff reorders re-render only changed items, flag-gated default-OFF (#1967). Node::For in 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 on RustLiveView that survives across render_with_diff calls) 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.price resolve under loop var x → allowed; prefix/flag → non-cacheable; tuple-unpacking for k, v allows both k and v); 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 %}), and dj-key templates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable via LIVEVIEW_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. New TestOutputIdentity / TestCacheBehavior / TestLoopRenderCacheDefaults / TestOuterContextNonCacheable classes in python/djust/tests/test_loop_render_cache_1967.py (13 end-to-end via RustLiveView.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-end render_with_diff win 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% of render_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 → parsed Vec<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), the Node::For arm 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_diff then 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 assigns next_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 (0 for an initial parse_html, max(old_ids)+1 for a continuing parse_html_continue after 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), and last_vdom are 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 residual dj-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 crafted h= (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.attrs now serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plain HashMap serializes 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 BOTH render_with_diff and render_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 in TestParseCacheByteIdentity1970 / TestParseCountProbe1970 / TestParseCacheSentinelCollision1970 (python/djust/tests/test_loop_render_cache_1967.py), the parse_cache_1970 module in crates/djust_templates/tests/test_loop_render_cache_1967.rs, and crates/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 (via engine.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 via LIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases in test_auto_hot_reload.py (TestFilterBridgeWarm-class behaviors, gate-off via the opt-out test). No steady-state behavior change.

All releases · Atom feed