This is a pre-release. djust 1.2.0 has shipped since.
Part of djust 1.2 — read the 1.2 release notes.
Before you upgrade, read Removed below.
Added
A Django-vs-djust template stress benchmark, and two findings it produced (#2731, #2732).
benchmarks/stress_templates.pyrenders 16 shapes — variable resolution, attribute walks, filter chains, escaping, branch density, nesting, tag dispatch,{% include %},{% extends %}— through Django's engine, djust'srender_template(src, dict), andRustLiveView.set_state(...)+render()(the LiveView path), at two sizes, plus a separate parse/compile measurement.It enforces three things the existing
benchmark.pydoes not: byte-identical output is a precondition, not a result (every shape is rendered through each engine and compared before it is timed; a mismatch is reported and excluded, because timing engines that emit different bytes compares different work), the median rather than the mean, and a refusal to run on a debug.so— which is unoptimized and reverses the comparison.The parity gate immediately found #2731:
{% regroup %}over plain objects returns a single group withgrouper=Noneon the LiveView path while Django and djust's own stateless path both group correctly. It fails silently, and model instances are objects, so{% regroup %}over a queryset in a LiveView renders one empty group today.The floor shape — static markup, which has nothing to accelerate and should sit at parity — measured 0.13x, which is not a rendering result. Tracing that found #2732: render cost scales with total state rather than with what the template reads. A template reading only a fixed
matrixgoes 8.75 ms → 73.82 ms as unrelated rows are added to state, while Django stays flat; on an empty context djust renders ~5.7x faster than Django, which is the engine's actual speed. The harness now carries that measurement as its own section, so the number is visible rather than silently distributed across every other row.A pytest run that loses collected items now exits red instead of quietly reporting fewer tests (#2746).
tests/lost_items_guard.py, registered for all three test roots by the new rootconftest.py, runs two checks on the process whose exit code the caller sees (the xdist controller under-n, the one process under a serial run). Every selected item — post-deselection, so-kand pytest-split's--groupare honoured — must have produced apytest_runtest_logreportin some phase; a shortfall prints the delta and the missing node ids and forcesTESTS_FAILED. UnderDJUST_COLLECTED_FLOOR=1(set on CI's python-tests shards andmain-health, never locally) the pre-deselection collected count must also reach the floor in the committed.test_collected_floor, whichmake test-collected-floorregenerates from a real collection — the guard writes what it counted, never a hand-typed number. Under xdist the controller never collects and workers forward only failed collect reports, so the count ridesconfig.workeroutput. New cases intests/test_lost_items_guard.pydrive each check throughpytester, including a real-subprocess and an-n 2proof that the forced status is the process exit code.ModalComponentaccepts afooter(HTML) the same way astitleandbody: a mount kwarg, afootercontext key and aset_footer()setter. Empty keeps each framework branch's default "Close" button (#2748)TableComponent: sortable columns announce themselves (#2778). Every sortable<th>now carriesaria-sort(none/ascending/descending) and a visual mark on all three branches, in each branch's existing icon convention: Bootstrap Icons classes onbootstrap5(bi-arrow-down-upunsorted,bi-caret-up-fill/bi-caret-down-fillactive — the conventionBreadcrumbComponent/IconComponentalready emit; load the Bootstrap Icons stylesheet), the vendored heroicons SVG ontailwind(arrows-up-down, newly added todjust.components.icons,arrow-up/arrow-down), and⇅/▲/▼onplain. The mark isaria-hidden;aria-sortis the accessible state.TableComponentcan filter (#2782).filterable=Truerenders a global filter input above the table;{"key": ..., "filterable": True}on a column renders a filter input under that header. The global query keeps a row when any column's string value contains it, case-insensitively; a column filter narrows on that column alone; the filters compose as an AND and compose with the sort (rows are filtered, then sorted). The rows you passed are never narrowed, so clearing an input restores them; an empty column value removes that filter. The conventions mirror{% data_table %}/DataTableMixin(icontains, pop-on-empty, andtoggle_allnow selects the visible rows, ason_table_select("__all__")selects the post-filter set). The handlers arefilter_rows(value)andfilter_column(value, column),@event_handler()-decorated; every input carriesdj-input,dj-debounce="300",data-component-idand anaria-label, on all three framework branches. State isfilter_query/column_filters, both inget_context().
Changed
The Django
template_testsscoreboard ratchet is now per cell, not only aggregate (#2722).scripts/run-django-template-suite.py compareratcheted two percentages, so a change that fixed five cells and broke five others kept both numbers and passed the blocking CI job green. The baseline (scripts/django-template-suite-baseline.json) now recordsnot_ok— every id whose status is not OK, keyed to that status (29 of 1470 ids; the OK set would be ~1441), derived from a real run by--write-baselineand never hand-typed.comparereports every id that was OK in the baseline and is FAIL/ERROR now, with its message, and exits 1 on a non-empty set independently of the percentage ratchet, which is kept. A baseline not-OK id absent from the run (removed or renamed upstream, or a--labelsubset) is listed as informational, not a regression, mirroring the tag-mismatch arm; a baseline written before the set existed warns and ratchets the percentages alone.The differential's corpus sweep and manifest run once per DISTINCT input instead of once per reader (#2723).
test_differential_reachability_manifest_2345.pywas 36% of the suite's recorded time: five cases each re-ran the full ~415,000-cell sweep on the unmutated script and differed only in what they read from the result, the module-scopedmanifestfixture was recomputed by every xdist worker that drew one of its cases, andtest_refusal_collapsed_agreement_2454.pyran the identical sweep once more. ACorpusCache(python/tests/differential_corpus_2723.py, session fixturecorpusinpython/tests/conftest.py) keyed on the script's text, the_rustbuild digest and the argv now shares each artifact across both modules and across xdist workers via the session's basetemp; a mutated copy is a different text and keeps its own run, and two canaries applying the identical mutation share one. No assertion changed; serial wall clock for the 2345 file 371 s -> 103 s (median of 3).A render is no longer charged for the whole view state's VALUES once per
{% for %}loop entry (#2732).Context::clonedeep-copied every value in every scope frame, and the{% for %}arm takes one such clone per loop execution — to serve two small reads of the parent. So a render cost time proportional to the entire state multiplied by the number of loop entries. The issue reports this as unused state being charged to every render; the mechanism never looks at whether a name is read, so it bit state the template does read just as hard.Scope frames now hold their values behind a copy-on-write
Arc: a clone shares the map, and a write through a shared frame copies first, so it is still a full clone and only the moment of the copy moves. No call site changed — the frame already had aDeref/DerefMutpair for the copy-on-write to sit behind. The copy path is live rather than vestigial:{% regroup %}and{% assign %}write into the enclosing context for their siblings, so they take it on every render that uses them, at a cost strictly below what the eager clone charged unconditionally.Measured on the issue's shape, a template reading only a fixed 250x8
matrix, against Django's flat 9.4 ms: 8.5 → 4.0 ms with no unused state at all, and 71.9 → 4.1 ms with 2 000 unused rows alongside it in state. Inbenchmarks/stress_templates.pyat 2 000 rows,10 nested loopsgoes 215.3 → 4.7 ms (0.04x → 1.96x Django) and the median across shapes goes 0.92x → 1.06x, with all 14 shapes byte-identical before and after. New cases incontext::testspin both halves independently: that a clone shares the map, and that a write through a shared frame cannot leak into the other holder.A frame's SAFETY metadata (
safe_keysand five sibling fields) is still deep-cloned per loop entry, so a view holding manySafeStrings still pays a linear — though ~100x smaller, and ~2.4x improved — per-entry cost. Tracked at #2735.A render is no longer charged for the whole view state's SAFETY METADATA once per
{% for %}loop entry (#2735). The follow-up to #2732: that fix made a scope frame's values copy-on-write, but the frame's six metadata fields —assignments,safe_keys,unsafe_keys,revoked_safe_subtrees,aliases,render_bindings— were still deep-cloned on everyContext::clone, and the{% for %}arm still takes one per loop entry.safe_keysscales with state: one dotted path is registered perSafeStringanywhere in it, so a view holding many marked strings still paid a per-entry cost linear in how many.The six fields now sit behind the same copy-on-write
Arcasvalues, each with exactly one mutable door (<field>_mut(), anArc::make_mut) — a clone shares all six, and a write through a shared frame copies only the field it writes. The sweep inrevoke_safe_subtree_attakes a door only when itsretainwould remove something, so a shared frame it would leave unchanged is never copied just to be scanned. A structural test derives every metadata mutation site from the source and asserts each is a door.Measured with the issue's own method — 2 000 cells emitted, 1 vs 251 loop entries, release build, identical state in both control rows differing only in whether the 2 000 strings are registered safe — the per-entry delta goes 0.0471 → 0.0049 ms with 2 000 safe keys (0.0051 → 0.0047 with none), and across 0 / 100 / 500 / 2 000 safe keys from 0.0049 / 0.0065 / 0.0140 / 0.0471 to 0.0050 / 0.0050 / 0.0050 / 0.0049 — flat. New cases in
context::testspin sharing, isolation (both directions, and only the written field copies), the loop-shaped parent snapshot, and the sweep guard, each independently reachable under gate-off.A render no longer copies the whole view state, so an unread context costs nothing (#2737).
RustLiveView.render()opened withContext::from_dict(self.state.clone())— a deep clone of every key and everyValue, then a rehashing rebuild of the result into a fresh map — on every render, at all three render entries, whether or not the template read any of it. Measured on a view holding 5,000 opaque objects that a<p>hello</p>template never mentions: 0.636 ms → 0.001 ms per render. On a realistic 200-row page (dicts of six scalars and two datetimes) whose template reads none of it: 0.383 ms → 0.001 ms. The stress harness's UNUSED STATE section, which holds the emitted cells fixed and varies only how much unread state is resident, goes from a +0.30 ms slope across 0 → 2,000 rows to flat (3.83 / 3.83 / 3.86 / 3.84 ms), against Django's 9.3 ms.This is the last per-render O(total state) charge on the LiveView path. #2733 removed the per-loop-entry one by making
ScopeFrame::valuescopy-on-write; this is the same fix one level up, and it is what that fix was waiting for — aContextcould share its frames with its own clones, but the view still handed it a fresh deep copy to start from.RustLiveViewBackend::stateis now held as adjust_core::SharedValues— the exactArc<AHashMap<String, Value>>a context's base frame wants — so a render isContext::from_shared(self.state.clone()), one atomic increment. Every mutating entry (set_state,update_state,retain_state_keys,clear_live_handles) writes throughArc::make_mut, which copies only while a render is actually holding the map; that is what preserves the isolation the deep copy used to provide, so a render still cannot write back into view state and{% regroup %}and the context-mutating custom tags bridged byregister_assign_tag_handlerstill take their copy. (The field type is also a hasher change,std::collections::HashMap→AHashMap: order-neutral, both are randomly seeded per process;deserialize_msgpacknow rehashes the map once per restore, on the path the memory state backend takes per cache hit. Measured on the same release builds, median of 25×20 calls: 5,000 opaque objects 4.651 → 4.650 ms and a 200-row page 1.222 → 1.213 ms — the decode dominates and the rehash is invisible; 5,000 scalar keys, where the values are nearly free, 0.248 → 0.337 ms, which is the rehash's actual size, ~18 ns per key.)from_dictroutes throughfrom_shared, so frame 0 has one construction (#1646).Nothing moves on the wire:
SerializableViewStatestill carries a plain map, converted at the two boundaries, and the conversion runs once per save rather than once per render. Conversion cost atset_stateis unchanged (12.1 ms for 5,000 objects, against 12.4 ms before — the remaining eager-conversion cost is #2740 and is not this).Copy-on-write is invisible by construction — a sharing render and a copying render produce byte-identical output and leave identical state — so no behavioural test can distinguish them, and the gate-off confirmed it: mutating the render entry back to
from_dictleft every behavioural test green. The property is therefore pinned as the pair it is:from_shared_adopts_the_callers_map_rather_than_rebuilding_it(behavioural,Arc::ptr_eq, incrates/djust_core/src/context.rs) plusTestTheRenderEntriesShareTheStateMap(structural,python/tests/test_state_shared_2737.py) — neither sufficient alone. The isolation half is behavioural and reached genuinely: swappingArc::make_mutforArc::get_mut(..).expect(..)inset_statepanics rather than passing, which is how the test is known to exercise the shared path and not a uniquely-owned one. All three mechanisms were gate-off verified red when removed alone. 14/14 stress shapes stay byte-identical.An aware
datetimeconverts in ~12.6 µs/object, down from ~21.5 — the nestedutcoffset/dst/tzinfovalues are built slim (#2770). #2740's measurement put ~11 µs of an aware datetime's conversion in three NESTED conversions insidedjango_json_encoded's name tables: thetimedeltafromutcoffset(), thetimedeltafromdst()(each a fullstr/repr/DjangoJSONEncoder/bool/isinstancesweep, ~3.7 µs apiece) and thetzinfoobject throughopaque_value(~3 µs) — of which every reader consumed one string and one bit. The twotimedeltas are now built from their three limbs in Rust (slim_timedelta_encoded) as the SAME eleven-slotEncoded, byte for byte on the wire, so{{ p.utcoffset }}still renders Django's9:00:00and{% if p.dst %}still answersbool(td)(the issue's "carry as seconds" would have broken both);tzinfois carried asstr(tz)— the one msgpack-shape change of the STATE payload, which the reader accepts in both the old nested and the new string form, so a state entry written by a pre-#2770 process restores under this one (pinned against a captured fixture). Client JSON never carried the map (verified, not assumed). Naive datetimes are unchanged (9.0 → 8.8 µs). One cell improves as a side effect: under the flag-off escape hatch atzinfowith a__dict__rendered as a dict for{{ p.tzinfo }}; it now renders Django'sstr(tz).
Fixed
A
live_redirect(or browser back/forward) is now the view-replacement boundary for@debounce/@throttle/@cachestate. #2721 reset that state on the page view's own mount frame, keyed on the view pathautoMountrecorded — but alive_redirectnever re-runsautoMount, so the new view's mount reply took the additive sibling branch: the old view's handler config, cache rules and optimistic rules kept governing the new view, and a timer armed before the navigation fired against it.LiveViewWebSocket.liveRedirectMount()now cancels every pending handler- and element-level timer the moment the navigation is sent (the socket is ordered, so a later send would reach the new view) and marks the new view as primary so its reply performs the full reset; bothlive_redirect_mountsenders route through it, pinned structurally. Lazily-hydrated siblings andmount_batchstay additive. (#2705)Test isolation: the Rust per-thread render environment is now cleared before each test (#2728).
apply_render_env()pushes the active locale's number format and timezone into Rustthread_local!cells; the #2234 reset restored Django's language but left the pushed format behind, so a test rendering throughdjust._rust.render_templatedirectly on an xdist worker that had just run atranslation.override("fr")/("de")render (test_static_now_django_parity.py,test_temporal_add_values.py,test_number_localization_2221.py) rendered{{ 12.3 }}as12,3.reset_djust_globals()now clears both number formats and the active timezone to the fresh-thread state; every framework render path re-pushes, so only the direct-_rusttests could ever observe the difference. Pinned bytest_reset_djust_globals_clears_the_rust_render_env_2728.A bridged Django tag now resolves an OBJECT operand instead of its
str()(#2731).{% regroup rows by group %}returned a single group withgrouper=Noneand an emptyliston the LiveView render path whenever the rows were plain Python objects — silently, with no exception and no warning. Any view that groups its own presenter/DTO/dataclass objects rendered one empty heading. (A queryset was never affected: aModelis normalized to a dict before it reaches this sink, so it grouped correctly throughout.){% url 'v' rows.0.pk %}raisedNoReverseMatchon the same input; the sweep found it at the same sink and the same change fixes it.A bridged Django tag does not resolve its operands through the renderer: it is handed a flat Python dict and Django's own
Variable._resolve_lookupwalks it.build_py_contextbuilt that dict withIntoPyObject for Value, which turns a non-temporalValue::Encoded— how an arbitrary Python object crosses the PyO3 boundary — intoe.display, itsstr()._resolve_lookupwas walking a string, so every dotted segment missed.{{ r.group }}over the same state resolved because the renderer answers throughContext::walk_live, the ADR-027 live handle, which never reaches this sink — the parallel-path shape of #1646.The fix converges the sink onto that handle.
value_into_handler_pyobjecthands a handler the live object, floor-protected through the sameprotect_sidecar_strictthe walk uses (extracted to a free function, so there is one floor with two callers rather than two floors). It does so only where the floor genuinely governs the object — when it is not iterable, so there is no "inside" for the floor to miss — because_protect_sidecar_valueis the leaf floor and this sink has no next segment to re-protect at. An iterable takes the wrapper instead, and its elements cross through theValueconversion, which descends: a model at any depth is the denylist-filtered dict, on both sides ofOPAQUE_ITEM_CAP. Everything else takes a newTemplateObject, which answers__getitem__/__getattr__throughcontext::lookup_segment— the renderer's own step — and its spelling, length, iteration and truthiness from the factsopaque_valuemeasured; that is strictly more than thestr()it replaces, so{% regroup tags by k %}over afrozensetnow matches Django too.Two behaviour changes worth naming. A carrier past
OPAQUE_ITEM_CAP(100 000 entries) — whose items the conversion declines to enumerate — used to render one wrong empty group; it now renders byte-identically to Django, because the wrapper falls back to iterating the live object and converts each element exactly as the enumerated branch does — so the floor does not change answer at the cap boundary. And a value reaching a handler is now an object rather than astr, so it is compared and iterated as one; it stays hashable, andcopy.deepcopy/pickledegrade it to exactly the string that used to arrive.{% ifchanged %},{% cycle %}with variable args,{% with %}and{% firstof %}were swept on both render paths with objects and with dicts and are clean — they are native Rust nodes and never touch this sink. They are kept as controls in the new three-engine parity matrix (python/djust/tests/test_tag_bridge_object_parity_2731.py), which renders every case through Django,render_template,RustLiveViewand a realLiveView, and is the cheap net that would have caught this: the existing{% regroup %}suite only ever grouped dicts.A plain
listof model instances now serializes its field values on the LiveView path, saved or not — the same as a QuerySet of the same rows (#2736). A list of UNSAVED instances (a preview, a bulk-create form holding[Model(...), ...]before saving) rendered identity-only:{{ rows.0.username }}empty,{% if rows.0.username == ... %}false,{% regroup rows by username %}giving[None],{% url 'v' rows.0.pk %}raisingNoReverseMatch— silently, with a QuerySet of the same rows working, which made it read as a saved-vs-unsaved distinction.It was not one. Tracing symptom-up found no branch keyed on
pkor_state.adding— the eager serializer already carries an unsaved row's concrete field values — and two upstream causes at the JIT serialization boundary, neither about being unsaved: (1) with no field path attributed to the variable (every whole-object use,{% regroup %}included) the QuerySet branch emitted the full dict while both list sites inmixins/context.pyserialized each element to the least-exposure identity map — right for a single{{ user }}, wrong for a row every sink then reads a field off. A saved list failed the same way; an unsaved row's identity ispk: None, which is what made{% url %}the visible casualty. (2){{ rows.0.username }}was attributed torowsas the attribute path0.username, so codegen serialized attribute0and shipped[{}]— for a list AND a QuerySet, saved or not.Both are fixed at the boundary (#1646), so every sink is fixed at once: one producer for the no-paths list case (
_jit_serialize_model_list, the QuerySet contract), and an all-digit path segment — a sequence index, never a field, since an identifier cannot start with a digit — is dropped at the one chokepoint every producer insideextract_template_variablesflows through. The serialization floor is unchanged: the list case now takes the samenormalize_django_valuethe QuerySet case always did, and an unsavedUser(password=...)in a list carriesusernameand notpassword. 11 regression cases (35 parametrized cells) inpython/djust/tests/test_unsaved_model_list_2736.py— every sink × unsaved list / saved list / QuerySet against Django, the floor for every carrier, and an in-place field mutation on an unsaved row re-rendering.A filter argument inside a TAG operand now reaches that node's dependency set, so a partial render no longer emits stale bytes (#2738).
{% if a|default:b %}kept rendering its old branch when onlybchanged;{% for x in items|slice:n %}kept its old slice whennchanged — and, worse, whenitemsitself changed;{% with v=y|default:z %}kept its old value. Silently, with no error: Django semantics render a missing name as the empty string, so there is no "missing dependency" signal to raise. The same|default:spelling in a{{ }}was correct throughout, which is what isolated the defect to tag operands.render_nodes_partialre-renders a node when its dependency set intersects the changed keys.{{ }}was sound because the parser splits a variable node intoNode::Variable(var_expr, filters, _), so extraction already reached each argument throughextract_from_filter_arg. Every TAG operand instead arrived as ONE raw string still carrying its filter chain:extract_from_variablesplits on.only, soitems|slice:nbecame a single root key spelleditems|slice:nand BOTH real names were lost; andextract_from_expression, which handles{% if %}, tokenized on a separator set containing|but not:, sodefault:bcame through as one bogus token whilebwas never recorded. One path split the operand correctly and its twin never learned to — the #1646 shape.The fix converges both onto the renderer's own splitter rather than adding
:to a separator set, which would have fixed the three cited spellings and left the class. A newextract_from_operandsplits withfilter_lexer::split_pipes— the quote-aware splitrenderer::get_value_safeuses to RESOLVE these same operands, so the analysis now splits an operand exactly the way the renderer resolves it — and routes the head and every filter argument through the existingextract_from_filter_arg, which is already the one statement of "is this token a name or a literal". Every operand site inextract_from_nodescalls it;{% if %}no longer tokenizes on|, since a pipe in a Django condition is always a filter (Django spells boolean or/and asor/and).What this covers, and what it does not. Operand FILTER CHAINS are retired. A compound EXPRESSION in an inline-if condition is a different shape —
{{ a if x > y else b }}still filesx > yas one key and loses both names — because that needsextract_from_expression, not the operand splitter. It is pre-existing rather than introduced here, and is tracked at #2745.One behaviour change at the unguarded sites, stated because the obvious corollary is wrong: the old callee was
extract_from_variable, which had no literal guard, so a site that passed a bare literal used to enter it as a context name.{% widthratio 100 200 50 %}extracted100/200/50and now extracts nothing. Those keys could never match a state key, so dropping them removes false positives rather than dependencies — but "a site whose operand cannot carry a chain is unchanged" would have been false.A second, independently reachable defect in the same class was found while fixing the first:
{% for %}also transfers the loop variable's dotted paths onto the iterable, and that transfer did its ownsplit('.')on the raw operand — so{% for r in rows|slice:':5' %}{{ r.name }}filednameunder a key spelledrows|slice:':5'that no state key can ever match, losing the body's paths and minting the bogus key a second time from a different line. It now strips the filter chain first.New cases in
TestTheFilterArgumentReachesTheDependencySetandTestThePartialRenderNoLongerEmitsStaleBytes(python/tests/test_tag_operand_deps_2738.py) cover the extraction half and the end-to-end half, with the{{ }}spelling kept as a control in both so the cases cannot pass vacuously. All three mechanisms — the{% if %}tokenizer, the operand call sites, and the loop-variable path transfer — were gate-off verified to turn the suite red when removed alone (5, 3 and 2 failures respectively).A
use_actors = Trueview ignored the configured render environment (#2741, ADR-029). The timezone (#2209), the number formats (#2221/#2266) and ADR-027'stemplate_resolve_lazyflag arethread_local!cells that Python pushes per render on the thread about to render; theViewActorrenders on a tokio worker that never pushed, so it read every cell at its compiled default —template_resolve_lazy: False,TIME_ZONEand the locale's separators were all silently ignored there (PR #2751's probe observed it). The environment is now carried the waytemplate_auto_callalready is: aRenderEnvvalue (djust_core::render_env) held as a field on the Rust backend, captured from Python right after the push (RustLiveView.capture_render_env(), besideset_template_auto_call) and by the actor mount on the mounting thread, and installed by every render entry —render,render_with_diff,render_binary_diff, hence theViewActor— under aRenderEnvGuardthat restores the previous cell values on drop, so a render leaves a pooled thread's cells as it found them.ComponentActor::renderapplies the parent'sauto_callflag and environment instead of a bareContext::from_dict, andrender_templategains arender_env=keyword besideauto_call(default: current behaviour). The #2751 probe now asserts the configured answer on its proven-distinct worker;python/djust/tests/test_render_env_per_view_2741.pycovers the Python half, including the actor mount over a real WebSocket.The
walk_from_handlerouting comment incrates/djust_core/src/context.rsclaimed the datetime family never carries a live handle;django_json_encodedattaches one unconditionally (opaque_value's is flag-gated). The corrected invariant is now pinned bypython/tests/test_datetime_live_handle_2741.py, which renders a live-only temporal attribute through the isolating binding against Django under both flag states, and VALUE_BOUNDARY.md row I11 cites it instead of "no test". No behaviour change. (#2741)An inline-if COMPOUND condition now reaches the node's dependency set, so a partial render after only one of its operands changes is no longer stale (#2745).
{{ a if x > y else b }}handed its condition to the tag-OPERAND extractor #2738 converged onto, which splits a filter chain and otherwise splits on.only — sox > y(orx and y) was filed as ONE bogus key and bothxandywere lost;render_nodes_partialthen skipped the node when onlyychanged and emitted the previous bytes, with no error. The condition is an expression (the renderer already evaluates it with the{% if %}machinery) and now goes throughextract_from_expression, the same helper{% if %}'s condition uses; the two arms stay on the operand helper. A sweep of every otherextract_from_operandsite inextract_from_nodesfound only genuine operands. Pinned bypython/tests/test_inline_if_compound_deps_2745.py(12 cases: extraction, a structural pin on the arm, and the issue's end-to-end stale-render table).Four i18n-bridge tests went red whenever xdist reshuffled the corpus — the second failing set in #2747, not the Rust tag registry (#2747). A
DjustTemplateBackendregisters the tags its{% load %}bridges into its own registry namespace (#2709) and restores namespace 0 when the render returns; the four tests rendered through the module's backend and then read_rust.has_*_tag_handler/owned_tags()at namespace 0, which holdsblocktranslateonly after a LiveView-entry sibling has rendered on the same worker. They now read insiderendering_with_backend(DJUST)and pass alone. Pinned by the parametrisedtest_i18n_registry_reader_passes_alone_in_a_fresh_process_2747intests/test_reset_fixture_hygiene_2234.py, which runs each in a process of its own. The first set was #2749 (PR #2753).ModalComponent's close controls (the cross, the footer "Close" button and, on Tailwind, the backdrop) emitdj-click="dismiss"on every framework branch, but the component never defined that handler — clicking any of them raisedNo handler found for event: dismiss. The component now defines an@event_handlerdismiss()that delegates tohide(), mirroringAlertComponent.dismiss(#2748)reset_djust_globals()now strips an instance-levelrendershadow from every built-in tag handler, and the tworegroupspies that planted one (a bound method "restored" as an instance attribute) restore withdel— fivetest_tag_bridge_object_parity_2731cases were red on serialmainbecause a later class-level patch was never reached (#2749).AlertComponentandBadgeComponentclose buttons now work under the default strictevent_security: bothdismisshandlers are@event_handler-decorated, and the badge's close button carriesdata-component-idon every framework branch so the click reaches the badge instead of the parent view. A package-wide structural test now walks everydjust.components.uicomponent and framework branch and asserts each self-targetingdj-clickis a decorated, routed handler (#2756, follows #2748)TableComponent: clicking a sortable column no longer raisesNo handler found for event: sort_by(#2776; link N+2 of #2748 → #2756). The header rendereddj-click="sort_by" data-column="k"with nodata-component-id, so the click was dispatched to the parent view (which has nosort_by);sort_bywas also undecorated (rejected under the default strictevent_securityeven when routed) and tookcolumn_keywhere the control sendscolumn. The same three defects were live onPaginationComponent(first_page/previous_page/go_to_page/next_page/last_page) andTabsComponent(activate_tab, nowtab=; a parent-suppliedactionstill routes to the parent), and the decoration/routing halves onManyToManySelect/ForeignKeySelect(toggle(...),select(value),search(value),clear,select_all).TableComponentlives indjust.components.data, outside thedjust.components.uiwalk #2764 pinned — the package-wide pin now derives every HTML-renderingLiveComponentunderdjust.components, covers call-form anddj-change/dj-inputtargets, and validates each control's params against its handler through the realvalidate_handler_params(48 rows red on the pre-fix code). Reproducers intest_table_sort_handler_2776.pybuild the event from the rendered control the way the client does, over a realWebsocketCommunicator.TableComponent(selectable=True): the checkboxes now select rows (#2779). They rendered as bare<input type="checkbox">cells — nodj-change, nodata-component-id, no row identity — and the component had no handler for them at all, soselected_rowscould never change. Every checkbox on all three branches (bootstrap5/tailwind/plain) is now adj-changerouted to the table: the row checkbox toggles that row (toggle_row, sendingdata-row-id), the header checkbox selects every row or clears the selection when every row is already selected (toggle_all); both are@event_handler()-decorated and the re-render reflectschecked. A row is identified by itsrow_keyvalue (default"id"), stored as a string — the same convention as{% data_table %}/DataTableMixin. Second half of the symptom, fixed at the base class:LiveComponent.trigger_update()was a no-op (it looked for a parent_trigger_updatehook no view defines), and a component is an opaque leaf to change detection, so a handler that mutated a component in place left{{ table.render }}stale over the WebSocket; registration now records the parent attribute andtrigger_updatehands it toset_changed_keys. The package-wide control pin gains a checkbox sweep (test_every_rendered_checkbox_has_an_event_target, red on the pre-fix table) and its table fixture isselectable=Trueso the new controls are validated automatically. Reproducers intest_table_selection_2779.pybuild the event from the rendered checkbox over a realWebsocketCommunicator.The pre-push test selector never names a
tests/playwright/ortests/js/file (#2781).scripts/select-tests.pyclassified any changed file under those directories as a regular pytest test and selected it as an explicit path for pytest to run.pyproject.toml's--ignore=tests/playwright/--ignore=tests/jsaddoptsonly suppress pytest's own directory-walk collection — not a node id passed explicitly — so a new standalone Playwright script (a top-levelasync def test_...(), not a pytest case) was collected and run, failing the push.is_test_file()now excludes both directories.TableComponent(selectable=True): checking multiple rows, unchecking a row, and checking every row now update the header checkbox correctly (#2781). The row checkbox carried its identity asdata-row-id, but a checkbox'sdj-changeruns the client's form-event path (buildFormEventParams,09-event-binding.js:525), which sends onlyvalue,field,component_id/view_id, and the element'sdj-value-*— it never readsdata-*(onlydj-clickdoes, viaextractTypedParams). Every row click reached the server astoggle_row(row_id=""), soselected_rowsonly ever toggled the empty string: checking many rows silently collapsed to one entry,_all_selected()could never see every real row id, and unchecking a row that never registered could never clear the header. The server-side logic (toggle_row,toggle_all,_all_selected()) added in #2779 was already correct — the #2780 WebSocket tests stayed green because they modeled thedj-clickcontract (forwarding everydata-*), not the realdj-changeone. Fixed by moving the row identity todj-value-row-id(python/djust/components/data/table.py), the same conventiontemplatetags/_forms.pyalready uses for itsdj-change/dj-inputcontrols. No client code changed. A Playwright regression test (tests/playwright/test_table_select.py) now drives a real browser and asserts the DOMcheckedproperty together with the server'sselected_rowsfor all four features the issue reports;tests/js/table_select_dj_value_2781.test.jspins the client contract directly (post-fix markup deliversrow_id, the pre-fixdata-row-idshape does not, and the same attribute DOES reach adj-clickhandler — the contrast that explains why it looked plausible).A tautological test (
test_both_paths_share_one_registry, #2547) pinned a cross-backend registry-sharing claim PR #2716 deliberately removed, and only passed because an earlier test in the same file primed it; replaced with the narrower, still-true, self-contained claim (#2785).CodeQL
py/cyclic-importalert #2847 betweendjust.template.backendanddjust.template_libraries: the only import creating the cycle (anisinstancecheck) is replaced with a duck-typed marker attribute, removing the cycle rather than deferring it.Share exhaustive corpus work across CI readers: Keep full-sweep consumers in one duration-balanced shard, preserve every test and comparison, and verify exact shard coverage. Refresh balancing data from a successful CI run while retaining independent mutation checks and worker-safe session caching.
Reduce test harness overhead: Collect the Python suite once for shard validation, preserve configured exclusions, and reject partial collections. Keep one real doctor smoke run while testing verdict scenarios with controlled external tools and explicit failure/timeout checks.
Reduce test overhead: Build the native extension once per Python CI job, batch every exhaustive Unicode title-filter comparison, and include every Python test root in
make test. Full JavaScript tests and Clippy run at push time instead of repeating on both commit and push.
Documentation
- ADR-027 limit documented and pinned: a handle-only datetime name (
{{ q.resolution }},max,min) renders empty on a state-backend clone rendered without a re-sync (#2767). No behaviour change. The msgpack round tripInMemoryStateBackend.getperforms carries the attr map ({{ q.year }}survives) but not the live handle, and nothing re-acquires one on restore.python/tests/test_datetime_live_handle_2741.pynow asserts the current empty render for all four temporal types (gate-off: neutering the round trip turns it red), with a real second-WebSocket-mount control showing the #2570 mount sync re-attaches the handle on the shipped reconnect path.docs/architecture/VALUE_BOUNDARY.md§3.4 and row I11 record the limit with the restore path cited by file:line.
Removed
- The
template_resolve_lazykill-switch and the enumeration arms it selected (ADR-027 Step 5, #2628).LIVEVIEW_CONFIG["template_resolve_lazy"]— the escape hatch the 1.2.0 release candidates carried after movement 3 (#2539) flipped dotted-lookup resolution onto the live-handle sink — is deleted, together with every code arm it kept alive: the eager__dict__bulk dump (public_dict_attrs/has_public_dict_attrsand theValue::Objectarm inimpl FromPyObject for Value), the threeopaque_gatedeclines (one-shot iterator, over-cap unsized walk, attribute-bearing object), theRESOLVE_LAZYthread-local with itsset_resolve_lazy/resolve_lazy_enabledPyO3 accessors, theRenderEnv.resolve_lazyfield (ADR-029), and the Python readersconfig.template_resolve_lazy_enabled/template_resolve_lazy_default/render_env.apply_resolve_lazy. Setting the key is now a no-op; the shipped behaviour is the only behaviour (an ordinary object crosses asstr(o)with a live handle, a generator is consumed by{% for %}, a filtered missing operand reaches its filter asNone). The maintainer waived the one-release soak the ADR scheduled for 1.3.0 (2026-09-10; prerequisite #2621 closed via PR #2729). Not deleted, contrary to the ADR's Step 5 list, because measuring showed they answer Django-parity cells for values that carry no handle: the by-name sidecar's alias fallback ({% with q=user %}{{ q.groups.count }}on a model),build_render_sidecar/_protect_sidecar_tree(a list of models under{% for %}, and the tag-handler floor), and the LiveView sidecar's breadth (request/user/permsreach the engine only through it, #1786). One arm needed a replacement rather than a deletion: an object whose__str__/__bool__/__repr__/ iteration probe raises used to fall to the__dict__dump on the default too, so it now crosses as a handle-onlyEncoded(handle_only_encoded) —{{ c.mount }}on a template-lessLiveComponentstill refuses the mutator, a raising__getattr__still propagates from{{ o.0 }}, and a raising__str__is deferred to the{{ o }}sink where it propagates (#2429), as Django does;{{ p.year }}on adatetimewhose tzinfo raises inside__str__now renders where djust used to refuse. Surfaced and fixed on the way: the live walk substituted""for an args-required method where Django assignsengine.string_if_invalid(Django'sbasic-syntax20). Pinned bypython/djust/tests/test_adr027_step5_deletion_2628.py, which derives the scanned file set frompython/djust+crates/*/srcand refuses to report a clean scan until it has seen a synthetic hit. - The context-narrowing filter in
_sync_state_to_rust(with_get_template_depsand the_template_depsattr) is gone. It read the template source fromself._template_content, which nothing ever assigned, so it never fired once; it is deleted rather than wired up because read-set narrowing is unsound while bridged tag handlers receive the whole context (#2737, #2738). No behavioural change; structural pins keep the dead names from returning. (#2739)