This is a pre-release. djust 1.0.0 has shipped since: read the djust 1.0.0 release notes.
Before you upgrade, read the upgrade guide.
Added
- Sticky-child
enable_state_snapshotopt-in mismatches are now surfaced — enforcement side (#1471, ADR-018 iter 18c). Completes ADR-018 and closes #1471. Sticky-child persistence requires both the child class and its embedding parent to setenable_state_snapshot = True(ADR-018 Decision 5 — restore must be tree-consistent). A child that opts in under a parent that does not is a misconfiguration: the child looks like it should persist, but iter 18a's both-opt-in gate silently skips its save. This iteration adds two enforcement mechanisms. Static: a newdjust.V011system check (check_sticky_child_optin, category V, aDjustWarning) scans templates for{% live_render ... sticky=True %}tags, resolves the embedded child class viaimport_string, matches the embedding parentLiveViewbytemplate_name, and warns when the child opts in but a matched parent does not. It is conservative — dynamic{% live_render variable %}paths, unresolvable child classes,{% verbatim %}doc examples, and templates with no statically-resolvable parent are all skipped, so it produces no false positives — and is suppressible viaDJUST_CONFIG['suppress_checks']. Runtime: a one-shotlogger.warning(warn_sticky_child_optin_skip) fires the first time a child save is skipped for this reason, at most once per(parent class, sticky_id), wired into both the WebSocket save path (websocket.py) and the HTTP-POST save path (mixins/request.py). This iteration changes no save (18a) or load (18b) logic — it is enforcement plus the newdocs/website/guides/sticky-child-persistence.mdguide only. Covered by 18 regression cases acrosstests/unit/test_checks_v011_sticky_optin.pyandtests/unit/test_sticky_optin_runtime_warning.py(including the #1459 empirical canary and the #1468 gate-off self-test). - Sticky-child
LiveViewstate is now restored on reconnect — LOAD side (#1471, ADR-018 iter 18b). Completes the round trip started by iter 18a. When{% live_render sticky=True %}constructs a sticky child during a render, the tag now — before calling the child'smount()— checks the session for the state iter 18a saved (liveview_<parent_path>__sticky__<sticky_id>). If a saved entry exists and both the child and its parent opted intoenable_state_snapshot, the child's public state is restored viasafe_setattrand its private state via_restore_private_state, the_restore_*side-effect replay runs (upload configs / presence / listen channels), and the child'smount()state-init is skipped — mirroring the parentLiveView's own skip-mount()-on-saved-state path. Restore is tag-driven (per ADR-018 Decision 2) and covers the WebSocket, HTTP-POST, and HTTP-GET render paths through the single{% live_render %}hook. A corrupt or partial session entry falls through to a freshmount()rather than breaking the render. The opt-indjust checkwarning + guide docs ship as iter 18c. Covered by 8 regression cases inpython/djust/tests/test_sticky_child_restore_1471_18b.py. - Sticky-child
LiveViews now persist their state across a WebSocket reconnect — SAVE side (#1471, ADR-018 iter 18a). A fullLiveViewembedded with{% live_render sticky=True %}is a sticky child: it is registered on the parent'sStickyChildRegistryand its events are routed byview_id. Until now the per-event state-save block (websocket.py) was gatedtarget_view is self.view_instance, so sticky-child events were skipped entirely — a sticky child's event-driven state was silently lost on a reconnect (page refresh, network blip, snapshot/restore), and the HTTP path had the same gap. This iteration adds the SAVE side: when a sticky-child event fires and both the child and its parent haveenable_state_snapshot = True, the child's public + private state is now written to a stable session keyliveview_<parent_path>__sticky__<sticky_id>(keyed on the child's stablesticky_idclass attribute, never the volatile per-process_view_id). The same parent-driven sweep was added to the HTTP POST path so both transports persist consistently. A GC ledgerliveview_<parent_path>__sticky_idsrecords the sticky ids rendered each cycle and prunes session entries for children no longer rendered. The matching LOAD/restore side ships next as ADR-018 iter 18b; opt-in enforcement + adjust checkwarning ship as iter 18c. Onlysticky=Trueembeds (which have a stablesticky_id) are persistable; non-sticky embeds are unaffected. Covered by 7 regression cases inpython/djust/tests/test_sticky_child_persistence_1471.py. - Keyboard interaction for the djust-native component library — focus trap,
Esc-to-close, and arrow-key roving navigation, out of the box (#1522). Accessibility phase 2 ships the client-side keyboard operability layer that PR #1491's component ARIA pass deliberately deferred — the roles and states it emitted are now keyboard-driveable. A new client-JS module (python/djust/static/djust/src/51-keyboard-nav.js) adds W3C ARIA Authoring-Practices keyboard behavior to the four djust-native templatetag components (thedj-*class family): a modal/dialog traps focus (Tab from the last focusable descendant wraps to the first, Shift+Tab wraps the other way, a no-focusable-children dialog traps focus on the container, and nested dialogs maintain a stack so the trap andEscalways act on the top-most dialog), focus moves into a dialog when it opens and is restored to the previously-focused element when it closes, andEscdispatches the modal's configured close event so server state stays in sync; a tablist gets ArrowLeft/Right rovingtabindexplus Home/End (manual activation — arrows move focus, Enter/Space activates); an accordion gets ArrowUp/Down focus movement plus Home/End (headers keep their native tab order, notabindexjuggling); and a dropdown menu gets ArrowUp/Down roving plus Home/End andEsc-to-close (which returns focus to the trigger). It is CSP-strict (Action #183): one delegatedkeydownlistener ondocumentplus a single document-levelMutationObserverfor focus-in-on-open / focus-restore-on-close — no inline scripts, no template changes, and delegation survives morphdom re-renders for free. The Bootstrap-flavoured_simple.pycomponent classes (data-bs-togglemarkup) are intentionally out of scope — those are Bootstrap-JS driven. The module adds +1121 B gzipped toclient.js. Covered by 27 cases intests/js/keyboard_nav.test.js. djust_audit --a11y— a new accessibility-audit mode for thedjust_auditmanagement command (#1523).python manage.py djust_audit --a11yruns theYaccessibility system checks (Y001–Y004 — missing accessible names, imagealttext, form-control labels, and positivetabindex) as a standalone mode and reports the findings, mirroring the existing--ast/--livemode-branch architecture. It composes with--jsonfor a machine-readable{"a11y_findings": [...], "summary": {...}}envelope and with--strictfor CI exit-code semantics. Because everyYfinding is aDjustWarning(there is no error tier), the exit-code contract is precise: normal mode always exits 0 (a stray false positive never breaks a build), and--strictexits 1 if any finding exists. This brings accessibility into thedjust_auditworkflow alongside the existing security (--ast) and runtime (--live) audits. Covered by 7 cases inTestA11yMode(python/tests/test_audit_command.py).djust._rustis now declared free-threaded-safe — no-GIL CPython users keep the GIL disabled (#1432). Importing thedjust._rustextension into a free-threaded CPython interpreter (python3.13t/python3.14t) previously made CPython auto-re-enable the GIL for the whole process — emitting aRuntimeWarningand silently downgrading every no-GIL user back to the GIL'd path — because the extension had not declared free-threading support. The PyO3 module is now marked#[pymodule(gil_used = false)](PyO3 0.25), which writes thePy_mod_gil = Py_MOD_GIL_NOT_USEDslot CPython reads to skip the auto-re-enable. The declaration is backed by a full thread-safety audit of every_rust-reachable shared global,#[pyclass]type, cross-threadPy<T>/PyObject, the Tokio actor system, the template registries, and the recursive Python↔Rust converters — Rust'sSend/Syncauto-trait checking statically verifies everystaticis correctly synchronized, with no shared mutable state lacking a lock or atomic. GIL'd interpreters (3.12 and the standard 3.13/3.14 builds) are entirely unaffected. Guarded by 6std::threadconcurrency regression tests acrosscrates/djust_templates/tests/free_threaded_safety.rsandcrates/djust_vdom/tests/free_threaded_safety.rs, plus a Pythonthreadingcall-path smoke test. Out-of-scope free-threading hardening (optionalRwLock/frozentweaks, apython3.14tCI leg) is tracked in #1534.optimistic,cache,client_state, andbackgroundare now re-exported from the top-leveldjustpackage (#1489). These four decorators are stable public-API symbols but were previously reachable only viafrom djust.decorators import …— they were absent from the top-leveldjustpackage's__all__. They are now also importable directly asfrom djust import optimistic, cache, client_state, background, matching every other public decorator (event_handler,action,computed, …). The top-level names are the same objects as thedjust.decoratorsoriginals — a pure re-export, not a redefinition — and thefrom djust.decorators import …path continues to work unchanged. Purely additive and SemVer-safe; this resolves finding F3 of the v1.0.0 API-stability audit (docs/API_STABILITY.md§F3 updated accordingly). Covered by 4 cases inpython/djust/tests/test_top_level_reexports_1489.py.
Changed
- Free-threaded hardening — dead-code removal,
frozenpyclasses,RwLocktemplate registries, and apython3.14tCI leg (#1534). A bucket of post-#1432hardening, deliberately deferred from #1432's scope per the broader-sweep discipline. (1) The unused Rust-sideCOMPONENT_REGISTRYand its three accessors incrates/djust_componentswere confirmed dead (zero call sites, never exported throughdjust._rust) and removed. (2)SupervisorStatsPyandSessionActorHandlePyare now#[pyclass(frozen)]— both are immutable / all-&self, sofrozendrops PyO3's per-instance runtime borrow-check overhead. (3) The four Rust template registries (tag / block / assign / filter) moved fromMutextoRwLock, so concurrent renders on a free-threaded interpreter share the read lock instead of serializing on registry lookups — registration (one-time bootstrap) takes the write lock, dispatch takes the read lock. (4) A new non-blockingpython3.14tCI job runs the free-threadedthreadingsmoke test on a genuine free-threaded interpreter, where the GIL-re-enable assertion (previouslyskipif-guarded) becomes real. All four are internal hardening — no public API or behavior change for application code. Guarded by a newrwlock_registry_allows_simultaneous_readersconcurrency test incrates/djust_templates/tests/free_threaded_safety.rs.
Fixed
- VDOM incremental diff no longer mis-paths
SetTextpatches when 2+ dynamic{{ }}text values change in one update (#1529). The text-fast-path'sbuild_fragment_text_map(crates/djust_live/src/lib.rs) mapped each rendered template fragment to the first VDOM text node whose content string equalled the fragment. Content equality is not a unique key: two template variables that render the same baseline string — e.g.{{ a }}and{{ b }}both0at mount — both matched the first such node, collapsing both map entries onto one VDOM path.render_with_diff()then emitted everySetTextpatch at that single path, so a page reliably updated only its first dynamic{{ }}text value while later ones were mis-pathed onto it (and the in-memory VDOM node at that path was mutated twice while its sibling was never touched). The fix tracks aVec<bool>parallel to the collected text nodes and claims each node at most once — the first unclaimed matching node — making the fragment→node map a bijection over matched fragments. Both the fragment list and the text-node collection are in document order, so first-unclaimed-match is positionally stable. No change to the VDOM differ, parser, patch types, or the patch-emission loop. Covered by 6 regression cases intests/unit/test_vdom_settext_mispath_1529.py. ThemeMixinviews now emit thecomponents.csslink and valid anti-FOUC JS —theme_headwas rendered with an incomplete context (#1531).ThemeMixin._setup_theme_context()renderedtheme_head.htmlwith only 3 of the 8 context keys the template consumes, omittinginclude_component_link,cookie_prefix_js,direction,deferred_css_block, andcomponent_css_block. Two visible breakages followed: the{% if include_component_link %}guard was falsy so the<link>todjust_theming/css/components.csswas never emitted (theme components —theme_panel, etc. — rendered unstyled in anyThemeMixinview), andwindow.__djust_theme_cookie_prefix = {{ cookie_prefix_js }};rendered aswindow.__djust_theme_cookie_prefix = ;— a JavaScript syntax error that broke the whole anti-FOUC inline<script>. The{% theme_head %}simple tag built the full context correctly, so{{ theme_head }}via the context processor was unaffected — only theThemeMixinpath was broken. This is the #1452 context-drift bug repeated on a third consumer oftheme_head.html. The fix extracts a sharedbuild_theme_head_context()so thetheme_headtag andThemeMixin._setup_theme_context()build the head context from a single source of truth — the two paths can no longer drift. Behavior change:ThemeMixinviews now also receive the same critical-CSS / deferred-CSS split that{% theme_head %}produces whencritical_cssis enabled (previously the mixin built a single combinedcss_block) — a consistency improvement, no migration needed. Covered by 6 cases inTestThemeMixinThemeHead(python/djust/tests/test_theming_context_cache.py), including aThemeMixin-theme_head-≡-{% theme_head %}output-symmetry pin.- A dropdown nested inside a modal/dialog now receives arrow-key and
Esckeyboard routing (#1533). The keyboard-interaction module (51-keyboard-nav.js, shipped in #1522) routed every keydown inside an openrole="dialog"through the dialog branch and returned early — so adropdowncomponent rendered inside a modal got no arrow-key roving navigation, andEscalways closed the whole dialog instead of the open dropdown. The dialog branch now checksTabfirst (the focus trap is unchanged), then, when the event target is within a.dj-dropdowncontained by the dialog, delegates Arrow/Home/End to the dropdown handler and routesEscto close an open inner dropdown before falling back to closing the dialog. Plain dropdowns and plain dialogs are unaffected. Covered by 9 new cases in thekeyboard-nav — dropdown nested in dialogtest block (tests/js/keyboard_nav.test.js). VNodemsgpack round-trips no longer fail when a node has nodjust_id(#1538). The RustVNodestruct'sdjust_idfield carried#[serde(skip_serializing_if = "Option::is_none")]but no#[serde(default)]. Under msgpack a struct serializes as a positional array, so aNonedjust_iddropped the trailing element and produced a 5-element array — which the derived 6-element deserializer rejected withinvalid length 5, expected struct VNode with 6 elements. Because the HTML parser assignsdjust_id = Noneto every text node, any view whose VDOM tree contained text hit this:RustLiveView.deserialize_msgpackfailed insideInMemoryStateBackend.get/RedisStateBackend, the cached state entry was discarded, an error was logged on every WebSocket resume, and cross-reconnect state continuity was lost for the affected view. Adding#[serde(default)]lets the sequence deserializer fill a missing trailing element withNone. The change is deserialize-only — serialized bytes are byte-identical, and a new deserializer still reads old 6-element payloads — so there is no wire-format migration. The #1448 wire-protocol snapshot suite tested only the JSON (named-map) encoding, which is why it missed this; it now also hasrmp_serde(msgpack, positional) round-trip coverage. Covered by 3msgpack_round_trip_*cases incrates/djust_vdom/tests/wire_protocol_snapshot.rsand 2 inTestVNodeMsgpackRoundTrip(python/tests/test_serialization_hardening.py).