djust 1.2.0rc4

Pre-releaseReleased
Install
pip install djust==1.2.0rc4

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.py renders 16 shapes — variable resolution, attribute walks, filter chains, escaping, branch density, nesting, tag dispatch, {% include %}, {% extends %} — through Django's engine, djust's render_template(src, dict), and RustLiveView.set_state(...) + render() (the LiveView path), at two sizes, plus a separate parse/compile measurement.

    It enforces three things the existing benchmark.py does 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 with grouper=None on 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 matrix goes 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 root conftest.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 -k and pytest-split's --group are honoured — must have produced a pytest_runtest_logreport in some phase; a shortfall prints the delta and the missing node ids and forces TESTS_FAILED. Under DJUST_COLLECTED_FLOOR=1 (set on CI's python-tests shards and main-health, never locally) the pre-deselection collected count must also reach the floor in the committed .test_collected_floor, which make test-collected-floor regenerates 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 rides config.workeroutput. New cases in tests/test_lost_items_guard.py drive each check through pytester, including a real-subprocess and an -n 2 proof that the forced status is the process exit code.

  • ModalComponent accepts a footer (HTML) the same way as title and body: a mount kwarg, a footer context key and a set_footer() setter. Empty keeps each framework branch's default "Close" button (#2748)

  • TableComponent: sortable columns announce themselves (#2778). Every sortable <th> now carries aria-sort (none / ascending / descending) and a visual mark on all three branches, in each branch's existing icon convention: Bootstrap Icons classes on bootstrap5 (bi-arrow-down-up unsorted, bi-caret-up-fill / bi-caret-down-fill active — the convention BreadcrumbComponent / IconComponent already emit; load the Bootstrap Icons stylesheet), the vendored heroicons SVG on tailwind (arrows-up-down, newly added to djust.components.icons, arrow-up / arrow-down), and / / on plain. The mark is aria-hidden; aria-sort is the accessible state.

  • TableComponent can filter (#2782). filterable=True renders 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, and toggle_all now selects the visible rows, as on_table_select("__all__") selects the post-filter set). The handlers are filter_rows(value) and filter_column(value, column), @event_handler()-decorated; every input carries dj-input, dj-debounce="300", data-component-id and an aria-label, on all three framework branches. State is filter_query / column_filters, both in get_context().

Changed

  • The Django template_tests scoreboard ratchet is now per cell, not only aggregate (#2722). scripts/run-django-template-suite.py compare ratcheted 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 records not_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-baseline and never hand-typed. compare reports 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 --label subset) 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.py was 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-scoped manifest fixture was recomputed by every xdist worker that drew one of its cases, and test_refusal_collapsed_agreement_2454.py ran the identical sweep once more. A CorpusCache (python/tests/differential_corpus_2723.py, session fixture corpus in python/tests/conftest.py) keyed on the script's text, the _rust build 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::clone deep-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 a Deref/DerefMut pair 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. In benchmarks/stress_templates.py at 2 000 rows, 10 nested loops goes 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 in context::tests pin 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_keys and five sibling fields) is still deep-cloned per loop entry, so a view holding many SafeStrings 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 every Context::clone, and the {% for %} arm still takes one per loop entry. safe_keys scales with state: one dotted path is registered per SafeString anywhere 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 Arc as values, each with exactly one mutable door (<field>_mut(), an Arc::make_mut) — a clone shares all six, and a write through a shared frame copies only the field it writes. The sweep in revoke_safe_subtree_at takes a door only when its retain would 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::tests pin 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 with Context::from_dict(self.state.clone()) — a deep clone of every key and every Value, 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::values copy-on-write; this is the same fix one level up, and it is what that fix was waiting for — a Context could share its frames with its own clones, but the view still handed it a fresh deep copy to start from.

    RustLiveViewBackend::state is now held as a djust_core::SharedValues — the exact Arc<AHashMap<String, Value>> a context's base frame wants — so a render is Context::from_shared(self.state.clone()), one atomic increment. Every mutating entry (set_state, update_state, retain_state_keys, clear_live_handles) writes through Arc::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 by register_assign_tag_handler still take their copy. (The field type is also a hasher change, std::collections::HashMapAHashMap: order-neutral, both are randomly seeded per process; deserialize_msgpack now 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_dict routes through from_shared, so frame 0 has one construction (#1646).

    Nothing moves on the wire: SerializableViewState still carries a plain map, converted at the two boundaries, and the conversion runs once per save rather than once per render. Conversion cost at set_state is 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_dict left 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, in crates/djust_core/src/context.rs) plus TestTheRenderEntriesShareTheStateMap (structural, python/tests/test_state_shared_2737.py) — neither sufficient alone. The isolation half is behavioural and reached genuinely: swapping Arc::make_mut for Arc::get_mut(..).expect(..) in set_state panics 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 datetime converts in ~12.6 µs/object, down from ~21.5 — the nested utcoffset / dst / tzinfo values are built slim (#2770). #2740's measurement put ~11 µs of an aware datetime's conversion in three NESTED conversions inside django_json_encoded's name tables: the timedelta from utcoffset(), the timedelta from dst() (each a full str / repr / DjangoJSONEncoder / bool / isinstance sweep, ~3.7 µs apiece) and the tzinfo object through opaque_value (~3 µs) — of which every reader consumed one string and one bit. The two timedeltas are now built from their three limbs in Rust (slim_timedelta_encoded) as the SAME eleven-slot Encoded, byte for byte on the wire, so {{ p.utcoffset }} still renders Django's 9:00:00 and {% if p.dst %} still answers bool(td) (the issue's "carry as seconds" would have broken both); tzinfo is carried as str(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 a tzinfo with a __dict__ rendered as a dict for {{ p.tzinfo }}; it now renders Django's str(tz).

Fixed

  • A live_redirect (or browser back/forward) is now the view-replacement boundary for @debounce / @throttle / @cache state. #2721 reset that state on the page view's own mount frame, keyed on the view path autoMount recorded — but a live_redirect never re-runs autoMount, 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; both live_redirect_mount senders route through it, pinned structurally. Lazily-hydrated siblings and mount_batch stay 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 Rust thread_local! cells; the #2234 reset restored Django's language but left the pushed format behind, so a test rendering through djust._rust.render_template directly on an xdist worker that had just run a translation.override("fr")/("de") render (test_static_now_django_parity.py, test_temporal_add_values.py, test_number_localization_2221.py) rendered {{ 12.3 }} as 12,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-_rust tests could ever observe the difference. Pinned by test_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 with grouper=None and an empty list on 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: a Model is normalized to a dict before it reaches this sink, so it grouped correctly throughout.) {% url 'v' rows.0.pk %} raised NoReverseMatch on 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_lookup walks it. build_py_context built that dict with IntoPyObject for Value, which turns a non-temporal Value::Encoded — how an arbitrary Python object crosses the PyO3 boundary — into e.display, its str(). _resolve_lookup was walking a string, so every dotted segment missed. {{ r.group }} over the same state resolved because the renderer answers through Context::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_pyobject hands a handler the live object, floor-protected through the same protect_sidecar_strict the 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_value is 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 the Value conversion, which descends: a model at any depth is the denylist-filtered dict, on both sides of OPAQUE_ITEM_CAP. Everything else takes a new TemplateObject, which answers __getitem__ / __getattr__ through context::lookup_segment — the renderer's own step — and its spelling, length, iteration and truthiness from the facts opaque_value measured; that is strictly more than the str() it replaces, so {% regroup tags by k %} over a frozenset now 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 a str, so it is compared and iterated as one; it stays hashable, and copy.deepcopy/pickle degrade 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, RustLiveView and a real LiveView, and is the cheap net that would have caught this: the existing {% regroup %} suite only ever grouped dicts.

  • A plain list of 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 %} raising NoReverseMatch — 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 pk or _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 in mixins/context.py serialized 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 is pk: None, which is what made {% url %} the visible casualty. (2) {{ rows.0.username }} was attributed to rows as the attribute path 0.username, so codegen serialized attribute 0 and 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 inside extract_template_variables flows through. The serialization floor is unchanged: the list case now takes the same normalize_django_value the QuerySet case always did, and an unsaved User(password=...) in a list carries username and not password. 11 regression cases (35 parametrized cells) in python/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 only b changed; {% for x in items|slice:n %} kept its old slice when n changed — and, worse, when items itself 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_partial re-renders a node when its dependency set intersects the changed keys. {{ }} was sound because the parser splits a variable node into Node::Variable(var_expr, filters, _), so extraction already reached each argument through extract_from_filter_arg. Every TAG operand instead arrived as ONE raw string still carrying its filter chain: extract_from_variable splits on . only, so items|slice:n became a single root key spelled items|slice:n and BOTH real names were lost; and extract_from_expression, which handles {% if %}, tokenized on a separator set containing | but not :, so default:b came through as one bogus token while b was 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 new extract_from_operand splits with filter_lexer::split_pipes — the quote-aware split renderer::get_value_safe uses 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 existing extract_from_filter_arg, which is already the one statement of "is this token a name or a literal". Every operand site in extract_from_nodes calls it; {% if %} no longer tokenizes on |, since a pipe in a Django condition is always a filter (Django spells boolean or/and as or/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 files x > y as one key and loses both names — because that needs extract_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 %} extracted 100 / 200 / 50 and 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 own split('.') on the raw operand — so {% for r in rows|slice:':5' %}{{ r.name }} filed name under a key spelled rows|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 TestTheFilterArgumentReachesTheDependencySet and TestThePartialRenderNoLongerEmitsStaleBytes (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 = True view ignored the configured render environment (#2741, ADR-029). The timezone (#2209), the number formats (#2221/#2266) and ADR-027's template_resolve_lazy flag are thread_local! cells that Python pushes per render on the thread about to render; the ViewActor renders on a tokio worker that never pushed, so it read every cell at its compiled default — template_resolve_lazy: False, TIME_ZONE and the locale's separators were all silently ignored there (PR #2751's probe observed it). The environment is now carried the way template_auto_call already is: a RenderEnv value (djust_core::render_env) held as a field on the Rust backend, captured from Python right after the push (RustLiveView.capture_render_env(), beside set_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 the ViewActor — under a RenderEnvGuard that restores the previous cell values on drop, so a render leaves a pooled thread's cells as it found them. ComponentActor::render applies the parent's auto_call flag and environment instead of a bare Context::from_dict, and render_template gains a render_env= keyword beside auto_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.py covers the Python half, including the actor mount over a real WebSocket.

  • The walk_from_handle routing comment in crates/djust_core/src/context.rs claimed the datetime family never carries a live handle; django_json_encoded attaches one unconditionally (opaque_value's is flag-gated). The corrected invariant is now pinned by python/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 — so x > y (or x and y) was filed as ONE bogus key and both x and y were lost; render_nodes_partial then skipped the node when only y changed 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 through extract_from_expression, the same helper {% if %}'s condition uses; the two arms stay on the operand helper. A sweep of every other extract_from_operand site in extract_from_nodes found only genuine operands. Pinned by python/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 DjustTemplateBackend registers 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 holds blocktranslate only after a LiveView-entry sibling has rendered on the same worker. They now read inside rendering_with_backend(DJUST) and pass alone. Pinned by the parametrised test_i18n_registry_reader_passes_alone_in_a_fresh_process_2747 in tests/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) emit dj-click="dismiss" on every framework branch, but the component never defined that handler — clicking any of them raised No handler found for event: dismiss. The component now defines an @event_handler dismiss() that delegates to hide(), mirroring AlertComponent.dismiss (#2748)

  • reset_djust_globals() now strips an instance-level render shadow from every built-in tag handler, and the two regroup spies that planted one (a bound method "restored" as an instance attribute) restore with del — five test_tag_bridge_object_parity_2731 cases were red on serial main because a later class-level patch was never reached (#2749).

  • AlertComponent and BadgeComponent close buttons now work under the default strict event_security: both dismiss handlers are @event_handler-decorated, and the badge's close button carries data-component-id on every framework branch so the click reaches the badge instead of the parent view. A package-wide structural test now walks every djust.components.ui component and framework branch and asserts each self-targeting dj-click is a decorated, routed handler (#2756, follows #2748)

  • TableComponent: clicking a sortable column no longer raises No handler found for event: sort_by (#2776; link N+2 of #2748#2756). The header rendered dj-click="sort_by" data-column="k" with no data-component-id, so the click was dispatched to the parent view (which has no sort_by); sort_by was also undecorated (rejected under the default strict event_security even when routed) and took column_key where the control sends column. The same three defects were live on PaginationComponent (first_page/previous_page/go_to_page/next_page/last_page) and TabsComponent (activate_tab, now tab=; a parent-supplied action still routes to the parent), and the decoration/routing halves on ManyToManySelect/ForeignKeySelect (toggle(...), select(value), search(value), clear, select_all). TableComponent lives in djust.components.data, outside the djust.components.ui walk #2764 pinned — the package-wide pin now derives every HTML-rendering LiveComponent under djust.components, covers call-form and dj-change/dj-input targets, and validates each control's params against its handler through the real validate_handler_params (48 rows red on the pre-fix code). Reproducers in test_table_sort_handler_2776.py build the event from the rendered control the way the client does, over a real WebsocketCommunicator.

  • TableComponent(selectable=True): the checkboxes now select rows (#2779). They rendered as bare <input type="checkbox"> cells — no dj-change, no data-component-id, no row identity — and the component had no handler for them at all, so selected_rows could never change. Every checkbox on all three branches (bootstrap5 / tailwind / plain) is now a dj-change routed to the table: the row checkbox toggles that row (toggle_row, sending data-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 reflects checked. A row is identified by its row_key value (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_update hook 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 and trigger_update hands it to set_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 is selectable=True so the new controls are validated automatically. Reproducers in test_table_selection_2779.py build the event from the rendered checkbox over a real WebsocketCommunicator.

  • The pre-push test selector never names a tests/playwright/ or tests/js/ file (#2781). scripts/select-tests.py classified 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/js addopts only suppress pytest's own directory-walk collection — not a node id passed explicitly — so a new standalone Playwright script (a top-level async 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 as data-row-id, but a checkbox's dj-change runs the client's form-event path (buildFormEventParams, 09-event-binding.js:525), which sends only value, field, component_id/view_id, and the element's dj-value-* — it never reads data-* (only dj-click does, via extractTypedParams). Every row click reached the server as toggle_row(row_id=""), so selected_rows only 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 the dj-click contract (forwarding every data-*), not the real dj-change one. Fixed by moving the row identity to dj-value-row-id (python/djust/components/data/table.py), the same convention templatetags/_forms.py already uses for its dj-change/dj-input controls. No client code changed. A Playwright regression test (tests/playwright/test_table_select.py) now drives a real browser and asserts the DOM checked property together with the server's selected_rows for all four features the issue reports; tests/js/table_select_dj_value_2781.test.js pins the client contract directly (post-fix markup delivers row_id, the pre-fix data-row-id shape does not, and the same attribute DOES reach a dj-click handler — 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-import alert #2847 between djust.template.backend and djust.template_libraries: the only import creating the cycle (an isinstance check) 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 trip InMemoryStateBackend.get performs 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.py now 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_lazy kill-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_attrs and the Value::Object arm in impl FromPyObject for Value), the three opaque_gate declines (one-shot iterator, over-cap unsized walk, attribute-bearing object), the RESOLVE_LAZY thread-local with its set_resolve_lazy / resolve_lazy_enabled PyO3 accessors, the RenderEnv.resolve_lazy field (ADR-029), and the Python readers config.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 as str(o) with a live handle, a generator is consumed by {% for %}, a filtered missing operand reaches its filter as None). 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 / perms reach 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-only Encoded (handle_only_encoded) — {{ c.mount }} on a template-less LiveComponent still 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 a datetime whose 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 assigns engine.string_if_invalid (Django's basic-syntax20). Pinned by python/djust/tests/test_adr027_step5_deletion_2628.py, which derives the scanned file set from python/djust + crates/*/src and 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_deps and the _template_deps attr) is gone. It read the template source from self._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)

All releases · Atom feed