djust 1.0.0rc4

Pre-releaseReleased
Install
pip install djust==1.0.0rc4

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_snapshot opt-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 set enable_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 new djust.V011 system check (check_sticky_child_optin, category V, a DjustWarning) scans templates for {% live_render ... sticky=True %} tags, resolves the embedded child class via import_string, matches the embedding parent LiveView by template_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 via DJUST_CONFIG['suppress_checks']. Runtime: a one-shot logger.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 new docs/website/guides/sticky-child-persistence.md guide only. Covered by 18 regression cases across tests/unit/test_checks_v011_sticky_optin.py and tests/unit/test_sticky_optin_runtime_warning.py (including the #1459 empirical canary and the #1468 gate-off self-test).
  • Sticky-child LiveView state 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's mount() — 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 into enable_state_snapshot, the child's public state is restored via safe_setattr and its private state via _restore_private_state, the _restore_* side-effect replay runs (upload configs / presence / listen channels), and the child's mount() state-init is skipped — mirroring the parent LiveView'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 fresh mount() rather than breaking the render. The opt-in djust check warning + guide docs ship as iter 18c. Covered by 8 regression cases in python/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 full LiveView embedded with {% live_render sticky=True %} is a sticky child: it is registered on the parent's StickyChildRegistry and its events are routed by view_id. Until now the per-event state-save block (websocket.py) was gated target_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 have enable_state_snapshot = True, the child's public + private state is now written to a stable session key liveview_<parent_path>__sticky__<sticky_id> (keyed on the child's stable sticky_id class 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 ledger liveview_<parent_path>__sticky_ids records 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 + a djust check warning ship as iter 18c. Only sticky=True embeds (which have a stable sticky_id) are persistable; non-sticky embeds are unaffected. Covered by 7 regression cases in python/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 (the dj-* 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 and Esc always 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, and Esc dispatches the modal's configured close event so server state stays in sync; a tablist gets ArrowLeft/Right roving tabindex plus 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, no tabindex juggling); and a dropdown menu gets ArrowUp/Down roving plus Home/End and Esc-to-close (which returns focus to the trigger). It is CSP-strict (Action #183): one delegated keydown listener on document plus a single document-level MutationObserver for 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.py component classes (data-bs-toggle markup) are intentionally out of scope — those are Bootstrap-JS driven. The module adds +1121 B gzipped to client.js. Covered by 27 cases in tests/js/keyboard_nav.test.js.
  • djust_audit --a11y — a new accessibility-audit mode for the djust_audit management command (#1523). python manage.py djust_audit --a11y runs the Y accessibility system checks (Y001–Y004 — missing accessible names, image alt text, form-control labels, and positive tabindex) as a standalone mode and reports the findings, mirroring the existing --ast / --live mode-branch architecture. It composes with --json for a machine-readable {"a11y_findings": [...], "summary": {...}} envelope and with --strict for CI exit-code semantics. Because every Y finding is a DjustWarning (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 --strict exits 1 if any finding exists. This brings accessibility into the djust_audit workflow alongside the existing security (--ast) and runtime (--live) audits. Covered by 7 cases in TestA11yMode (python/tests/test_audit_command.py).
  • djust._rust is now declared free-threaded-safe — no-GIL CPython users keep the GIL disabled (#1432). Importing the djust._rust extension into a free-threaded CPython interpreter (python3.13t / python3.14t) previously made CPython auto-re-enable the GIL for the whole process — emitting a RuntimeWarning and 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 the Py_mod_gil = Py_MOD_GIL_NOT_USED slot 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-thread Py<T>/PyObject, the Tokio actor system, the template registries, and the recursive Python↔Rust converters — Rust's Send/Sync auto-trait checking statically verifies every static is 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 6 std::thread concurrency regression tests across crates/djust_templates/tests/free_threaded_safety.rs and crates/djust_vdom/tests/free_threaded_safety.rs, plus a Python threading call-path smoke test. Out-of-scope free-threading hardening (optional RwLock/frozen tweaks, a python3.14t CI leg) is tracked in #1534.
  • optimistic, cache, client_state, and background are now re-exported from the top-level djust package (#1489). These four decorators are stable public-API symbols but were previously reachable only via from djust.decorators import … — they were absent from the top-level djust package's __all__. They are now also importable directly as from 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 the djust.decorators originals — a pure re-export, not a redefinition — and the from 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 in python/djust/tests/test_top_level_reexports_1489.py.

Changed

  • Free-threaded hardening — dead-code removal, frozen pyclasses, RwLock template registries, and a python3.14t CI leg (#1534). A bucket of post-#1432 hardening, deliberately deferred from #1432's scope per the broader-sweep discipline. (1) The unused Rust-side COMPONENT_REGISTRY and its three accessors in crates/djust_components were confirmed dead (zero call sites, never exported through djust._rust) and removed. (2) SupervisorStatsPy and SessionActorHandlePy are now #[pyclass(frozen)] — both are immutable / all-&self, so frozen drops PyO3's per-instance runtime borrow-check overhead. (3) The four Rust template registries (tag / block / assign / filter) moved from Mutex to RwLock, 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-blocking python3.14t CI job runs the free-threaded threading smoke test on a genuine free-threaded interpreter, where the GIL-re-enable assertion (previously skipif-guarded) becomes real. All four are internal hardening — no public API or behavior change for application code. Guarded by a new rwlock_registry_allows_simultaneous_readers concurrency test in crates/djust_templates/tests/free_threaded_safety.rs.

Fixed

  • VDOM incremental diff no longer mis-paths SetText patches when 2+ dynamic {{ }} text values change in one update (#1529). The text-fast-path's build_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 }} both 0 at mount — both matched the first such node, collapsing both map entries onto one VDOM path. render_with_diff() then emitted every SetText patch 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 a Vec<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 in tests/unit/test_vdom_settext_mispath_1529.py.
  • ThemeMixin views now emit the components.css link and valid anti-FOUC JS — theme_head was rendered with an incomplete context (#1531). ThemeMixin._setup_theme_context() rendered theme_head.html with only 3 of the 8 context keys the template consumes, omitting include_component_link, cookie_prefix_js, direction, deferred_css_block, and component_css_block. Two visible breakages followed: the {% if include_component_link %} guard was falsy so the <link> to djust_theming/css/components.css was never emitted (theme components — theme_panel, etc. — rendered unstyled in any ThemeMixin view), and window.__djust_theme_cookie_prefix = {{ cookie_prefix_js }}; rendered as window.__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 the ThemeMixin path was broken. This is the #1452 context-drift bug repeated on a third consumer of theme_head.html. The fix extracts a shared build_theme_head_context() so the theme_head tag and ThemeMixin._setup_theme_context() build the head context from a single source of truth — the two paths can no longer drift. Behavior change: ThemeMixin views now also receive the same critical-CSS / deferred-CSS split that {% theme_head %} produces when critical_css is enabled (previously the mixin built a single combined css_block) — a consistency improvement, no migration needed. Covered by 6 cases in TestThemeMixinThemeHead (python/djust/tests/test_theming_context_cache.py), including a ThemeMixin-theme_head-≡-{% theme_head %} output-symmetry pin.
  • A dropdown nested inside a modal/dialog now receives arrow-key and Esc keyboard routing (#1533). The keyboard-interaction module (51-keyboard-nav.js, shipped in #1522) routed every keydown inside an open role="dialog" through the dialog branch and returned early — so a dropdown component rendered inside a modal got no arrow-key roving navigation, and Esc always closed the whole dialog instead of the open dropdown. The dialog branch now checks Tab first (the focus trap is unchanged), then, when the event target is within a .dj-dropdown contained by the dialog, delegates Arrow/Home/End to the dropdown handler and routes Esc to 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 the keyboard-nav — dropdown nested in dialog test block (tests/js/keyboard_nav.test.js).
  • VNode msgpack round-trips no longer fail when a node has no djust_id (#1538). The Rust VNode struct's djust_id field carried #[serde(skip_serializing_if = "Option::is_none")] but no #[serde(default)]. Under msgpack a struct serializes as a positional array, so a None djust_id dropped the trailing element and produced a 5-element array — which the derived 6-element deserializer rejected with invalid length 5, expected struct VNode with 6 elements. Because the HTML parser assigns djust_id = None to every text node, any view whose VDOM tree contained text hit this: RustLiveView.deserialize_msgpack failed inside InMemoryStateBackend.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 with None. 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 has rmp_serde (msgpack, positional) round-trip coverage. Covered by 3 msgpack_round_trip_* cases in crates/djust_vdom/tests/wire_protocol_snapshot.rs and 2 in TestVNodeMsgpackRoundTrip (python/tests/test_serialization_hardening.py).

All releases · Atom feed